From 3f8679be9373ee7508d512dfdfc1dda0839c7f90 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 20:14:47 +0200 Subject: krino: acting — trash, journal, apply, lock, review, undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/apply/fs.go | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 internal/apply/fs.go (limited to 'internal/apply/fs.go') diff --git a/internal/apply/fs.go b/internal/apply/fs.go new file mode 100644 index 0000000..205089f --- /dev/null +++ b/internal/apply/fs.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package apply + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" +) + +// copyFile copies src to dst by streaming its content through a temporary +// file created in dst's directory, syncing it, then renaming it into place. +// dst must not already exist. Mode and modification time are preserved from +// src. On any failure the temporary file is removed and neither src nor a +// pre-existing dst is touched. +// +// This is not internal/config's replaceFile reused: that helper resolves a +// destination symlink and overwrites a file that is already there, and it +// takes the whole replacement as a []byte. copy's destination must not exist +// beforehand, and a []byte cannot stand in for a file that may be many +// gigabytes, so this is a separate, streaming equivalent kept local to +// internal/apply rather than shared with internal/config. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + fi, err := in.Stat() + if err != nil { + return err + } + + dir := filepath.Dir(dst) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, ".krino-*") + if err != nil { + return err + } + tmpName := tmp.Name() + done := false + defer func() { + if !done { + os.Remove(tmpName) + } + }() + + if _, err := io.Copy(tmp, in); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(fi.Mode().Perm()); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chtimes(tmpName, fi.ModTime(), fi.ModTime()); err != nil { + return err + } + + // Deliberate, not redundant: the executor's own conflict re-check + // (runFileStep, apply.go) already found a name free of anything on disk + // before ever calling copyFile, so dst existing here means something + // else claimed it in the meantime. Refusing is the only safe response — + // silently overwriting it, via os.Rename below, would destroy whatever + // just raced us. + if _, err := os.Lstat(dst); err == nil { + return fmt.Errorf("copy: destination already exists: %s", dst) + } else if !os.IsNotExist(err) { + return err + } + if err := os.Rename(tmpName, dst); err != nil { + return err + } + done = true + return nil +} + +// moveFile moves src to dst. It tries os.Rename first, which is atomic when +// src and dst are on the same filesystem. Only when that fails with EXDEV +// (a different filesystem) does it fall back to copying src to dst through +// copyFile — itself leaving no temporary file and touching neither src nor +// dst on failure — and, only once that copy has landed at dst, removing src. +// A failure at any point before the copy has landed leaves src exactly +// where it was; a failure to remove src afterward leaves both a full copy +// at dst and the original at src rather than risk deleting the only good +// copy. +// +// The filesystem check is done by unwrapping the error for syscall.EXDEV, +// never by comparing a Stat_t's device field: that field's type differs +// across the platforms `make ci` vets (freebsd, openbsd), while syscall.EXDEV +// itself is defined identically on all three. +func moveFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + err := os.Rename(src, dst) + if err == nil { + return nil + } + if !errors.Is(err, syscall.EXDEV) { + return err + } + if err := copyFile(src, dst); err != nil { + return err + } + return os.Remove(src) +} + +// maxSuffixAttempts bounds nextFreeName. internal/plan/conflict.go and +// internal/trash/trash.go each have their own cap of the same size, for the +// same reason given below: nextFreeName solves yet another, independent +// collision problem and is not sharing code with either. +const maxSuffixAttempts = 10000 + +// nextFreeName finds the first stem_N.ext, N starting at 1, that does not +// currently exist on disk. It is the executor's own conflict re-check (spec +// §7.4, last paragraph): planning already resolved every conflict once +// against the filesystem as it was then, but something else can claim the +// planned name before the executor gets to it, so the executor looks again, +// right before acting, using only the real filesystem — it has no run-wide +// claim set to consult, unlike planning's. +// +// internal/plan's own suffixed() and splitExt are unexported, so they are +// not reachable from here; nextFreeName and splitExt below are a second, +// small implementation, the same shape as internal/plan's and +// internal/trash's for the same reason those two do not share code with +// each other either — each resolves a distinct, independently changing set +// of collisions (planned destinations; entries already in the Trash; names +// that appeared on disk since this plan was made). +func nextFreeName(dst string) (string, error) { + dir, base := filepath.Split(dst) + stem, ext := splitExt(base) + for n := 1; n <= maxSuffixAttempts; n++ { + candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext)) + if _, err := os.Lstat(candidate); os.IsNotExist(err) { + return candidate, nil + } + } + return "", errors.New("too many conflicting names") +} + +// splitExt splits name on its last dot, which does not count when it is the +// first character: "a.tar.gz" -> "a.tar", ".gz"; ".bashrc" -> ".bashrc", "". +// Duplicated from internal/plan/conflict.go and internal/trash/trash.go +// (unexported in both) rather than shared; see nextFreeName's comment. +func splitExt(name string) (stem, ext string) { + i := strings.LastIndexByte(name, '.') + if i <= 0 { + return name, "" + } + return name[:i], name[i:] +} + +// mkdirAllTracked creates dir and any missing ancestors (mode 0755), +// returning every directory it actually created, outermost first, so undo +// can later remove the empty ones again. A directory that already existed +// is not included, and nothing is created or returned on error. +func mkdirAllTracked(dir string) ([]string, error) { + dir = filepath.Clean(dir) + if fi, err := os.Stat(dir); err == nil { + if !fi.IsDir() { + return nil, fmt.Errorf("%s exists and is not a directory", dir) + } + return nil, nil + } else if !os.IsNotExist(err) { + return nil, err + } + + parent := filepath.Dir(dir) + var made []string + if parent != dir { + parentMade, err := mkdirAllTracked(parent) + if err != nil { + return nil, err + } + made = parentMade + } + if err := os.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) { + return made, err + } + return append(made, dir), nil +} -- cgit v1.3