check / check (push) Canceled after 0s
`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 and rename that over it only on success, using the new `secret.WriteFileAtomicFunc`, which `secret.WriteFileAtomic` now calls. A device or pipe given as `--output`, such as `/dev/null`, is written to directly instead of being replaced by a file. Judgement call: the device and pipe case goes beyond the plan. Judgement call: a symbolic link given as `--output` is replaced, not followed. Model: opus-5-5
188 lines
5.2 KiB
Go
188 lines
5.2 KiB
Go
package secret
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// tempNamePart is in the name of every temporary file WriteFileAtomicFunc
|
|
// 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 {
|
|
return WriteFileAtomicFunc(fs, path, func(w io.Writer) error {
|
|
_, err := w.Write(data)
|
|
|
|
return err
|
|
})
|
|
}
|
|
|
|
// WriteFileAtomicFunc is WriteFileAtomic for content that write streams
|
|
// into the temporary file instead of holding it in memory. An error from
|
|
// write fails it as any other step does.
|
|
func WriteFileAtomicFunc(
|
|
fs afero.Fs, path string, write func(w io.Writer) error,
|
|
) 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 = write(tmp)
|
|
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
|
|
}
|