11 Commits

Author SHA1 Message Date
1e7a519608 Split the stat pass back out of the walk
All checks were successful
check / check (push) Successful in 4s
Phases are strictly sequential again — walk, stat, hash, update per
operand — with parallelism only inside each phase. The walk
enumerates paths with per-directory workers (no lstat of file
entries); the stat pass lstats every collected path with per-file
workers, restoring its exact-total/ETA progress bar and per-file
parallelism inside wide flat directories.
2026-07-24 08:12:47 +07:00
09ff9b5f30 Merge branch 'scan-operand-progress': announce operands on stderr
All checks were successful
check / check (push) Successful in 1m15s
2026-07-24 07:52:55 +07:00
a0f0050ada Announce each operand on stderr before its passes
All checks were successful
check / check (push) Successful in 6s
With per-operand walk/hash/update cycles, a multi-operand invocation
(e.g. scan /srv/*) showed pass totals that looked like the whole
run's: an operator watching operand 3 of 14 hash 300k files concluded
the other 20M files were being skipped. Print the operand path and
its position before each cycle.
2026-07-24 07:52:54 +07:00
6a15b879de Merge branch 'parallel-walk': parallel per-directory walk, per-operand commits
All checks were successful
check / check (push) Successful in 1m10s
2026-07-24 07:44:28 +07:00
dced5cf0d2 Parallelize the walk and commit per operand
All checks were successful
check / check (push) Successful in 5s
Replace the single-goroutine WalkDir traversal with a per-directory
worker pool: workers read directories concurrently and lstat entries
while each directory is fresh in cache, recording size and mtime
during the walk. This folds the separate stat pass away (halving
metadata I/O per run) and overlaps metadata latency, which dominated
on busy pools — a sequential walk of a ~22M-file tree was observed
taking over 4 hours.

Each PATH operand now loads its scope, walks, hashes, and commits in
its own transaction, so an interrupted scan keeps every operand
completed so far; a later overlapping operand sees the records
committed by earlier ones and reuses them unchanged.
2026-07-24 07:44:18 +07:00
3ebf98940a Specify parallel per-directory walk and per-operand commits
The walk pass is a single goroutine; on a busy ZFS pool it manages
only a few thousand directory entries per second and takes hours at
~20M files. Respecify it as a worker-pool traversal that reads
directories concurrently and records size/mtime during the walk,
folding away the separate stat pass and halving metadata I/O. Each
PATH operand now commits in its own transaction so an interrupted
scan keeps the operands completed so far.
2026-07-24 06:28:12 +07:00
abea945730 Merge branch 'persistent-database': persistent SQLite scan database
All checks were successful
check / check (push) Successful in 1m2s
2026-07-24 03:08:58 +07:00
d8fbcb32c2 Implement persistent SQLite scan database
All checks were successful
check / check (push) Successful in 3s
scan now synchronizes a database that survives between runs
(SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite) instead of
emitting a stream: operands are resolved to absolute paths, unchanged
files (same size, mtime not newer than recorded) are never re-read, new
and changed files are hashed, and records under the scanned operands
that were not verified this run are deleted; records outside the
operands are untouched. All changes commit in a single transaction, and
WAL journaling with a busy timeout keeps a report run during a cron
scan safe.

report and trees read the database (no positional arguments); the
NUL-terminated stream format, its parser, and the malformed-record
handling are gone. The driver is modernc.org/sqlite (pure Go), so
builds keep cgo disabled.
2026-07-24 03:08:49 +07:00
e0d578a707 Specify persistent SQLite scan database in README; plan in TODO.md
scan will maintain a persistent database of file signatures
(default /var/lib/sfdupes/db.sqlite, overridable via
SFDUPES_DATABASE) that survives between runs; rescans hash only new
or changed files (mtime/size) and remove records for vanished files,
so scan can be cronned daily. report and trees will read the
database instead of a scan stream.
2026-07-24 02:54:08 +07:00
90c9ef3546 Record remote setup and v0.0.1 tag in TODO.md
All checks were successful
check / check (push) Successful in 40s
2026-07-23 09:08:28 +07:00
13b9839e73 Disable background-session worktree isolation for this repo 2026-07-23 09:06:24 +07:00
14 changed files with 1687 additions and 546 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"worktree": {
"bgIsolation": "none"
}
}

3
.gitignore vendored
View File

@@ -28,6 +28,9 @@ node_modules/
# Local scan data # Local scan data
files.dat files.dat
*.sqlite
*.sqlite-shm
*.sqlite-wal
# Agent worktrees # Agent worktrees
.claude/worktrees/ .claude/worktrees/

327
README.md
View File

@@ -12,7 +12,12 @@ SHA-256 of their first 1024 bytes, and identical SHA-256 of their last
content (the middle of the file is never read); the intended use is content (the middle of the file is never read); the intended use is
finding duplicate downloads and duplicated directory trees on finding duplicate downloads and duplicated directory trees on
multi-terabyte ZFS servers where reading every byte is prohibitively multi-terabyte ZFS servers where reading every byte is prohibitively
expensive. expensive. `scan` maintains a persistent SQLite database of file
signatures that survives between runs, so it can be run from cron and
the reports can be generated at any time from the most recent scan.
This tool was created by [@sneak](https://sneak.berlin) to scratch an itch,
using Claude Code/Fable.
This README is the complete and authoritative specification. This README is the complete and authoritative specification.
@@ -20,29 +25,39 @@ This README is the complete and authoritative specification.
```sh ```sh
make build make build
./sfdupes scan /srv > files.dat export SFDUPES_DATABASE="$HOME/.local/share/sfdupes/db.sqlite"
./sfdupes report files.dat > dupes.tsv ./sfdupes scan /srv
./sfdupes trees files.dat > dupetrees.tsv ./sfdupes report > dupes.tsv
./sfdupes trees > dupetrees.tsv
``` ```
`scan` walks one or more filesystem trees and emits one record per `scan` walks one or more filesystem trees and maintains one database
regular file (path, size, mtime, head hash, tail hash). `report` record per regular file (path, size, mtime, head hash, tail hash). The
ingests that stream and prints the file-level duplicates report. database persists between runs; a rescan only hashes files that are new
`trees` ingests the same stream and prints the duplicate-tree report. A or changed, and removes records for files that no longer exist.
missing/invalid subcommand — or a `scan` invocation with no `PATH` `report` reads the database and prints the file-level duplicates
operand — prints a usage message and exits 2. report. `trees` reads the same database and prints the duplicate-tree
report. A missing/invalid subcommand — or a `scan` invocation with no
`PATH` operand — prints a usage message and exits 2.
The database defaults to `/var/lib/sfdupes/db.sqlite` and can be placed
anywhere by setting `SFDUPES_DATABASE`. The intended deployment is a
daily `sfdupes scan` cron job, with the reporting commands run
interactively whenever needed; their results are as fresh as the last
completed scan.
## Rationale ## Rationale
Duplicate finders that hash entire files do not scale to the target Duplicate finders that hash entire files do not scale to the target
environment: ~10 million files and ~150 TB on possibly slow or busy environment: ~10 million files and ~150 TB on possibly slow or busy
disks (a ZFS pool under resilver). Reading at most 2 KiB per file makes disks (a ZFS pool under resilver). Reading at most 2 KiB per file makes
a full-filesystem sweep tractable, and the resulting scan stream is a full-filesystem sweep tractable, and the signatures are kept in a
self-contained, so the expensive filesystem pass runs exactly once and persistent database, so the expensive filesystem pass is incremental: a
all analysis happens offline. The end goal is not individual files but rescan re-hashes only files whose recorded mtime or size changed, and
whole duplicated trees — duplicate extractions, duplicate downloads, all analysis happens offline from the database alone. The end goal is
copied project trees — which an operator can consider removing as a not individual files but whole duplicated trees — duplicate
unit. extractions, duplicate downloads, copied project trees — which an
operator can consider removing as a unit.
## Design ## Design
@@ -59,10 +74,12 @@ Goals, in order:
filesystem, possibly slow or busy disks (ZFS pool under resilver). filesystem, possibly slow or busy disks (ZFS pool under resilver).
Holding the full file list in memory is acceptable; reading file Holding the full file list in memory is acceptable; reading file
contents beyond 2 KiB per file is not. contents beyond 2 KiB per file is not.
3. **Scan once, analyze offline.** The expensive filesystem scan 3. **Scan incrementally, analyze offline.** The expensive filesystem
produces a self-contained stream; all analysis (`report`, `trees`) scan maintains a persistent database; an unchanged file is never
works from that stream alone and must never touch the scanned read again on a rescan. All analysis (`report`, `trees`) works from
filesystem again. the database alone and must never touch the scanned filesystem
again. `scan` is designed to be cronned; the reports run at any
time against the last completed scan.
4. **Clean stream separation.** Everything on stdout is machine-readable 4. **Clean stream separation.** Everything on stdout is machine-readable
data. All progress, warnings, and summaries go to stderr. Never mix data. All progress, warnings, and summaries go to stderr. Never mix
them. them.
@@ -71,54 +88,137 @@ Goals, in order:
- Language: Go (module `sneak.berlin/go/sfdupes`). Binary name: - Language: Go (module `sneak.berlin/go/sfdupes`). Binary name:
`sfdupes`. `sfdupes`.
- Dependencies: standard library, `github.com/spf13/cobra` for the CLI, - Dependencies: standard library, `github.com/spf13/cobra` for the
and **one progress-bar library** CLI, **one progress-bar library**
(`github.com/schollz/progressbar/v3`). `github.com/spf13/viper` is (`github.com/schollz/progressbar/v3`), and **one SQLite driver**
permitted if configuration-file support is ever needed, but is not (`modernc.org/sqlite`, pure Go, so builds keep cgo disabled).
currently used. No other third-party deps. `github.com/spf13/viper` is permitted if configuration-file support
is ever needed, but is not currently used. No other third-party
deps.
- Cross-compilation is not a concern. Builds run with cgo disabled (the - Cross-compilation is not a concern. Builds run with cgo disabled (the
`Makefile` exports `CGO_ENABLED=0`); the code must remain pure Go. `Makefile` exports `CGO_ENABLED=0`); the code must remain pure Go.
- Analysis modes (`report`, `trees`) must be deterministic: identical - Analysis modes (`report`, `trees`) must be deterministic: identical
input stream, identical output, regardless of record order. database contents, identical output, regardless of the order in
which records were inserted.
### Subcommands ### Subcommands
Three subcommands, all implemented: Three subcommands, all implemented:
1. `scan` — walk the filesystem and emit one signature record per 1. `scan` — walk the filesystem and synchronize the database: one
regular file. signature record per regular file.
2. `report` — file-level duplicate report from the scan stream. 2. `report` — file-level duplicate report from the database.
3. `trees` — tree-level duplicate report: reconstruct the directory 3. `trees` — tree-level duplicate report: reconstruct the directory
hierarchy from the scan stream, compute a Merkle-style digest per hierarchy from the database records, compute a Merkle-style digest
directory, and report maximal groups of identical trees. per directory, and report maximal groups of identical trees.
``` ```
sfdupes scan [--workers N] [-x] PATH... > files.dat sfdupes scan [--workers N] [-x] PATH...
sfdupes report [files.dat|-] > dupes.tsv sfdupes report > dupes.tsv
sfdupes trees [files.dat|-] > dupetrees.tsv sfdupes trees > dupetrees.tsv
``` ```
### Database
All three subcommands operate on a single SQLite database file:
- Location: the value of the `SFDUPES_DATABASE` environment variable
when set and non-empty, otherwise `/var/lib/sfdupes/db.sqlite`.
There is no command-line flag.
- `scan` creates the database (and its parent directory) on first
use. `report` and `trees` require an existing database; a missing
database file is a fatal error (exit 1) telling the user to run
`scan` first.
- The database uses WAL journal mode and a busy timeout, so running a
report while a cron `scan` is in progress is safe; the reports see
the last committed state. Each `PATH` operand's changes are
committed in a single transaction when that operand completes, so
a report never observes a half-finished operand, and a scan that
dies partway keeps every operand completed so far (the interrupted
operand's changes are lost, not corrupted).
- Schema (`PRAGMA user_version` is the schema version, currently 1; a
database with any other version is a fatal error):
```sql
CREATE TABLE files (
path BLOB PRIMARY KEY, -- absolute path, raw bytes
size INTEGER NOT NULL, -- bytes, from lstat
mtime INTEGER NOT NULL, -- Unix seconds, from lstat
head TEXT NOT NULL, -- lowercase-hex SHA-256, first 1 KiB
tail TEXT NOT NULL -- lowercase-hex SHA-256, last 1 KiB
) WITHOUT ROWID;
```
Paths are stored as BLOBs because Unix paths are raw bytes, not
guaranteed UTF-8. `mtime` is used only for change detection; it is
not part of the duplicate key.
### `scan` mode ### `scan` mode
`scan` requires one or more `PATH` operands naming the trees to scan. `scan` requires one or more `PATH` operands naming the trees to scan.
There is no default path; invoking `scan` with no operand is a usage There is no default path; invoking `scan` with no operand is a usage
error (usage message on stderr, exit 2). An operand may be a directory error (usage message on stderr, exit 2). An operand may be a directory
or a regular file; an operand that does not exist is a fatal error or a regular file; an operand that does not exist is a fatal error
(exit 1). Operands are walked in the order given; overlapping operands (exit 1). Because database records persist between runs and are keyed
(one containing another) emit their common files once per operand, so by absolute path, each operand is resolved to an absolute, lexically
callers should pass disjoint paths. cleaned path (symlinks are not resolved) before walking, so results do
not depend on the working directory. Operands are processed in the
order given, each one walked and committed independently; overlapping
operands (one containing another) are harmless — a file reached via
multiple operands produces one database record (later operands see the
records committed by earlier ones and reuse them unchanged).
`scan` runs **three sequential passes**, in this order, so that every `scan` synchronizes the database with the filesystem state under the
expensive pass has an exact total for meaningful progress and ETA: scanned operands:
1. **walk** — recursively enumerate the tree under each `PATH` in - A file not yet in the database is hashed and inserted.
turn, collecting the list of regular-file paths. Total unknown - A file already in the database is **skipped without reading its
while running: show a live count, not a percentage. contents** when its lstat size equals the recorded size and its
2. **stat**`lstat` every collected path, recording size and mtime. lstat mtime is not newer than the recorded mtime. This is what
3. **hash** — for each file, read the first `min(1024, size)` bytes and makes a daily rescan cheap.
the last `min(1024, size)` bytes (the two reads overlap when - A file whose mtime is newer than recorded, or whose size differs,
`size < 2048`; for `size == 0` hash the empty input) and compute the is re-hashed and its record updated.
SHA-256 of each. Emit the output record. - A database record whose path lies under one of the scanned operands
but was not successfully processed this run is deleted. This
removes records for deleted files. It also removes records for
paths that failed to stat or hash this run: the database only ever
contains signatures verified by the most recent scan that covered
them (a subsequent successful scan re-adds such files).
- Database records outside the scanned operands are untouched, so
disjoint trees can be scanned on different schedules into the same
database.
`scan` runs **four sequential passes per operand**, committing each
operand before starting the next. Parallelism lives strictly inside
each pass; the passes themselves never overlap:
1. **walk** — enumerate the tree with the worker pool: each worker
reads one directory at a time, collecting regular-file paths and
handing discovered subdirectories back to the shared queue.
Sequential directory enumeration is metadata-latency-bound and
takes hours at tens of millions of files; per-directory
parallelism is what makes the walk tractable on large or busy
pools. Total unknown while running: show a live count, not a
percentage.
2. **stat** — `lstat` every collected path with the worker pool
(per-file parallelism), recording size and mtime.
3. **hash** — for each new or changed file (per the rules above), read
the first `min(1024, size)` bytes and the last `min(1024, size)`
bytes (the two reads overlap when `size < 2048`; for `size == 0`
hash the empty input) and compute the SHA-256 of each. Unchanged
files are not read and do not appear in this pass's total, which
is therefore exact for meaningful progress and ETA.
4. **update** — apply the operand's insertions, updates, and
deletions to the database in a single transaction.
Because every operand runs its own three passes with its own totals,
`scan` announces each operand on stderr before starting it, so a
multi-operand invocation (e.g. a shell glob) is not mistaken for a
nearly-finished run:
```
scan: /srv/media (operand 3 of 14)
```
Rules for the walk: Rules for the walk:
@@ -134,52 +234,49 @@ Rules for the walk:
- On any per-path error (permission denied, file vanished between - On any per-path error (permission denied, file vanished between
passes, unreadable): print a one-line warning to stderr, skip the passes, unreadable): print a one-line warning to stderr, skip the
path, and continue. Per-file errors never abort the run; the final path, and continue. Per-file errors never abort the run; the final
summary reports how many were skipped. summary reports how many were skipped. As specified above, a
skipped path that has a database record from an earlier scan loses
that record; an unreadable directory subtree likewise loses its
records (accepted: the database mirrors what the latest scan could
actually verify).
Concurrency: the stat and hash passes use a worker pool (`--workers`, Concurrency: the walk, stat, and hash passes each use a worker pool
default `runtime.NumCPU()`). The main goroutine owns stdout writing and (`--workers`, default `runtime.NumCPU()`); the walk parallelizes
progress rendering; progress display must never block the workers. across directories, stat and hash across files, so raising
`--workers` can speed up metadata-bound passes on busy pools. The
main goroutine owns database writes and progress rendering; progress
display must never block the workers.
#### Output record format `scan` writes nothing to stdout. The summary line on stderr reports the
files seen this run broken down by disposition, plus skips:
One record per file on stdout, NUL-terminated (`\x00`), with
tab-separated fields, **path last** so tabs or newlines embedded in
paths cannot corrupt the record structure:
``` ```
<size>\t<mtime_unix>\t<sha256_first1k_hex>\t<sha256_last1k_hex>\t<path>\x00 scan: 123456 files seen (1200 added, 34 updated, 56 removed, 122166 unchanged), 3 skipped
``` ```
- `size`: decimal bytes, from the stat pass. (`removed` counts deleted database records, which are not part of the
- `mtime_unix`: decimal Unix seconds. Informational only; not part of files-seen total.)
the duplicate key.
- Hashes: lowercase hex, 64 chars each.
- Record order is unspecified (workers complete out of order); the
analysis modes must not depend on ordering.
### `report` mode ### `report` mode
`report` reads the scan stream from the file named in its first `report` reads every record from the database and takes no positional
positional argument, or from stdin if the argument is absent or `-`. arguments.
**`report` must never touch the filesystem being analyzed.** It does not **`report` must never touch the filesystem being analyzed.** It does not
stat, open, or otherwise access any path that appears in the records; its stat, open, or otherwise access any path that appears in the records; its
only I/O is reading the scan file/stdin and writing stdout/stderr. It must only I/O is reading the database and writing stdout/stderr. It must
produce identical output whether or not the scanned filesystem is still produce identical output whether or not the scanned filesystem is still
mounted. mounted.
Processing: Processing:
- Parse records; a record that does not have exactly 5 fields or whose
size is non-numeric is counted as malformed and skipped (warn once
with the total malformed count in the summary, not per record).
- Group records by the key `(size, head_hash, tail_hash)`. - Group records by the key `(size, head_hash, tail_hash)`.
- Every group with two or more paths is a duplicate group. - Every group with two or more paths is a duplicate group.
- Within each group, sort paths lexicographically (byte order). The - Within each group, sort paths lexicographically (byte order). The
first path is the group's `first`; every other path is a `dupe`. first path is the group's `first`; every other path is a `dupe`.
- Order groups by size descending (biggest reclaimable space first), - Order groups by size descending (biggest reclaimable space first),
tie-broken by `first` path ascending. Output must be fully tie-broken by `first` path ascending. Output must be fully
deterministic for a given input. deterministic for a given database state.
#### Report output format #### Report output format
@@ -192,16 +289,16 @@ first dupe size
/srv/a/big.iso /srv/c/big-copy2.iso 4294967296 /srv/a/big.iso /srv/c/big-copy2.iso 4294967296
``` ```
Summary to stderr: records read, malformed count (if any), number of Summary to stderr: records read, number of duplicate groups, number of
duplicate groups, number of dupe files, and total reclaimable bytes dupe files, and total reclaimable bytes (sum of `size` over all dupe
(sum of `size` over all dupe rows) in human units. rows) in human units.
### `trees` mode ### `trees` mode
`trees` reads the same scan stream as `report` (same argument handling, `trees` reads the same database as `report` (no positional arguments)
same parsing and malformed-record rules) and reports **entire duplicate and reports **entire duplicate directory trees**: directories under
directory trees**: directories under which the exact same set of relative which the exact same set of relative paths exists with the exact same
paths exists with the exact same file signatures. file signatures.
**`trees` must never touch the filesystem being analyzed** — the same **`trees` must never touch the filesystem being analyzed** — the same
rule as `report`. The directory hierarchy is reconstructed purely from rule as `report`. The directory hierarchy is reconstructed purely from
@@ -222,10 +319,10 @@ Definitions:
equal. Equal digests imply equal recursive file count and equal equal. Equal digests imply equal recursive file count and equal
total byte size. total byte size.
Known limitation (accepted): only regular files that appear in the scan Known limitation (accepted): only regular files that appear in the
stream define a tree. Empty directories are invisible, and a file skipped database define a tree. Empty directories are invisible, and a file
during the scan (e.g. permission error) in one copy but not the other skipped during the scan (e.g. permission error) in one copy but not the
will make otherwise-identical trees compare as different. other will make otherwise-identical trees compare as different.
Processing: Processing:
@@ -254,22 +351,22 @@ first dupe files size
/srv/a/project /srv/backup/project 3417 104857600 /srv/a/project /srv/backup/project 3417 104857600
``` ```
Summary to stderr: records read, malformed count (if any), number of Summary to stderr: records read, number of duplicate-tree groups,
duplicate-tree groups, number of dupe trees, and total reclaimable bytes number of dupe trees, and total reclaimable bytes (sum of `size` over
(sum of `size` over all dupe rows) in human units. all dupe rows) in human units.
### Progress ### Progress
Use the progress-bar library for all scan-pass progress; rendering in the Use the progress-bar library for all scan-pass progress; rendering in the
style of `pv` is the model. All progress goes to stderr. style of `pv` is the model. All progress goes to stderr.
Each scan pass gets its own bar. Required elements for the stat and hash Each scan pass gets its own bar, repeated per operand. Required
passes (known totals): elements for the stat, hash, and update passes (known totals):
- elapsed time - elapsed time
- estimated time remaining - estimated time remaining
- a `[m/n] x%` display (files processed / total files, percent) - a `[m/n] x%` display (items processed / total items, percent)
- current rate (files/s) - current rate (items/s)
Example shape (exact layout is flexible, content is not): Example shape (exact layout is flexible, content is not):
@@ -292,9 +389,11 @@ Additional requirements:
### Error handling and exit codes ### Error handling and exit codes
- `0`: success, even if individual files were skipped with warnings. - `0`: success, even if individual files were skipped with warnings.
- `1`: fatal error (e.g., a `PATH` operand does not exist, cannot - `1`: fatal error (e.g., a `PATH` operand does not exist, the
read the scan input, stdout write failure). database cannot be created/opened/read/written, a missing database
- `2`: usage error (including `scan` with no `PATH` operand). for `report`/`trees`, stdout write failure).
- `2`: usage error (including `scan` with no `PATH` operand and
`report`/`trees` with any positional argument).
## Build ## Build
@@ -310,7 +409,7 @@ The `Makefile` is the single source of truth for all operations:
- `make docker` — build the Docker image, which runs `make check` as - `make docker` — build the Docker image, which runs `make check` as
a build stage. a build stage.
- `make hooks` — install the pre-commit hook. - `make hooks` — install the pre-commit hook.
- `make clean` — remove the binary and any local `files.dat`. - `make clean` — remove the binary and any legacy local `files.dat`.
### Definition of done ### Definition of done
@@ -323,6 +422,7 @@ All of the following, run in this directory, must pass:
```sh ```sh
d=$(mktemp -d) d=$(mktemp -d)
export SFDUPES_DATABASE="$d/db.sqlite"
mkdir -p "$d/a" "$d/b" mkdir -p "$d/a" "$d/b"
head -c 2000 /dev/urandom > "$d/a/one.bin" head -c 2000 /dev/urandom > "$d/a/one.bin"
cp "$d/a/one.bin" "$d/b/copy.bin" cp "$d/a/one.bin" "$d/b/copy.bin"
@@ -339,28 +439,41 @@ All of the following, run in this directory, must pass:
cp "$d/t1/sub/f2" "$d/t2/sub/f2" cp "$d/t1/sub/f2" "$d/t2/sub/f2"
cp "$d/t1/f1" "$d/t3/f1" cp "$d/t1/f1" "$d/t3/f1"
cp "$d/t1/sub/f2" "$d/t3/sub/f2renamed" cp "$d/t1/sub/f2" "$d/t3/sub/f2renamed"
./sfdupes scan "$d" > files.dat ./sfdupes scan "$d"
./sfdupes report files.dat ./sfdupes report
./sfdupes trees files.dat ./sfdupes trees
# incremental behavior (scan a subtree; records elsewhere persist):
./sfdupes scan "$d/a" # everything unchanged, nothing hashed
printf 'z' >> "$d/a/one.bin" # modify: next scan re-hashes it
rm "$d/a/unique.bin" # delete: next scan removes its record
./sfdupes scan "$d/a" # 1 updated, 1 removed
./sfdupes report
``` ```
Expected from `report`: `one.bin`/`copy.bin`/`copy2.bin` form one (The scan database lives inside `$d` here purely for test hygiene;
group (two dupe rows, `first` is the lexicographically smallest scanning `$d` therefore also records the SQLite file itself, which
path); `t1/f1`/`t2/f1`/`t3/f1` form one group; `t1/sub/f2`/ is harmless.)
`t2/sub/f2`/`t3/sub/f2renamed` form one group; `tiny1`/`tiny2` pair;
`empty1`/`empty2` pair; `unique.bin` and `tiny3` appear nowhere; Expected from the first `report`: `one.bin`/`copy.bin`/`copy2.bin`
groups ordered by size descending; piping scan directly into report form one group (two dupe rows, `first` is the lexicographically
(`./sfdupes scan "$d" | ./sfdupes report`) gives the same smallest path); `t1/f1`/`t2/f1`/`t3/f1` form one group;
rows. `t1/sub/f2`/ `t2/sub/f2`/`t3/sub/f2renamed` form one group;
`tiny1`/`tiny2` pair; `empty1`/`empty2` pair; `unique.bin` and
`tiny3` appear nowhere; groups ordered by size descending.
Expected from `trees`: exactly one row — `first` `$d/t1`, `dupe` Expected from `trees`: exactly one row — `first` `$d/t1`, `dupe`
`$d/t2`, 2 files, 3100 bytes. `$d/t1/sub` vs `$d/t2/sub` is `$d/t2`, 2 files, 3100 bytes. `$d/t1/sub` vs `$d/t2/sub` is
suppressed as non-maximal (implied by the `t1`/`t2` group), and `t3` suppressed as non-maximal (implied by the `t1`/`t2` group), and `t3`
appears nowhere (its file set differs by name). appears nowhere (its file set differs by name).
Expected from the second `report` (after the modify/delete rescan):
`one.bin` has left its group (its content changed), so
`copy.bin`/`copy2.bin` remain as one pair, and `unique.bin` is
gone from the database.
The test suite automates this scenario (see `scan_test.go`), plus a The test suite automates this scenario (see `scan_test.go`), plus a
negative check: `report` and `trees` operate on the captured stream negative check: `report` and `trees` operate on the database alone
alone and never touch the scanned filesystem. and never touch the scanned filesystem.
## TODO ## TODO
@@ -371,7 +484,9 @@ Tracked in [TODO.md](TODO.md).
- No full-content verification, no byte-for-byte compare, no deletion - No full-content verification, no byte-for-byte compare, no deletion
or linking of duplicates. The reports are advisory; acting on them is or linking of duplicates. The reports are advisory; acting on them is
the user's job. the user's job.
- No persistence formats beyond the scan stream described above. - No persistence beyond the SQLite database described above; no
export/import formats.
- No daemon or filesystem watcher; scheduling rescans is cron's job.
## License ## License

38
TODO.md
View File

@@ -14,10 +14,43 @@
# Next Step # Next Step
- add a remote on git.eeqj.de and push (`main` plus tags) - convert Makefile targets to scripts-to-rule-them-all `script/`
entrypoints like the other managed repos
# Completed Steps # Completed Steps
- split the stat pass back out of the walk (2026-07-24, branch
`parallel-phases`): phases are strictly sequential again — walk,
stat, hash, update per operand — with parallelism only inside each
phase; the walk enumerates paths with per-directory workers and the
stat pass lstats them with per-file workers, restoring the exact
total/ETA stat bar
- announce each operand on stderr before its passes (2026-07-24,
branch `scan-operand-progress`): with per-operand walk/hash/update
cycles, a multi-operand run (e.g. `scan /srv/*`) showed pass totals
that looked like the whole run's — an operator watching operand 3 of
14 hash 300k files concluded 20M files were being skipped
- parallel walk (2026-07-24, branch `parallel-walk`): the walk pass
was a single goroutine and took hours at ~20M files on a busy pool
(observed: 22M files in 4h on a ZFS server); it is now a
per-directory worker-pool traversal that records size/mtime during
the walk (folding away the separate stat pass, halving metadata
I/O), and each `PATH` operand commits in its own transaction so an
interrupted scan keeps completed operands
- persistent scan database (2026-07-24, branch `persistent-database`):
`scan` now maintains a SQLite database (`modernc.org/sqlite`, pure
Go, cgo stays disabled) keyed by absolute path that survives between
runs — a rescan hashes only new or changed files (by mtime/size),
deletes records for files vanished from under the scanned operands,
and leaves records outside them untouched, so `scan` can be cronned
daily; `report` and `trees` read the database (no positional
arguments) instead of a scan stream. Database at
`/var/lib/sfdupes/db.sqlite`, overridable via `SFDUPES_DATABASE`;
WAL journaling plus a single-transaction update keep a report run
during a scan safe
- add the `origin` remote (`git@git.eeqj.de:sneak/sfdupes.git`), tag
`v0.0.1`, and push `main` plus tags (2026-07-23)
- `scan` CLI rework (2026-07-23, branch `scan-required-paths`): required - `scan` CLI rework (2026-07-23, branch `scan-required-paths`): required
`PATH...` operands via cobra flags replacing the `/srv` `-root` `PATH...` operands via cobra flags replacing the `/srv` `-root`
default; new `-x`/`--one-file-system` flag (GNU convention) to stop default; new `-x`/`--one-file-system` flag (GNU convention) to stop
@@ -30,9 +63,6 @@
# Future Steps # Future Steps
- convert Makefile targets to scripts-to-rule-them-all `script/`
entrypoints like the other managed repos
- tag `v0.0.1` once the compliance branch is merged
- possible later features (explicitly out of scope per README): - possible later features (explicitly out of scope per README):
full-content verification of candidates, removal-script helpers full-content verification of candidates, removal-script helpers

319
db.go Normal file
View File

@@ -0,0 +1,319 @@
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
// The pure-Go SQLite driver, registered as "sqlite"; keeps cgo
// disabled.
_ "modernc.org/sqlite"
)
// defaultDatabasePath is where the persistent scan database lives when
// SFDUPES_DATABASE is not set.
const defaultDatabasePath = "/var/lib/sfdupes/db.sqlite"
// databaseEnv is the environment variable that overrides the database
// path.
const databaseEnv = "SFDUPES_DATABASE"
// schemaVersion is the database schema version this build reads and
// writes, stored in PRAGMA user_version.
const schemaVersion = 1
// dbDirPerm is the mode for a database parent directory created by
// scan.
const dbDirPerm = 0o755
// createTableSQL is the schema applied to a fresh database. Paths are
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8.
const createTableSQL = `
CREATE TABLE files (
path BLOB PRIMARY KEY,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL,
head TEXT NOT NULL,
tail TEXT NOT NULL
) WITHOUT ROWID
`
// upsertSQL inserts one file record, replacing any existing record for
// the same path.
const upsertSQL = `
INSERT INTO files (path, size, mtime, head, tail)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (path) DO UPDATE SET
size = excluded.size, mtime = excluded.mtime,
head = excluded.head, tail = excluded.tail
`
// errNoDatabase reports a missing database file for report/trees.
var errNoDatabase = errors.New(
"no database (run \"sfdupes scan\" first, or set " + databaseEnv + ")")
// errSchemaVersion reports a database whose schema version this build
// does not understand.
var errSchemaVersion = errors.New("unsupported database schema version")
// databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise.
func databasePath() string {
if p := os.Getenv(databaseEnv); p != "" {
return p
}
return defaultDatabasePath
}
// openDB opens the SQLite database at path with WAL journaling and a
// busy timeout, so a report can run while a cron scan is in progress.
// It does not create or verify the schema.
func openDB(path string) (*sql.DB, error) {
dsn := "file:" + path +
"?_pragma=busy_timeout(10000)" +
"&_pragma=journal_mode(WAL)" +
"&_pragma=synchronous(NORMAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database %s: %w", path, err)
}
// A single connection avoids SQLITE_BUSY between this process's
// own connections; concurrency lives in the worker pools, not in
// parallel database access.
db.SetMaxOpenConns(1)
return db, nil
}
// openScanDatabase opens the database for the scan subcommand, creating
// the file, its parent directory, and the schema as needed.
func openScanDatabase(path string) (*sql.DB, error) {
err := os.MkdirAll(filepath.Dir(path), dbDirPerm)
if err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
db, err := openDB(path)
if err != nil {
return nil, err
}
err = initSchema(db)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("database %s: %w", path, err)
}
return db, nil
}
// openReportDatabase opens an existing database for the report and
// trees subcommands. A missing database file is an error directing the
// user to run scan first; the schema version must match exactly.
func openReportDatabase(path string) (*sql.DB, error) {
_, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("%s: %w", path, errNoDatabase)
}
if err != nil {
return nil, fmt.Errorf("database: %w", err)
}
db, err := openDB(path)
if err != nil {
return nil, err
}
v, err := userVersion(db)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("database %s: %w", path, err)
}
if v != schemaVersion {
_ = db.Close()
return nil, fmt.Errorf("database %s: version %d, want %d: %w",
path, v, schemaVersion, errSchemaVersion)
}
return db, nil
}
// initSchema creates the schema on a fresh database and verifies the
// schema version on an existing one.
func initSchema(db *sql.DB) error {
v, err := userVersion(db)
if err != nil {
return err
}
switch v {
case 0:
return createSchema(db)
case schemaVersion:
return nil
default:
return fmt.Errorf("version %d, want %d: %w",
v, schemaVersion, errSchemaVersion)
}
}
// createSchema applies the schema to a fresh database and stamps the
// schema version.
func createSchema(db *sql.DB) error {
ctx := context.Background()
_, err := db.ExecContext(ctx, createTableSQL)
if err != nil {
return fmt.Errorf("create schema: %w", err)
}
_, err = db.ExecContext(ctx,
"PRAGMA user_version = "+strconv.Itoa(schemaVersion))
if err != nil {
return fmt.Errorf("set schema version: %w", err)
}
return nil
}
// userVersion reads the database's PRAGMA user_version.
func userVersion(db *sql.DB) (int, error) {
var v int
err := db.QueryRowContext(context.Background(),
"PRAGMA user_version").Scan(&v)
if err != nil {
return 0, fmt.Errorf("read schema version: %w", err)
}
return v, nil
}
// loadFileRows reads every record from the files table.
func loadFileRows(db *sql.DB) ([]scanRec, error) {
rows, err := db.QueryContext(context.Background(),
"SELECT path, size, mtime, head, tail FROM files")
if err != nil {
return nil, fmt.Errorf("read records: %w", err)
}
defer func() { _ = rows.Close() }()
var recs []scanRec
for rows.Next() {
var (
path []byte
r scanRec
)
err = rows.Scan(&path, &r.size, &r.mtime, &r.head, &r.tail)
if err != nil {
return nil, fmt.Errorf("read record: %w", err)
}
r.path = string(path)
recs = append(recs, r)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("read records: %w", err)
}
return recs, nil
}
// applyChanges writes one scan's database changes — upserts for new and
// changed files, deletes for vanished ones — in a single transaction,
// so a concurrent report never observes a half-finished scan. Progress
// is rendered on prog (one increment per change).
func applyChanges(db *sql.DB, upserts []scanRec, deletes []string,
prog *progress,
) error {
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
err = execUpserts(ctx, tx, upserts, prog)
if err != nil {
return err
}
err = execDeletes(ctx, tx, deletes, prog)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
// execUpserts inserts or updates one record per new or changed file.
func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
prog *progress,
) error {
st, err := tx.PrepareContext(ctx, upsertSQL)
if err != nil {
return fmt.Errorf("prepare upsert: %w", err)
}
defer func() { _ = st.Close() }()
for _, r := range upserts {
_, err = st.ExecContext(ctx,
[]byte(r.path), r.size, r.mtime, r.head, r.tail)
if err != nil {
return fmt.Errorf("upsert %s: %w", r.path, err)
}
prog.increment()
}
return nil
}
// execDeletes removes the records for paths no longer present.
func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string,
prog *progress,
) error {
st, err := tx.PrepareContext(ctx, "DELETE FROM files WHERE path = ?")
if err != nil {
return fmt.Errorf("prepare delete: %w", err)
}
defer func() { _ = st.Close() }()
for _, p := range deletes {
_, err = st.ExecContext(ctx, []byte(p))
if err != nil {
return fmt.Errorf("delete %s: %w", p, err)
}
prog.increment()
}
return nil
}

180
db_test.go Normal file
View File

@@ -0,0 +1,180 @@
package main
import (
"context"
"database/sql"
"errors"
"path/filepath"
"slices"
"strings"
"testing"
)
// testDBPath returns a database path inside a fresh temp dir.
func testDBPath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "db.sqlite")
}
// openTestDB creates a fresh scan database in a temp dir.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := openScanDatabase(testDBPath(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestDatabasePath(t *testing.T) {
t.Setenv(databaseEnv, "")
if got := databasePath(); got != defaultDatabasePath {
t.Errorf("databasePath() = %q, want %q", got, defaultDatabasePath)
}
t.Setenv(databaseEnv, "/custom/place.sqlite")
if got := databasePath(); got != "/custom/place.sqlite" {
t.Errorf("databasePath() = %q, want the env override", got)
}
}
func TestOpenScanDatabaseCreates(t *testing.T) {
t.Parallel()
// The parent directory does not exist yet; scan must create it.
path := filepath.Join(t.TempDir(), "nested", "dir", "db.sqlite")
db, err := openScanDatabase(path)
if err != nil {
t.Fatalf("openScanDatabase: %v", err)
}
v, err := userVersion(db)
if err != nil || v != schemaVersion {
t.Fatalf("userVersion = %d, %v; want %d, nil", v, err, schemaVersion)
}
_ = db.Close()
// Reopening an existing database must succeed and find the schema.
db, err = openScanDatabase(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer func() { _ = db.Close() }()
recs, err := loadFileRows(db)
if err != nil || len(recs) != 0 {
t.Fatalf("loadFileRows = %v, %v; want empty, nil", recs, err)
}
}
func TestOpenReportDatabaseMissing(t *testing.T) {
t.Parallel()
_, err := openReportDatabase(testDBPath(t))
if !errors.Is(err, errNoDatabase) {
t.Fatalf("err = %v, want errNoDatabase", err)
}
}
func TestOpenReportDatabaseVersionMismatch(t *testing.T) {
t.Parallel()
path := testDBPath(t)
db, err := openScanDatabase(path)
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(context.Background(), "PRAGMA user_version = 99")
if err != nil {
t.Fatal(err)
}
_ = db.Close()
_, err = openReportDatabase(path)
if !errors.Is(err, errSchemaVersion) {
t.Fatalf("err = %v, want errSchemaVersion", err)
}
}
func TestOpenReportDatabaseOK(t *testing.T) {
t.Parallel()
path := testDBPath(t)
db, err := openScanDatabase(path)
if err != nil {
t.Fatal(err)
}
_ = db.Close()
db, err = openReportDatabase(path)
if err != nil {
t.Fatalf("openReportDatabase: %v", err)
}
_ = db.Close()
}
func TestApplyChangesRoundTrip(t *testing.T) {
t.Parallel()
db := openTestDB(t)
// Paths may contain tabs and newlines; the database must store
// them byte-exactly.
recs := []scanRec{
{size: 2, mtime: 20, head: "h2", tail: "t2", path: "/a/tab\tnew\nline"},
{size: 1, mtime: 10, head: "h1", tail: "t1", path: "/a/x"},
}
err := applyChanges(db, recs, nil, newProgress("update", 2))
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
got, err := loadFileRows(db)
if err != nil {
t.Fatal(err)
}
slices.SortFunc(got, func(a, b scanRec) int {
return strings.Compare(a.path, b.path)
})
if !slices.Equal(got, recs) {
t.Fatalf("rows = %+v, want %+v", got, recs)
}
// An upsert for an existing path updates in place; a delete
// removes exactly its path.
upd := scanRec{size: 3, mtime: 30, head: "h3", tail: "t3", path: "/a/x"}
err = applyChanges(db, []scanRec{upd},
[]string{"/a/tab\tnew\nline"}, newProgress("update", 2))
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
got, err = loadFileRows(db)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != upd {
t.Fatalf("rows = %+v, want just %+v", got, upd)
}
}

9
go.mod
View File

@@ -5,13 +5,22 @@ go 1.25.7
require ( require (
github.com/schollz/progressbar/v3 v3.19.1 github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
modernc.org/sqlite v1.54.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect golang.org/x/term v0.44.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

48
go.sum
View File

@@ -3,14 +3,28 @@ github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -23,10 +37,44 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

32
main.go
View File

@@ -2,13 +2,15 @@
// very large filesystems without reading full file contents. Files are // very large filesystems without reading full file contents. Files are
// considered duplicates when they have identical size, identical SHA-256 // considered duplicates when they have identical size, identical SHA-256
// of their first 1024 bytes, and identical SHA-256 of their last 1024 // of their first 1024 bytes, and identical SHA-256 of their last 1024
// bytes. // bytes. scan maintains a persistent SQLite database of file signatures
// (SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite) that the
// reporting subcommands read.
// //
// Usage: // Usage:
// //
// sfdupes scan [--workers N] [-x] PATH... > files.dat // sfdupes scan [--workers N] [-x] PATH...
// sfdupes report [files.dat|-] > dupes.tsv // sfdupes report > dupes.tsv
// sfdupes trees [files.dat|-] > dupetrees.tsv // sfdupes trees > dupetrees.tsv
// //
// See README.md for the complete specification. // See README.md for the complete specification.
package main package main
@@ -60,7 +62,7 @@ func main() {
scanCmd := &cobra.Command{ scanCmd := &cobra.Command{
Use: "scan [--workers N] [-x] PATH...", Use: "scan [--workers N] [-x] PATH...",
Short: "Walk trees and emit one record per regular file on stdout", Short: "Walk trees and synchronize the scan database",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(_ *cobra.Command, args []string) { Run: func(_ *cobra.Command, args []string) {
runScan(args, scanWorkers, scanOneFS) runScan(args, scanWorkers, scanOneFS)
@@ -72,20 +74,20 @@ func main() {
"do not cross filesystem boundaries") "do not cross filesystem boundaries")
reportCmd := &cobra.Command{ reportCmd := &cobra.Command{
Use: "report [files.dat|-]", Use: "report",
Short: "Read a scan stream and print the file-level duplicates report", Short: "Read the scan database and print the file-level duplicates report",
Args: cobra.MaximumNArgs(1), Args: cobra.NoArgs,
Run: func(_ *cobra.Command, args []string) { Run: func(_ *cobra.Command, _ []string) {
runReport(args) runReport()
}, },
} }
treesCmd := &cobra.Command{ treesCmd := &cobra.Command{
Use: "trees [files.dat|-]", Use: "trees",
Short: "Read a scan stream and print the duplicate-tree report", Short: "Read the scan database and print the duplicate-tree report",
Args: cobra.MaximumNArgs(1), Args: cobra.NoArgs,
Run: func(_ *cobra.Command, args []string) { Run: func(_ *cobra.Command, _ []string) {
runTrees(args) runTrees()
}, },
} }

153
report.go
View File

@@ -2,106 +2,49 @@ package main
import ( import (
"bufio" "bufio"
"bytes"
"fmt" "fmt"
"io"
"os" "os"
"slices" "slices"
"strconv"
"strings" "strings"
) )
// ioBufSize is the buffer size for the buffered scan-stream reader and // ioBufSize is the buffer size for the buffered stdout writers.
// the buffered stdout writers.
const ioBufSize = 1 << 20 const ioBufSize = 1 << 20
// recordFieldCount is the number of tab-separated fields in a scan
// record: size, mtime, head hash, tail hash, path.
const recordFieldCount = 5
// scanInitBufSize and scanMaxRecordSize bound the scanner buffer used
// to read scan records; a record longer than scanMaxRecordSize is a
// fatal read error.
const (
scanInitBufSize = 64 << 10
scanMaxRecordSize = 4 << 20
)
// minGroupSize is the smallest number of members that makes a // minGroupSize is the smallest number of members that makes a
// duplicate group. // duplicate group.
const minGroupSize = 2 const minGroupSize = 2
// scanRec is one well-formed record parsed from a scan stream. The // scanRec is one file record from the database. The signature (size,
// signature (size, head, tail) is the duplicate key; mtime is // head, tail) is the duplicate key; mtime is informational only and
// informational and not retained. // used by scan for change detection.
type scanRec struct { type scanRec struct {
size int64 size int64
head string mtime int64
tail string head string
path string tail string
path string
} }
// openScanInput resolves the analysis-mode input: the file named by the // loadRecords opens the database and reads every file record for the
// single optional positional argument, or stdin when it is absent or // report and trees subcommands. Any database problem — including a
// "-". The returned closer must be called when reading is done. // missing database — is fatal.
func openScanInput(args []string) (io.Reader, string, func()) { func loadRecords() []scanRec {
if len(args) == 1 && args[0] != "-" { dbPath := databasePath()
f, err := os.Open(args[0])
if err != nil {
fatalf("open %s: %v", args[0], err)
}
return f, args[0], func() { _ = f.Close() } db, err := openReportDatabase(dbPath)
}
return os.Stdin, "stdin", func() {}
}
// parseScanStream reads every NUL-terminated record from in. A record
// that does not have exactly 5 fields or whose size is non-numeric is
// counted as malformed and skipped. Total records read is
// len(recs) + malformed.
func parseScanStream(in io.Reader, name string) ([]scanRec, int) {
sc := bufio.NewScanner(bufio.NewReaderSize(in, ioBufSize))
sc.Buffer(make([]byte, 0, scanInitBufSize), scanMaxRecordSize)
sc.Split(splitNUL)
var (
recs []scanRec
malformed int
)
for sc.Scan() {
// The path is the last field and may itself contain tabs,
// so split into at most recordFieldCount fields.
fields := strings.SplitN(sc.Text(), "\t", recordFieldCount)
if len(fields) != recordFieldCount {
malformed++
continue
}
size, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
malformed++
continue
}
recs = append(recs, scanRec{
size: size,
head: fields[2],
tail: fields[3],
path: fields[4],
})
}
err := sc.Err()
if err != nil { if err != nil {
fatalf("read %s: %v", name, err) fatalf("%v", err)
} }
return recs, malformed defer func() { _ = db.Close() }()
recs, err := loadFileRows(db)
if err != nil {
fatalf("database %s: %v", dbPath, err)
}
return recs
} }
// dupeGroup is one set of candidate-duplicate files: identical size, // dupeGroup is one set of candidate-duplicate files: identical size,
@@ -112,18 +55,12 @@ type dupeGroup struct {
paths []string paths []string
} }
// runReport implements the report subcommand: it reads a scan stream // runReport implements the report subcommand: it reads every record
// from the named file (or stdin when absent or "-") and prints the // from the database and prints the file-level duplicates report as TSV
// file-level duplicates report as TSV on stdout. It never touches the // on stdout. It never touches the scanned filesystem; its only I/O is
// scanned filesystem; its only I/O is the scan input, stdout, and // the database, stdout, and stderr.
// stderr. args holds the positional arguments already validated by func runReport() {
// cobra (at most one). recs := loadRecords()
func runReport(args []string) {
in, name, closer := openScanInput(args)
defer closer()
recs, malformed := parseScanStream(in, name)
records := len(recs) + malformed
dupes := collectDupeGroups(recs) dupes := collectDupeGroups(recs)
out := bufio.NewWriterSize(os.Stdout, ioBufSize) out := bufio.NewWriterSize(os.Stdout, ioBufSize)
@@ -156,9 +93,9 @@ func runReport(args []string) {
} }
fmt.Fprintf(os.Stderr, fmt.Fprintf(os.Stderr,
"report: %d records read%s, %d duplicate groups, %d dupe files, %s reclaimable\n", "report: %d records read, %d duplicate groups, %d dupe files, "+
records, malformedNote(malformed), len(dupes), dupeFiles, "%s reclaimable\n",
humanBytes(reclaimable)) len(recs), len(dupes), dupeFiles, humanBytes(reclaimable))
} }
// collectDupeGroups groups records by signature and returns every group // collectDupeGroups groups records by signature and returns every group
@@ -199,30 +136,6 @@ func collectDupeGroups(recs []scanRec) []dupeGroup {
return dupes return dupes
} }
// malformedNote formats the optional malformed-record note for the
// stderr summaries.
func malformedNote(malformed int) string {
if malformed == 0 {
return ""
}
return fmt.Sprintf(" (%d malformed, skipped)", malformed)
}
// splitNUL is a bufio.SplitFunc for NUL-terminated records. Trailing
// data without a terminator at EOF is returned as a final record.
func splitNUL(data []byte, atEOF bool) (int, []byte, error) {
if i := bytes.IndexByte(data, 0); i >= 0 {
return i + 1, data[:i], nil
}
if atEOF && len(data) > 0 {
return len(data), data, nil
}
return 0, nil, nil
}
// humanBytes formats a byte count in human units (binary prefixes). // humanBytes formats a byte count in human units (binary prefixes).
func humanBytes(n int64) string { func humanBytes(n int64) string {
const unit = 1024 const unit = 1024

View File

@@ -1,92 +1,10 @@
package main package main
import ( import (
"bufio"
"fmt"
"slices" "slices"
"strings"
"testing" "testing"
) )
// mkRecord serializes one scan record in the on-the-wire format.
func mkRecord(size, mtime int64, head, tail, path string) string {
return fmt.Sprintf("%d\t%d\t%s\t%s\t%s\x00",
size, mtime, head, tail, path)
}
func TestSplitNULScanner(t *testing.T) {
t.Parallel()
sc := bufio.NewScanner(strings.NewReader("a\x00bb\x00\x00tail"))
sc.Split(splitNUL)
var got []string
for sc.Scan() {
got = append(got, sc.Text())
}
err := sc.Err()
if err != nil {
t.Fatalf("scanner error: %v", err)
}
want := []string{"a", "bb", "", "tail"}
if !slices.Equal(got, want) {
t.Fatalf("tokens = %q, want %q", got, want)
}
}
func TestParseScanStream(t *testing.T) {
t.Parallel()
stream := mkRecord(10, 1, "h1", "t1", "/a/x") +
"garbage-without-tabs\x00" +
"notanumber\t1\th\tt\t/a/bad\x00" +
mkRecord(20, 2, "h2", "t2", "/a/tab\tin\tname") +
"30\t3\th3\tt3\t/trailing/no-nul"
recs, malformed := parseScanStream(strings.NewReader(stream), "test")
if malformed != 2 {
t.Errorf("malformed = %d, want 2", malformed)
}
want := []scanRec{
{size: 10, head: "h1", tail: "t1", path: "/a/x"},
{size: 20, head: "h2", tail: "t2", path: "/a/tab\tin\tname"},
{size: 30, head: "h3", tail: "t3", path: "/trailing/no-nul"},
}
if !slices.Equal(recs, want) {
t.Fatalf("recs = %+v, want %+v", recs, want)
}
}
func TestParseScanStreamPathWithNewline(t *testing.T) {
t.Parallel()
in := strings.NewReader(mkRecord(5, 9, "h", "t", "/a/new\nline"))
recs, malformed := parseScanStream(in, "test")
if malformed != 0 || len(recs) != 1 {
t.Fatalf("got %d recs, %d malformed, want 1, 0",
len(recs), malformed)
}
if recs[0].path != "/a/new\nline" {
t.Fatalf("path = %q, want %q", recs[0].path, "/a/new\nline")
}
}
func TestParseScanStreamEmpty(t *testing.T) {
t.Parallel()
recs, malformed := parseScanStream(strings.NewReader(""), "test")
if len(recs) != 0 || malformed != 0 {
t.Fatalf("got %d recs, %d malformed, want 0, 0",
len(recs), malformed)
}
}
func TestCollectDupeGroups(t *testing.T) { func TestCollectDupeGroups(t *testing.T) {
t.Parallel() t.Parallel()
@@ -120,6 +38,22 @@ func TestCollectDupeGroups(t *testing.T) {
} }
} }
func TestCollectDupeGroupsMtimeExcluded(t *testing.T) {
t.Parallel()
// mtime is informational only; records differing only in mtime
// still group together.
recs := []scanRec{
{size: 9, mtime: 100, head: "h", tail: "t", path: "/m/1"},
{size: 9, mtime: 200, head: "h", tail: "t", path: "/m/2"},
}
groups := collectDupeGroups(recs)
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
}
func TestCollectDupeGroupsTieBreak(t *testing.T) { func TestCollectDupeGroupsTieBreak(t *testing.T) {
t.Parallel() t.Parallel()
@@ -190,15 +124,3 @@ func TestHumanBytes(t *testing.T) {
} }
} }
} }
func TestMalformedNote(t *testing.T) {
t.Parallel()
if got := malformedNote(0); got != "" {
t.Errorf("malformedNote(0) = %q, want empty", got)
}
if got := malformedNote(3); got != " (3 malformed, skipped)" {
t.Errorf("malformedNote(3) = %q", got)
}
}

524
scan.go
View File

@@ -1,14 +1,17 @@
package main package main
import ( import (
"bufio"
"crypto/sha256" "crypto/sha256"
"database/sql"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings"
"sync"
"syscall" "syscall"
) )
@@ -19,8 +22,8 @@ const chunk = 1024
// and hash worker pools. // and hash worker pools.
const workQueueDepth = 1024 const workQueueDepth = 1024
// errNotRegular reports a path that stopped being a regular file // errNotRegular reports a path whose type changed between the
// between the walk and stat passes. // directory read and its lstat.
var errNotRegular = errors.New("no longer a regular file") var errNotRegular = errors.New("no longer a regular file")
// fileRec carries one file between the stat and hash passes. // fileRec carries one file between the stat and hash passes.
@@ -30,131 +33,447 @@ type fileRec struct {
mtime int64 mtime int64
} }
// runScan implements the scan subcommand: three sequential passes // runScan implements the scan subcommand: four sequential passes
// (walk, stat, hash) over the trees named by the PATH operands, // (walk, stat, hash, update) that synchronize the persistent database
// emitting one NUL-terminated record per regular file on stdout. Flag // with the filesystem state under the PATH operands. Flag parsing and
// parsing and the at-least-one-operand check are done by cobra. // the at-least-one-operand check are done by cobra.
func runScan(roots []string, workers int, oneFS bool) { func runScan(roots []string, workers int, oneFS bool) {
if workers < 1 { if workers < 1 {
workers = 1 workers = 1
} }
// A nonexistent operand is a fatal error before any scanning. roots = resolveRoots(roots)
dbPath := databasePath()
db, err := openScanDatabase(dbPath)
if err != nil {
fatalf("%v", err)
}
defer func() { _ = db.Close() }()
st, err := syncScan(db, roots, workers, oneFS)
if err != nil {
fatalf("update database %s: %v", dbPath, err)
}
fmt.Fprintf(os.Stderr,
"scan: %d files seen (%d added, %d updated, %d removed, "+
"%d unchanged), %d skipped\n",
st.added+st.updated+st.unchanged, st.added, st.updated,
st.removed, st.unchanged, st.skipped)
}
// 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.
func resolveRoots(roots []string) []string {
abs := make([]string, 0, len(roots))
for _, root := range roots { for _, root := range roots {
_, err := os.Lstat(root) a, err := filepath.Abs(root)
if err != nil {
fatalf("resolve %s: %v", root, err)
}
// A nonexistent operand is a fatal error before any scanning.
_, err = os.Lstat(a)
if err != nil { if err != nil {
fatalf("%v", err) fatalf("%v", err)
} }
abs = append(abs, a)
} }
paths, walkErrs := walkPass(roots, oneFS) return abs
recs, statErrs := statPass(paths, workers)
emitted, hashErrs := hashPass(recs, workers)
skipped := walkErrs + statErrs + hashErrs
fmt.Fprintf(os.Stderr, "scan: %d files emitted, %d skipped\n",
emitted, skipped)
} }
// treeWalker carries the walk-pass state shared by all PATH operands. // scanStats summarizes one scan's database synchronization for the
type treeWalker struct { // final stderr summary.
prog *progress type scanStats struct {
oneFS bool added int
paths []string updated int
errs int removed int
unchanged int
skipped int
} }
// walkPass enumerates every regular file under each root operand in // syncScan synchronizes the database with the filesystem under roots.
// order. It never follows symlinks, never descends into directories // Operands are processed in order, each one walked, hashed, and
// named .zfs, and warns and continues on any per-path error. With // committed independently, so an interrupted scan keeps every operand
// oneFS set it never descends into a directory on a different // completed so far. Records outside the roots are never touched.
// filesystem than its root operand. func syncScan(db *sql.DB, roots []string, workers int,
func walkPass(roots []string, oneFS bool) ([]string, int) { oneFS bool,
w := &treeWalker{prog: newProgress("walk", -1), oneFS: oneFS} ) (scanStats, error) {
var st scanStats
for _, root := range roots { for i, root := range roots {
w.walkRoot(root) // Each operand runs its own walk/hash/update sequence, so
} // announce it: without this, the per-operand pass totals look
// like the whole run's.
fmt.Fprintf(os.Stderr, "scan: %s (operand %d of %d)\n",
root, i+1, len(roots))
w.prog.finish() err := syncRoot(db, root, workers, oneFS, &st)
return w.paths, w.errs
}
// walkRoot walks a single PATH operand, appending regular-file paths.
func (w *treeWalker) walkRoot(root string) {
rootDev, rootDevOK := deviceOf(root)
walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
w.errs++ return st, err
}
}
w.prog.warnf("walk %s: %v", p, err) return st, nil
}
if d != nil && d.IsDir() { // syncRoot walks one PATH operand, hashes its new and changed files,
return filepath.SkipDir // and commits the operand's record insertions, updates, and deletions
} // in a single transaction.
func syncRoot(db *sql.DB, root string, workers int, oneFS bool,
st *scanStats,
) error {
existing, err := loadScopedRows(db, root)
if err != nil {
return err
}
return nil paths, walkErrs := walkPass(root, oneFS, workers)
recs, statErrs := statPass(paths, workers)
toHash, unchanged := partitionChanged(recs, existing)
hashed, hashErrs := hashPass(toHash, workers)
st.unchanged += len(unchanged)
st.skipped += walkErrs + statErrs + hashErrs
for _, r := range hashed {
if _, ok := existing[r.path]; ok {
st.updated++
} else {
st.added++
}
}
deletes := collectDeletes(existing, unchanged, hashed)
st.removed += len(deletes)
return applyPass(db, hashed, deletes)
}
// loadScopedRows loads the database records whose paths lie under
// root, keyed by path. Records outside the scanned operands belong to
// other trees and are left untouched.
func loadScopedRows(db *sql.DB, root string) (map[string]scanRec, error) {
all, err := loadFileRows(db)
if err != nil {
return nil, err
}
scoped := make(map[string]scanRec)
for _, r := range all {
if underRoot(r.path, root) {
scoped[r.path] = r
}
}
return scoped, nil
}
// underRoot reports whether path is root itself or lies under it. Both
// must be absolute and lexically clean.
func underRoot(path, root string) bool {
if path == root {
return true
}
prefix := root
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
return strings.HasPrefix(path, prefix)
}
// partitionChanged splits the stat results into files that must be
// hashed (new, or changed) and files whose existing records are reused
// without reading them: a file is unchanged when its statted size
// equals the recorded size and its statted mtime is not newer than the
// recorded mtime.
func partitionChanged(recs []fileRec,
existing map[string]scanRec,
) ([]fileRec, []scanRec) {
var (
toHash []fileRec
unchanged []scanRec
)
for _, rec := range recs {
old, ok := existing[rec.path]
if ok && old.size == rec.size && old.mtime >= rec.mtime {
unchanged = append(unchanged, old)
continue
} }
if d.IsDir() { toHash = append(toHash, rec)
return w.dirAction(p, d, rootDev, rootDevOK) }
return toHash, unchanged
}
// collectDeletes returns the existing record paths that were not
// successfully processed this run: vanished files, plus paths that
// failed to stat or hash. The database keeps only records verified by
// the latest scan covering them. The result is sorted so the update
// pass is deterministic.
func collectDeletes(existing map[string]scanRec,
unchanged, hashed []scanRec,
) []string {
kept := make(map[string]bool, len(unchanged)+len(hashed))
for _, r := range unchanged {
kept[r.path] = true
}
for _, r := range hashed {
kept[r.path] = true
}
var deletes []string
for p := range existing {
if !kept[p] {
deletes = append(deletes, p)
}
}
slices.Sort(deletes)
return deletes
}
// applyPass writes the scan's changes to the database under an update
// progress display (one item per insertion, update, or deletion).
func applyPass(db *sql.DB, upserts []scanRec, deletes []string) error {
prog := newProgress("update", int64(len(upserts)+len(deletes)))
err := applyChanges(db, upserts, deletes, prog)
prog.finish()
return err
}
// dirJob is one directory awaiting traversal by the walk workers. It
// carries its operand's filesystem device so -x can stop at
// filesystem boundaries.
type dirJob struct {
path string
rootDev uint64
rootDevOK bool
}
// walkEvent is one walk result delivered to the main goroutine: a
// regular-file path, or a warning when fail is set.
type walkEvent struct {
path string
warn string
fail bool
}
// walkPass enumerates every regular file under root with a
// per-directory worker pool. It never follows symlinks, never
// descends into directories named .zfs, and warns and continues on
// any per-path error. With oneFS set it never descends into a
// directory on a different filesystem than root.
func walkPass(root string, oneFS bool, workers int) ([]string, int) {
prog := newProgress("walk", -1)
paths, initial, errs := seedRoot(root, prog)
jobs, subdirs, events := startWalkWorkers(workers, oneFS)
dispatchDirs(initial, jobs, subdirs)
for ev := range events {
if ev.fail {
errs++
prog.warnf("%s", ev.warn)
continue
}
paths = append(paths, ev.path)
prog.increment()
}
prog.finish()
return paths, errs
}
// seedRoot turns the PATH operand into the walk's starting state: a
// regular-file operand becomes a path directly, a directory operand
// becomes the initial job, and a symlink or other non-regular operand
// yields nothing (symlinks are never followed, including as
// operands).
func seedRoot(root string, prog *progress) ([]string, []dirJob, int) {
fi, err := os.Lstat(root)
if err != nil {
prog.warnf("walk %s: %v", root, err)
return nil, nil, 1
}
switch {
case fi.IsDir():
if filepath.Base(root) == ".zfs" {
return nil, nil, 0
}
dev, ok := deviceOfInfo(fi)
return nil, []dirJob{{path: root, rootDev: dev, rootDevOK: ok}}, 0
case fi.Mode().IsRegular():
prog.increment()
return []string{root}, nil, 0
default:
return nil, nil, 0
}
}
// startWalkWorkers starts the walk worker pool. Each worker processes
// one directory at a time, emitting an event per regular file and
// handing discovered subdirectories back to the dispatcher; events is
// closed once every worker has finished.
func startWalkWorkers(workers int,
oneFS bool,
) (chan dirJob, chan []dirJob, chan walkEvent) {
jobs := make(chan dirJob, workQueueDepth)
subdirs := make(chan []dirJob, workers)
events := make(chan walkEvent, workQueueDepth)
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for job := range jobs {
subdirs <- walkOneDir(job, oneFS, events)
}
})
}
go func() {
wg.Wait()
close(events)
}()
return jobs, subdirs, events
}
// dispatchDirs feeds directory jobs to the walk workers, queueing
// newly discovered subdirectories (newest first, which keeps the
// frontier small) until every directory has been processed, then
// closes jobs.
func dispatchDirs(initial []dirJob, jobs chan<- dirJob,
subdirs <-chan []dirJob,
) {
go func() {
queue := slices.Clone(initial)
pending := len(queue)
for pending > 0 {
var (
out chan<- dirJob
next dirJob
)
if len(queue) > 0 {
out = jobs
next = queue[len(queue)-1]
}
select {
case out <- next:
queue = queue[:len(queue)-1]
case subs := <-subdirs:
pending += len(subs) - 1
queue = append(queue, subs...)
}
}
close(jobs)
}()
}
// walkOneDir reads one directory, emitting an event per regular-file
// entry and a warning event per unreadable one, and returns the
// subdirectories to descend into.
func walkOneDir(job dirJob, oneFS bool, events chan<- walkEvent) []dirJob {
entries, err := os.ReadDir(job.path)
if err != nil {
events <- walkEvent{
warn: fmt.Sprintf("walk %s: %v", job.path, err),
fail: true,
}
return nil
}
var subs []dirJob
for _, e := range entries {
p := filepath.Join(job.path, e.Name())
if e.IsDir() {
if sub, ok := subdirJob(p, e, job, oneFS, events); ok {
subs = append(subs, sub)
}
continue
} }
// Regular files only: skip symlinks, sockets, FIFOs, and // Regular files only: skip symlinks, sockets, FIFOs, and
// device nodes. // device nodes.
if !d.Type().IsRegular() { if !e.Type().IsRegular() {
return nil continue
} }
w.paths = append(w.paths, p) events <- walkEvent{path: p}
w.prog.increment()
return nil
})
if walkErr != nil {
fatalf("walk %s: %v", root, walkErr)
} }
return subs
} }
// dirAction decides whether the walk descends into directory p. // subdirJob applies the descent rules to directory p: never enter
func (w *treeWalker) dirAction(p string, d fs.DirEntry, rootDev uint64, rootDevOK bool) error { // .zfs (ZFS snapshot pseudo-dirs would list every file once per
// ZFS snapshot pseudo-dirs would list every file once per // snapshot), and with -x never enter a directory on a different
// snapshot; never descend. // filesystem than its operand.
if d.Name() == ".zfs" { func subdirJob(p string, e fs.DirEntry, parent dirJob, oneFS bool,
return filepath.SkipDir events chan<- walkEvent,
) (dirJob, bool) {
if e.Name() == ".zfs" {
return dirJob{}, false
} }
if !w.oneFS || !rootDevOK { job := dirJob{path: p, rootDev: parent.rootDev, rootDevOK: parent.rootDevOK}
return nil if !oneFS || !parent.rootDevOK {
return job, true
} }
info, err := d.Info() info, err := e.Info()
if err != nil { if err != nil {
w.errs++ events <- walkEvent{
warn: fmt.Sprintf("walk %s: %v", p, err),
fail: true,
}
w.prog.warnf("walk %s: %v", p, err) return dirJob{}, false
return filepath.SkipDir
} }
if dev, ok := deviceOfInfo(info); ok && dev != rootDev { if dev, ok := deviceOfInfo(info); ok && dev != parent.rootDev {
return filepath.SkipDir return dirJob{}, false
} }
return nil return job, true
}
// deviceOf returns the filesystem device ID of path without following
// symlinks.
func deviceOf(path string) (uint64, bool) {
fi, err := os.Lstat(path)
if err != nil {
return 0, false
}
return deviceOfInfo(fi)
} }
// deviceOfInfo extracts the filesystem device ID from a FileInfo, when // deviceOfInfo extracts the filesystem device ID from a FileInfo, when
@@ -271,15 +590,15 @@ func startHashWorkers(recs []fileRec, workers int) <-chan hashResult {
} }
// hashPass hashes the first and last chunk bytes of every file in a // hashPass hashes the first and last chunk bytes of every file in a
// worker pool and emits the output records on stdout from the main // worker pool and collects the resulting records on the main
// goroutine. Files that fail to open or read are warned about and // goroutine. Files that fail to open or read are warned about and
// dropped. // dropped. Only new or changed files reach this pass.
func hashPass(recs []fileRec, workers int) (int, int) { func hashPass(recs []fileRec, workers int) ([]scanRec, int) {
results := startHashWorkers(recs, workers) results := startHashWorkers(recs, workers)
prog := newProgress("hash", int64(len(recs))) prog := newProgress("hash", int64(len(recs)))
out := bufio.NewWriterSize(os.Stdout, ioBufSize) out := make([]scanRec, 0, len(recs))
var emitted, errs int var errs int
for range recs { for range recs {
r := <-results r := <-results
@@ -292,25 +611,20 @@ func hashPass(recs []fileRec, workers int) (int, int) {
continue continue
} }
_, err := fmt.Fprintf(out, "%d\t%d\t%s\t%s\t%s\x00", out = append(out, scanRec{
r.rec.size, r.rec.mtime, r.head, r.tail, r.rec.path) size: r.rec.size,
if err != nil { mtime: r.rec.mtime,
fatalf("write stdout: %v", err) head: r.head,
} tail: r.tail,
path: r.rec.path,
emitted++ })
prog.increment() prog.increment()
} }
prog.finish() prog.finish()
err := out.Flush() return out, errs
if err != nil {
fatalf("write stdout: %v", err)
}
return emitted, errs
} }
// hashHeadTail returns the lowercase-hex SHA-256 of the first // hashHeadTail returns the lowercase-hex SHA-256 of the first

View File

@@ -1,15 +1,17 @@
package main package main
import ( import (
"bytes" "context"
"crypto/sha256" "crypto/sha256"
"database/sql"
"encoding/hex" "encoding/hex"
"io" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
) )
// writeFile creates a file with the given content and returns its path. // writeFile creates a file with the given content and returns its path.
@@ -129,7 +131,7 @@ func TestWalkPass(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
paths, errs := walkPass([]string{dir}, false) paths, errs := walkPass(dir, false, 4)
if errs != 0 { if errs != 0 {
t.Fatalf("errs = %d, want 0", errs) t.Fatalf("errs = %d, want 0", errs)
} }
@@ -141,25 +143,33 @@ func TestWalkPass(t *testing.T) {
} }
} }
func TestWalkPassMultipleRoots(t *testing.T) { func TestWalkPassDeepAndWide(t *testing.T) {
t.Parallel() t.Parallel()
rootA := t.TempDir() // Exercise the dispatcher with more directories than workers and
rootB := t.TempDir() // with nesting deeper than the worker count.
want := []string{ dir := t.TempDir()
writeFile(t, rootA, "a1", []byte("1")), deep := "deep" + strings.Repeat("/d", 30)
writeFile(t, rootA, "sub/a2", []byte("2")),
writeFile(t, rootB, "b1", []byte("3")), want := make([]string, 0, 41)
want = append(want, writeFile(t, dir, deep+"/f", []byte("x")))
for i := range 40 {
want = append(want, writeFile(t, dir,
fmt.Sprintf("wide/%02d/f", i), []byte("y")))
} }
paths, errs := walkPass([]string{rootA, rootB}, false) slices.Sort(want)
paths, errs := walkPass(dir, false, 8)
if errs != 0 { if errs != 0 {
t.Fatalf("errs = %d, want 0", errs) t.Fatalf("errs = %d, want 0", errs)
} }
// Operands are walked in the order given. slices.Sort(paths)
if !slices.Equal(paths, want) { if !slices.Equal(paths, want) {
t.Fatalf("paths = %q, want %q", paths, want) t.Fatalf("walked %d paths, want %d", len(paths), len(want))
} }
} }
@@ -177,13 +187,13 @@ func TestWalkPassFileAndSymlinkOperands(t *testing.T) {
} }
// A regular-file operand is emitted as itself. // A regular-file operand is emitted as itself.
paths, errs := walkPass([]string{f}, false) paths, errs := walkPass(f, false, 2)
if errs != 0 || !slices.Equal(paths, []string{f}) { if errs != 0 || !slices.Equal(paths, []string{f}) {
t.Fatalf("file operand: paths = %q, errs = %d", paths, errs) t.Fatalf("file operand: paths = %q, errs = %d", paths, errs)
} }
// A symlink operand is not followed and yields nothing. // A symlink operand is not followed and yields nothing.
paths, errs = walkPass([]string{link}, false) paths, errs = walkPass(link, false, 2)
if errs != 0 || len(paths) != 0 { if errs != 0 || len(paths) != 0 {
t.Fatalf("symlink operand: paths = %q, errs = %d", paths, errs) t.Fatalf("symlink operand: paths = %q, errs = %d", paths, errs)
} }
@@ -199,7 +209,7 @@ func TestWalkPassOneFilesystemSameFS(t *testing.T) {
writeFile(t, dir, "sub/deep/b", []byte("b")), writeFile(t, dir, "sub/deep/b", []byte("b")),
} }
paths, errs := walkPass([]string{dir}, true) paths, errs := walkPass(dir, true, 4)
if errs != 0 { if errs != 0 {
t.Fatalf("errs = %d, want 0", errs) t.Fatalf("errs = %d, want 0", errs)
} }
@@ -211,23 +221,6 @@ func TestWalkPassOneFilesystemSameFS(t *testing.T) {
} }
} }
func TestDeviceOf(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dev1, ok1 := deviceOf(dir)
dev2, ok2 := deviceOf(dir)
if !ok1 || !ok2 || dev1 != dev2 {
t.Fatalf("deviceOf unstable: %d/%v vs %d/%v", dev1, ok1, dev2, ok2)
}
if _, ok := deviceOf(filepath.Join(dir, "missing")); ok {
t.Fatal("deviceOf reported ok for a missing path")
}
}
func TestStatPass(t *testing.T) { func TestStatPass(t *testing.T) {
t.Parallel() t.Parallel()
@@ -254,36 +247,28 @@ func TestStatPass(t *testing.T) {
} }
} }
// captureStdout runs fn with os.Stdout redirected to a temp file and func TestDeviceOfInfo(t *testing.T) {
// returns everything fn wrote to it. t.Parallel()
func captureStdout(t *testing.T, fn func()) []byte {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "stdout") dir := t.TempDir()
fi1, err := os.Lstat(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
orig := os.Stdout fi2, err := os.Lstat(dir)
os.Stdout = f
defer func() { os.Stdout = orig }()
fn()
_, err = f.Seek(0, io.SeekStart)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
data, err := io.ReadAll(f) dev1, ok1 := deviceOfInfo(fi1)
if err != nil {
t.Fatal(err) dev2, ok2 := deviceOfInfo(fi2)
if !ok1 || !ok2 || dev1 != dev2 {
t.Fatalf("deviceOfInfo unstable: %d/%v vs %d/%v",
dev1, ok1, dev2, ok2)
} }
_ = f.Close()
return data
} }
// buildSmokeTree recreates the README smoke-test filesystem layout // buildSmokeTree recreates the README smoke-test filesystem layout
@@ -316,45 +301,65 @@ func buildSmokeTree(t *testing.T) string {
return dir return dir
} }
// scanToRecords runs the walk, stat, and hash passes over dir and // smokeTreeFiles is the number of regular files buildSmokeTree creates.
// parses the emitted stream back into records. const smokeTreeFiles = 15
func scanToRecords(t *testing.T, dir string) []scanRec {
// syncTree synchronizes the database with the given roots and returns
// the scan stats.
func syncTree(t *testing.T, db *sql.DB, roots ...string) scanStats {
t.Helper() t.Helper()
paths, walkErrs := walkPass([]string{dir}, false) st, err := syncScan(db, roots, 4, false)
if walkErrs != 0 { if err != nil {
t.Fatalf("walk errors: %d", walkErrs) t.Fatalf("syncScan: %v", err)
} }
recs, statErrs := statPass(paths, 4) return st
if statErrs != 0 {
t.Fatalf("stat errors: %d", statErrs)
}
var emitted, hashErrs int
out := captureStdout(t, func() {
emitted, hashErrs = hashPass(recs, 4)
})
if emitted != len(paths) || hashErrs != 0 {
t.Fatalf("emitted %d of %d, %d hash errors",
emitted, len(paths), hashErrs)
}
parsed, malformed := parseScanStream(bytes.NewReader(out), "pipe")
if malformed != 0 || len(parsed) != len(paths) {
t.Fatalf("parsed %d records, %d malformed, want %d, 0",
len(parsed), malformed, len(paths))
}
return parsed
} }
//nolint:paralleltest // redirects the process-wide os.Stdout // dbRecords returns every record currently in the database.
func TestScanPipeline(t *testing.T) { func dbRecords(t *testing.T, db *sql.DB) []scanRec {
dir := buildSmokeTree(t) t.Helper()
parsed := scanToRecords(t, dir)
recs, err := loadFileRows(db)
if err != nil {
t.Fatal(err)
}
return recs
}
// recordByPath finds the record with the given path.
func recordByPath(t *testing.T, recs []scanRec, path string) scanRec {
t.Helper()
for _, r := range recs {
if r.path == path {
return r
}
}
t.Fatalf("no record for %q", path)
return scanRec{}
}
// recordPaths returns the sorted paths of recs.
func recordPaths(recs []scanRec) []string {
paths := make([]string, 0, len(recs))
for _, r := range recs {
paths = append(paths, r.path)
}
slices.Sort(paths)
return paths
}
// assertSmokeDupeGroups checks the file-level duplicate groups for the
// smoke tree rooted at dir.
func assertSmokeDupeGroups(t *testing.T, dir string, parsed []scanRec) {
t.Helper()
groups := collectDupeGroups(parsed) groups := collectDupeGroups(parsed)
if len(groups) != 5 { if len(groups) != 5 {
@@ -377,6 +382,12 @@ func TestScanPipeline(t *testing.T) {
if !slices.Equal(groups[0].paths, wantF1) { if !slices.Equal(groups[0].paths, wantF1) {
t.Errorf("groups[0].paths = %q, want %q", groups[0].paths, wantF1) t.Errorf("groups[0].paths = %q, want %q", groups[0].paths, wantF1)
} }
}
// assertSmokeTreeGroups checks the duplicate-tree groups for the smoke
// tree rooted at dir.
func assertSmokeTreeGroups(t *testing.T, dir string, parsed []scanRec) {
t.Helper()
super, dirs := buildHierarchy(parsed) super, dirs := buildHierarchy(parsed)
super.compute() super.compute()
@@ -396,3 +407,278 @@ func TestScanPipeline(t *testing.T) {
tg[0][0].fileCount, tg[0][0].totalSize) tg[0][0].fileCount, tg[0][0].totalSize)
} }
} }
func TestScanPipeline(t *testing.T) {
t.Parallel()
dir := buildSmokeTree(t)
db := openTestDB(t)
st := syncTree(t, db, dir)
if st != (scanStats{added: smokeTreeFiles}) {
t.Fatalf("stats = %+v, want %d added only", st, smokeTreeFiles)
}
parsed := dbRecords(t, db)
if len(parsed) != smokeTreeFiles {
t.Fatalf("len(records) = %d, want %d", len(parsed), smokeTreeFiles)
}
assertSmokeDupeGroups(t, dir, parsed)
assertSmokeTreeGroups(t, dir, parsed)
}
func TestSyncScanUnchangedReuse(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
a := writeFile(t, dir, "a.bin", pattern(1, 500))
writeFile(t, dir, "b.bin", pattern(2, 600))
st := syncTree(t, db, dir)
if st != (scanStats{added: 2}) {
t.Fatalf("first scan stats = %+v, want 2 added", st)
}
// An immediate rescan reuses every record without reading file
// contents. Prove the files are not re-read by corrupting a stored
// hash and observing that it survives the rescan.
_, err := db.ExecContext(context.Background(),
"UPDATE files SET head = 'sentinel' WHERE path = ?", []byte(a))
if err != nil {
t.Fatal(err)
}
st = syncTree(t, db, dir)
if st != (scanStats{unchanged: 2}) {
t.Fatalf("rescan stats = %+v, want 2 unchanged", st)
}
if r := recordByPath(t, dbRecords(t, db), a); r.head != "sentinel" {
t.Fatalf("head = %q, want sentinel (file must not be re-read)",
r.head)
}
}
func TestSyncScanMtimeBump(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
a := writeFile(t, dir, "a.bin", pattern(1, 500))
syncTree(t, db, dir)
// Bump the mtime forward: the file must be re-hashed even though
// its size is unchanged.
future := time.Now().Add(time.Hour)
err := os.Chtimes(a, future, future)
if err != nil {
t.Fatal(err)
}
st := syncTree(t, db, dir)
if st != (scanStats{updated: 1}) {
t.Fatalf("mtime-bump stats = %+v, want 1 updated", st)
}
if r := recordByPath(t, dbRecords(t, db), a); r.mtime != future.Unix() {
t.Fatalf("mtime = %d, want %d", r.mtime, future.Unix())
}
}
func TestSyncScanAddRemove(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
a := writeFile(t, dir, "a.bin", pattern(1, 500))
writeFile(t, dir, "b.bin", pattern(2, 600))
syncTree(t, db, dir)
// Add one file, remove another.
c := writeFile(t, dir, "c.bin", pattern(3, 700))
err := os.Remove(a)
if err != nil {
t.Fatal(err)
}
st := syncTree(t, db, dir)
if st != (scanStats{added: 1, removed: 1, unchanged: 1}) {
t.Fatalf("add/remove stats = %+v, want 1 added 1 removed 1 unchanged",
st)
}
want := []string{filepath.Join(dir, "b.bin"), c}
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, want) {
t.Fatalf("paths = %q, want %q", got, want)
}
}
func TestSyncScanSizeChange(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
p := writeFile(t, dir, "f", pattern(1, 100))
syncTree(t, db, dir)
// Rewrite with a different size but force the mtime back to the
// recorded value: the size mismatch alone must trigger a re-hash.
old := recordByPath(t, dbRecords(t, db), p)
writeFile(t, dir, "f", pattern(1, 200))
mt := time.Unix(old.mtime, 0)
err := os.Chtimes(p, mt, mt)
if err != nil {
t.Fatal(err)
}
st := syncTree(t, db, dir)
if st.updated != 1 {
t.Fatalf("stats = %+v, want 1 updated", st)
}
if got := recordByPath(t, dbRecords(t, db), p); got.size != 200 {
t.Fatalf("size = %d, want 200", got.size)
}
}
func TestSyncScanScope(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
writeFile(t, dir, "a/keep", pattern(1, 10))
gone := writeFile(t, dir, "b/gone", pattern(2, 10))
syncTree(t, db, dir)
// Deleting a file outside the rescanned root must not remove its
// record: records outside the scanned operands are untouched.
err := os.Remove(gone)
if err != nil {
t.Fatal(err)
}
st := syncTree(t, db, filepath.Join(dir, "a"))
if st.removed != 0 || st.unchanged != 1 {
t.Fatalf("subtree stats = %+v, want 0 removed 1 unchanged", st)
}
if got := recordPaths(dbRecords(t, db)); len(got) != 2 {
t.Fatalf("records = %q, want both retained", got)
}
// Rescanning the parent now removes the vanished file's record.
st = syncTree(t, db, dir)
if st.removed != 1 {
t.Fatalf("parent stats = %+v, want 1 removed", st)
}
}
func TestSyncScanRemovesNonRegular(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
p := writeFile(t, dir, "f", pattern(1, 10))
keep := writeFile(t, dir, "g", pattern(2, 10))
syncTree(t, db, dir)
// Replace the file with a symlink: it is no longer walked, so its
// record must be deleted.
err := os.Remove(p)
if err != nil {
t.Fatal(err)
}
err = os.Symlink(keep, p)
if err != nil {
t.Fatal(err)
}
st := syncTree(t, db, dir)
if st.removed != 1 || st.unchanged != 1 {
t.Fatalf("stats = %+v, want 1 removed 1 unchanged", st)
}
}
func TestSyncScanOverlappingRoots(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
writeFile(t, dir, "sub/f", pattern(1, 10))
// A file reachable via two overlapping operands yields one
// record: the second operand sees the rows the first operand
// committed and reuses them unchanged, which also proves each
// operand commits before the next starts.
st := syncTree(t, db, dir, filepath.Join(dir, "sub"))
if st != (scanStats{added: 1, unchanged: 1}) {
t.Fatalf("stats = %+v, want 1 added 1 unchanged", st)
}
if got := recordPaths(dbRecords(t, db)); len(got) != 1 {
t.Fatalf("records = %q, want exactly one", got)
}
}
func TestReportsNeverTouchFilesystem(t *testing.T) {
t.Parallel()
dir := buildSmokeTree(t)
db := openTestDB(t)
syncTree(t, db, dir)
// Remove the scanned tree entirely; the analysis must be
// unaffected because it reads the database alone.
err := os.RemoveAll(dir)
if err != nil {
t.Fatal(err)
}
recs := dbRecords(t, db)
assertSmokeDupeGroups(t, dir, recs)
assertSmokeTreeGroups(t, dir, recs)
}
func TestUnderRoot(t *testing.T) {
t.Parallel()
const abRoot = "/a/b"
cases := []struct {
path string
root string
want bool
}{
{"/a/b/c", abRoot, true},
{abRoot, abRoot, true},
{"/a/bc", abRoot, false},
{"/a", abRoot, false},
{"/x/y", "/", true},
{"/", "/", true},
}
for _, c := range cases {
if got := underRoot(c.path, c.root); got != c.want {
t.Errorf("underRoot(%q, %q) = %v, want %v",
c.path, c.root, got, c.want)
}
}
}

View File

@@ -28,19 +28,14 @@ type treeNode struct {
totalSize int64 totalSize int64
} }
// runTrees implements the trees subcommand: it reads a scan stream from // runTrees implements the trees subcommand: it reads every record from
// the named file (or stdin when absent or "-"), reconstructs the // the database, reconstructs the directory hierarchy from the record
// directory hierarchy from the record paths, computes a Merkle-style // paths, computes a Merkle-style digest per directory, and prints
// digest per directory, and prints maximal duplicate-tree groups as TSV // maximal duplicate-tree groups as TSV on stdout. It never touches the
// on stdout. It never touches the scanned filesystem; its only I/O is // scanned filesystem; its only I/O is the database, stdout, and
// the scan input, stdout, and stderr. args holds the positional // stderr.
// arguments already validated by cobra (at most one). func runTrees() {
func runTrees(args []string) { recs := loadRecords()
in, name, closer := openScanInput(args)
defer closer()
recs, malformed := parseScanStream(in, name)
records := len(recs) + malformed
super, allDirs := buildHierarchy(recs) super, allDirs := buildHierarchy(recs)
super.compute() super.compute()
@@ -78,9 +73,9 @@ func runTrees(args []string) {
} }
fmt.Fprintf(os.Stderr, fmt.Fprintf(os.Stderr,
"trees: %d records read%s, %d duplicate tree groups, %d dupe trees, %s reclaimable\n", "trees: %d records read, %d duplicate tree groups, %d dupe trees, "+
records, malformedNote(malformed), len(dupes), dupeTrees, "%s reclaimable\n",
humanBytes(reclaimable)) len(recs), len(dupes), dupeTrees, humanBytes(reclaimable))
} }
// buildHierarchy reconstructs the directory hierarchy from the record // buildHierarchy reconstructs the directory hierarchy from the record