check / check (push) Waiting to run
A command killed part-way could leave a temporary file or directory of secret.WriteFileAtomic or secret.TempDirFor, encrypted keys included, for good. LockStateDir now empties the lock file once it holds the lock and writes "finished" there just before releasing it. A holder that does not find that deletes such leftovers from the state directory, each vault, each secret and each version, the only places those helpers make them, matching names that start with "." and hold ".tmp-". After a command that finished nothing is searched, so the added time does not grow with the number of secrets and versions. A test shows that `unlocker remove` removes an unlocker directory with no metadata file. Model: opus-5-5
167 lines
5.3 KiB
Go
167 lines
5.3 KiB
Go
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
|
|
}
|