check / check (push) Waiting to run
A command killed part-way could leave a temporary file or directory of secret.WriteFileAtomic or secret.TempDirFor, encrypted keys included, for good. LockStateDir now empties the lock file once it holds the lock and writes "finished" there just before releasing it. A holder that does not find that deletes such leftovers from the state directory, each vault, each secret and each version, the only places those helpers make them, matching names that start with "." and hold ".tmp-". After a command that finished nothing is searched, so the added time does not grow with the number of secrets and versions. A test shows that `unlocker remove` removes an unlocker directory with no metadata file. Model: opus-5-5
173 lines
4.8 KiB
Go
173 lines
4.8 KiB
Go
package secret
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// tempNamePart is in the name of every temporary file WriteFileAtomic makes,
|
|
// ".NAME.tmp-123", and every temporary directory TempDirFor makes, ".tmp-123".
|
|
const tempNamePart = ".tmp-"
|
|
|
|
// 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)+tempNamePart+"*")
|
|
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)), tempNamePart)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to create temporary directory for %s: %w", target, err)
|
|
}
|
|
|
|
return dir, nil
|
|
}
|
|
|
|
// RemoveLeftovers deletes from dir the temporary files of WriteFileAtomic
|
|
// and the temporary directories of TempDirFor that a command killed
|
|
// part-way left there: each entry whose name starts with "." and holds
|
|
// tempNamePart. The caller must hold the state directory lock, so that no
|
|
// running command is still using one. A dir that does not exist holds none.
|
|
func RemoveLeftovers(fs afero.Fs, dir string) error {
|
|
entries, err := afero.ReadDir(fs, dir)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read %s: %w", dir, err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, ".") || !strings.Contains(name, tempNamePart) {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(dir, name)
|
|
|
|
err = fs.RemoveAll(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove %s: %w", path, err)
|
|
}
|
|
|
|
Debug("Removed what an interrupted command left", "path", path)
|
|
}
|
|
|
|
return 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
|
|
}
|