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 }