package secret import ( "errors" "fmt" "os" "path/filepath" "github.com/spf13/afero" ) // 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)+".tmp-*") 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)), ".tmp-") if err != nil { return "", fmt.Errorf( "failed to create temporary directory for %s: %w", target, err) } return dir, 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 }