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 61 additions and 63 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
// set and non-empty, the compiled-in default otherwise. It returns the
// path, made absolute by absolutePath, and where the path came from,
// "default" or "from SFDUPES_DATABASE", for the line each subcommand
// prints first on stderr.
// path, with the working directory, its symlinks resolved, put in front
// of it when it is relative, and where the path came from, "default" or
// "from SFDUPES_DATABASE", for the line each subcommand prints first on
// stderr.
func databasePath() (string, string, error) {
path, source := defaultDatabasePath, "default"
if p := os.Getenv(databaseEnv); p != "" {
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 {
return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
}
return abs, source, nil
}
// 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
}
// 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.
// 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
+18 -3
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
@@ -219,9 +221,22 @@ func resolveRoots(roots []string) ([]string, error) {
resolved := make([]string, 0, len(roots))
for _, root := range roots {
a, err := absolutePath(root)
if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err)
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)
+27 -33
View File
@@ -2310,24 +2310,34 @@ func TestUnderRoot(t *testing.T) {
}
// TestRunScanPassesOverDatabase scans a tree that holds the scan's own
// database, and in one case also a hard link to the database file. The
// walk knows the database's files by device ID and inode number, so it
// passes over the database file, its -wal and -shm files, its .lock
// file and the hard link with no line and no count. The record an
// earlier scan left for the .lock file is deleted. A rescan of the
// unchanged tree updates nothing, and report lists none of the
// database's files.
// database, given directly and through a symlinked directory, which
// the scan resolves to the same real path. The walk passes over the
// database file, its -wal and -shm files and its .lock file with no
// line and no count, and the record an earlier scan left for the .lock
// file is deleted. A rescan of the unchanged tree updates nothing, and
// report lists none of the database's files.
func TestRunScanPassesOverDatabase(t *testing.T) {
for _, viaHardLink := range []bool{false, true} {
t.Run(fmt.Sprintf("via hard link %v", viaHardLink), func(t *testing.T) {
tree := realTempDir(t)
hardLink := filepath.Join(tree, "hardlink")
for _, viaSymlink := range []bool{false, true} {
t.Run(fmt.Sprintf("via symlink %v", viaSymlink), func(t *testing.T) {
base := realTempDir(t)
tree := filepath.Join(base, "real", "tree")
operand := tree
writeFile(t, tree, "a", pattern(1, 100))
writeFile(t, tree, "b", pattern(1, 100))
writeFile(t, tree, "empty1", 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")
t.Setenv(databaseEnv, dbPath)
@@ -2345,28 +2355,15 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
closeScanDatabase(t.Context(), db, dbPath)
if viaHardLink {
err = os.Link(dbPath, hardLink)
if err != nil {
t.Fatal(err)
}
}
stderr := assertScanSummary(t,
assertScanSummary(t,
"4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)",
tree)
stderr += assertScanSummary(t,
operand)
assertScanSummary(t,
"4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)",
tree)
if strings.Contains(stderr, hardLink) {
t.Errorf("scan stderr = %q, want no line naming %s",
stderr, hardLink)
}
operand)
report := runStdout(t, cmdReport)
if strings.Contains(report, "db.sqlite") ||
strings.Contains(report, hardLink) ||
!strings.Contains(report, filepath.Join(tree, "empty2")) {
t.Errorf("report = %q, want the empty files and none of "+
"the database's files", report)
@@ -2392,9 +2389,8 @@ func TestRunScanPassesOverDatabaseOperands(t *testing.T) {
// assertScanSummary scans operands and fails the test unless the
// 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
// everything the scan printed to stderr.
func assertScanSummary(t *testing.T, counts string, operands ...string) string {
// the database is the only one that names one of its files.
func assertScanSummary(t *testing.T, counts string, operands ...string) {
t.Helper()
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 "+
"database to name one of its files", operands, stderr)
}
return stderr
}