package installer import ( "context" "encoding/hex" "errors" "fmt" "hash/crc32" "io" "io/fs" "os" "path" "path/filepath" "strings" "gitgud.io/mike/mpv-manager/pkg/constants" "github.com/bodgit/sevenzip" ) const ( // The upstream MPV and FFmpeg archives contain far fewer files and expand // to well under these bounds. The limits keep a malformed archive from // consuming unbounded filesystem space or parser work. maxSevenZipEntries = 4096 maxSevenZipExpandedSize = uint64(2 << 30) // 2 GiB ) type sevenZipEntry struct { file *sevenzip.File name string // slash-separated, validated archive path localName string // native path below the extraction root isDir bool } // requireSevenZipHash ensures downloaded 7z payloads never reach the parser // without a concrete, syntactically valid BLAKE3 digest from the manifest. func requireSevenZipHash(expectedHash, archiveName string) error { const prefix = "blake3:" expectedHash = strings.TrimSpace(expectedHash) if expectedHash == "" || expectedHash == constants.Blake3Placeholder { return fmt.Errorf("refusing to download %s without a BLAKE3 checksum", archiveName) } if !strings.HasPrefix(expectedHash, prefix) || len(expectedHash) != len(prefix)+64 { return fmt.Errorf("invalid BLAKE3 checksum for %s", archiveName) } if _, err := hex.DecodeString(strings.TrimPrefix(expectedHash, prefix)); err != nil { return fmt.Errorf("invalid BLAKE3 checksum for %s: %w", archiveName, err) } return nil } // extractSevenZip extracts a 7z archive without invoking an external binary. // Every archive path is validated before any destination files are created. func extractSevenZip(ctx context.Context, archivePath, destination string) (err error) { return extractSevenZipWithPolicy(ctx, archivePath, destination, nil) } func extractSevenZipWithPolicy(ctx context.Context, archivePath, destination string, policy archiveInventoryPolicy) error { return extractIntoPrivateStage(ctx, destination, func(stage string) error { return extractSevenZipRaw(ctx, archivePath, stage, policy) }) } func extractSevenZipRaw(ctx context.Context, archivePath, destination string, policy archiveInventoryPolicy) (err error) { if ctx == nil { ctx = context.Background() } if err := ctx.Err(); err != nil { return fmt.Errorf("7z extraction cancelled: %w", err) } reader, err := sevenzip.OpenReader(archivePath) if err != nil { return fmt.Errorf("open 7z archive: %w", err) } defer func() { err = errors.Join(err, reader.Close()) }() entries, err := validateSevenZipEntries(reader.File) if err != nil { return err } if policy != nil { inventory := make([]archiveInventoryEntry, 0, len(entries)) for _, entry := range entries { inventory = append(inventory, archiveInventoryEntry{ name: entry.name, localName: entry.localName, isDir: entry.isDir, size: entry.file.UncompressedSize, }) } if err := policy(inventory); err != nil { return err } } if err := os.MkdirAll(destination, constants.DirPermission); err != nil { return fmt.Errorf("create 7z extraction directory: %w", err) } root, err := os.OpenRoot(destination) if err != nil { return fmt.Errorf("open 7z extraction root: %w", err) } defer func() { err = errors.Join(err, root.Close()) }() for _, entry := range entries { if err := ctx.Err(); err != nil { return fmt.Errorf("7z extraction cancelled: %w", err) } if entry.isDir { if err := ensureSevenZipDirectory(root, entry.localName); err != nil { return fmt.Errorf("create 7z directory %q: %w", entry.name, err) } continue } parent := filepath.Dir(entry.localName) if parent != "." { if err := ensureSevenZipDirectory(root, parent); err != nil { return fmt.Errorf("create parent for 7z entry %q: %w", entry.name, err) } } if err := extractSevenZipFile(ctx, root, entry); err != nil { return err } } return nil } // validateSevenZipEntries performs a complete preflight so unsafe or // resource-exhausting archives fail before anything is written. func validateSevenZipEntries(files []*sevenzip.File) ([]sevenZipEntry, error) { if len(files) > maxSevenZipEntries { return nil, fmt.Errorf("7z archive contains too many entries: %d (limit %d)", len(files), maxSevenZipEntries) } entries := make([]sevenZipEntry, 0, len(files)) type seenEntry struct { name string isDir bool } seen := make(map[string]seenEntry, len(files)) var expandedSize uint64 for _, file := range files { if file == nil { return nil, errors.New("7z archive contains a nil entry") } mode := file.Mode() if !mode.IsRegular() && !mode.IsDir() { return nil, fmt.Errorf("7z entry %q has unsupported special file type %s", file.Name, mode.Type()) } name, localName, err := portableSevenZipName(file.Name) if err != nil { return nil, err } isDir := mode.IsDir() if strings.HasSuffix(strings.ReplaceAll(file.Name, `\`, "/"), "/") != isDir { return nil, fmt.Errorf("7z entry %q has inconsistent directory metadata", file.Name) } // MPV archives are consumed on Windows. Always reject case-only // collisions, even when validating on a case-sensitive build host. key := strings.ToLower(name) if previous, ok := seen[key]; ok { return nil, fmt.Errorf("7z entries %q and %q collide on Windows", previous.name, name) } seen[key] = seenEntry{name: name, isDir: isDir} if !isDir { if file.UncompressedSize > maxSevenZipExpandedSize-expandedSize { return nil, fmt.Errorf("7z archive expands beyond %d bytes", maxSevenZipExpandedSize) } expandedSize += file.UncompressedSize } entries = append(entries, sevenZipEntry{ file: file, name: name, localName: localName, isDir: isDir, }) } // Detect a regular file that is also needed as an implicit parent // directory, including case-only forms of the same path. for _, entry := range entries { 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("7z entry %q uses file %q as a directory", entry.name, existing.name) } } } return entries, nil } // portableSevenZipName converts a 7z name to a local path while rejecting // traversal and names that are unsafe or ambiguous on Windows. func portableSevenZipName(rawName string) (string, string, error) { return portableArchiveName(rawName, "7z") } func portableArchiveName(rawName, format string) (string, string, error) { name := strings.ReplaceAll(rawName, `\`, "/") name = strings.TrimSuffix(name, "/") if name == "" || name == "." || !fs.ValidPath(name) { return "", "", fmt.Errorf("unsafe path in %s archive: %q", format, rawName) } for _, component := range strings.Split(name, "/") { if !portableSevenZipComponent(component) { return "", "", fmt.Errorf("non-portable path in %s archive: %q", format, rawName) } } localName, err := filepath.Localize(name) if err != nil || !filepath.IsLocal(localName) { return "", "", fmt.Errorf("unsafe path in %s archive: %q", format, rawName) } return name, localName, nil } func portableSevenZipComponent(component string) bool { if component == "" || strings.TrimRight(component, " .") != component { return false } for _, char := range component { if char < 0x20 || strings.ContainsRune(`<>:"|?*`, char) { return false } } base := component if dot := strings.IndexByte(base, '.'); dot >= 0 { base = base[:dot] } base = strings.ToUpper(base) switch base { case "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$": return false } if strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT") { suffix := base[3:] switch suffix { case "1", "2", "3", "4", "5", "6", "7", "8", "9", "¹", "²", "³": return false } } return true } // ensureSevenZipDirectory creates each directory component through os.Root // and refuses to traverse pre-existing symlinks or non-directories. func ensureSevenZipDirectory(root *os.Root, localName string) error { current := "" for _, component := range strings.Split(filepath.Clean(localName), string(filepath.Separator)) { if current == "" { current = component } else { current = filepath.Join(current, component) } info, err := root.Lstat(current) switch { case err == nil: if !info.IsDir() || info.Mode()&fs.ModeSymlink != 0 { return fmt.Errorf("path component %q is not a safe directory", current) } case errors.Is(err, fs.ErrNotExist): if err := root.Mkdir(current, constants.DirPermission); err != nil { if !errors.Is(err, fs.ErrExist) { return err } // A concurrent creator won the race. Re-check instead of // assuming the new object is a directory. info, statErr := root.Lstat(current) if statErr != nil { return statErr } if !info.IsDir() || info.Mode()&fs.ModeSymlink != 0 { return fmt.Errorf("path component %q is not a safe directory", current) } } default: return err } } return nil } func extractSevenZipFile(ctx context.Context, root *os.Root, entry sevenZipEntry) (err error) { input, err := entry.file.Open() if err != nil { return fmt.Errorf("open 7z entry %q: %w", entry.name, err) } if info, statErr := root.Lstat(entry.localName); statErr == nil { if !info.Mode().IsRegular() { return errors.Join(fmt.Errorf("refusing to replace non-regular destination %q", entry.name), input.Close()) } if err := root.Remove(entry.localName); err != nil { return errors.Join(fmt.Errorf("remove previous destination %q: %w", entry.name, err), input.Close()) } } else if !errors.Is(statErr, fs.ErrNotExist) { return errors.Join(fmt.Errorf("inspect destination %q: %w", entry.name, statErr), input.Close()) } permissions := entry.file.Mode().Perm() if permissions == 0 { permissions = constants.FilePermission } output, err := root.OpenFile(entry.localName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, permissions) if err != nil { return errors.Join(fmt.Errorf("create destination for 7z entry %q: %w", entry.name, err), input.Close()) } defer func() { err = errors.Join(err, output.Close(), input.Close()) if err != nil { _ = root.Remove(entry.localName) } }() checksum := crc32.NewIEEE() limited := io.LimitReader(input, int64(entry.file.UncompressedSize)+1) written, err := copyWithContext(ctx, io.MultiWriter(output, checksum), limited) if err != nil { return fmt.Errorf("extract 7z entry %q: %w", entry.name, err) } if written != int64(entry.file.UncompressedSize) { return fmt.Errorf("7z entry %q size mismatch: expected %d bytes, wrote %d", entry.name, entry.file.UncompressedSize, written) } if entry.file.CRC32 != 0 && checksum.Sum32() != entry.file.CRC32 { return fmt.Errorf("7z entry %q CRC mismatch", entry.name) } return nil }