aboutsummaryrefslogtreecommitdiff
path: root/internal/apply/fs.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 14:02:24 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 14:02:24 +0200
commita65e8a0587d3e5c971bef3062dd0d8a3b5cb53b7 (patch)
tree82c606411fd814cb5003633cfa592393abcbe057 /internal/apply/fs.go
parentb44222fc2b061382dc601014cde287cc41b857ca (diff)
downloadkrino-a65e8a0587d3e5c971bef3062dd0d8a3b5cb53b7.tar.gz
krino-a65e8a0587d3e5c971bef3062dd0d8a3b5cb53b7.zip
a symlink in the sorted directory no longer redirects a step
Placeholders were already stopped from sending a file out of the directory a rule named. A symlink is a name too, and the directory krino sorts is by the threat model's own premise a place the internet writes into: a link named after a rule's destination sent moves and copies anywhere, and under (on-conflict overwrite) trashed a file OUTSIDE the sorted directory - while the plan showed the in-tree text and the run reported success. A step whose destination passes through a symlink at or below the directory being sorted now fails. A destination the configuration names outside it - ~/docs on another disk - is the user's own arrangement and is followed as before; both cases have a test. End to end, the review's scenario (Out -> ~/secret, overwrite): before: 1 applied, the user's file replaced and trashed after: 0 applied 1 failed, the file untouched, nothing trashed Two bookkeeping bugs in MkdirAllTracked went with it: a dangling symlink read as a missing directory and was then recorded as one krino had created - undo would have unlinked a link krino never made - and a directory created by someone else between the check and the mkdir was recorded the same way.
Diffstat (limited to 'internal/apply/fs.go')
-rw-r--r--internal/apply/fs.go59
1 files changed, 57 insertions, 2 deletions
diff --git a/internal/apply/fs.go b/internal/apply/fs.go
index a8feccc..0eee780 100644
--- a/internal/apply/fs.go
+++ b/internal/apply/fs.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
+ "io/fs"
"os"
"path/filepath"
"strings"
@@ -206,7 +207,17 @@ func splitExt(name string) (stem, ext string) {
// 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 {
+ // Lstat, not Stat: a dangling symlink here reports ENOENT to Stat, so
+ // the "already a directory" branch is missed, os.Mkdir then fails with
+ // EEXIST, and the path used to be recorded as one krino created - which
+ // undo would later remove, unlinking a symlink krino never made.
+ if fi, err := os.Lstat(dir); err == nil {
+ if fi.Mode()&fs.ModeSymlink != 0 {
+ if resolved, serr := os.Stat(dir); serr != nil || !resolved.IsDir() {
+ return nil, fmt.Errorf("%s is a symlink that does not lead to a directory", dir)
+ }
+ return nil, nil
+ }
if !fi.IsDir() {
return nil, fmt.Errorf("%s exists and is not a directory", dir)
}
@@ -224,8 +235,52 @@ func MkdirAllTracked(dir string) ([]string, error) {
}
made = parentMade
}
- if err := os.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) {
+ if err := os.Mkdir(dir, 0o755); err != nil {
+ if os.IsExist(err) {
+ // Something else created it between the Lstat and here: it is
+ // not ours to remove again.
+ return made, nil
+ }
return made, err
}
return append(made, dir), nil
}
+
+// UnderSymlink reports the first component of path, at or below root, that
+// is a symlink - "" when there is none, and "" when path is not under root
+// at all.
+//
+// Placeholders are already stopped from redirecting a step out of the
+// directory a rule named (internal/plan's expandDir); a symlink is a name
+// too, and one planted inside the directory krino sorts - by an unpacked
+// archive, say - redirects a move or a copy exactly the same way, while the
+// plan the user approved shows only the text. A destination the
+// configuration itself names, outside the sorted directory, is the user's
+// own arrangement and is not second-guessed.
+func UnderSymlink(path, root string) string {
+ if root == "" {
+ return ""
+ }
+ root = filepath.Clean(root)
+ path = filepath.Clean(path)
+ rel, err := filepath.Rel(root, path)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return ""
+ }
+ at := root
+ for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
+ if part == "." || part == "" {
+ continue
+ }
+ at = filepath.Join(at, part)
+ fi, err := os.Lstat(at)
+ if err != nil {
+ // Not there yet: nothing below it can be a symlink either.
+ return ""
+ }
+ if fi.Mode()&fs.ModeSymlink != 0 {
+ return at
+ }
+ }
+ return ""
+}