package installer import ( "archive/tar" "archive/zip" "compress/gzip" "context" "errors" "fmt" "io" "io/fs" "os" "path" "path/filepath" "strings" "gitgud.io/mike/mpv-manager/pkg/constants" "github.com/ulikunitz/xz" ) type archiveInventoryEntry struct { name string localName string isDir bool size uint64 } type archiveInventoryPolicy func([]archiveInventoryEntry) error // psQuote escapes a string for safe interpolation into a single-quoted // PowerShell literal used by the platform-specific installer helpers. func psQuote(value string) string { return strings.ReplaceAll(value, "'", "''") } // ExtractArchiveWithOutput extracts an archive through a private sibling // staging directory. The live destination is not touched until the complete // archive has passed path/type/size preflight and staged-tree validation. func (i *Installer) ExtractArchiveWithOutput(cr *CommandRunner, src, dest string) error { cr.outputChan <- fmt.Sprintf("Extracting: %s", src) if err := i.extractArchive(cr.Context(), src, dest, nil); err != nil { cr.outputChan <- fmt.Sprintf("Error: Extraction failed: %v", err) return err } cr.outputChan <- fmt.Sprintf("Extracted to: %s", dest) return nil } // extractUOSCArchive accepts only the reviewed upstream uOSC layout. A valid // archive cannot use the generic overlay to replace unrelated MPV files. func (i *Installer) extractUOSCArchive(cr *CommandRunner, src, dest string, durableCommit bool) error { cr.outputChan <- fmt.Sprintf("Extracting: %s", src) if err := extractZipTransactionalMode(cr.Context(), src, dest, validateUOSCArchiveInventory, durableCommit); err != nil { cr.outputChan <- fmt.Sprintf("Error: Extraction failed: %v", err) return err } cr.outputChan <- fmt.Sprintf("Extracted to: %s", dest) return nil } func validateUOSCArchiveInventory(entries []archiveInventoryEntry) error { hasScript := false for _, entry := range entries { name := strings.ToLower(entry.name) allowed := name == "scripts" || name == "fonts" || name == "script-opts" || name == "scripts/uosc.lua" || name == "script-opts/uosc.conf" || name == "fonts/"+constants.UOSCIconsFontFile || name == "fonts/"+constants.UOSCTexturesFontFile || name == "scripts/uosc" || name == "fonts/uosc" || strings.HasPrefix(name, "scripts/uosc/") || strings.HasPrefix(name, "fonts/uosc/") if !allowed { return fmt.Errorf("uOSC archive contains unexpected path %q", entry.name) } if (name == "scripts/uosc.lua" || name == "scripts/uosc/main.lua") && !entry.isDir { hasScript = true } } if !hasScript { return errors.New("uOSC archive does not contain scripts/uosc/main.lua or scripts/uosc.lua") } return nil } // ExtractArchive extracts an archive without progress output. func (i *Installer) ExtractArchive(src, dest string) error { return i.extractArchive(context.Background(), src, dest, nil) } func (i *Installer) extractArchive(ctx context.Context, src, dest string, policy archiveInventoryPolicy) error { if ctx == nil { ctx = context.Background() } if err := contextError(ctx); err != nil { return err } lower := strings.ToLower(src) switch { case strings.HasSuffix(lower, ".tar.gz"): return extractTarTransactional(ctx, src, dest, "gzip", policy) case strings.HasSuffix(lower, ".tar.xz"): return extractTarTransactional(ctx, src, dest, "xz", policy) case strings.HasSuffix(lower, ".zip"): return extractZipTransactional(ctx, src, dest, policy) case strings.HasSuffix(lower, ".7z") || strings.HasSuffix(lower, ".7zip"): return extractSevenZipWithPolicy(ctx, src, dest, policy) case strings.HasSuffix(lower, ".tar"): return extractTarTransactional(ctx, src, dest, "", policy) case strings.HasSuffix(lower, ".gz"): return extractCompressedFileTransactional(ctx, src, dest, "gzip", policy) case strings.HasSuffix(lower, ".xz"): return extractCompressedFileTransactional(ctx, src, dest, "xz", policy) case strings.HasSuffix(lower, ".dmg"): return errors.New("DMG files must be opened manually") case strings.HasSuffix(lower, ".exe"): return errors.New("EXE files must be executed manually") default: return fmt.Errorf("unsupported archive format: %s", strings.ToLower(filepath.Ext(src))) } } func extractIntoPrivateStage(ctx context.Context, destination string, extract func(string) error) error { return extractIntoPrivateStageMode(ctx, destination, extract, true) } func extractIntoPrivateStageMode(ctx context.Context, destination string, extract func(string) error, durableCommit bool) error { if ctx == nil { ctx = context.Background() } if err := contextError(ctx); err != nil { return err } destination = filepath.Clean(destination) parent := filepath.Dir(destination) if err := os.MkdirAll(parent, constants.DirPermission); err != nil { return fmt.Errorf("create archive staging parent: %w", err) } stage, err := os.MkdirTemp(parent, ".mpv-manager-archive-stage-*") if err != nil { return fmt.Errorf("create private archive staging directory: %w", err) } defer os.RemoveAll(stage) if err := extract(stage); err != nil { return err } if _, err := collectOverlayEntries(ctx, stage, nil); err != nil { return fmt.Errorf("validate extracted archive inventory: %w", err) } if durableCommit { err = transactionalOverlay(ctx, stage, destination, nil, os.Rename) } else { err = transactionalOverlayVolatile(ctx, stage, destination, nil, os.Rename) } if err != nil { return fmt.Errorf("commit extracted archive: %w", err) } return nil } func extractZipTransactional(ctx context.Context, archivePath, destination string, policy archiveInventoryPolicy) error { return extractZipTransactionalMode(ctx, archivePath, destination, policy, true) } func extractZipTransactionalMode(ctx context.Context, archivePath, destination string, policy archiveInventoryPolicy, durableCommit bool) error { reader, err := zip.OpenReader(archivePath) if err != nil { return fmt.Errorf("open ZIP archive: %w", err) } defer reader.Close() inventory, err := validateZipEntries(reader.File) if err != nil { return err } if policy != nil { if err := policy(inventory); err != nil { return err } } return extractIntoPrivateStageMode(ctx, destination, func(stage string) error { root, err := os.OpenRoot(stage) if err != nil { return err } defer root.Close() for index, entry := range inventory { if err := contextError(ctx); err != nil { return err } if entry.isDir { if err := ensureSevenZipDirectory(root, entry.localName); err != nil { return fmt.Errorf("create ZIP directory %q: %w", entry.name, err) } continue } if err := ensureArchiveFileParent(root, entry); err != nil { return err } input, err := reader.File[index].Open() if err != nil { return fmt.Errorf("open ZIP entry %q: %w", entry.name, err) } if err := writeArchiveFile(ctx, root, entry, reader.File[index].Mode().Perm(), input); err != nil { return errors.Join(err, input.Close()) } if err := input.Close(); err != nil { return fmt.Errorf("close ZIP entry %q: %w", entry.name, err) } } return nil }, durableCommit) } func validateZipEntries(files []*zip.File) ([]archiveInventoryEntry, error) { if len(files) > maxSevenZipEntries { return nil, fmt.Errorf("ZIP archive contains too many entries: %d (limit %d)", len(files), maxSevenZipEntries) } inventory := make([]archiveInventoryEntry, 0, len(files)) for _, file := range files { mode := file.Mode() isDir := file.FileInfo().IsDir() if !isDir && !mode.IsRegular() { return nil, fmt.Errorf("ZIP entry %q has unsupported special file type %s", file.Name, mode.Type()) } if strings.HasSuffix(strings.ReplaceAll(file.Name, `\`, "/"), "/") != isDir { return nil, fmt.Errorf("ZIP entry %q has inconsistent directory metadata", file.Name) } if isDir && file.UncompressedSize64 != 0 { return nil, fmt.Errorf("ZIP directory %q contains unexpected data", file.Name) } name, localName, err := portableArchiveName(file.Name, "ZIP") if err != nil { return nil, err } inventory = append(inventory, archiveInventoryEntry{name: name, localName: localName, isDir: isDir, size: file.UncompressedSize64}) } return validateArchiveInventory("ZIP", inventory) } func extractTarTransactional(ctx context.Context, archivePath, destination, compression string, policy archiveInventoryPolicy) error { if ctx == nil { ctx = context.Background() } if err := contextError(ctx); err != nil { return err } var inventory []archiveInventoryEntry err := withTarReader(ctx, archivePath, compression, func(reader *tar.Reader) error { var preflightErr error inventory, preflightErr = preflightTar(ctx, reader) return preflightErr }) if err != nil { return err } if policy != nil { if err := policy(inventory); err != nil { return err } } return extractIntoPrivateStage(ctx, destination, func(stage string) error { root, err := os.OpenRoot(stage) if err != nil { return err } defer root.Close() index := 0 err = withTarReader(ctx, archivePath, compression, func(reader *tar.Reader) error { for { header, nextErr := reader.Next() if errors.Is(nextErr, io.EOF) { break } if nextErr != nil { return fmt.Errorf("read tar archive: %w", nextErr) } if index >= len(inventory) { return errors.New("tar archive changed after preflight") } entry, entryErr := inventoryEntryFromTarHeader(header) if entryErr != nil || entry != inventory[index] { return errors.New("tar archive changed after preflight") } index++ if err := contextError(ctx); err != nil { return err } if entry.isDir { if err := ensureSevenZipDirectory(root, entry.localName); err != nil { return fmt.Errorf("create tar directory %q: %w", entry.name, err) } continue } if err := ensureArchiveFileParent(root, entry); err != nil { return err } if err := writeArchiveFile(ctx, root, entry, fs.FileMode(header.Mode).Perm(), reader); err != nil { return err } } if index != len(inventory) { return errors.New("tar archive changed after preflight") } return nil }) return err }) } func withTarReader(ctx context.Context, archivePath, compression string, use func(*tar.Reader) error) error { file, err := os.Open(archivePath) if err != nil { return fmt.Errorf("open tar archive: %w", err) } defer file.Close() var input io.Reader = contextReader{ctx: ctx, reader: file} var gzipReader *gzip.Reader switch compression { case "gzip": gzipReader, err = gzip.NewReader(input) if err != nil { return fmt.Errorf("open gzip stream: %w", err) } defer gzipReader.Close() input = gzipReader case "xz": input, err = xz.NewReader(input) if err != nil { return fmt.Errorf("open xz stream: %w", err) } case "": default: return fmt.Errorf("unsupported tar compression %q", compression) } return use(tar.NewReader(contextReader{ctx: ctx, reader: input})) } func preflightTar(ctx context.Context, reader *tar.Reader) ([]archiveInventoryEntry, error) { inventory := make([]archiveInventoryEntry, 0) var expanded uint64 for { if err := contextError(ctx); err != nil { return nil, err } header, err := reader.Next() if errors.Is(err, io.EOF) { break } if err != nil { return nil, fmt.Errorf("read tar archive: %w", err) } entry, err := inventoryEntryFromTarHeader(header) if err != nil { return nil, err } // Reject an oversized header before Next skips/decompresses its body. if !entry.isDir { if entry.size > maxSevenZipExpandedSize-expanded { return nil, fmt.Errorf("tar archive expands beyond %d bytes", maxSevenZipExpandedSize) } expanded += entry.size } inventory = append(inventory, entry) if len(inventory) > maxSevenZipEntries { return nil, fmt.Errorf("tar archive contains too many entries: %d (limit %d)", len(inventory), maxSevenZipEntries) } } return validateArchiveInventory("tar", inventory) } func inventoryEntryFromTarHeader(header *tar.Header) (archiveInventoryEntry, error) { isDir := header.Typeflag == tar.TypeDir // A zero typeflag is the historical tar representation of a regular file. const legacyRegularType byte = 0 if !isDir && header.Typeflag != tar.TypeReg && header.Typeflag != legacyRegularType { return archiveInventoryEntry{}, fmt.Errorf("tar entry %q has unsupported type %d", header.Name, header.Typeflag) } if header.Size < 0 { return archiveInventoryEntry{}, fmt.Errorf("tar entry %q has a negative size", header.Name) } if strings.HasSuffix(strings.ReplaceAll(header.Name, `\`, "/"), "/") != isDir { return archiveInventoryEntry{}, fmt.Errorf("tar entry %q has inconsistent directory metadata", header.Name) } if isDir && header.Size != 0 { return archiveInventoryEntry{}, fmt.Errorf("tar directory %q contains unexpected data", header.Name) } name, localName, err := portableArchiveName(header.Name, "tar") if err != nil { return archiveInventoryEntry{}, err } return archiveInventoryEntry{name: name, localName: localName, isDir: isDir, size: uint64(header.Size)}, nil } func validateArchiveInventory(format string, inventory []archiveInventoryEntry) ([]archiveInventoryEntry, error) { type seenEntry struct { name string isDir bool } seen := make(map[string]seenEntry, len(inventory)) var expanded uint64 for _, entry := range inventory { key := strings.ToLower(entry.name) if previous, ok := seen[key]; ok { return nil, fmt.Errorf("%s entries %q and %q collide on Windows", format, previous.name, entry.name) } seen[key] = seenEntry{name: entry.name, isDir: entry.isDir} if !entry.isDir { if entry.size > maxSevenZipExpandedSize-expanded { return nil, fmt.Errorf("%s archive expands beyond %d bytes", format, maxSevenZipExpandedSize) } expanded += entry.size } } for _, entry := range inventory { for parent := path.Dir(entry.name); parent != "."; parent = path.Dir(parent) { if existing, ok := seen[strings.ToLower(parent)]; ok && !existing.isDir { return nil, fmt.Errorf("%s entry %q uses file %q as a directory", format, entry.name, existing.name) } } } return inventory, nil } func ensureArchiveFileParent(root *os.Root, entry archiveInventoryEntry) error { parent := filepath.Dir(entry.localName) if parent == "." { return nil } if err := ensureSevenZipDirectory(root, parent); err != nil { return fmt.Errorf("create parent for archive entry %q: %w", entry.name, err) } return nil } func writeArchiveFile(ctx context.Context, root *os.Root, entry archiveInventoryEntry, mode fs.FileMode, input io.Reader) (err error) { if mode == 0 { mode = constants.FilePermission } output, err := root.OpenFile(entry.localName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { return fmt.Errorf("create archive entry %q: %w", entry.name, err) } defer func() { err = errors.Join(err, output.Close()) if err != nil { _ = root.Remove(entry.localName) } }() written, err := copyWithContext(ctx, output, io.LimitReader(input, int64(entry.size)+1)) if err != nil { return fmt.Errorf("extract archive entry %q: %w", entry.name, err) } if written != int64(entry.size) { return fmt.Errorf("archive entry %q size mismatch: expected %d bytes, wrote %d", entry.name, entry.size, written) } if err := output.Sync(); err != nil { return fmt.Errorf("sync archive entry %q: %w", entry.name, err) } return nil } func extractCompressedFileTransactional(ctx context.Context, source, destination, compression string, policy archiveInventoryPolicy) error { base := filepath.Base(source) base = strings.TrimSuffix(base, filepath.Ext(base)) name, localName, err := portableArchiveName(base, compression) if err != nil { return err } inventory := []archiveInventoryEntry{{name: name, localName: localName}} return extractIntoPrivateStage(ctx, destination, func(stage string) error { inputFile, err := os.Open(source) if err != nil { return fmt.Errorf("open %s stream: %w", compression, err) } defer inputFile.Close() var input io.Reader = inputFile var gzipReader *gzip.Reader switch compression { case "gzip": gzipReader, err = gzip.NewReader(inputFile) if err != nil { return fmt.Errorf("open gzip stream: %w", err) } defer gzipReader.Close() input = gzipReader case "xz": input, err = xz.NewReader(inputFile) if err != nil { return fmt.Errorf("open xz stream: %w", err) } default: return fmt.Errorf("unsupported compression %q", compression) } root, err := os.OpenRoot(stage) if err != nil { return err } defer root.Close() output, err := root.OpenFile(localName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755) if err != nil { return err } written, copyErr := copyWithContext(ctx, output, io.LimitReader(input, int64(maxSevenZipExpandedSize)+1)) if copyErr == nil && written > int64(maxSevenZipExpandedSize) { copyErr = fmt.Errorf("%s stream expands beyond %d bytes", compression, maxSevenZipExpandedSize) } copyErr = errors.Join(copyErr, output.Sync(), output.Close()) if copyErr != nil { return copyErr } inventory[0].size = uint64(written) if policy != nil { return policy(inventory) } return nil }) }