package vault import ( "errors" "fmt" "os" "path/filepath" "sync" "syscall" "git.eeqj.de/sneak/secret/internal/secret" "github.com/spf13/afero" ) // lockFileName is the file in the state directory that LockStateDir locks. const lockFileName = "lock" // finishedMark is what the lock file holds once the command that last held // the lock has released it. A command killed while holding it leaves the // file empty. const finishedMark = "finished\n" // memFsLock stands in for the lock file on the in-memory filesystem, which // has no file locks. Every in-memory filesystem in the process shares it. // //nolint:gochecknoglobals // must outlive the call that takes it var memFsLock sync.Mutex // LockStateDir takes the lock that a command changing anything under // stateDir holds until it returns, and returns the function that releases // it. While one command holds it, the next one waits here. Reads take no // lock: each file or directory a command changes is replaced in a single // rename, so a reader finds it as it was before or after, never half-made. // Once it holds the lock, it empties the lock file, and the function it // returns writes finishedMark there just before releasing the lock, so a // command killed while holding the lock leaves the mark missing. Finding it // missing, LockStateDir first deletes the temporary files and directories // such a command may have left, since no command still using them can be // running. After a command that finished, it searches nothing. // // On the real filesystem the lock is flock(2) on the file "lock" in // stateDir, which the kernel releases when the process dies, so a killed // command never leaves the tool locked. The in-memory filesystem the tests // use has no file locks, so a process-wide mutex stands in for flock there. // Any other filesystem is refused rather than left unlocked. func LockStateDir(fs afero.Fs, stateDir string) (func(), error) { var release func() switch fs.(type) { case *afero.OsFs: var err error release, err = flockStateDir(stateDir) if err != nil { return nil, err } case *afero.MemMapFs: memFsLock.Lock() release = memFsLock.Unlock default: return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs) } // The lock file is written in place, never replaced: a command waiting // for flock on the old file would then take a lock nobody else checks. lockPath := filepath.Join(stateDir, lockFileName) mark, err := afero.ReadFile(fs, lockPath) if err != nil || string(mark) != finishedMark { removeLeftovers(fs, stateDir) } err = afero.WriteFile(fs, lockPath, nil, secret.FilePerms) if err != nil { release() return nil, fmt.Errorf("failed to empty lock file %s: %w", lockPath, err) } return func() { // If this fails, the next command searches when it need not. _ = afero.WriteFile(fs, lockPath, []byte(finishedMark), secret.FilePerms) release() }, nil } // removeLeftovers deletes the temporary files and directories that commands // killed part-way left in each directory where secret.WriteFileAtomic and // secret.TempDirFor make them: the state directory, each vault, each secret // and each version. Unlocker directories are written whole by // secret.WriteDir and never changed after, so they hold none. A failure is // only warned about, and the command goes on. func removeLeftovers(fs afero.Fs, stateDir string) { dirs := []string{stateDir} for _, vaultDir := range subdirs(fs, filepath.Join(stateDir, "vaults.d")) { dirs = append(dirs, vaultDir) for _, secretDir := range subdirs(fs, filepath.Join(vaultDir, "secrets.d")) { dirs = append(dirs, secretDir) dirs = append(dirs, subdirs(fs, filepath.Join(secretDir, "versions"))...) } } for _, dir := range dirs { err := secret.RemoveLeftovers(fs, dir) if err != nil { secret.Warn("Failed to remove what an interrupted command left", "error", err) } } } // subdirs returns the directories in dir: none if dir does not exist, and // none, with a warning, if it cannot be read. func subdirs(fs afero.Fs, dir string) []string { entries, err := afero.ReadDir(fs, dir) if err != nil { if !errors.Is(err, os.ErrNotExist) { secret.Warn("Failed to look for what an interrupted command left", "directory", dir, "error", err) } return nil } var dirs []string for _, entry := range entries { if entry.IsDir() { dirs = append(dirs, filepath.Join(dir, entry.Name())) } } return dirs } // flockStateDir takes flock(2) on the lock file in stateDir, creating the // directory and the file if needed. Go opens files close-on-exec, so // programs the command runs, such as gpg, do not inherit the lock. func flockStateDir(stateDir string) (func(), error) { err := os.MkdirAll(stateDir, secret.DirPerms) if err != nil { return nil, fmt.Errorf("failed to create state directory: %w", err) } lockPath := filepath.Join(stateDir, lockFileName) //nolint:gosec // G304: the path is the lock file in the state directory file, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, secret.FilePerms) if err != nil { return nil, fmt.Errorf("failed to open lock file: %w", err) } err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX) if err != nil { _ = file.Close() return nil, fmt.Errorf("failed to lock %s: %w", lockPath, err) } // Closing the file releases the lock. return func() { _ = file.Close() }, nil }