Resolve scan operands to their real paths (closes #144)
check / check (push) Canceled after 0s

scan now resolves every operand with its symlinks followed before the
walk, and uses that real path for pruning, walking, records and
warnings. A relative operand is joined to the working directory as the
kernel reports it, so a ".." after a symlink counts from its target, as
with realpath. A symlink operand is scanned at its target instead of
skipped. An operand given through a symlink below another operand, to a
directory outside it, is walked on its own instead of dropped with its
records deleted. An operand that cannot be resolved is a fatal error
naming the operand as given. The walk itself still follows no symlink.

Model: opus-5-5
This commit is contained in:
2026-11-06 18:06:05 +00:00
parent f52bbe5d95
commit 8e39e1b293
5 changed files with 346 additions and 132 deletions
+45 -33
View File
@@ -18,6 +18,8 @@ import (
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
)
// The hash phase gives every file it reads three hashes (see
@@ -209,29 +211,43 @@ func interrupted(walked int) error {
return errInterrupted
}
// resolveRoots converts each PATH operand to an absolute, lexically
// cleaned path (symlinks are not resolved) and verifies that it
// exists. Database records are keyed by absolute path, so scan results
// must not depend on the working directory.
// resolveRoots converts each PATH operand to its real path: absolute,
// with every symlink in it followed. Database records are keyed by
// that path, so scan results depend neither on the working directory
// nor on the symlinks an operand was given through. An operand that
// cannot be resolved, because it or a symlink's target does not exist
// or cannot be read, is a fatal error before any scanning.
func resolveRoots(roots []string) ([]string, error) {
abs := make([]string, 0, len(roots))
resolved := make([]string, 0, len(roots))
for _, root := range roots {
a, err := filepath.Abs(root)
a := root
if !filepath.IsAbs(a) {
// The working directory as the kernel reports it, with no
// symlink in it. os.Getwd may return PWD instead, which can
// name it through a symlink.
wd, err := unix.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err)
}
// Not filepath.Abs or filepath.Join: they clean the path,
// which drops each ".." together with the directory name
// before it. EvalSymlinks follows a symlink before it
// applies the ".." after it, as realpath does. The working
// directory ends in "/" only when it is the root.
a = strings.TrimSuffix(wd, "/") + "/" + root
}
r, err := filepath.EvalSymlinks(a)
if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err)
}
// A nonexistent operand is a fatal error before any scanning.
_, err = os.Lstat(a)
if err != nil {
return nil, err
}
abs = append(abs, a)
resolved = append(resolved, r)
}
return abs, nil
return resolved, nil
}
// pruneRoots drops operands already covered by another operand:
@@ -417,14 +433,16 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
// walkableRoots returns the operands the walk can start from: regular
// files, and directories not named .zfs, none of them inside a
// directory named .zfs. Every other operand is warned about, counted as
// skipped, and dropped. A dropped operand is no longer a root, so the
// records stored beneath it are treated as if it had not been given:
// those under no other root count as outside the roots and are not
// deleted as unverified. An operand on a filesystem in rules that is not
// persistent is dropped the same way, with a line saying so that is not
// a warning, and is not counted as skipped. An operand that fails lstat
// here is kept, and the walk warns about it unless it no longer exists.
// directory named .zfs. The operands are real paths from resolveRoots,
// so these checks apply to what a symlink operand points at. Every
// other operand is warned about, counted as skipped, and dropped. A
// dropped operand is no longer a root, so the records stored beneath it
// are treated as if it had not been given: those under no other root
// count as outside the roots and are not deleted as unverified. An
// operand on a filesystem in rules that is not persistent is dropped
// the same way, with a line saying so that is not a warning, and is not
// counted as skipped. An operand that fails lstat here is kept, and the
// walk warns about it unless it no longer exists.
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
kept := make([]string, 0, len(roots))
@@ -894,19 +912,13 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
// operandWarning returns the one-line warning for an operand the walk
// does not start from, naming the path and what it is, or "" for one it
// does: a regular file, or a directory not named .zfs, that is not
// inside a directory named .zfs, neither as given nor with its symlinks
// resolved. Symlinks are never followed, including as operands.
// inside a directory named .zfs. resolveRoots has already followed the
// operand's symlinks, so a symlink here is an operand replaced by one
// after it was resolved, and is not followed.
func operandWarning(root string, fi fs.FileInfo) string {
// The walk never enters a .zfs directory, so it starts from nothing
// inside one either, not even through a symlink. An operand whose
// symlinks cannot be resolved is checked as given.
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
resolved = root
}
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
// inside one either.
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") {
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
root)
}