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") ) // Extractor writes only payload/ contents from a pre-verified package ZIP. type Extractor struct { limits Limits durability durabilityFence } type ExtractResult struct { Files int Bytes int64 EntrypointPath string 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 NewExtractor(limits Limits) (Extractor, error) { if err := limits.validate(); err != nil { return Extractor{}, err } return Extractor{limits: limits, durability: defaultDurability()}, 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) destinationRoot, entrypointPath, err := planOutputPaths( destination, entrypoint, plan, ) if err != nil { return ExtractResult{}, err } if err := os.MkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging parent: %w", err) } if err := os.Mkdir(destinationRoot, 0o700); err != nil { if os.IsExist(err) { return ExtractResult{}, ErrDestinationExists } return ExtractResult{}, fmt.Errorf("create staging destination: %w", err) } complete := false defer func() { if !complete { _ = os.RemoveAll(destinationRoot) } }() var written int64 for _, entry := range plan { if entry.directory { if entry.outputPath == "" { continue } if err := os.MkdirAll(entry.targetPath, 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging directory: %w", err) } continue } if err := os.MkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil { return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err) } source, err := entry.file.Open() if err != nil { return ExtractResult{}, fmt.Errorf("%w: open %s: %v", ErrArchiveCorrupt, entry.archivePath, err) } mode := os.FileMode(0o600) if entry.file.Mode().Perm()&0o111 != 0 { mode = 0o700 } output, err := os.OpenFile( entry.targetPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode, ) if err != nil { source.Close() return ExtractResult{}, fmt.Errorf("create staging file: %w", err) } remaining := extractor.limits.MaxUncompressedBytes - written readLimit := remaining if readLimit < math.MaxInt64 { readLimit++ } digest := sha256.New() copied, copyErr := io.Copy( io.MultiWriter(output, digest), io.LimitReader(source, readLimit), ) closeSourceErr := source.Close() if copyErr != nil { _ = output.Close() return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr) } if closeSourceErr != nil { _ = output.Close() return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr) } if copied > remaining { _ = output.Close() return ExtractResult{}, ErrExpandedTooLarge } if uint64(copied) != entry.file.UncompressedSize64 { _ = output.Close() return ExtractResult{}, fmt.Errorf( "%w: %s expanded to %d bytes, header declares %d", ErrArchiveCorrupt, entry.archivePath, copied, entry.file.UncompressedSize64, ) } if err := syncFileWithFence(fence, output, "staging payload"); err != nil { _ = output.Close() return ExtractResult{}, err } if err := output.Close(); err != nil { return ExtractResult{}, fmt.Errorf("close staging file: %w", 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{}, err } result.Bytes = written result.EntrypointPath = entrypointPath complete = true return result, nil } 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 }