Lock the state directory and write vault files atomically (closes #34)
check / check (push) Successful in 1m49s

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
This commit is contained in:
2026-10-04 02:07:04 +00:00
committed by sneak
parent bdb1c7ec18
commit 0713f1ceff
24 changed files with 1906 additions and 204 deletions
+42 -4
View File
@@ -383,6 +383,15 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
valueBuffer := combineBuffers(buffers, totalSize)
defer valueBuffer.Destroy()
// Locked only now that stdin has been read: in `secret encrypt key |
// secret add name`, holding the lock while reading would leave each
// command waiting for the other.
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Add the secret to the vault
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
"value_length", valueBuffer.Size(), "force", force)
@@ -660,6 +669,14 @@ func (cli *Instance) ImportSecret(
valueBuffer := combineBuffers(buffers, totalSize)
defer valueBuffer.Destroy()
// Locked only now that the file has been read, as in AddSecret: the
// file may be a pipe written by another secret command.
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Store the secret in the vault
err = vlt.AddSecret(secretName, valueBuffer, force)
if err != nil {
@@ -679,6 +696,12 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
return err
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// Get current vault
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@@ -713,7 +736,7 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
}
// Remove the secret directory
err = cli.fs.RemoveAll(secretDir)
err = secret.RemoveDirAtomic(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to remove secret: %w", err)
}
@@ -724,9 +747,24 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
return nil
}
// MoveSecret moves or renames a secret (within or across vaults)
// MoveSecret moves or renames a secret (within or across vaults), holding
// the state directory lock.
func (cli *Instance) MoveSecret(
cmd *cobra.Command, source, dest string, force bool,
) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
return cli.moveSecret(cmd, source, dest, force)
}
// moveSecret does the work of MoveSecret. The caller holds the state
// directory lock.
func (cli *Instance) moveSecret(
cmd *cobra.Command, source, dest string, force bool,
) error {
// Parse source and destination
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
@@ -833,7 +871,7 @@ func (cli *Instance) moveSecretWithinVault(
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
}
err = cli.fs.RemoveAll(destDir)
err = secret.RemoveDirAtomic(cli.fs, destDir)
if err != nil {
return fmt.Errorf("failed to remove existing destination: %w", err)
}
@@ -914,7 +952,7 @@ func (cli *Instance) moveSecretCrossVault(
}
// Delete source secret
err = cli.fs.RemoveAll(srcSecretDir)
err = secret.RemoveDirAtomic(cli.fs, srcSecretDir)
if err != nil {
// Copy succeeded but delete failed - warn but don't fail
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)