Author SHA1 Message Date
sneak 8e39e1b293 Resolve scan operands to their real paths (closes #144)
check / check (push) Waiting to run
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
clawbot f52bbe5d95 Say ZFS counts a new file's blocks up to seconds after the write (closes #152)
check / check (push) Canceled after 0s
`README.md` and a comment in `scan.go` said ZFS counts a new file's blocks only seconds after the write. The count can land in under a second, so both now say up to seconds after the write, as the comment on `diskSizeOf` already does.

Model: opus-5-5
2026-11-03 05:00:51 +01:00
clawbot 30dae35c51 Stop the disk size tests failing at random on ZFS (closes #150)
check / check (push) Canceled after 0s
The three disk size tests in `scan_test.go` failed at random on ZFS, which failed the build on `next`. ZFS counts a write's blocks up to seconds later, sometimes through several sizes, and `unix.Sync` does not wait for that. The rewrite test now waits for the size to change, then rescans until the size is the same on both sides of a rescan, and compares exactly, all within 30 seconds. The other two tests fail only on a `disk_size` outside the reads around their scan.

Limit: those two can pass on a wrong 512 when ZFS has not yet counted the blocks.

Model: opus-5-5
2026-11-03 04:29:19 +01:00
clawbot 4ecf8dc484 Rewrite TODO.md sentence by sentence (closes #135)
check / check (push) Canceled after 0s
Rewrites `TODO.md` from scratch for #135, by the rules applied to `README.md`. Every completed step keeps its date, its issue or branch and its facts, in short sentences, with lists as bullets. An entry superseded later says so. Entries record history, so they were not corrected against the code.

Known stale: the 2026-10-17 release-rule entry.
Changed: one product name in the 2026-10-15 entry is replaced by a description.

Model: fable-5-1 (rewrite); opus-5-5 (review, landing message)
2026-11-02 23:29:22 +01:00
clawbot 2322f8bb02 Rewrite README.md sentence by sentence for a newcomer
check / check (push) Canceled after 0s
Rewrites `README.md` from scratch for #135, so that a newcomer can follow each sentence on one reading. It leads with what the program is and how to use it, then the reference, then development. Where the old text and the code disagreed, it states what the code does; #140 lists each point. Test comments that cited moved text cite the new headings.

Review scope: full reviews through the 16th, then only text changed since.
Dropped: the permission to add `github.com/spf13/viper`.

Model: fable-5-1 (rewrite); opus-5-5 (review, landing message)
2026-11-02 23:12:27 +01:00
7 changed files with 2093 additions and 1505 deletions
+1069 -922
View File
File diff suppressed because it is too large Load Diff
+622 -437
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -17,7 +17,9 @@ import (
func TestDefaultExcludes(t *testing.T) {
t.Parallel()
// README "Rules for the walk": each system's list.
// README "Rules for the walk": the Linux and macOS lists, and none
// on any other system. The Windows list exists in the code only: the
// binary does not build for Windows, so the README leaves it out.
cases := map[string][]string{
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
goosDarwin: {
+264 -73
View File
@@ -14,6 +14,8 @@ import (
"strconv"
"strings"
"testing"
"golang.org/x/sys/unix"
)
// usageMarker is the first line of cobra's usage text, which a usage
@@ -394,7 +396,7 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
}
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
// README §Database: the path names the file exactly. With link a
// README §Settings: the path names the file exactly. With link a
// symlink to the directory real/sub, link/../db.sqlite names
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
// use that file, relative or absolute, and its first line must name
@@ -649,7 +651,7 @@ func TestRunUsageErrors(t *testing.T) {
}
func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
// README §scan mode: --workers below 1 is a usage error reported in
// README §Settings: --workers below 1 is a usage error reported in
// one line on stderr, before the scan opens the database.
for _, workers := range []string{"0", "-1"} {
t.Run(workers, func(t *testing.T) {
@@ -687,8 +689,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
func TestRunHelp(t *testing.T) {
t.Parallel()
// README §Subcommands: help goes to stderr, exits 0, and leaves
// stdout empty.
// README §Settings: help goes to stderr, exits 0, and leaves stdout
// empty.
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
for _, args := range cases {
@@ -714,8 +716,8 @@ func TestRunHelp(t *testing.T) {
func TestRunVersion(t *testing.T) {
t.Parallel()
// README §Subcommands: the version is one line on stdout, with
// nothing on stderr, and exits 0.
// README §Settings: the version is one line on stdout, with nothing
// on stderr, and exits 0.
info, _ := debug.ReadBuildInfo()
for _, arg := range []string{"--version", "-v"} {
@@ -774,8 +776,8 @@ func TestBuildVersion(t *testing.T) {
func TestRunVersionWriteFailureIsFatal(t *testing.T) {
t.Parallel()
// README §Error handling: a stdout write failure exits 1, reported
// in one line on stderr.
// README §Error handling: a failed write to stdout is a fatal error,
// exit 1, reported on stderr starting with "sfdupes:".
var stderr bytes.Buffer
code := run([]string{"--version"}, failingWriter{}, &stderr)
@@ -895,54 +897,262 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
assertNoSidecars(t, path)
}
func TestRunScanSkipsSymlinkOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
// realTempDir returns a new temporary directory by its real path, so
// tests hold where the temporary directory is reached through a
// symlink.
func realTempDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
err := os.Symlink(filepath.Join(dir, "target"), link)
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
// Scanning a directory through the symlink stores a record beneath
// the symlink's own path for a file beneath its target.
scanOK(t, filepath.Join(link, "sub"))
assertOperandSkipped(t, path, link, "symlink",
filepath.Join(link, "sub", "f"))
return dir
}
// symlink creates link pointing at target.
func symlink(t *testing.T, target, link string) {
t.Helper()
err := os.Symlink(target, link)
if err != nil {
t.Fatal(err)
}
}
// assertSummary checks that stderr holds the scan summary line want.
func assertSummary(t *testing.T, stderr, want string) {
t.Helper()
if !strings.Contains(stderr, want) {
t.Errorf("stderr = %q, want %q", stderr, want)
}
}
// TestRunScanFollowsSymlinkOperand scans link, a symlink to target.
// The operand is scanned at its real path, and its file is recorded
// there.
func TestRunScanFollowsSymlinkOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
assertSummary(t, scanOK(t, link), "scan: 1 files seen (1 added, "+
"0 updated, 0 removed, 0 unchanged), 0 skipped\n")
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanWalksOperandUnderSymlinkOperand scans link and link/sub,
// with link a symlink to target. Both resolve under target, so
// target/sub is walked once, as part of target.
func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
writeFile(t, dir, "target/sub/f", pattern(1, 10))
dir := realTempDir(t)
f := writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
err := os.Symlink(filepath.Join(dir, "target"), link)
assertSummary(t, scanOK(t, link, filepath.Join(link, "sub")),
"scan: 1 files seen (1 added, 0 updated, 0 removed, "+
"0 unchanged), 0 skipped\n")
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanWalksSymlinkedOperandBelowOperand is the case of
// https://git.eeqj.de/sneak/sfdupes/issues/144: scan dir dir/link/sub,
// with dir/link a symlink to a directory outside dir. The walk of dir
// does not follow link, so dir/link/sub is walked on its own at its
// real path, and the record an earlier scan stored there survives.
func TestRunScanWalksSymlinkedOperandBelowOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
a := writeFile(t, dir, "a", pattern(1, 10))
outside := realTempDir(t)
f := writeFile(t, outside, "sub/f", pattern(2, 10))
symlink(t, outside, filepath.Join(dir, "link"))
scanOK(t, filepath.Join(outside, "sub"))
stderr := scanOK(t, dir, filepath.Join(dir, "link", "sub"))
assertSummary(t, stderr, "scan: 2 files seen (1 added, 0 updated, "+
"0 removed, 1 unchanged), 0 skipped\n")
want := []string{a, f}
slices.Sort(want)
if got := storedPaths(t, path); !slices.Equal(got, want) {
t.Errorf("records = %q, want %q", got, want)
}
}
// TestRunScanDropsSymlinkOperandInsideOperand scans dir and link, with
// link, outside dir, a symlink to dir/real. link resolves inside dir,
// so it is dropped as covered by dir, and its file is recorded once.
func TestRunScanDropsSymlinkOperandInsideOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "real/f", pattern(1, 10))
link := filepath.Join(realTempDir(t), "link")
symlink(t, filepath.Join(dir, "real"), link)
assertSummary(t, scanOK(t, dir, link), "scan: 1 files seen (1 added, "+
"0 updated, 0 removed, 0 unchanged), 0 skipped\n")
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanDanglingSymlinkOperandIsFatal scans a valid directory and
// a symlink whose target does not exist. The scan fails with exit 1 and
// an error naming the symlink as given, before it walks anything, so no
// database is created.
func TestRunScanDanglingSymlinkOperandIsFatal(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
writeFile(t, dir, "f", pattern(1, 10))
dangling := filepath.Join(realTempDir(t), "dangling")
symlink(t, filepath.Join(dir, "missing"), dangling)
stdout := captureStdout(t)
var stderr bytes.Buffer
code := run([]string{cmdScan, dir, dangling}, os.Stdout, &stderr)
if code != exitFatal {
t.Errorf("run(scan %s %s) = %d, want %d", dir, dangling, code,
exitFatal)
}
assertFatalOutput(t, stderr.String(), stdout())
want := "sfdupes: resolve " + dangling + ": "
if !strings.Contains(stderr.String(), want) {
t.Errorf("stderr = %q, want %q", stderr.String(), want)
}
_, err := os.Stat(path)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("database: stat error %v, want it missing", err)
}
}
// TestRunScanFromSymlinkedWorkingDirectoryRecordsRealPath runs scan .
// after a cd through home/proj, a symlink to data/proj. The file is
// recorded under data/proj, its real path.
func TestRunScanFromSymlinkedWorkingDirectoryRecordsRealPath(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "data/proj/f", pattern(1, 10))
err := os.Mkdir(filepath.Join(dir, "home"), 0o750)
if err != nil {
t.Fatal(err)
}
// link is dropped as a symlink, but link/sub must still be scanned,
// not dropped as lying under link.
scanOK(t, link, filepath.Join(link, "sub"))
symlink(t, filepath.Join(dir, "data", "proj"),
filepath.Join(dir, "home", "proj"))
db, err := openDB(path, reportParams)
// t.Chdir also sets PWD to the path through the symlink, as a
// shell does after cd.
t.Chdir(filepath.Join(dir, "home", "proj"))
scanOK(t, ".")
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanFollowsSymlinkBeforeDotDot scans a path with a ".." after
// home/proj, a symlink to data/proj: home/proj/.. from dir, and .. after
// a cd through home/proj. As with realpath, the ".." counts from
// data/proj, so data is scanned and home is not.
func TestRunScanFollowsSymlinkBeforeDotDot(t *testing.T) {
for _, tc := range []struct{ wd, operand string }{
{"", "home/proj/.."},
{"home/proj", ".."},
} {
t.Run(tc.operand, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "data/proj/f", pattern(1, 10))
g := writeFile(t, dir, "data/g", pattern(2, 10))
writeFile(t, dir, "home/h", pattern(3, 10))
symlink(t, filepath.Join(dir, "data", "proj"),
filepath.Join(dir, "home", "proj"))
// t.Chdir also sets PWD to the path given, as a shell does
// after cd.
t.Chdir(filepath.Join(dir, tc.wd))
scanOK(t, tc.operand)
want := []string{f, g}
slices.Sort(want)
if got := storedPaths(t, path); !slices.Equal(got, want) {
t.Errorf("records = %q, want %q", got, want)
}
})
}
}
// TestRunScanSkipsSymlinkToFIFOOperand scans link, a symlink to a FIFO.
// The scan warns about the FIFO by its real path, counts it as skipped,
// and exits 0.
func TestRunScanSkipsSymlinkToFIFOOperand(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := realTempDir(t)
fifo := filepath.Join(dir, "fifo")
err := unix.Mkfifo(fifo, 0o600)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
link := filepath.Join(dir, "link")
symlink(t, fifo, link)
recordByPath(t, dbRecords(t, db), filepath.Join(link, "sub", "f"))
stderr := scanOK(t, link)
warning := "walk " + fifo + ": skipping FIFO operand\n"
if !strings.Contains(stderr, warning) {
t.Errorf("stderr = %q, want %q", stderr, warning)
}
assertSummary(t, stderr, "scan: 0 files seen (0 added, 0 updated, "+
"0 removed, 0 unchanged), 1 skipped\n")
}
func TestRunScanSkipsZFSOperand(t *testing.T) {
@@ -1041,23 +1251,19 @@ func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
// by the path given, counted as skipped, and not walked.
// by its real path, counted as skipped, and not walked.
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := t.TempDir()
dir := realTempDir(t)
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
snaps := filepath.Join(dir, "snaps")
symlink(t, filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(snaps, "daily")
stderr := scanOK(t, daily)
stderr := scanOK(t, filepath.Join(snaps, "daily"))
daily := filepath.Join(dir, "pool", ".zfs", "snapshot", "daily")
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n"
@@ -1069,39 +1275,25 @@ func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
}
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
// a symlink to a directory outside any .zfs directory. The operand is
// scanned as usual, and its file is recorded by the path given.
// a symlink to pool/data. The operand is scanned as usual, and its file
// is recorded by its real path.
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
dir := realTempDir(t)
f := writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
data := filepath.Join(dir, "data")
symlink(t, filepath.Join(dir, "pool", "data"), data)
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
if err != nil {
t.Fatal(err)
assertSummary(t, scanOK(t, filepath.Join(data, "daily")),
"scan: 1 files seen (1 added, 0 updated, 0 removed, "+
"0 unchanged), 0 skipped\n")
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
daily := filepath.Join(data, "daily")
stderr := scanOK(t, daily)
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
}
func TestRunReportSucceeds(t *testing.T) {
@@ -1257,7 +1449,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
}
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
// README §Database: the path names the database file exactly. In
// README §Settings: the path names the database file exactly. In
// SQLite's connection string an unescaped ? or # would end the file
// name and % would start an escape, and a path starting with //
// could be read as a host name. %25 is a valid escape, so unescaped
@@ -1302,9 +1494,8 @@ func holdScanLock(t *testing.T, path string) {
}
func TestRunSecondScanFails(t *testing.T) {
// README §Database: while one scan holds the lock, a second scan
// fails at once, naming the lock file, without opening the
// database.
// README §Database: a second scan that finds the lock held fails
// at once, naming the lock file, without opening the database.
path := testDBPath(t)
t.Setenv(databaseEnv, path)
@@ -1363,7 +1554,7 @@ func assertSecondScanFails(t *testing.T, path string) {
}
func TestRunSecondScanThroughSymlinkFails(t *testing.T) {
// README §Database: every path to one database names the same lock
// README §Database: a path through a symlink names the same lock
// file, so while a scan holds it through one path, a scan through
// another fails, naming the lock file beside the database's real
// path.
@@ -1468,8 +1659,8 @@ func TestRunReportsDuringScan(t *testing.T) {
}
func TestRunStdoutClosedIsFatal(t *testing.T) {
// README §Error handling: a stdout write failure exits 1, reported
// in one line on stderr.
// README §Error handling: a failed write to stdout is a fatal error,
// exit 1, reported on stderr starting with "sfdupes:".
for _, name := range []string{cmdReport, cmdTrees, cmdInfo} {
t.Run(name, func(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
+46 -34
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))
@@ -554,7 +572,7 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
// The space a file takes on disk can change while its size and
// mtime stay the same. ZFS, for one, counts a new file's blocks
// only seconds after the write.
// up to seconds after the write.
if old.diskSize != ev.rec.diskSize {
s.diskSizes = append(s.diskSizes, ev.rec)
}
@@ -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)
}
+86 -35
View File
@@ -43,10 +43,16 @@ func writeFile(t *testing.T, dir, name string, data []byte) string {
}
// diskSizeOf returns the bytes the file at path takes on disk, from its
// own lstat: st_blocks counts 512-byte units. ZFS counts a new file's
// blocks only seconds after the write, and fsync does not hasten that.
// A test therefore calls unix.Sync, which waits for it, before the scan
// whose disk_size it compares with this.
// own lstat: st_blocks counts 512-byte units. ZFS counts a write's
// blocks only when it commits the write, up to seconds later, and one
// commit can take the file through several growing sizes. So this value
// can change at any moment. Neither fsync nor unix.Sync hastens that,
// but a unix.Sync call often takes long enough that the blocks are
// counted when it returns. A test that needs the value to change waits
// for the change. A test that compares it with a scan's disk_size reads
// it before and after the scan. The rewrite test rescans until the two
// reads match, then compares exactly. The other two tests fail only on
// a disk_size outside the two reads.
func diskSizeOf(t *testing.T, path string) int64 {
t.Helper()
@@ -1082,8 +1088,9 @@ func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
}
}
// buildSmokeTree recreates the README smoke-test filesystem layout
// with deterministic content and returns the tree root.
// buildSmokeTree recreates the tree of the test under README
// "Definition of done" with deterministic content and returns the tree
// root.
func buildSmokeTree(t *testing.T) string {
t.Helper()
@@ -1447,20 +1454,26 @@ func TestScanStoresDiskSize(t *testing.T) {
unix.Sync() // see diskSizeOf
want := diskSizeOf(t, p)
if want == 0 || want >= length {
size := diskSizeOf(t, p)
if size == 0 || size >= length {
t.Fatalf("the file takes %d bytes on disk, want more than none "+
"and fewer than its length %d", want, length)
"and fewer than its length %d", size, length)
}
for _, operand := range []string{filepath.Dir(p), p} {
db := openTestDB(t)
// ZFS may count the blocks during the scan; see diskSizeOf.
before := diskSizeOf(t, p)
syncTree(t, db, operand)
after := diskSizeOf(t, p)
got := recordByPath(t, dbRecords(t, db), p).diskSize
if got != want {
t.Errorf("scan of %s: disk_size = %d, want %d",
operand, got, want)
if got < before || got > after {
t.Errorf("scan of %s: disk_size = %d, want %d to %d",
operand, got, before, after)
}
}
}
@@ -1478,7 +1491,7 @@ func TestSyncScanRewriteUpdatesDiskSize(t *testing.T) {
syncTree(t, db, dir)
before := recordByPath(t, dbRecords(t, db), p).diskSize
recorded := recordByPath(t, dbRecords(t, db), p).diskSize
pokeAt(t, p, 512*1024, pattern(7, 64*1024))
@@ -1491,22 +1504,53 @@ func TestSyncScanRewriteUpdatesDiskSize(t *testing.T) {
t.Fatal(err)
}
unix.Sync() // see diskSizeOf
// Wait for ZFS to count the write; see diskSizeOf.
deadline := time.Now().Add(30 * time.Second)
for diskSizeOf(t, p) == recorded {
if time.Now().After(deadline) {
t.Fatalf("the file still takes %d bytes on disk 30 seconds "+
"after the write into its hole", recorded)
}
want := diskSizeOf(t, p)
if want == before {
t.Fatalf("the file still takes %d bytes on disk after the write "+
"into its hole", want)
unix.Sync()
}
st := syncTree(t, db, dir)
if st != (scanStats{walked: 1, updated: 1}) {
t.Fatalf("rescan stats = %+v, want 1 updated", st)
}
// ZFS may still be counting, so the size may change during a rescan.
// Only a rescan with the same size on both sides of it can be compared
// exactly. A rescan that keeps the old size still fails, because the
// wait made before differ from it.
for {
before := diskSizeOf(t, p)
got := recordByPath(t, dbRecords(t, db), p).diskSize
if got != want {
t.Errorf("disk_size = %d, want %d", got, want)
st := syncTree(t, db, dir)
after := diskSizeOf(t, p)
if st != (scanStats{walked: 1, updated: 1}) {
t.Fatalf("rescan stats = %+v, want 1 updated", st)
}
if before == after {
got := recordByPath(t, dbRecords(t, db), p).diskSize
if got != after {
t.Errorf("disk_size = %d, want %d", got, after)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("the size on disk was still changing 30 seconds after "+
"the write into its hole: %d, then %d", before, after)
}
// A later mtime makes the next rescan read the file again.
later = later.Add(time.Second)
err = os.Chtimes(p, later, later)
if err != nil {
t.Fatal(err)
}
}
}
@@ -1532,15 +1576,21 @@ func TestSyncScanUnchangedUpdatesDiskSize(t *testing.T) {
unix.Sync() // see diskSizeOf
// ZFS may count the blocks during the rescan; see diskSizeOf.
before := diskSizeOf(t, p)
// A file read again would count as updated.
st := syncTree(t, db, dir)
after := diskSizeOf(t, p)
if st != (scanStats{walked: 1, unchanged: 1}) {
t.Fatalf("rescan stats = %+v, want 1 unchanged", st)
}
got := recordByPath(t, dbRecords(t, db), p).diskSize
if want := diskSizeOf(t, p); got != want {
t.Errorf("disk_size = %d, want %d", got, want)
if got < before || got > after {
t.Errorf("disk_size = %d, want %d to %d", got, before, after)
}
}
@@ -2260,15 +2310,16 @@ func TestUnderRoot(t *testing.T) {
}
// TestRunScanPassesOverDatabase scans a tree that holds the scan's own
// database, reached directly and through a symlinked directory. 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.
// 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 _, viaSymlink := range []bool{false, true} {
t.Run(fmt.Sprintf("via symlink %v", viaSymlink), func(t *testing.T) {
base := t.TempDir()
base := realTempDir(t)
tree := filepath.Join(base, "real", "tree")
operand := tree
@@ -2296,7 +2347,7 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
}
err = applyChanges(t.Context(), db, []scanRec{
{path: filepath.Join(operand, "db.sqlite.lock")},
{path: filepath.Join(tree, "db.sqlite.lock")},
}, nil, nil)
if err != nil {
t.Fatal(err)
@@ -2313,7 +2364,7 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
report := runStdout(t, cmdReport)
if strings.Contains(report, "db.sqlite") ||
!strings.Contains(report, filepath.Join(operand, "empty2")) {
!strings.Contains(report, filepath.Join(tree, "empty2")) {
t.Errorf("report = %q, want the empty files and none of "+
"the database's files", report)
}
+3 -3
View File
@@ -15,9 +15,9 @@ import (
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
"remain unknown"
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
// /d/t2 are identical, /d/t3 differs from them only by one filename.
// Each file takes one 4 KiB block on disk.
// smokeTreeRecs mirrors the tree of the test under README "Definition
// of done": /d/t1 and /d/t2 are identical, /d/t3 differs from them only
// by one filename. Each file takes one 4 KiB block on disk.
func smokeTreeRecs() []scanRec {
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")