package installer import ( "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "runtime" "sort" "strings" "gitgud.io/mike/mpv-manager/internal/recoverytrust" "gitgud.io/mike/mpv-manager/pkg/constants" ) const ( installerTransactionSchema = 2 installerJournalSuffix = ".journal.json" ) type installerTransactionKind string const ( installerTransactionOverlay installerTransactionKind = "overlay" installerTransactionSwap installerTransactionKind = "path-swap" installerTransactionFile installerTransactionKind = "file-swap" installerTransactionUI installerTransactionKind = "ui-replacement" ) type installerJournalChange struct { Target string `json:"target"` Backup string `json:"backup,omitempty"` HadOriginal bool `json:"had_original"` } type installerTransactionJournal struct { Authentication string `json:"authentication"` UIVersions []uiVersionChange `json:"ui_versions,omitempty"` Schema int `json:"schema"` Kind installerTransactionKind `json:"kind"` State string `json:"state"` Destination string `json:"destination"` BackupRoot string `json:"backup_root"` Changes []installerJournalChange `json:"changes,omitempty"` ManagedPaths []string `json:"managed_paths,omitempty"` BackedUp []string `json:"backed_up,omitempty"` } type installerTransaction struct { journal installerTransactionJournal journalPath string lock *os.File resolved bool authenticationKey []byte } func beginInstallerTransaction( kind installerTransactionKind, destination, backupRoot string, managedPaths, backedUp []string, ) (*installerTransaction, error) { absDestination, err := filepath.Abs(destination) if err != nil { return nil, fmt.Errorf("resolve transaction destination: %w", err) } absBackup, err := filepath.Abs(backupRoot) if err != nil { return nil, fmt.Errorf("resolve transaction backup: %w", err) } lock, err := acquireInstallerTransactionLock(filepath.Dir(absDestination)) if err != nil { return nil, err } state := "applying" if kind == installerTransactionUI { state = "preparing" } txn := &installerTransaction{ journal: installerTransactionJournal{ Schema: installerTransactionSchema, Kind: kind, State: state, Destination: filepath.Clean(absDestination), BackupRoot: filepath.Clean(absBackup), ManagedPaths: append([]string(nil), managedPaths...), BackedUp: append([]string(nil), backedUp...), }, lock: lock, } if err := txn.write(); err != nil { txn.releaseLock() return nil, fmt.Errorf("write installer transaction intent: %w", err) } return txn, nil } func (t *installerTransaction) activateUI(backedUp []string) error { if t == nil || t.resolved || t.journal.Kind != installerTransactionUI || t.journal.State != "preparing" { return errors.New("UI installer transaction is not preparing") } t.journal.BackedUp = append([]string(nil), backedUp...) t.journal.State = "applying" if err := t.write(); err != nil { t.journal.BackedUp = nil t.journal.State = "preparing" return fmt.Errorf("persist UI transaction activation: %w", err) } return nil } func (t *installerTransaction) record(change installerJournalChange) error { if t == nil || t.resolved { return errors.New("installer transaction is not active") } absTarget, err := filepath.Abs(change.Target) if err != nil { return err } change.Target = filepath.Clean(absTarget) if change.Backup != "" { absBackup, err := filepath.Abs(change.Backup) if err != nil { return err } change.Backup = filepath.Clean(absBackup) } t.journal.Changes = append(t.journal.Changes, change) if err := t.write(); err != nil { t.journal.Changes = t.journal.Changes[:len(t.journal.Changes)-1] return fmt.Errorf("persist installer transaction step: %w", err) } return nil } func (t *installerTransaction) commit() error { if t == nil || t.resolved { return nil } if err := t.syncMutations(); err != nil { return fmt.Errorf("sync installer transaction: %w", err) } previousState := t.journal.State t.journal.State = "committed" if err := t.write(); err != nil { t.journal.State = previousState return fmt.Errorf("persist installer transaction commit: %w", err) } if err := t.removeArtifacts(); err != nil { return fmt.Errorf("clean committed installer transaction: %w", err) } t.resolved = true t.releaseLock() return nil } func (t *installerTransaction) rolledBack() error { if t == nil || t.resolved { return nil } if err := t.syncMutations(); err != nil { return fmt.Errorf("sync installer transaction rollback: %w", err) } previousState := t.journal.State t.journal.State = "rolled-back" if err := t.write(); err != nil { t.journal.State = previousState return fmt.Errorf("persist installer transaction rollback: %w", err) } if err := t.removeArtifacts(); err != nil { return fmt.Errorf("clean rolled-back installer transaction: %w", err) } t.resolved = true t.releaseLock() return nil } func (t *installerTransaction) syncMutations() error { directories := map[string]struct{}{ filepath.Dir(t.journal.Destination): {}, filepath.Dir(t.journal.BackupRoot): {}, } if info, err := os.Stat(t.journal.Destination); err == nil && info.IsDir() { directories[t.journal.Destination] = struct{}{} } for _, change := range t.journal.Changes { directories[filepath.Dir(change.Target)] = struct{}{} if change.Backup != "" { directories[filepath.Dir(change.Backup)] = struct{}{} } } var syncErr error for directory := range directories { if _, err := os.Stat(directory); os.IsNotExist(err) { continue } if err := syncInstallerDirectory(directory); err != nil { syncErr = errors.Join(syncErr, err) } } return syncErr } func (t *installerTransaction) retain() { if t != nil { t.releaseLock() } } func (t *installerTransaction) write() error { if t.authenticationKey == nil { key, err := installerJournalKey(true) if err != nil { return err } t.authenticationKey = key } journalPath, err := installerJournalPath(t.journal.BackupRoot, true) if err != nil { return err } t.journalPath = journalPath mac, err := installerJournalMAC(t.journal, t.authenticationKey) if err != nil { return err } t.journal.Authentication = hex.EncodeToString(mac) content, err := json.MarshalIndent(t.journal, "", " ") if err != nil { return err } return recoverytrust.WritePrivateFile(journalPath, content) } func (t *installerTransaction) removeArtifacts() error { var result error if err := os.RemoveAll(t.journal.BackupRoot); err != nil { result = errors.Join(result, err) } if err := os.Remove(t.journalPath); err != nil && !os.IsNotExist(err) { result = errors.Join(result, err) } result = errors.Join(result, syncInstallerDirectory(filepath.Dir(t.journalPath)), syncInstallerDirectory(filepath.Dir(t.journal.BackupRoot))) return result } func (t *installerTransaction) releaseLock() { if t.lock == nil { return } _ = unlockInstallerFile(t.lock) _ = t.lock.Close() t.lock = nil } func acquireInstallerTransactionLock(parent string) (*os.File, error) { if err := os.MkdirAll(parent, constants.DirPermission); err != nil { return nil, fmt.Errorf("create installer transaction parent: %w", err) } // Application parents such as /Applications are legitimately group writable. // The signed private journal and protected backup authorize recovery; the // parent only needs to be a real directory and the coordination file trusted. info, err := os.Lstat(parent) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, fmt.Errorf("installer transaction parent is not a real directory: %s", parent) } path := filepath.Join(parent, constants.InstallerLockFileName) file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, constants.FilePermission) if err != nil { return nil, fmt.Errorf("open installer transaction lock: %w", err) } if err := recoverytrust.ValidateFile(path); err != nil { _ = file.Close() return nil, err } if err := lockInstallerFile(file); err != nil { _ = file.Close() return nil, fmt.Errorf("another installer transaction is active for %s: %w", parent, err) } return file, nil } // RecoverInstallerTransactions rolls back interrupted installer/UI mutations // for the supplied live paths using private per-user journal authority. Public // sibling journals are retained for manual recovery, even with a valid signature. func RecoverInstallerTransactions(paths ...string) error { parents := make(map[string]map[string]bool) for _, candidate := range paths { if strings.TrimSpace(candidate) == "" { continue } absolute, err := filepath.Abs(candidate) if err != nil { return err } parent := filepath.Dir(filepath.Clean(absolute)) if parents[parent] == nil { parents[parent] = make(map[string]bool) } parents[parent][installerDestinationIdentity(absolute)] = true } ordered := make([]string, 0, len(parents)) for parent := range parents { ordered = append(ordered, parent) } sort.Strings(ordered) var recoveryErr error for _, parent := range ordered { if err := recoverInstallerTransactionsInParent(parent, parents[parent]); err != nil { recoveryErr = errors.Join(recoveryErr, err) } } return recoveryErr } func recoverInstallerTransactionsInParent(parent string, destinations map[string]bool) error { lock, err := acquireInstallerTransactionLock(parent) if err != nil { return err } defer func() { _ = unlockInstallerFile(lock) _ = lock.Close() }() entries, err := os.ReadDir(parent) if err != nil { if os.IsNotExist(err) { return nil } return err } var recoveryErr error for _, entry := range entries { if !entry.IsDir() && strings.HasPrefix(entry.Name(), ".mpv-manager-") && strings.HasSuffix(entry.Name(), installerJournalSuffix) { recoveryErr = errors.Join(recoveryErr, fmt.Errorf("installer journal in public parent has no recovery authority and requires manual recovery; evidence retained: %s", filepath.Join(parent, entry.Name()))) } } directory, err := installerJournalDirectory(false) if errors.Is(err, os.ErrNotExist) { return recoveryErr } if err != nil { return errors.Join(recoveryErr, err) } privateEntries, err := os.ReadDir(directory) if err != nil { return errors.Join(recoveryErr, err) } for _, entry := range privateEntries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), installerJournalSuffix) { continue } journalPath := filepath.Join(directory, entry.Name()) info, err := os.Lstat(journalPath) if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { recoveryErr = errors.Join(recoveryErr, fmt.Errorf("installer transaction journal is not a regular file: %s", journalPath)) continue } if err := recoverytrust.ValidatePrivateFile(journalPath); err != nil { recoveryErr = errors.Join(recoveryErr, err) continue } journal, err := readInstallerJournal(journalPath) if err != nil { recoveryErr = errors.Join(recoveryErr, fmt.Errorf("read installer transaction %s: %w", journalPath, err)) continue } if !destinations[installerDestinationIdentity(journal.Destination)] { // Other legitimate installations share this private inventory; only // caller-supplied exact destinations are in this recovery scope. continue } recoveryKey, err := verifyInstallerJournal(journal) if err != nil { recoveryErr = errors.Join(recoveryErr, err) continue } if err := validateInstallerJournal(parent, journalPath, journal); err != nil { recoveryErr = errors.Join(recoveryErr, err) continue } switch journal.State { case "preparing", "committed", "rolled-back": err = removeRecoveredInstallerArtifacts(journalPath, journal.BackupRoot) case "applying": if journal.Kind == installerTransactionUI { err = withUIConfigLocks(journal.Destination, func() error { return rollbackInstallerJournal(journal) }) } else { err = rollbackInstallerJournal(journal) } if err == nil { // Publish a durable terminal state before consuming any backup. // Recovery may itself be interrupted during artifact cleanup. txn := &installerTransaction{journal: journal, journalPath: journalPath, authenticationKey: recoveryKey} err = txn.rolledBack() } default: err = fmt.Errorf("installer transaction %s has invalid state %q", journalPath, journal.State) } if err != nil { recoveryErr = errors.Join(recoveryErr, err) } } return recoveryErr } func readInstallerJournal(path string) (installerTransactionJournal, error) { file, err := os.Open(path) if err != nil { return installerTransactionJournal{}, err } defer file.Close() decoder := json.NewDecoder(io.LimitReader(file, 1<<20)) decoder.DisallowUnknownFields() var journal installerTransactionJournal if err := decoder.Decode(&journal); err != nil { return journal, err } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return journal, errors.New("installer transaction journal has trailing data") } return journal, nil } func validateInstallerJournal(parent, journalPath string, journal installerTransactionJournal) error { if journal.Schema != installerTransactionSchema { return fmt.Errorf("installer transaction %s has unsupported schema %d", journalPath, journal.Schema) } validKind := journal.Kind == installerTransactionOverlay || journal.Kind == installerTransactionSwap || journal.Kind == installerTransactionFile || journal.Kind == installerTransactionUI if !validKind { return fmt.Errorf("installer transaction %s has invalid kind %q", journalPath, journal.Kind) } seenVersions := make(map[string]bool) for _, change := range journal.UIVersions { if journal.Kind != installerTransactionUI || (change.UIType != constants.UITypeUOSC && change.UIType != constants.UITypeModernZ) || seenVersions[change.UIType] || len(change.Before) > 256 || len(change.After) > 256 { return fmt.Errorf("installer transaction %s has invalid UI version metadata", journalPath) } seenVersions[change.UIType] = true } backupRoot := filepath.Clean(journal.BackupRoot) expectedJournal, err := installerJournalPath(backupRoot, false) if err != nil { return err } if !filepath.IsAbs(backupRoot) || installerDestinationIdentity(filepath.Dir(backupRoot)) != installerDestinationIdentity(parent) || installerDestinationIdentity(journalPath) != installerDestinationIdentity(expectedJournal) { return fmt.Errorf("installer transaction %s has an unsafe backup root or inventory identity", journalPath) } expectedPrefix := map[installerTransactionKind]string{ installerTransactionOverlay: ".mpv-manager-overlay-backup-", installerTransactionSwap: ".mpv-manager-swap-backup-", installerTransactionFile: ".mpv-manager-file-backup-", installerTransactionUI: ".mpv-manager-ui-backup-", }[journal.Kind] if !strings.HasPrefix(filepath.Base(backupRoot), expectedPrefix) { return fmt.Errorf("installer transaction %s has an unexpected backup name", journalPath) } if info, err := os.Lstat(backupRoot); err == nil { if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("installer transaction %s backup root is not a real directory", journalPath) } if err := recoverytrust.ValidateOwnedDirectory(backupRoot); err != nil { return err } } else if !os.IsNotExist(err) || journal.State == "applying" { return fmt.Errorf("installer transaction %s has an unavailable backup root", journalPath) } destination := filepath.Clean(journal.Destination) if !filepath.IsAbs(destination) || installerDestinationIdentity(filepath.Dir(destination)) != installerDestinationIdentity(parent) { return fmt.Errorf("installer transaction %s has an unsafe destination", journalPath) } for _, change := range journal.Changes { target := filepath.Clean(change.Target) if !filepath.IsAbs(target) || (target != destination && (journal.Kind != installerTransactionOverlay || !pathWithin(destination, target))) { return fmt.Errorf("installer transaction %s contains unsafe target %q", journalPath, change.Target) } if change.Backup != "" && !pathWithin(backupRoot, filepath.Clean(change.Backup)) { return fmt.Errorf("installer transaction %s contains unsafe backup %q", journalPath, change.Backup) } if journal.Kind == installerTransactionOverlay { if err := validateOverlayRecoveryParents(destination, target); err != nil { return fmt.Errorf("installer transaction %s: %w", journalPath, err) } } } if journal.Kind == installerTransactionUI { for _, relative := range append(append([]string(nil), journal.ManagedPaths...), journal.BackedUp...) { if !filepath.IsLocal(relative) || !isManagedUIPath(relative) { return fmt.Errorf("installer transaction %s contains unsafe UI path %q", journalPath, relative) } } } return nil } func validateOverlayRecoveryParents(destination, target string) error { if target == destination { return nil } relative, err := filepath.Rel(destination, filepath.Dir(target)) if err != nil || (relative != "." && !filepath.IsLocal(relative)) { return fmt.Errorf("unsafe overlay recovery parent for %q", target) } current := destination checkCurrent := func() error { info, err := os.Lstat(current) if os.IsNotExist(err) { return os.ErrNotExist } if err != nil { return err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("overlay recovery parent %q is not a real directory", current) } return nil } if err := checkCurrent(); err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } if relative == "." { return nil } for _, component := range strings.Split(relative, string(filepath.Separator)) { current = filepath.Join(current, component) if err := checkCurrent(); err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } } return nil } func pathWithin(root, candidate string) bool { relative, err := filepath.Rel(root, candidate) return err == nil && relative != "." && filepath.IsLocal(relative) } func installerBundleRecovery(journal installerTransactionJournal, target string) bool { return journal.Kind == installerTransactionSwap && strings.EqualFold(filepath.Ext(target), ".app") } // Framework links may chain through Versions/Current, but must be relative and // resolve within the same bundle. Missing, looping or escaping links stay unsafe. func validateInternalInstallerLink(root, path string) error { link, err := os.Readlink(path) if err != nil { return err } if filepath.IsAbs(link) || !pathWithin(root, filepath.Join(filepath.Dir(path), link)) { return fmt.Errorf("installer link escapes bundle: %s", path) } canonicalRoot, err := filepath.EvalSymlinks(root) if err != nil { return err } resolved, err := filepath.EvalSymlinks(path) if err != nil { return fmt.Errorf("resolve installer bundle link %s: %w", path, err) } if !pathWithin(canonicalRoot, resolved) { return fmt.Errorf("installer link resolves outside bundle: %s", path) } return nil } func isManagedUIPath(candidate string) bool { allowed := append(append([]string(nil), managedUIPaths...), transientUIPaths...) for _, managed := range allowed { if filepath.Clean(candidate) == filepath.Clean(managed) { return true } } return false } func rollbackInstallerJournal(journal installerTransactionJournal) error { // An intent can be durable before the original is moved to its backup. // Older recovery also consumed backups before retiring its journal. In // either case a missing backup is ambiguous: preserve every live target // and the journal for reconciliation rather than deleting the only copy. if err := validateInstallerRollbackBackups(journal); err != nil { return err } if journal.Kind == installerTransactionUI { for _, relative := range journal.ManagedPaths { if err := validateOverlayRecoveryParents(journal.Destination, filepath.Join(journal.Destination, relative)); err != nil { return fmt.Errorf("unsafe UI recovery parent; live files preserved: %w", err) } } return rollbackUIJournal(journal) } var rollbackErr error for index := len(journal.Changes) - 1; index >= 0; index-- { change := journal.Changes[index] if err := os.RemoveAll(change.Target); err != nil { rollbackErr = errors.Join(rollbackErr, fmt.Errorf("remove interrupted target %q: %w", change.Target, err)) continue } if change.HadOriginal { // Copy, never consume, recovery evidence. A second recovery must // still be able to restore after an interrupted first attempt. if err := copyInstallerPath(OSFileSystem{}, change.Backup, change.Target, installerBundleRecovery(journal, change.Target)); err != nil { rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore interrupted target %q: %w", change.Target, err)) continue } if err := syncInstallerTree(change.Target, installerBundleRecovery(journal, change.Target)); err != nil { rollbackErr = errors.Join(rollbackErr, fmt.Errorf("sync restored target %q: %w", change.Target, err)) } } } return rollbackErr } func validateInstallerRollbackBackups(journal installerTransactionJournal) error { var backups []string if journal.Kind == installerTransactionUI { for _, relative := range journal.BackedUp { backups = append(backups, filepath.Join(journal.BackupRoot, relative)) } } else { for _, change := range journal.Changes { if change.HadOriginal { backups = append(backups, change.Backup) } } } for _, backup := range backups { // Validate the entire tree before removing any target and ensure // the evidence survives a crash while recovery copies it back. if err := syncInstallerTree(backup, installerBundleRecovery(journal, journal.Destination)); err != nil { return fmt.Errorf("rollback backup %q unavailable or unsafe; live targets preserved and journal retained: %w", backup, err) } } return nil } func rollbackUIJournal(journal installerTransactionJournal) error { var rollbackErr error for _, relative := range journal.ManagedPaths { if err := os.RemoveAll(filepath.Join(journal.Destination, relative)); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } fs := OSFileSystem{} for _, relative := range journal.BackedUp { target := filepath.Join(journal.Destination, relative) if err := copyInstallerPath(fs, filepath.Join(journal.BackupRoot, relative), target, false); err != nil { rollbackErr = errors.Join(rollbackErr, err) continue } if err := syncInstallerTree(target, false); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } return errors.Join(rollbackErr, restoreUIVersions(journal.UIVersions)) } func removeRecoveredInstallerArtifacts(journalPath, backupRoot string) error { var result error if err := os.RemoveAll(backupRoot); err != nil { result = errors.Join(result, err) } if err := os.Remove(journalPath); err != nil && !os.IsNotExist(err) { result = errors.Join(result, err) } return errors.Join(result, syncInstallerDirectory(filepath.Dir(journalPath)), syncInstallerDirectory(filepath.Dir(backupRoot))) } func installerDestinationIdentity(path string) string { clean := filepath.Clean(path) if runtime.GOOS == "windows" { return strings.ToLower(clean) } return clean }