1 Commits
Author SHA1 Message Date
sneak 8e39e1b293 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
2026-11-06 18:06:05 +00:00
3 changed files with 59 additions and 61 deletions

No files matched your search

+16 -27
View File
@@ -146,44 +146,33 @@ var errHashSize = errors.New("hash is not 32 bytes")
// databasePath resolves the database location: SFDUPES_DATABASE when // databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the // set and non-empty, the compiled-in default otherwise. It returns the
// path, made absolute by absolutePath, and where the path came from, // path, with the working directory, its symlinks resolved, put in front
// "default" or "from SFDUPES_DATABASE", for the line each subcommand // of it when it is relative, and where the path came from, "default" or
// prints first on stderr. // "from SFDUPES_DATABASE", for the line each subcommand prints first on
// stderr.
func databasePath() (string, string, error) { func databasePath() (string, string, error) {
path, source := defaultDatabasePath, "default" path, source := defaultDatabasePath, "default"
if p := os.Getenv(databaseEnv); p != "" { if p := os.Getenv(databaseEnv); p != "" {
path, source = p, "from "+databaseEnv path, source = p, "from "+databaseEnv
} }
abs, err := absolutePath(path) if filepath.IsAbs(path) {
return path, source, nil
}
// The working directory as the kernel reports it, with no symlink
// in it, as README "Database" says. os.Getwd may return PWD
// instead, which can name it through a symlink.
wd, err := unix.Getwd()
if err != nil { if err != nil {
return "", "", fmt.Errorf("resolve database path %s: %w", path, err) return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
} }
return abs, source, nil // Not filepath.Abs or filepath.Join: they clean the path, which
} // drops each ".." together with the directory name before it. When
// that directory is a symlink, the cleaned path names another file.
// absolutePath returns path unchanged when it is absolute, and
// otherwise the working directory, a "/" and path. The working
// directory is the one the kernel reports, with no symlink in it;
// os.Getwd may return PWD instead, which can name it through a symlink.
// The result is not cleaned, as filepath.Abs and filepath.Join would
// clean it. Cleaning drops each ".." together with the directory name
// before it. When that name is a symlink, the cleaned path names
// another file than the one the system reaches by following the symlink
// first.
func absolutePath(path string) (string, error) {
if filepath.IsAbs(path) {
return path, nil
}
wd, err := unix.Getwd()
if err != nil {
return "", err
}
// The working directory ends in "/" only when it is the root. // The working directory ends in "/" only when it is the root.
return strings.TrimSuffix(wd, "/") + "/" + path, nil return strings.TrimSuffix(wd, "/") + "/" + path, source, nil
} }
// createDatabaseDirectory creates the directory the database at path // createDatabaseDirectory creates the directory the database at path
+16 -1
View File
@@ -18,6 +18,8 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"golang.org/x/sys/unix"
) )
// The hash phase gives every file it reads three hashes (see // The hash phase gives every file it reads three hashes (see
@@ -219,11 +221,24 @@ func resolveRoots(roots []string) ([]string, error) {
resolved := make([]string, 0, len(roots)) resolved := make([]string, 0, len(roots))
for _, root := range roots { for _, root := range roots {
a, err := absolutePath(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 { if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err) 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) r, err := filepath.EvalSymlinks(a)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err) return nil, fmt.Errorf("resolve %s: %w", root, err)
+27 -33
View File
@@ -2310,24 +2310,34 @@ func TestUnderRoot(t *testing.T) {
} }
// TestRunScanPassesOverDatabase scans a tree that holds the scan's own // TestRunScanPassesOverDatabase scans a tree that holds the scan's own
// database, and in one case also a hard link to the database file. The // database, given directly and through a symlinked directory, which
// walk knows the database's files by device ID and inode number, so it // the scan resolves to the same real path. The walk passes over the
// passes over the database file, its -wal and -shm files, its .lock // database file, its -wal and -shm files and its .lock file with no
// file and the hard link with no line and no count. The record an // line and no count, and the record an earlier scan left for the .lock
// earlier scan left for the .lock file is deleted. A rescan of the // file is deleted. A rescan of the unchanged tree updates nothing, and
// unchanged tree updates nothing, and report lists none of the // report lists none of the database's files.
// database's files.
func TestRunScanPassesOverDatabase(t *testing.T) { func TestRunScanPassesOverDatabase(t *testing.T) {
for _, viaHardLink := range []bool{false, true} { for _, viaSymlink := range []bool{false, true} {
t.Run(fmt.Sprintf("via hard link %v", viaHardLink), func(t *testing.T) { t.Run(fmt.Sprintf("via symlink %v", viaSymlink), func(t *testing.T) {
tree := realTempDir(t) base := realTempDir(t)
hardLink := filepath.Join(tree, "hardlink") tree := filepath.Join(base, "real", "tree")
operand := tree
writeFile(t, tree, "a", pattern(1, 100)) writeFile(t, tree, "a", pattern(1, 100))
writeFile(t, tree, "b", pattern(1, 100)) writeFile(t, tree, "b", pattern(1, 100))
writeFile(t, tree, "empty1", nil) writeFile(t, tree, "empty1", nil)
writeFile(t, tree, "empty2", nil) writeFile(t, tree, "empty2", nil)
if viaSymlink {
err := os.Symlink(filepath.Join(base, "real"),
filepath.Join(base, "link"))
if err != nil {
t.Fatal(err)
}
operand = filepath.Join(base, "link", "tree")
}
dbPath := filepath.Join(tree, "db.sqlite") dbPath := filepath.Join(tree, "db.sqlite")
t.Setenv(databaseEnv, dbPath) t.Setenv(databaseEnv, dbPath)
@@ -2345,28 +2355,15 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
closeScanDatabase(t.Context(), db, dbPath) closeScanDatabase(t.Context(), db, dbPath)
if viaHardLink { assertScanSummary(t,
err = os.Link(dbPath, hardLink)
if err != nil {
t.Fatal(err)
}
}
stderr := assertScanSummary(t,
"4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)", "4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)",
tree) operand)
stderr += assertScanSummary(t, assertScanSummary(t,
"4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)", "4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)",
tree) operand)
if strings.Contains(stderr, hardLink) {
t.Errorf("scan stderr = %q, want no line naming %s",
stderr, hardLink)
}
report := runStdout(t, cmdReport) report := runStdout(t, cmdReport)
if strings.Contains(report, "db.sqlite") || if strings.Contains(report, "db.sqlite") ||
strings.Contains(report, hardLink) ||
!strings.Contains(report, filepath.Join(tree, "empty2")) { !strings.Contains(report, filepath.Join(tree, "empty2")) {
t.Errorf("report = %q, want the empty files and none of "+ t.Errorf("report = %q, want the empty files and none of "+
"the database's files", report) "the database's files", report)
@@ -2392,9 +2389,8 @@ func TestRunScanPassesOverDatabaseOperands(t *testing.T) {
// assertScanSummary scans operands and fails the test unless the // assertScanSummary scans operands and fails the test unless the
// summary line gives counts with nothing skipped, and the line naming // summary line gives counts with nothing skipped, and the line naming
// the database is the only one that names one of its files. It returns // the database is the only one that names one of its files.
// everything the scan printed to stderr. func assertScanSummary(t *testing.T, counts string, operands ...string) {
func assertScanSummary(t *testing.T, counts string, operands ...string) string {
t.Helper() t.Helper()
stderr := scanOK(t, operands...) stderr := scanOK(t, operands...)
@@ -2408,6 +2404,4 @@ func assertScanSummary(t *testing.T, counts string, operands ...string) string {
t.Errorf("scan %q: stderr = %q, want only the line naming the "+ t.Errorf("scan %q: stderr = %q, want only the line naming the "+
"database to name one of its files", operands, stderr) "database to name one of its files", operands, stderr)
} }
return stderr
} }