check / check (push) Failing after 1s
A passphrase unlocker added to a vault that had one, and a PGP, keychain or Secure Enclave unlocker added on the same day as another of its type, were written into the existing unlocker's directory file by file, so a crash part-way left a current unlocker whose files did not belong together. Unlocker directories, keychain items and Secure Enclave keys are now named with the time to the nanosecond, and secret.WriteDir refuses a directory that exists. Adding a passphrase unlocker writes the new one, points current-unlocker at it, and only then removes the vault's other passphrase unlockers. Model: opus-5-5
134 lines
3.7 KiB
Go
134 lines
3.7 KiB
Go
package secret
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"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
|
|
}
|
|
|
|
// WriteDir calls write to write the files of the new directory dir into a
|
|
// temporary directory from TempDirFor, which is then renamed to dir, so that
|
|
// neither a failure nor a crash leaves dir half-written; on a failure the
|
|
// temporary directory is removed, and a failure to remove it is returned
|
|
// along with the first. A directory cannot be replaced in one rename, so if
|
|
// dir already exists, WriteDir fails without calling write.
|
|
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
|
|
exists, err := afero.Exists(fs, dir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check for %s: %w", dir, err)
|
|
}
|
|
|
|
if exists {
|
|
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
|
|
}
|
|
|
|
// Create the directory the finished one is renamed into
|
|
err = fs.MkdirAll(filepath.Dir(dir), DirPerms)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create %s: %w", filepath.Dir(dir), err)
|
|
}
|
|
|
|
tmp, err := TempDirFor(fs, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = write(tmp)
|
|
if err == nil {
|
|
err = fs.Rename(tmp, dir)
|
|
}
|
|
|
|
if err != nil {
|
|
removeErr := fs.RemoveAll(tmp)
|
|
if removeErr != nil {
|
|
err = errors.Join(err,
|
|
fmt.Errorf("failed to remove %s: %w", tmp, removeErr))
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
return 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
|
|
}
|