aboutsummaryrefslogtreecommitdiff
path: root/internal/apply/fs.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
commit3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch)
treeec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/apply/fs.go
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/apply/fs.go')
-rw-r--r--internal/apply/fs.go197
1 files changed, 197 insertions, 0 deletions
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
+}