package installer import ( "archive/zip" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "math" "os" "path/filepath" "strings" "softbox.local/core/internal/safepath" ) var ( ErrInvalidArchive = errors.New("invalid ZIP archive") ErrPathEscape = errors.New("ZIP path escapes payload") ErrUnsupportedEntry = errors.New("unsupported ZIP entry") ErrUnexpectedEntry = errors.New("unexpected ZIP package entry") ErrDuplicateEntry = errors.New("duplicate ZIP entry") ErrEncryptedEntry = errors.New("encrypted ZIP entry is unsupported") ErrArchiveSizeMismatch = errors.New("ZIP archive size does not match expected package size") ErrArchiveTooLarge = errors.New("ZIP archive size limit exceeded") ErrCentralDirectoryTooLarge = errors.New("ZIP central directory size limit exceeded") ErrTooManyEntries = errors.New("ZIP entry limit exceeded") ErrExpandedTooLarge = errors.New("ZIP expanded size limit exceeded") ErrCompressionRatio = errors.New("ZIP compression ratio limit exceeded") ErrEntrypointInvalid = errors.New("invalid package entrypoint") ErrEntrypointMissing = errors.New("package entrypoint is missing") ErrAppManifestMissing = errors.New("package app.json is missing") ErrDestinationExists = errors.New("staging destination already exists") ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt") ErrStagingOutput = errors.New("staging output failed") ErrStagingCleanup = errors.New("staging cleanup failed") ) // Extractor writes only payload/ contents from a pre-verified package ZIP. type Extractor struct { limits Limits durability durabilityFence files stagingFileOperations } type ExtractResult struct { Files int Bytes int64 EntrypointPath string WorkingDir string ProductID string SupportsTrial bool PayloadFiles []ExtractedFile } // ExtractedFile is one payload file written to staging after ZIP CRC and // length validation. Its digest is calculated from the bytes written there. type ExtractedFile struct { Path string Size int64 SHA256 string } type plannedEntry struct { file *zip.File archivePath string outputPath string targetPath string directory bool } func verifiedPackageFromPlan(plan []plannedEntry, entrypoint string) (VerifiedPackage, error) { verified := VerifiedPackage{Entrypoint: entrypoint} for _, entry := range plan { if entry.directory { continue } if entry.file == nil || entry.file.UncompressedSize64 > uint64(math.MaxInt64) { return VerifiedPackage{}, ErrExpandedTooLarge } size := int64(entry.file.UncompressedSize64) if verified.PayloadBytes > math.MaxInt64-size { return VerifiedPackage{}, ErrExpandedTooLarge } verified.PayloadBytes += size verified.PayloadFiles++ } return verified, nil } func NewExtractor(limits Limits) (Extractor, error) { if err := limits.validate(); err != nil { return Extractor{}, err } return Extractor{ limits: limits, durability: defaultDurability(), files: defaultStagingFileOperations(), }, nil } // ExtractFile requires expectedPackageSize from the verified Catalog package. // The completed download file must have precisely that size before any ZIP data is parsed. func (extractor Extractor) ExtractFile( zipPath string, destination string, entrypoint string, expectedPackageSize int64, ) (ExtractResult, error) { file, size, err := extractor.openAndScanArchive(zipPath, expectedPackageSize) if err != nil { return ExtractResult{}, err } defer file.Close() archive, err := zip.NewReader(file, size) if err != nil { return ExtractResult{}, fmt.Errorf("%w: %v", ErrInvalidArchive, err) } return extractor.extract(archive, destination, entrypoint) } func (extractor Extractor) extract( archive *zip.Reader, destination string, entrypoint string, ) (result ExtractResult, err error) { if err := extractor.limits.validate(); err != nil { return ExtractResult{}, err } normalizedEntrypoint, err := normalizeEntrypoint(entrypoint) if err != nil { return ExtractResult{}, err } plan, err := extractor.preflight(archive, normalizedEntrypoint) if err != nil { return ExtractResult{}, err } return extractor.extractPlan(destination, normalizedEntrypoint, plan) } func (extractor Extractor) extractPlan( destination string, entrypoint string, plan []plannedEntry, ) (result ExtractResult, err error) { fence := effectiveDurability(extractor.durability) files := effectiveStagingFileOperations(extractor.files) destinationRoot, entrypointPath, err := planOutputPaths( destination, entrypoint, plan, ) if err != nil { return ExtractResult{}, err } if err := files.mkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil { return ExtractResult{}, stagingOutputError("create staging parent", err) } if err := files.mkdir(destinationRoot, 0o700); err != nil { if os.IsExist(err) { return ExtractResult{}, ErrDestinationExists } return ExtractResult{}, stagingOutputError("create staging destination", err) } complete := false defer func() { if !complete { if cleanupErr := files.removeAll(destinationRoot); cleanupErr != nil { err = errors.Join(err, stagingCleanupError(cleanupErr)) } } }() var written int64 for _, entry := range plan { if entry.directory { if entry.outputPath == "" { continue } if err := files.mkdirAll(entry.targetPath, 0o700); err != nil { return ExtractResult{}, stagingOutputError("create staging directory", err) } continue } if err := files.mkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil { return ExtractResult{}, stagingOutputError("create staging file parent", err) } source, err := entry.file.Open() if err != nil { return ExtractResult{}, archiveInputError("open", entry.archivePath, err) } mode := os.FileMode(0o600) if entry.file.Mode().Perm()&0o111 != 0 { mode = 0o700 } output, err := files.openFile( entry.targetPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode, ) if err != nil { outputErr := stagingOutputError("create staging file", err) if closeErr := source.Close(); closeErr != nil { return ExtractResult{}, errors.Join( outputErr, archiveInputError("close", entry.archivePath, closeErr), ) } return ExtractResult{}, outputErr } remaining := extractor.limits.MaxUncompressedBytes - written readLimit := remaining if readLimit < math.MaxInt64 { readLimit++ } digest := sha256.New() writer := stagingWriter{writer: output} copied, copyErr := io.Copy( io.MultiWriter(&writer, digest), io.LimitReader(source, readLimit), ) closeSourceErr := source.Close() if copyErr != nil { primary := archiveInputError("read", entry.archivePath, copyErr) if writer.err != nil { primary = stagingOutputError("write staging file", writer.err) } return ExtractResult{}, joinStagingCloseError(primary, output) } if closeSourceErr != nil { return ExtractResult{}, joinStagingCloseError( archiveInputError("close", entry.archivePath, closeSourceErr), output, ) } if copied > remaining { return ExtractResult{}, joinStagingCloseError(ErrExpandedTooLarge, output) } if uint64(copied) != entry.file.UncompressedSize64 { return ExtractResult{}, joinStagingCloseError(fmt.Errorf( "%w: %s expanded to %d bytes, header declares %d", ErrArchiveCorrupt, entry.archivePath, copied, entry.file.UncompressedSize64, ), output) } if err := syncFileWithFence(fence, output.osFile(), "staging payload"); err != nil { return ExtractResult{}, joinStagingCloseError( stagingOutputError("sync staging file", err), output, ) } if err := output.Close(); err != nil { return ExtractResult{}, stagingOutputError("close staging file", err) } written += copied result.Files++ result.PayloadFiles = append(result.PayloadFiles, ExtractedFile{ Path: entry.outputPath, Size: copied, SHA256: hex.EncodeToString(digest.Sum(nil)), }) } if err := syncStagingTree(fence, destinationRoot); err != nil { return ExtractResult{}, stagingOutputError("sync staging tree", err) } result.Bytes = written result.EntrypointPath = entrypointPath complete = true return result, nil } type stagingWriter struct { writer io.Writer err error } func (writer *stagingWriter) Write(data []byte) (int, error) { written, err := writer.writer.Write(data) if err != nil { writer.err = err } else if written != len(data) { writer.err = io.ErrShortWrite } return written, err } func archiveInputError(operation, path string, cause error) error { return fmt.Errorf("%w: %s %s: %w", ErrArchiveCorrupt, operation, path, cause) } func stagingOutputError(operation string, cause error) error { return fmt.Errorf("%w: %s: %w", ErrStagingOutput, operation, cause) } func stagingCleanupError(cause error) error { return fmt.Errorf("%w: remove staging: %w", ErrStagingCleanup, cause) } func joinStagingCloseError(primary error, output stagingOutputFile) error { if closeErr := output.Close(); closeErr != nil { return errors.Join(primary, stagingOutputError("close staging file", closeErr)) } return primary } func planOutputPaths( destination string, entrypoint string, plan []plannedEntry, ) (string, string, error) { if destination == "" { return "", "", fmt.Errorf("%w: staging destination is empty", ErrPathEscape) } destinationRoot, err := filepath.Abs(destination) if err != nil { return "", "", fmt.Errorf("%w: resolve staging destination: %v", ErrPathEscape, err) } destinationRoot = filepath.Clean(destinationRoot) for index := range plan { if plan[index].outputPath == "" { plan[index].targetPath = destinationRoot continue } target, err := safepath.JoinUnder(destinationRoot, plan[index].outputPath) if err != nil { return "", "", fmt.Errorf( "%w: output %q: %v", ErrPathEscape, plan[index].outputPath, err, ) } plan[index].targetPath = target } entrypointPath, err := safepath.JoinUnder(destinationRoot, entrypoint) if err != nil { return "", "", fmt.Errorf( "%w: entrypoint %q: %v", ErrEntrypointInvalid, entrypoint, err, ) } return destinationRoot, entrypointPath, nil } func (extractor Extractor) preflight( archive *zip.Reader, entrypoint string, ) ([]plannedEntry, error) { if len(archive.File) > extractor.limits.MaxEntries { return nil, fmt.Errorf( "%w: got %d, limit %d", ErrTooManyEntries, len(archive.File), extractor.limits.MaxEntries, ) } entrypointArchivePath := "payload/" + entrypoint seenPaths := make(map[string]string, len(archive.File)) plan := make([]plannedEntry, 0, len(archive.File)) var totalUncompressed uint64 var totalCompressed uint64 appManifestFound := false entrypointFound := false for _, file := range archive.File { normalized, directory, err := validateArchiveEntry(file) if err != nil { return nil, err } folded := safepath.CollisionKey(normalized) if previous, exists := seenPaths[folded]; exists { return nil, fmt.Errorf( "%w: %q conflicts with %q", ErrDuplicateEntry, normalized, previous, ) } seenPaths[folded] = normalized if file.UncompressedSize64 > uint64(extractor.limits.MaxUncompressedBytes)-totalUncompressed { return nil, ErrExpandedTooLarge } totalUncompressed += file.UncompressedSize64 if ^uint64(0)-totalCompressed < file.CompressedSize64 { return nil, fmt.Errorf("%w: compressed size overflow", ErrInvalidArchive) } totalCompressed += file.CompressedSize64 if exceedsCompressionRatio( file.UncompressedSize64, file.CompressedSize64, extractor.limits.MaxCompressionRatio, ) { return nil, fmt.Errorf("%w: %s", ErrCompressionRatio, normalized) } switch { case normalized == "app.json": if directory { return nil, fmt.Errorf("%w: app.json is a directory", ErrUnexpectedEntry) } appManifestFound = true case normalized == "files.json": if directory { return nil, fmt.Errorf("%w: files.json is a directory", ErrUnexpectedEntry) } case normalized == "payload": if !directory { return nil, fmt.Errorf("%w: payload must be a directory", ErrUnexpectedEntry) } plan = append(plan, plannedEntry{ file: file, archivePath: normalized, outputPath: "", directory: true, }) case strings.HasPrefix(normalized, "payload/"): outputPath := strings.TrimPrefix(normalized, "payload/") plan = append(plan, plannedEntry{ file: file, archivePath: normalized, outputPath: outputPath, directory: directory, }) if normalized == entrypointArchivePath && !directory { entrypointFound = true } default: return nil, fmt.Errorf("%w: %s", ErrUnexpectedEntry, normalized) } } if !appManifestFound { return nil, ErrAppManifestMissing } if exceedsCompressionRatio( totalUncompressed, totalCompressed, extractor.limits.MaxCompressionRatio, ) { return nil, fmt.Errorf("%w: whole archive", ErrCompressionRatio) } if !entrypointFound { return nil, fmt.Errorf("%w: %s", ErrEntrypointMissing, entrypoint) } return plan, nil } func validateArchiveEntry(file *zip.File) (string, bool, error) { if file.Flags&0x1 != 0 { return "", false, fmt.Errorf("%w: %s", ErrEncryptedEntry, file.Name) } normalized, directory, err := normalizeArchivePath(file.Name) if err != nil { return "", false, err } mode := file.Mode() if mode&os.ModeSymlink != 0 { return "", false, fmt.Errorf("%w: symlink %s", ErrUnsupportedEntry, normalized) } if directory { if !mode.IsDir() { return "", false, fmt.Errorf("%w: non-directory mode for %s", ErrUnsupportedEntry, normalized) } return normalized, true, nil } if !mode.IsRegular() { return "", false, fmt.Errorf("%w: special file %s", ErrUnsupportedEntry, normalized) } return normalized, false, nil } func normalizeArchivePath(name string) (string, bool, error) { directory := strings.HasSuffix(name, "/") trimmed := strings.TrimSuffix(name, "/") if err := safepath.ValidateRelative(trimmed); err != nil { return "", false, fmt.Errorf("%w: %q: %v", ErrPathEscape, name, err) } return trimmed, directory, nil } func normalizeEntrypoint(entrypoint string) (string, error) { if err := safepath.ValidateRelative(entrypoint); err != nil { return "", fmt.Errorf("%w: %q: %v", ErrEntrypointInvalid, entrypoint, err) } if entrypoint == "payload" || strings.HasPrefix(entrypoint, "payload/") { return "", fmt.Errorf("%w: %q", ErrEntrypointInvalid, entrypoint) } return entrypoint, nil } func exceedsCompressionRatio(uncompressed, compressed uint64, maximum float64) bool { if uncompressed == 0 { return false } if compressed == 0 { return true } return float64(uncompressed)/float64(compressed) > maximum }