check / check (push) Failing after 2s
REPO_POLICIES.md requires the module root sneak.berlin/go/<name>. go.mod, every import (rewritten with gofmt -r), the -X flags in script/build and the examples in the pkg READMEs now use the new path. go mod tidy lists go-humanize and fatih/color as direct requirements, since internal/cli imports them. This breaks anyone who fetched or imported git.eeqj.de/sneak/secret: they must switch to sneak.berlin/go/secret, which resolves to this repository. 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"
|
|
|
|
"github.com/spf13/afero"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
)
|
|
|
|
// 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
|
|
}
|