// Package fileops provides process-wide coordination and atomic replacement // for files that are edited by multiple UI and API entry points. package fileops import ( "fmt" "io/fs" "os" "path/filepath" "runtime" "strings" "sync" ) type lockEntry struct { mu sync.Mutex refs int } var pathLocks = struct { sync.Mutex entries map[string]*lockEntry }{entries: make(map[string]*lockEntry)} // WithLock runs fn while holding the process-wide lock for path. Relative and // lexically equivalent paths share a lock. Windows paths are case-insensitive. // Entries are reference counted so paths do not accumulate forever. func WithLock(path string, fn func() error) error { key := lockKey(path) pathLocks.Lock() entry := pathLocks.entries[key] if entry == nil { entry = &lockEntry{} pathLocks.entries[key] = entry } entry.refs++ pathLocks.Unlock() entry.mu.Lock() defer func() { entry.mu.Unlock() pathLocks.Lock() entry.refs-- if entry.refs == 0 { delete(pathLocks.entries, key) } pathLocks.Unlock() }() release, err := AcquireAdvisory(path) if err != nil { return err } defer release() return fn() } // AcquireAdvisory takes a cross-process advisory lock associated with path. // The returned function must be called exactly once. Callers that already own // their own in-process mutex can use this directly; other callers should use // WithLock so goroutines and processes share one serialization boundary. func AcquireAdvisory(path string) (func(), error) { absolute, err := filepath.Abs(path) if err != nil { absolute = filepath.Clean(path) } if err := os.MkdirAll(filepath.Dir(absolute), 0755); err != nil { return nil, fmt.Errorf("failed to create lock directory: %w", err) } lockPath := filepath.Join(filepath.Dir(absolute), "."+filepath.Base(absolute)+".lock") lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) if err != nil { return nil, fmt.Errorf("failed to open advisory lock: %w", err) } if err := lockAdvisoryFile(lockFile); err != nil { _ = lockFile.Close() return nil, fmt.Errorf("failed to acquire advisory lock: %w", err) } released := false return func() { if released { return } released = true _ = unlockAdvisoryFile(lockFile) _ = lockFile.Close() }, nil } func lockKey(path string) string { key, err := filepath.Abs(path) if err != nil { key = filepath.Clean(path) } key = filepath.Clean(key) if runtime.GOOS == "windows" { key = strings.ToLower(key) } return key } // AtomicWrite replaces path using a uniquely named temporary file in the // target directory. Keeping the temporary file beside the target preserves // atomic-rename semantics. Callers coordinating a read-modify-write cycle // should call AtomicWrite from inside WithLock. func AtomicWrite(path string, content []byte, mode fs.FileMode) error { return atomicWrite(path, content, mode, nil) } // AtomicWritePreserve atomically replaces a regular file while retaining its // permission and ownership metadata, including the Windows owner and DACL. // Symlinks and non-regular targets are rejected; a missing target uses fallbackMode. func AtomicWritePreserve(path string, content []byte, fallbackMode fs.FileMode) error { info, err := os.Lstat(path) if err != nil { if os.IsNotExist(err) { return atomicWrite(path, content, fallbackMode, nil) } return fmt.Errorf("failed to inspect target metadata: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to replace non-regular target %s", path) } return atomicWrite(path, content, info.Mode().Perm(), info) } func atomicWrite(path string, content []byte, mode fs.FileMode, owner fs.FileInfo) error { dir := filepath.Dir(path) pattern := "." + filepath.Base(path) + ".tmp-*" var temp *os.File var err error if owner != nil || mode.Perm()&0077 == 0 { temp, err = createPrivateTemp(dir, pattern) } else { temp, err = os.CreateTemp(dir, pattern) } if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } tempPath := temp.Name() removeTemp := true defer func() { _ = temp.Close() if removeTemp { _ = os.Remove(tempPath) } }() if err := temp.Chmod(mode); err != nil { return fmt.Errorf("failed to set temp file permissions: %w", err) } if owner != nil { if err := preserveFileMetadata(temp, path, owner); err != nil { return fmt.Errorf("failed to preserve target metadata: %w", err) } } if _, err := temp.Write(content); err != nil { return fmt.Errorf("failed to write temp file: %w", err) } if err := temp.Sync(); err != nil { return fmt.Errorf("failed to sync temp file: %w", err) } if err := temp.Close(); err != nil { return fmt.Errorf("failed to close temp file: %w", err) } if err := renameReplacing(tempPath, path); err != nil { return fmt.Errorf("failed to rename temp file: %w", err) } removeTemp = false return syncDirectory(dir) } // WriteUnique creates and durably writes one uniquely named file. pattern uses // os.CreateTemp syntax and should include the final extension after its last // '*', for example "2026-08-31-120000-*" + "_mpv.conf". func WriteUnique(dir, pattern string, content []byte, mode fs.FileMode) (string, error) { file, err := createPrivateTemp(dir, pattern) if err != nil { return "", fmt.Errorf("failed to create unique file: %w", err) } path := file.Name() remove := true defer func() { _ = file.Close() if remove { _ = os.Remove(path) } }() if _, err := file.Write(content); err != nil { return "", fmt.Errorf("failed to write unique file: %w", err) } if err := file.Chmod(mode); err != nil { return "", fmt.Errorf("failed to set unique file permissions: %w", err) } if err := file.Sync(); err != nil { return "", fmt.Errorf("failed to sync unique file: %w", err) } if err := file.Close(); err != nil { return "", fmt.Errorf("failed to close unique file: %w", err) } if err := syncDirectory(dir); err != nil { return "", err } remove = false return path, nil } // RemoveDurable removes path and syncs its parent directory so callers can // durably retire intent journals after the protected operation commits. A // missing path is already in the desired state. func RemoveDurable(path string) error { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } return syncDirectory(filepath.Dir(path)) } // RenameDurable renames oldPath to newPath and syncs the affected directory // entries. It is used when corrupt persisted state is quarantined before a // fresh authoritative file is published. func RenameDurable(oldPath, newPath string) error { if err := renameReplacing(oldPath, newPath); err != nil { return err } oldDir := filepath.Dir(oldPath) newDir := filepath.Dir(newPath) if err := syncDirectory(newDir); err != nil { return err } if filepath.Clean(oldDir) != filepath.Clean(newDir) { return syncDirectory(oldDir) } return nil } func syncDirectory(dir string) error { // Windows does not support syncing a directory handle through os.File. // Atomic rename still applies there; native replacement durability remains // covered by the updater's platform-specific transaction layer. if runtime.GOOS == "windows" { return nil } handle, err := os.Open(dir) if err != nil { return fmt.Errorf("failed to open parent directory for sync: %w", err) } defer handle.Close() if err := handle.Sync(); err != nil { return fmt.Errorf("failed to sync parent directory: %w", err) } return nil }