package uiconfig import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "os" "path/filepath" "gitgud.io/mike/mpv-manager/internal/fileops" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/modernzconf" ) const ( migrationJournalSchema = 2 maxMigrationOriginalSize = 16 << 20 maxMigrationJournalSize = 24 << 20 ) type migrationJournal struct { Schema int `json:"schema"` MigrationID string `json:"migration_id"` Action string `json:"action"` Target string `json:"target"` OriginalExisted bool `json:"original_existed"` OriginalMode uint32 `json:"original_mode"` OriginalContent []byte `json:"original_content,omitempty"` AppliedDigest string `json:"applied_sha256,omitempty"` } func migrationJournalPath(target string) string { return filepath.Join(filepath.Dir(target), ".mpv-manager-"+MigrationModernZScalingV1+".transaction.json") } func captureMigrationJournal(target, action string) (migrationJournal, error) { absolute, err := filepath.Abs(target) if err != nil { return migrationJournal{}, fmt.Errorf("resolve ModernZ config path: %w", err) } journal := migrationJournal{ Schema: migrationJournalSchema, MigrationID: MigrationModernZScalingV1, Action: action, Target: filepath.Clean(absolute), } info, err := os.Lstat(absolute) if os.IsNotExist(err) { applied, editErr := modernzconf.EditValueContent(nil, "vidscale", modernzconf.ManagedVidscaleDefault) if editErr != nil { return migrationJournal{}, editErr } journal.AppliedDigest = migrationContentDigest(applied) return journal, nil } if err != nil { return migrationJournal{}, fmt.Errorf("inspect ModernZ config before migration: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return migrationJournal{}, fmt.Errorf("ModernZ config is not a regular file") } content, err := readBoundedFile(absolute, maxMigrationOriginalSize) if err != nil { return migrationJournal{}, fmt.Errorf("snapshot ModernZ config before migration: %w", err) } journal.OriginalExisted = true journal.OriginalMode = uint32(info.Mode().Perm()) journal.OriginalContent = content applied, err := modernzconf.EditValueContent(content, "vidscale", modernzconf.ManagedVidscaleDefault) if err != nil { return migrationJournal{}, err } journal.AppliedDigest = migrationContentDigest(applied) return journal, nil } func migrationContentDigest(content []byte) string { digest := sha256.Sum256(content) return hex.EncodeToString(digest[:]) } func writeMigrationJournal(target string, journal migrationJournal) error { if err := validateMigrationJournal(target, journal); err != nil { return err } if err := os.MkdirAll(filepath.Dir(target), constants.DirPermission); err != nil { return err } data, err := json.Marshal(journal) if err != nil { return err } return fileops.AtomicWrite(migrationJournalPath(target), data, 0o600) } func readMigrationJournal(target string) (migrationJournal, error) { data, err := readBoundedFile(migrationJournalPath(target), maxMigrationJournalSize) if err != nil { return migrationJournal{}, err } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() var journal migrationJournal if err := decoder.Decode(&journal); err != nil { return migrationJournal{}, fmt.Errorf("parse migration journal: %w", err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { return migrationJournal{}, fmt.Errorf("parse migration journal: trailing data") } if err := validateMigrationJournal(target, journal); err != nil { return migrationJournal{}, err } return journal, nil } func validateMigrationJournal(target string, journal migrationJournal) error { absolute, err := filepath.Abs(target) if err != nil { return err } if (journal.Schema != 1 && journal.Schema != migrationJournalSchema) || journal.MigrationID != MigrationModernZScalingV1 || journal.Action != MigrationActionApply { return fmt.Errorf("invalid ModernZ migration journal metadata") } if filepath.Clean(journal.Target) != filepath.Clean(absolute) { return fmt.Errorf("ModernZ migration journal target mismatch") } if len(journal.OriginalContent) > maxMigrationOriginalSize { return fmt.Errorf("ModernZ migration journal snapshot is too large") } if !journal.OriginalExisted && (journal.OriginalMode != 0 || len(journal.OriginalContent) != 0) { return fmt.Errorf("invalid absent-file snapshot in ModernZ migration journal") } if journal.OriginalMode&^uint32(os.FileMode(0o777).Perm()) != 0 { return fmt.Errorf("invalid file mode in ModernZ migration journal") } if journal.Schema == migrationJournalSchema { digest, err := hex.DecodeString(journal.AppliedDigest) if err != nil || len(digest) != sha256.Size { return fmt.Errorf("invalid applied-file digest in ModernZ migration journal") } } else if journal.AppliedDigest != "" { return fmt.Errorf("legacy ModernZ migration journal contains unsupported applied evidence") } return nil } func restoreMigrationOriginal(target string, journal migrationJournal) error { info, err := os.Lstat(target) if os.IsNotExist(err) && !journal.OriginalExisted { return nil // Replay after an already completed absent-file rollback. } if err != nil { return fmt.Errorf("ModernZ config changed during migration; recovery requires review: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("ModernZ config changed during migration; recovery requires review: not a regular file") } current, err := readBoundedFile(target, maxMigrationOriginalSize) if err != nil { return err } if journal.OriginalExisted && bytes.Equal(current, journal.OriginalContent) { return nil // The intent preceded the edit, or rollback already completed. } if journal.AppliedDigest == "" || migrationContentDigest(current) != journal.AppliedDigest { return fmt.Errorf("ModernZ config changed during migration; recovery requires review; journal retained") } if !journal.OriginalExisted { return fileops.RemoveDurable(target) } mode := os.FileMode(journal.OriginalMode).Perm() if mode == 0 { mode = constants.FilePermission } return fileops.AtomicWritePreserve(target, journal.OriginalContent, mode) } // RecoverMigrationTransactions resolves an interrupted ModernZ migration. If // the manager decision committed, the script change is retained; otherwise // the pre-migration script config is restored only if the file still matches // the recorded migration result (or was already restored). Conflicting edits // and ambiguous legacy journals retain recovery evidence for manual review. // The fixed, derived target and strict journal schema prevent an // attacker-controlled journal from redirecting recovery writes. func RecoverMigrationTransactions() error { target, err := modernzconf.GetModernZConfPath() if err != nil { return err } journalPath := migrationJournalPath(target) if _, err := os.Lstat(journalPath); os.IsNotExist(err) { return nil } else if err != nil { return fmt.Errorf("inspect UI migration journal: %w", err) } return fileops.WithLock(target, func() error { journal, err := readMigrationJournal(target) if err != nil { return err } if _, err := config.Reload(); err != nil { return fmt.Errorf("reload manager config for migration recovery: %w", err) } if config.GetUIConfigMigrationResolution(journal.MigrationID) != journal.Action { if err := restoreMigrationOriginal(target, journal); err != nil { return fmt.Errorf("roll back interrupted ModernZ migration: %w", err) } } if err := fileops.RemoveDurable(journalPath); err != nil { return fmt.Errorf("retire ModernZ migration journal: %w", err) } return nil }) } func readBoundedFile(path string, maximum int64) ([]byte, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() content, err := io.ReadAll(io.LimitReader(file, maximum+1)) if err != nil { return nil, err } if int64(len(content)) > maximum { return nil, fmt.Errorf("file exceeds %d bytes", maximum) } return content, nil }