check / check (push) Waiting to run
When `secret mv` copied a secret into another vault but could not remove the source, it printed a warning and the usual "Moved" line and exited 0. It now fails. If the source secret's directory is still in place, the error says the copy holds all versions and the source remains. If the source left its vault and only the temporary directory it was moved into could not be deleted, the error says the secret is moved and names that directory, which the error of RemoveDirAtomic now does. The tests are in package cli and call moveSecret, because the state directory lock refuses the wrapped filesystems that make the removal fail. Model: opus-5-5
176 lines
5.0 KiB
Go
176 lines
5.0 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. If
|
|
// only deleting the temporary directory fails, dir is already gone, and the
|
|
// error names the temporary directory, which holds what is left of it.
|
|
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 after moving %s into it: %w",
|
|
tmp, dir, err)
|
|
}
|
|
|
|
return nil
|
|
}
|