Lock the state directory and write vault files atomically (closes #34)
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>
This commit was merged in pull request #69.
This commit is contained in:
2026-10-04 04:57:58 +02:00
committed by clawbot
parent bdb1c7ec18
commit 32a61ff963
24 changed files with 1906 additions and 204 deletions
+43 -25
View File
@@ -132,7 +132,10 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
return fmt.Sprintf("%s.%03d", today, newSerial), nil
}
// Save saves the version metadata and value
// Save saves the version metadata and value. The files are written into a
// temporary directory that is renamed to sv.Directory once all of them are
// complete, so the version directory is either whole or absent, even if the
// process dies part-way.
func (sv *Version) Save(value *memguard.LockedBuffer) error {
if value == nil {
return errNilValueBuffer
@@ -146,14 +149,22 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
fs := sv.vault.GetFilesystem()
// Create version directory
err := fs.MkdirAll(sv.Directory, DirPerms)
// Create the versions directory the finished version is renamed into
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
if err != nil {
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
Debug("Failed to create versions directory", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to create version directory: %w", err)
return fmt.Errorf("failed to create versions directory: %w", err)
}
tmpDir, err := TempDirFor(fs, sv.Directory)
if err != nil {
return err
}
// Once the rename below has moved it into place, this finds nothing.
defer func() { _ = fs.RemoveAll(tmpDir) }()
// Generate a new keypair for this version
Debug("Generating version-specific keypair", "version", sv.Version)
@@ -174,21 +185,28 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
slog.String("public_key", versionIdentity.Recipient().String()),
)
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
if err != nil {
return err
}
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
if err != nil {
return err
}
err = sv.writeEncryptedMetadata(fs, versionIdentity)
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
if err != nil {
return err
}
err = fs.Rename(tmpDir, sv.Directory)
if err != nil {
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to move version into place: %w", err)
}
Debug("Successfully saved secret version",
"version", sv.Version, "secret_name", sv.SecretName)
@@ -359,17 +377,18 @@ func (sv *Version) GetValue(
}
// writePublicKeyAndValue stores the version's public key and the value
// encrypted to it.
// encrypted to it in dir.
func (sv *Version) writePublicKeyAndValue(
fs afero.Fs,
dir string,
versionIdentity *age.X25519Identity,
value *memguard.LockedBuffer,
) error {
versionPublicKey := versionIdentity.Recipient().String()
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
pubKeyPath := filepath.Join(dir, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
if err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
@@ -386,10 +405,10 @@ func (sv *Version) writePublicKeyAndValue(
return fmt.Errorf("failed to encrypt version value: %w", err)
}
valuePath := filepath.Join(sv.Directory, "value.age")
valuePath := filepath.Join(dir, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
err = WriteFileAtomic(fs, valuePath, encryptedValue)
if err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
@@ -400,9 +419,10 @@ func (sv *Version) writePublicKeyAndValue(
}
// writeEncryptedPrivateKey encrypts the version's private key to the
// vault's long-term public key and stores it.
// vault's long-term public key and stores it in dir.
func (sv *Version) writeEncryptedPrivateKey(
fs afero.Fs,
dir string,
versionPrivateKeyBuffer *memguard.LockedBuffer,
) error {
vaultDir, _ := sv.vault.GetDirectory()
@@ -436,10 +456,10 @@ func (sv *Version) writeEncryptedPrivateKey(
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
privKeyPath := filepath.Join(sv.Directory, "priv.age")
privKeyPath := filepath.Join(dir, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
if err != nil {
Debug("Failed to write encrypted version private key",
"error", err, "path", privKeyPath)
@@ -451,9 +471,10 @@ func (sv *Version) writeEncryptedPrivateKey(
}
// writeEncryptedMetadata encrypts the version metadata to the version's
// public key and stores it.
// public key and stores it in dir.
func (sv *Version) writeEncryptedMetadata(
fs afero.Fs,
dir string,
versionIdentity *age.X25519Identity,
) error {
Debug("Encrypting version metadata", "version", sv.Version)
@@ -477,10 +498,10 @@ func (sv *Version) writeEncryptedMetadata(
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(sv.Directory, "metadata.age")
metadataPath := filepath.Join(dir, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
if err != nil {
Debug("Failed to write encrypted version metadata",
"error", err, "path", metadataPath)
@@ -553,15 +574,12 @@ func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
}
// SetCurrentVersion updates the "current" file to point to a specific version
// The file contains just the version name (e.g., "20231215.001")
// The file contains just the version name (e.g., "20231215.001"). It is
// replaced in one rename, so once written it always exists.
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current")
// Remove existing file if it exists
_ = fs.Remove(currentPath)
// Write just the version name to the file
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
err := WriteFileAtomic(fs, currentPath, []byte(version))
if err != nil {
return fmt.Errorf("failed to create current version file: %w", err)
}