check / check (push) Successful in 1m31s
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. Left for later: replacing an unlocker (#71) and deleting what an interrupted command leaves under a `.tmp-` name (#75). Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
87 lines
2.4 KiB
Go
87 lines
2.4 KiB
Go
package secret
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
|
// a crash at any moment, finds either the old content or the new, never a
|
|
// partial file. The data goes into a temporary file that afero.TempFile
|
|
// creates with mode 0600 in the same directory (a rename is only atomic
|
|
// within one filesystem), is synced to disk, and is renamed over path. The
|
|
// temporary file is removed if any step fails.
|
|
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
|
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
|
"."+filepath.Base(path)+".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
|
}
|
|
|
|
_, err = tmp.Write(data)
|
|
if err == nil {
|
|
err = tmp.Sync()
|
|
}
|
|
|
|
closeErr := tmp.Close()
|
|
if err == nil {
|
|
err = closeErr
|
|
}
|
|
|
|
if err == nil {
|
|
err = fs.Rename(tmp.Name(), path)
|
|
}
|
|
|
|
if err != nil {
|
|
_ = fs.Remove(tmp.Name())
|
|
|
|
return fmt.Errorf("failed to write %s: %w", path, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TempDirFor creates an empty temporary directory in which to build the
|
|
// directory target before renaming it into place, or into which to move
|
|
// target before deleting it. It is made in target's grandparent: on the
|
|
// same filesystem, so the rename is atomic, and outside target's parent,
|
|
// the directory that is listed to find vaults, secrets, versions and
|
|
// unlockers, so one left behind by a crash is never taken for one of them.
|
|
// Its name leaves out target's, which may already be as long as a file name
|
|
// can be.
|
|
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
|
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to create temporary directory for %s: %w", target, err)
|
|
}
|
|
|
|
return dir, nil
|
|
}
|
|
|
|
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
|
// rename: dir is moved into a new directory from TempDirFor, which is then
|
|
// deleted. A crash part-way leaves only that temporary directory behind.
|
|
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
|
tmp, err := TempDirFor(fs, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = fs.Rename(dir, filepath.Join(tmp, filepath.Base(dir)))
|
|
if err != nil {
|
|
_ = fs.Remove(tmp)
|
|
|
|
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
|
}
|
|
|
|
err = fs.RemoveAll(tmp)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
|
}
|
|
|
|
return nil
|
|
}
|