check / check (push) Waiting to run
`secret encrypt` and `secret decrypt` emptied the file named by `--output` before reading any input, so a failure left it empty, and a file given as both `--input` and `--output` was emptied before it was read. They now write into a temporary file of mode 0600 next to it, or next to the file it is a symbolic link to, and rename that over it only on success, with the new `secret.WriteFileAtomicFunc`. An output file that cannot be written still fails. Ctrl-C, SIGTERM or a hangup removes the temporary file. A device or pipe, such as `/dev/null`, is written to directly. Judgement calls: the device and pipe case; a command started with `nohup` still ignores a hangup. Model: opus-5-5
194 lines
5.4 KiB
Go
194 lines
5.4 KiB
Go
package secret
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// tempNamePart starts the name of every temporary file WriteFileAtomicFunc
|
|
// makes 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 {
|
|
return WriteFileAtomicFunc(fs, path, func(file afero.File) error {
|
|
_, err := file.Write(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write %s: %w", path, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// WriteFileAtomicFunc is WriteFileAtomic for content that write streams
|
|
// into the temporary file, which it is given, instead of holding it in
|
|
// memory. An error from write is returned as it is. The temporary file's
|
|
// name leaves out path's, which may already be as long as a name can be.
|
|
func WriteFileAtomicFunc(
|
|
fs afero.Fs, path string, write func(file afero.File) error,
|
|
) error {
|
|
tmp, err := afero.TempFile(fs, filepath.Dir(path), tempNamePart)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
|
}
|
|
|
|
err = write(tmp)
|
|
if err != nil {
|
|
_ = tmp.Close()
|
|
_ = fs.Remove(tmp.Name())
|
|
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|