check / check (push) Successful in 50s
Each command that changes the state directory holds one lock: flock(2) on `lock` in the state directory, dropped by the kernel if the process dies, or a process-wide mutex on the in-memory test filesystem. It covers the state directory, not each vault, because `currentvault`, `vault create` and cross-vault moves span vaults, and a lock file in a vault would be deleted by `vault remove` under a waiting command. Files go through `secret.WriteFileAtomic`; versions, new secrets and cross-vault copies are built in a temporary directory and renamed into place; removals rename out of the way first. An unlocker added under an existing unlocker's directory name is still rewritten file by file: #71. Model: opus-5-5
74 lines
2.4 KiB
Go
74 lines
2.4 KiB
Go
package vault
|
|
|
|
import (
|
|
"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"
|
|
|
|
// 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.
|
|
//
|
|
// 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) {
|
|
switch fs.(type) {
|
|
case *afero.OsFs:
|
|
return flockStateDir(stateDir)
|
|
case *afero.MemMapFs:
|
|
memFsLock.Lock()
|
|
|
|
return memFsLock.Unlock, nil
|
|
default:
|
|
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|