package installer import ( "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "sort" "strings" "lukechampine.com/blake3" ) type overlayEntry struct { rel string path string mode fs.FileMode isDir bool } type overlayChange struct { target string backup string hadOriginal bool installed bool createdDir bool } // transactionalOverlay moves the files under staging into destination. Files // already present at the same paths are first moved to a sibling backup area. // If any move fails or ctx is cancelled, the operation removes files it added // and restores every path it backed up. Unrelated destination files survive. // // Names in preservedTopLevel are compared case-insensitively and their entire // subtrees are excluded from the overlay. This lets the Windows installer // protect portable_config and manager-owned files even if an upstream archive // unexpectedly contains paths with those names. func transactionalOverlay( ctx context.Context, staging, destination string, preservedTopLevel []string, rename renameFileFunc, previouslyOwned ...[]string, ) error { if ctx == nil { ctx = context.Background() } if rename == nil { rename = os.Rename } entries, err := collectOverlayEntries(ctx, staging, preservedTopLevel) if err != nil { return err } if err := contextError(ctx); err != nil { return err } parent := filepath.Dir(destination) if err := os.MkdirAll(parent, 0755); err != nil { return fmt.Errorf("create destination parent: %w", err) } if err := RecoverInstallerTransactions(destination); err != nil { return fmt.Errorf("recover interrupted installer transaction: %w", err) } backupRoot, err := os.MkdirTemp(parent, ".mpv-manager-overlay-backup-") if err != nil { return fmt.Errorf("create overlay backup: %w", err) } transaction, err := beginInstallerTransaction(installerTransactionOverlay, destination, backupRoot, nil, nil) if err != nil { _ = os.RemoveAll(backupRoot) return err } defer transaction.retain() changes := make([]overlayChange, 0, len(entries)+1) fail := func(commitErr error) error { rollbackErr := rollbackOverlay(changes, rename) if rollbackErr != nil { return errors.Join(commitErr, fmt.Errorf("restore previous installation (backup retained at %s): %w", backupRoot, rollbackErr)) } return errors.Join(commitErr, transaction.rolledBack()) } if err := prepareOverlayDestination(destination, &changes, transaction); err != nil { return fail(err) } for _, entry := range entries { if err := contextError(ctx); err != nil { return fail(err) } target := filepath.Join(destination, entry.rel) backup := filepath.Join(backupRoot, entry.rel) change, err := applyOverlayEntry(ctx, entry, target, backup, rename, transaction) if change != nil { changes = append(changes, *change) } if err != nil { return fail(err) } } if len(previouslyOwned) != 0 { if err := preserveUnownedOverlayFiles(ctx, destination, backupRoot, previouslyOwned[0], &changes, rename, transaction); err != nil { return fail(err) } } if err := transaction.commit(); err != nil { if transaction.journal.State == "applying" { return fail(err) } return err } return nil } // Copy unclaimed originals from the transaction's exact rollback snapshot. // Add those copies through the same journal, so rollback also removes the new // preservation tree. Successful commit leaves it outside the ownership list. func preserveUnownedOverlayFiles(ctx context.Context, destination, backupRoot string, owned []string, changes *[]overlayChange, rename renameFileFunc, transaction *installerTransaction) error { known := make(map[string]bool, len(owned)) for _, rel := range owned { known[strings.ToLower(filepath.ToSlash(rel))] = true } var originals []overlayChange for _, change := range *changes { if !change.hadOriginal { continue } rel, err := filepath.Rel(destination, change.target) if err != nil { return err } if !known[strings.ToLower(filepath.ToSlash(rel))] { originals = append(originals, change) } } if len(originals) == 0 { return nil } stage, err := os.MkdirTemp(filepath.Dir(destination), ".mpv-manager-preservation-stage-") if err != nil { return err } defer os.RemoveAll(stage) name := windowsPreservedCollisionPrefix + strings.TrimPrefix(filepath.Base(backupRoot), ".mpv-manager-overlay-backup-") targetRoot := filepath.Join(destination, name) if _, err := os.Lstat(targetRoot); !os.IsNotExist(err) { return fmt.Errorf("preservation destination already exists or cannot be inspected: %s", targetRoot) } for _, change := range originals { if err := contextError(ctx); err != nil { return err } rel, err := filepath.Rel(destination, change.target) if err != nil { return err } if err := copyInstallerPath(OSFileSystem{}, change.backup, filepath.Join(stage, name, rel), false); err != nil { return fmt.Errorf("preserve unowned original %s: %w", rel, err) } } entries, err := collectOverlayEntries(ctx, stage, nil) if err != nil { return err } for _, entry := range entries { change, err := applyOverlayEntry(ctx, entry, filepath.Join(destination, entry.rel), filepath.Join(backupRoot, entry.rel), rename, transaction) if change != nil { *changes = append(*changes, *change) } if err != nil { return err } } return nil } // transactionalOverlayVolatile is used only inside a broader durable UI // transaction that already owns the destination-parent lock and rollback // journal. It retains the same in-process all-or-nothing behavior without // attempting to acquire a nested cross-process lock. func transactionalOverlayVolatile( ctx context.Context, staging, destination string, preservedTopLevel []string, rename renameFileFunc, ) error { if ctx == nil { ctx = context.Background() } if rename == nil { rename = os.Rename } entries, err := collectOverlayEntries(ctx, staging, preservedTopLevel) if err != nil { return err } parent := filepath.Dir(destination) if err := os.MkdirAll(parent, 0755); err != nil { return fmt.Errorf("create destination parent: %w", err) } backupRoot, err := os.MkdirTemp(parent, ".mpv-manager-overlay-backup-") if err != nil { return fmt.Errorf("create nested overlay backup: %w", err) } removeBackup := true defer func() { if removeBackup { _ = os.RemoveAll(backupRoot) } }() changes := make([]overlayChange, 0, len(entries)+1) if _, err := os.Lstat(destination); os.IsNotExist(err) { changes = append(changes, overlayChange{target: destination, installed: true, createdDir: true}) if err := os.Mkdir(destination, 0755); err != nil { return err } } else if err != nil { return err } fail := func(commitErr error) error { if rollbackErr := rollbackOverlay(changes, rename); rollbackErr != nil { removeBackup = false return errors.Join(commitErr, fmt.Errorf("restore nested overlay (backup retained at %s): %w", backupRoot, rollbackErr)) } return commitErr } for _, entry := range entries { if err := contextError(ctx); err != nil { return fail(err) } target := filepath.Join(destination, entry.rel) backup := filepath.Join(backupRoot, entry.rel) change, err := applyOverlayEntryVolatile(ctx, entry, target, backup, rename) if change != nil { changes = append(changes, *change) } if err != nil { return fail(err) } } return nil } func applyOverlayEntryVolatile( ctx context.Context, entry overlayEntry, target, backup string, rename renameFileFunc, ) (*overlayChange, error) { change := &overlayChange{target: target, backup: backup, createdDir: entry.isDir} targetInfo, err := os.Lstat(target) if err != nil && !os.IsNotExist(err) { return nil, err } if err == nil { if targetInfo.Mode()&os.ModeSymlink != 0 || (!targetInfo.IsDir() && !targetInfo.Mode().IsRegular()) { return nil, fmt.Errorf("overlay target %q has unsupported type %s", entry.rel, targetInfo.Mode().Type()) } if entry.isDir && targetInfo.IsDir() { return nil, nil } if err := os.MkdirAll(filepath.Dir(backup), 0755); err != nil { return nil, err } if err := rename(target, backup); err != nil { return nil, err } change.hadOriginal = true } if err := contextError(ctx); err != nil { return change, err } if entry.isDir { if err := os.Mkdir(target, entry.mode.Perm()); err != nil { return change, err } change.installed = true return change, nil } if err := rename(entry.path, target); err != nil { return change, err } change.installed = true return change, nil } func collectOverlayEntries(ctx context.Context, staging string, preservedTopLevel []string) ([]overlayEntry, error) { info, err := os.Lstat(staging) if err != nil { return nil, fmt.Errorf("inspect staging directory: %w", err) } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, fmt.Errorf("staging path is not a real directory: %s", staging) } entries := make([]overlayEntry, 0) err = filepath.WalkDir(staging, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if err := contextError(ctx); err != nil { return err } if path == staging { return nil } rel, err := filepath.Rel(staging, path) if err != nil || !filepath.IsLocal(rel) { return fmt.Errorf("invalid staged path %q", path) } if isPreservedOverlayPath(rel, preservedTopLevel) { if entry.IsDir() { return filepath.SkipDir } return nil } info, err := entry.Info() if err != nil { return fmt.Errorf("inspect staged path %q: %w", rel, err) } if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) { return fmt.Errorf("staged path %q has unsupported type %s", rel, info.Mode().Type()) } entries = append(entries, overlayEntry{ rel: rel, path: path, mode: info.Mode(), isDir: info.IsDir(), }) return nil }) if err != nil { return nil, fmt.Errorf("validate staged overlay: %w", err) } // Directories must exist before their children are moved. A stable lexical // order makes commit and fault-injection behavior deterministic. sort.Slice(entries, func(i, j int) bool { if entries[i].isDir != entries[j].isDir { return entries[i].isDir } if entries[i].isDir { leftDepth := strings.Count(filepath.Clean(entries[i].rel), string(filepath.Separator)) rightDepth := strings.Count(filepath.Clean(entries[j].rel), string(filepath.Separator)) if leftDepth != rightDepth { return leftDepth < rightDepth } } return entries[i].rel < entries[j].rel }) return entries, nil } func isPreservedOverlayPath(rel string, preservedTopLevel []string) bool { top := rel if separator := strings.IndexRune(rel, filepath.Separator); separator >= 0 { top = rel[:separator] } for _, preserved := range preservedTopLevel { if strings.EqualFold(top, preserved) { return true } } return false } func prepareOverlayDestination(destination string, changes *[]overlayChange, transaction *installerTransaction) error { info, err := os.Lstat(destination) switch { case err == nil: if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("overlay destination is not a real directory: %s", destination) } return nil case !os.IsNotExist(err): return fmt.Errorf("inspect overlay destination: %w", err) } change := overlayChange{target: destination, createdDir: true} if err := transaction.record(installerJournalChange{Target: destination}); err != nil { return err } if err := os.Mkdir(destination, 0755); err != nil { return fmt.Errorf("create overlay destination: %w", err) } change.installed = true *changes = append(*changes, change) return nil } func applyOverlayEntry( ctx context.Context, entry overlayEntry, target, backup string, rename renameFileFunc, transaction *installerTransaction, ) (*overlayChange, error) { change := &overlayChange{target: target, backup: backup, createdDir: entry.isDir} targetInfo, err := os.Lstat(target) if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("inspect overlay target %q: %w", entry.rel, err) } if err == nil { if targetInfo.Mode()&os.ModeSymlink != 0 || (!targetInfo.IsDir() && !targetInfo.Mode().IsRegular()) { return nil, fmt.Errorf("overlay target %q has unsupported type %s", entry.rel, targetInfo.Mode().Type()) } if entry.isDir && targetInfo.IsDir() { return nil, nil } change.hadOriginal = true } if err := transaction.record(installerJournalChange{Target: target, Backup: backup, HadOriginal: change.hadOriginal}); err != nil { return nil, err } if err == nil { if err := os.MkdirAll(filepath.Dir(backup), 0755); err != nil { return nil, fmt.Errorf("prepare backup for %q: %w", entry.rel, err) } if err := rename(target, backup); err != nil { return nil, fmt.Errorf("back up %q: %w", entry.rel, err) } } if err := contextError(ctx); err != nil { return change, err } if entry.isDir { if err := os.Mkdir(target, entry.mode.Perm()); err != nil { return change, fmt.Errorf("create overlay directory %q: %w", entry.rel, err) } change.installed = true return change, nil } if err := rename(entry.path, target); err != nil { return change, fmt.Errorf("install overlay file %q: %w", entry.rel, err) } change.installed = true return change, nil } func rollbackOverlay(changes []overlayChange, rename renameFileFunc) error { var rollbackErr error for index := len(changes) - 1; index >= 0; index-- { change := changes[index] if change.installed { if err := os.Remove(change.target); err != nil && !os.IsNotExist(err) { rollbackErr = errors.Join(rollbackErr, fmt.Errorf("remove new path %q: %w", change.target, err)) continue } } if change.hadOriginal { if err := rename(change.backup, change.target); err != nil { rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore %q: %w", change.target, err)) } } } return rollbackErr } // transactionalPathReplace atomically swaps a staged path into livePath using // a sibling backup. If the second rename fails or cancellation arrives after // the backup move, the previous live path is restored. func transactionalPathReplace(ctx context.Context, stagedPath, livePath string, rename renameFileFunc) error { if ctx == nil { ctx = context.Background() } if rename == nil { rename = os.Rename } if err := contextError(ctx); err != nil { return err } parent := filepath.Dir(livePath) if err := RecoverInstallerTransactions(livePath); err != nil { return fmt.Errorf("recover interrupted installer transaction: %w", err) } backupRoot, err := os.MkdirTemp(parent, ".mpv-manager-swap-backup-") if err != nil { return fmt.Errorf("create replacement backup: %w", err) } backupPath := filepath.Join(backupRoot, filepath.Base(livePath)) liveInfo, statErr := os.Lstat(livePath) hadLive := statErr == nil if statErr != nil && !os.IsNotExist(statErr) { _ = os.RemoveAll(backupRoot) return fmt.Errorf("inspect current installation: %w", statErr) } if hadLive { if !liveInfo.IsDir() || liveInfo.Mode()&os.ModeSymlink != 0 { _ = os.RemoveAll(backupRoot) return fmt.Errorf("current installation is not a real directory: %s", livePath) } } transaction, err := beginInstallerTransaction(installerTransactionSwap, livePath, backupRoot, nil, nil) if err != nil { _ = os.RemoveAll(backupRoot) return err } defer transaction.retain() if err := transaction.record(installerJournalChange{Target: livePath, Backup: backupPath, HadOriginal: hadLive}); err != nil { return errors.Join(err, transaction.rolledBack()) } if hadLive { if err := rename(livePath, backupPath); err != nil { return errors.Join(fmt.Errorf("back up current installation: %w", err), transaction.rolledBack()) } } restore := func(commitErr error) error { if err := os.RemoveAll(livePath); err != nil && !os.IsNotExist(err) { return errors.Join(commitErr, fmt.Errorf("remove failed installation (backup retained at %s): %w", backupRoot, err)) } if hadLive { if err := rename(backupPath, livePath); err != nil { return errors.Join(commitErr, fmt.Errorf("restore current installation (backup retained at %s): %w", backupRoot, err)) } } return errors.Join(commitErr, transaction.rolledBack()) } if err := contextError(ctx); err != nil { return restore(err) } if err := rename(stagedPath, livePath); err != nil { return restore(fmt.Errorf("activate staged installation: %w", err)) } if err := transaction.commit(); err != nil { if transaction.journal.State == "applying" { return restore(err) } return err } return nil } // transactionalRegularFileReplace swaps a validated sibling staging file into // livePath while retaining the previous file until validation and postActivate // both succeed. If restoration fails, the backup directory is deliberately // retained and named in the returned error. func transactionalRegularFileReplace( ctx context.Context, stagedPath, livePath string, validate func(string) error, postActivate func() error, rename renameFileFunc, ) error { if ctx == nil { ctx = context.Background() } if rename == nil { rename = os.Rename } if validate == nil { validate = func(string) error { return nil } } if filepath.Clean(filepath.Dir(stagedPath)) != filepath.Clean(filepath.Dir(livePath)) { return fmt.Errorf("staged replacement must be beside the live file") } if err := validate(stagedPath); err != nil { return fmt.Errorf("validate staged replacement: %w", err) } if err := contextError(ctx); err != nil { return err } parent := filepath.Dir(livePath) if err := RecoverInstallerTransactions(livePath); err != nil { return fmt.Errorf("recover interrupted installer transaction: %w", err) } backupRoot, err := os.MkdirTemp(parent, ".mpv-manager-file-backup-") if err != nil { return fmt.Errorf("create replacement backup: %w", err) } backupPath := filepath.Join(backupRoot, filepath.Base(livePath)) liveInfo, statErr := os.Lstat(livePath) hadLive := statErr == nil if statErr != nil && !os.IsNotExist(statErr) { _ = os.RemoveAll(backupRoot) return fmt.Errorf("inspect current file: %w", statErr) } if hadLive { if !liveInfo.Mode().IsRegular() || liveInfo.Mode()&os.ModeSymlink != 0 { _ = os.RemoveAll(backupRoot) return fmt.Errorf("current installation is not a regular file: %s", livePath) } } transaction, err := beginInstallerTransaction(installerTransactionFile, livePath, backupRoot, nil, nil) if err != nil { _ = os.RemoveAll(backupRoot) return err } defer transaction.retain() if err := transaction.record(installerJournalChange{Target: livePath, Backup: backupPath, HadOriginal: hadLive}); err != nil { return errors.Join(err, transaction.rolledBack()) } if hadLive { if err := rename(livePath, backupPath); err != nil { return errors.Join(fmt.Errorf("back up current file: %w", err), transaction.rolledBack()) } } activated := false restore := func(commitErr error) error { if activated { if err := os.Remove(livePath); err != nil && !os.IsNotExist(err) { return errors.Join(commitErr, fmt.Errorf("remove failed replacement (backup retained at %s): %w", backupRoot, err)) } } if hadLive { if err := rename(backupPath, livePath); err != nil { return errors.Join(commitErr, fmt.Errorf("restore current file (backup retained at %s): %w", backupRoot, err)) } } return errors.Join(commitErr, transaction.rolledBack()) } if err := contextError(ctx); err != nil { return restore(err) } if err := rename(stagedPath, livePath); err != nil { return restore(fmt.Errorf("activate staged file: %w", err)) } activated = true if err := validate(livePath); err != nil { return restore(fmt.Errorf("validate activated file: %w", err)) } if err := contextError(ctx); err != nil { return restore(err) } if postActivate != nil { if err := postActivate(); err != nil { return restore(fmt.Errorf("commit replacement metadata: %w", err)) } } if err := transaction.commit(); err != nil { if transaction.journal.State == "applying" { return restore(err) } return err } return nil } // stageRegularFileBeside copies source to a unique file beside livePath, // fsyncs it, and verifies the staged bytes against a streaming BLAKE3 digest // of the source. The caller owns the returned path. func stageRegularFileBeside(ctx context.Context, source, livePath string, mode fs.FileMode) (string, error) { if ctx == nil { ctx = context.Background() } if err := validateNonEmptyRegularFile(source, false); err != nil { return "", fmt.Errorf("validate replacement source: %w", err) } if err := os.MkdirAll(filepath.Dir(livePath), 0755); err != nil { return "", fmt.Errorf("create replacement directory: %w", err) } sourceFile, err := os.Open(source) if err != nil { return "", fmt.Errorf("open replacement source: %w", err) } defer sourceFile.Close() stage, err := os.CreateTemp(filepath.Dir(livePath), "."+filepath.Base(livePath)+".stage-*") if err != nil { return "", fmt.Errorf("create sibling staging file: %w", err) } stagePath := stage.Name() removeStage := true defer func() { _ = stage.Close() if removeStage { _ = os.Remove(stagePath) } }() sourceHash := blake3.New(32, nil) buffer := make([]byte, 128*1024) for { if err := contextError(ctx); err != nil { return "", err } count, readErr := sourceFile.Read(buffer) if count > 0 { if _, err := stage.Write(buffer[:count]); err != nil { return "", fmt.Errorf("write sibling staging file: %w", err) } if _, err := sourceHash.Write(buffer[:count]); err != nil { return "", fmt.Errorf("hash replacement source: %w", err) } } if readErr == io.EOF { break } if readErr != nil { return "", fmt.Errorf("read replacement source: %w", readErr) } } if err := stage.Chmod(mode); err != nil { return "", fmt.Errorf("set sibling staging permissions: %w", err) } if err := stage.Sync(); err != nil { return "", fmt.Errorf("sync sibling staging file: %w", err) } if err := stage.Close(); err != nil { return "", fmt.Errorf("close sibling staging file: %w", err) } if err := validateNonEmptyRegularFile(stagePath, false); err != nil { return "", fmt.Errorf("validate sibling staging file: %w", err) } stagedFile, err := os.Open(stagePath) if err != nil { return "", fmt.Errorf("reopen sibling staging file: %w", err) } stagedHash := blake3.New(32, nil) _, copyErr := io.Copy(stagedHash, stagedFile) closeErr := stagedFile.Close() if copyErr != nil { return "", fmt.Errorf("hash sibling staging file: %w", copyErr) } if closeErr != nil { return "", fmt.Errorf("close sibling staging verification file: %w", closeErr) } if !strings.EqualFold(fmt.Sprintf("%x", sourceHash.Sum(nil)), fmt.Sprintf("%x", stagedHash.Sum(nil))) { return "", fmt.Errorf("sibling staging verification failed: content digest mismatch") } removeStage = false return stagePath, nil } func contextError(ctx context.Context) error { if err := ctx.Err(); err != nil { return fmt.Errorf("installation transaction cancelled: %w", err) } return nil }