package config import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "gitgud.io/mike/mpv-manager/pkg/constants" ) // The default Windows locator is also a write-ahead record. Publishing it is // the commit point; a custom config is never changed before that succeeds. // The previous digest prevents recovery from overwriting intervening edits. type windowsConfigLocator struct { Config Pending *pendingConfigWrite `json:"pending_config_write,omitempty"` } type pendingConfigWrite struct { PreviousSHA256 string `json:"previous_sha256"` PreviousExists bool `json:"previous_exists"` } func configFileDigest(path string) (string, bool, error) { info, err := os.Lstat(path) if os.IsNotExist(err) { return "", false, nil } if err != nil { return "", false, err } if !info.Mode().IsRegular() { return "", false, fmt.Errorf("config target is not a regular file: %s", path) } data, err := os.ReadFile(path) if err != nil { return "", false, err } digest := sha256.Sum256(data) return hex.EncodeToString(digest[:]), true, nil } func publishWindowsConfig(locatorPath, configPath string, candidate *Config) error { if !filepath.IsAbs(candidate.InstallPath) { return fmt.Errorf("custom Windows installation path must be absolute") } digest, exists, err := configFileDigest(configPath) if err != nil { return err } locator := windowsConfigLocator{Config: *candidate, Pending: &pendingConfigWrite{ PreviousSHA256: digest, PreviousExists: exists, }} data, err := json.MarshalIndent(locator, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(locatorPath), constants.DirPermission); err != nil { return err } if err := writeFileAtomically(locatorPath, data); err != nil { return fmt.Errorf("publish Windows config locator: %w", err) } if err := finishWindowsConfigPublication(locatorPath, &locator); err != nil { return fmt.Errorf("config publication committed; recovery required: %w", err) } return nil } // Callers hold the manager configuration advisory lock throughout publication // and recovery. The locator retains the full candidate until both writes finish. func finishWindowsConfigPublication(locatorPath string, locator *windowsConfigLocator) error { if locator.Pending == nil { return nil } target := filepath.Join(locator.InstallPath, constants.PortableConfigDir, constants.ConfigFileName) if !filepath.IsAbs(locator.InstallPath) || normalizeInstallPathForIdentity(target) == normalizeInstallPathForIdentity(locatorPath) { return fmt.Errorf("invalid pending config destination; locator retained") } data, err := json.MarshalIndent(&locator.Config, "", " ") if err != nil { return err } want := sha256.Sum256(data) digest, exists, err := configFileDigest(target) if err != nil { return err } if !exists || digest != hex.EncodeToString(want[:]) { if exists != locator.Pending.PreviousExists || digest != locator.Pending.PreviousSHA256 { return fmt.Errorf("config changed during pending publication; files and locator retained: %s", target) } if err := os.MkdirAll(filepath.Dir(target), constants.DirPermission); err != nil { return err } if err := writeFileAtomically(target, data); err != nil { return err } } return writeFileAtomically(locatorPath, data) }