3 Commits
Author SHA1 Message Date
clawbot 68881f04f2 Store each hash as its raw 32 bytes in a BLOB column (closes #134)
check / check (push) Waiting to run
The head, tail and content columns of the files table are BLOB NOT
NULL and hold the raw 32-byte SHA-256, half the size of the hex text,
in the table and in the files_signature index. The hash phase, the
records, report and trees carry each hash as a [sha256.Size]byte and
compare it with ==; nothing is hex-encoded. Reading a record whose
hash column is not 32 bytes is an error naming its path.

In the tests, testHash is a stand-in hash: a string's bytes padded
with zeros, so fixtures keep the sort order their strings give.

Judgement call: a hash of the wrong length is refused on reading,
not copied silently.

Model: opus-5-5
2026-10-26 03:15:38 +01:00
clawbot f7a746f912 Hash every file's head, tail and content in the hash phase (closes #132)
check / check (push) Canceled after 0s
Every file the walk records now gets three hashes: head and tail, of
its first and last 64 KiB, and content. The content hash covers the
whole file under 100 MiB, and 1 MiB at each gigabyte from 100 MiB up,
so a large file is never read in full. One 100 MiB constant replaces
the 10 MiB and 50 MiB thresholds. The size census, the records stored
without hashes and the content phase are gone, so a rescan reads only
new and changed files. report and trees no longer special-case a
missing content hash. README "Duplicate detection" and "Rationale" are
rewritten.

Judgement call: info drops its two hash-count lines, which now always
equal the record count.

Model: opus-5-5
2026-10-26 02:02:48 +01:00
clawbot f4ddb58ae1 Keep modules, lint cache and system packages in check builds (closes #109)
check / check (push) Canceled after 0s
Re-vendors REPO_POLICIES.md and script/cibuild, docker, lint and test
from sneak/prompts at 0653579, on sneak's ruling in
sneak/prompts#130. In the Dockerfile's lint,
test and builder stages, the system-package install and the module
download now come before ARG CACHEBUST, so Docker keeps those layers
until the base image, the install line, go.mod or go.sum changes.
golangci-lint run also mounts golangci-lint's cache. Every step that
lints, tests or compiles still runs on every check build, and go test
keeps -count=1 and -race. README and TODO.md say the same.

Model: opus-5-5
2026-10-18 19:25:33 +02:00
17 changed files with 861 additions and 1920 deletions
+201 -281
View File
@@ -3,22 +3,22 @@
## Description ## Description
`sfdupes` is an MIT-licensed Go CLI tool by [@sneak](https://sneak.berlin) that `sfdupes` is an MIT-licensed Go CLI tool by [@sneak](https://sneak.berlin) that
quickly identifies _candidate_ duplicate files — and, ultimately, entire quickly finds _candidate_ duplicate files, and ultimately whole duplicate
duplicate directory trees — across very large filesystems without reading every directory trees, on very large filesystems. It does this without reading every
byte of every file. Files are considered duplicates when their sizes are equal byte of every file.
and they agree on a short ladder of hashes. A file under 10 MiB is hashed in
full and compared directly. A larger file is gated first on the SHA-256 of its Two files are duplicates when they have the same size and the same three SHA-256
first 64 KiB and of its last 64 KiB, and only when its size and both of those hashes. Two of the hashes cover the first and the last 64 KiB of the file. The
match another file's is it read for a content hash to compare — the SHA-256 of third, the content hash, covers the whole file when it is under 100 MiB, so a
the whole file when it is under 50 MiB, or of gigabyte-spaced 1 MiB samples when match proves identical content. At 100 MiB and above it covers only 1 MiB at
it is 50 MiB or larger. Below 50 MiB the content hash is proof of identical each gigabyte, so such a file is never read in full. A match there is a strong
content; at or above 50 MiB it is a strong candidate signal rather than proof, candidate rather than proof.
because the gaps between samples are never read. The intended use is finding
duplicate downloads and duplicated directory trees on multi-terabyte ZFS servers The intended use is finding duplicate downloads and duplicated directory trees
where reading every byte of every file is prohibitively expensive. `scan` on multi-terabyte ZFS servers, where reading every byte of every file is
maintains a persistent SQLite database of file signatures that survives between prohibitively expensive. `scan` keeps a persistent SQLite database of file
runs, so it can be run from cron and the reports can be generated at any time signatures between runs, so it can run from cron. The reports can be generated
from the most recent scan. at any time from the most recent scan.
This README is the complete and authoritative specification. This README is the complete and authoritative specification.
@@ -34,13 +34,13 @@ export SFDUPES_DATABASE="$HOME/.local/share/sfdupes/db.sqlite"
`scan` walks one or more filesystem trees and maintains one database record per `scan` walks one or more filesystem trees and maintains one database record per
regular file (path, size, mtime, head hash, tail hash, content hash). The regular file (path, size, mtime, head hash, tail hash, content hash). The
database persists between runs; a rescan only hashes files that are new or database persists between runs. A rescan hashes only the files that are new or
changed, or that may have gained a duplicate since the last scan, and removes changed, and removes the records of files that no longer exist. `report` reads
records for files that no longer exist. `report` reads the database and prints the database and prints the file-level duplicates report. `trees` reads the same
the file-level duplicates report. `trees` reads the same database and prints the database and prints the duplicate-tree report. `info` prints the database's
duplicate-tree report. `info` prints the database's details: its path, the sizes details: its path, the sizes of its files and the number of its records. A
of its files and the number of its records. A missing/invalid subcommand — or a missing/invalid subcommand — or a `scan` invocation with no `PATH` operand —
`scan` invocation with no `PATH` operand — prints a usage message and exits 2. prints a usage message and exits 2.
The database defaults to `/var/lib/sfdupes/db.sqlite` and can be placed anywhere 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` by setting `SFDUPES_DATABASE`. The intended deployment is a daily `sfdupes scan`
@@ -122,14 +122,19 @@ As a file `/etc/cron.d/sfdupes`:
the first scan. To keep it elsewhere, set the first scan. To keep it elsewhere, set
`SFDUPES_DATABASE=/path/to/db.sqlite` before the command on the same line. `SFDUPES_DATABASE=/path/to/db.sqlite` before the command on the same line.
- `scan` writes nothing to stdout. Its stderr, appended here to - `scan` writes nothing to stdout. Its stderr, appended here to
`/var/log/sfdupes.log`, holds a first line naming the database (see `/var/log/sfdupes.log`, holds these lines:
"Database"), plain progress lines naming each phase and its place among the - a first line naming the database (see "Database");
five, as `[2/5] walk` (one as the phase starts, one every 5 seconds while it - plain progress lines naming each phase and its place among the four, as
runs, and one as it ends), a warning for each path it skips, a line for each `[2/4] walk`: one as the phase starts, one every 5 seconds while it runs,
default exclude its walk does not enter and for each path it leaves out and one as it ends;
because it is not on a persistent filesystem (see "Rules for the walk"), and - a warning for each path it skips;
the summary line (see "Progress" and "`scan` mode"). The log grows with every - a line for each default exclude its walk does not enter, and for each path
scan; rotate it like any other. it leaves out because it is not on a persistent filesystem (see "Rules for
the walk");
- the summary line (see "Progress" and "`scan` mode").
The log grows with every scan; rotate it like any other.
- Skipped paths do not fail a scan: it still exits 0, and cron sends nothing. A - Skipped paths do not fail a scan: it still exits 0, and cron sends nothing. A
scan that fails, or is stopped by `SIGINT` or `SIGTERM`, exits 1 with the scan that fails, or is stopped by `SIGINT` or `SIGTERM`, exits 1 with the
reason among the last lines of the log; `tail` prints them, and cron mails reason among the last lines of the log; `tail` prints them, and cron mails
@@ -153,7 +158,7 @@ A row is a candidate, not proof:
- The reports read only the database, so they show the files as of the last - The reports read only the database, so they show the files as of the last
scan; a file may have changed or gone since. scan; a file may have changed or gone since.
- A file of 50 MiB or more is compared only on samples of its content (see - A file of 100 MiB or more is compared only on samples of its content (see
"Duplicate detection"). "Duplicate detection").
- Paths that are hard links to one file are listed as duplicates, but they share - Paths that are hard links to one file are listed as duplicates, but they share
their data, so removing one frees nothing. their data, so removing one frees nothing.
@@ -188,22 +193,23 @@ or a symlink, which `trees` does not see.
## Rationale ## Rationale
Duplicate finders that hash entire files do not scale to the target environment: Duplicate finders that hash every file in full do not scale to the target
~10 million files and ~150 TB on possibly slow or busy disks (a ZFS pool under environment. It holds about 10 million files and 150 TB, on disks that may be
resilver). sfdupes spends disk I/O only on files whose size at least one other slow or busy, such as a ZFS pool under resilver.
file shares, since a size-unique file cannot be a duplicate. Of those, a file
under 10 MiB is read in full; a larger one has its cheap end windows read first, sfdupes reads a file under 100 MiB in full, and a larger one only at its two
and is read for a content hash only when its size and both end windows match ends and at 1 MiB per gigabyte. A full read of a small file is cheap, and it
another file's — the whole file below 50 MiB, but only gigabyte-spaced samples makes the comparison exact. A full read of every large file would take
at or above 50 MiB, so the largest files are never read in full. This keeps a unreasonably long. Sampling cuts a 150 GiB file to about 150 MiB of reads, at
full-filesystem sweep tractable, and the signatures are kept in a persistent the price of a comparison that is not exact.
database, so the expensive filesystem pass is incremental: a rescan re-hashes
only files whose recorded mtime or size changed, plus — for its content hash — a The hashes are kept in a persistent database, so the expensive filesystem pass
file of 10 MiB or more whose size and end windows have come to match another is incremental. A rescan reads only the files that are new or whose size or
file's. All analysis happens offline from the database alone. The end goal is mtime changed. All analysis then works from the database alone.
not individual files but whole duplicated trees — duplicate extractions,
duplicate downloads, copied project trees — which an operator can consider The end goal is not individual files but whole duplicated trees, such as a
removing as a unit. download extracted twice. An operator can consider removing such a tree as a
unit.
## Design ## Design
@@ -215,14 +221,10 @@ Goals, in order:
trees), so the operator can consider removing an entire subtree at once. trees), so the operator can consider removing an entire subtree at once.
File-level duplicate detection is the foundation; tree-level detection is File-level duplicate detection is the foundation; tree-level detection is
built on top of it. built on top of it.
2. **Spend I/O in proportion to duplicate likelihood.** Only files whose size 2. **Bound the I/O per file.** A file is read only when it is new or has
at least one other file shares are read at all — a size-unique file cannot changed. A file under 100 MiB is then read in full, and a larger one only at
be a duplicate. Those are compared by the ladder in "Duplicate detection" its ends and at 1 MiB per gigabyte (see "Duplicate detection" below).
below: a file under 10 MiB is hashed in full, while a larger file is gated Therefore no file of 100 MiB or more is read in full. Scale target: tens of
on cheap 64 KiB end windows first, and gets a content hash only when its
size and both end windows match another file's. That hash reads the whole
file below 50 MiB but only gigabyte-spaced 1 MiB samples at or above it, so
the very largest files are still never read in full. Scale target: tens of
millions of files, ~150 TB filesystem, possibly slow or busy disks (ZFS pool millions of files, ~150 TB filesystem, possibly slow or busy disks (ZFS pool
under resilver). Holding one small record (path, size, mtime) per file in under resilver). Holding one small record (path, size, mtime) per file in
memory during a scan is acceptable; holding every file's hashes is not (they memory during a scan is acceptable; holding every file's hashes is not (they
@@ -235,12 +237,10 @@ Goals, in order:
being read, so its memory grows with the number of directories and with the being read, so its memory grows with the number of directories and with the
size of the largest directory. size of the largest directory.
3. **Scan incrementally, analyze offline.** The expensive filesystem scan 3. **Scan incrementally, analyze offline.** The expensive filesystem scan
maintains a persistent database; an unchanged file is never read again on a maintains a persistent database, and an unchanged file is never read again
rescan, except to compute its content hash once a file of 10 MiB or more on a rescan. All analysis (`report`, `trees`) works from the database alone
comes to match another on size and both end windows. All analysis (`report`, and must never touch the scanned filesystem again. `scan` is designed to be
`trees`) works from the database alone and must never touch the scanned cronned; the reports run at any time against the last completed scan.
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 data. 4. **Clean stream separation.** Everything on stdout is machine-readable data.
All progress, warnings, summaries, and help and usage text go to stderr. All progress, warnings, summaries, and help and usage text go to stderr.
Never mix them. Never mix them.
@@ -272,7 +272,7 @@ Four subcommands, all implemented:
from the database records, compute a Merkle-style digest per directory, and from the database records, compute a Merkle-style digest per directory, and
report maximal groups of identical trees. report maximal groups of identical trees.
4. `info` — the database's details: its path, the sizes of its files, its 4. `info` — the database's details: its path, the sizes of its files, its
schema version and the counts of its records. schema version, the number of its records and their total size.
``` ```
sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH... sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
@@ -368,9 +368,7 @@ All four subcommands operate on a single SQLite database file:
- While it loads the existing records (`load`) and walks: no records. A - While it loads the existing records (`load`) and walks: no records. A
first scan's database holds none, however long its walk has run. first scan's database holds none, however long its walk has run.
- During the hash phase: the first records, committed 10,000 at a time. - During the hash phase: the first records, committed 10,000 at a time.
- In the update phase: the rest of the hashed records, the records for - In the update phase: the rest of the hashed records, and the deletions.
size-unique files, and the deletions.
- In the content phase: content hashes, again committed 10,000 at a time.
- At the end, as it closes the database: the `-wal` and `-shm` files go - At the end, as it closes the database: the `-wal` and `-shm` files go
away, unless a report still has the database open (see above). away, unless a report still has the database open (see above).
- `report`, `trees` and `info` open the database read-only and need only read - `report`, `trees` and `info` open the database read-only and need only read
@@ -390,9 +388,9 @@ All four subcommands operate on a single SQLite database file:
size INTEGER NOT NULL, -- bytes, from lstat size INTEGER NOT NULL, -- bytes, from lstat
mtime INTEGER NOT NULL, -- whole Unix seconds of the mtime, from lstat mtime INTEGER NOT NULL, -- whole Unix seconds of the mtime, from lstat
mtime_nsec INTEGER NOT NULL, -- nanoseconds within that second, 0 to 999999999 mtime_nsec INTEGER NOT NULL, -- nanoseconds within that second, 0 to 999999999
head TEXT NOT NULL, -- lowercase-hex SHA-256; first 64 KiB, or whole file under 10 MiB head BLOB NOT NULL, -- raw 32-byte SHA-256 of the first 64 KiB
tail TEXT NOT NULL, -- lowercase-hex SHA-256; last 64 KiB, or whole file under 10 MiB tail BLOB NOT NULL, -- raw 32-byte SHA-256 of the last 64 KiB
content TEXT NOT NULL -- lowercase-hex SHA-256, whole file or samples content BLOB NOT NULL -- raw 32-byte SHA-256; whole file under 100 MiB, else samples
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE INDEX files_signature ON files (size, head, tail, content); CREATE INDEX files_signature ON files (size, head, tail, content);
``` ```
@@ -403,57 +401,46 @@ All four subcommands operate on a single SQLite database file:
nanoseconds past that second. Split this way they hold any mtime a nanoseconds past that second. Split this way they hold any mtime a
filesystem can record, one before 1678 or after 2262 included, which a filesystem can record, one before 1678 or after 2262 included, which a
single 64-bit count of nanoseconds cannot. They are used only for change single 64-bit count of nanoseconds cannot. They are used only for change
detection and are not part of the duplicate key. For a file under 10 MiB detection and are not part of the duplicate key. Every record holds the
`head`, `tail`, and `content` all hold the whole-file hash (that range is three hashes that "Duplicate detection" below describes. The
hashed in full, with no end windows); for a larger file `head` and `tail` `files_signature` index lets `report` read the records in signature order
hold the first- and last-64 KiB hashes and `content` the whole-file or without sorting the table.
sampled hash. All three are empty strings when the file has never been
hashed because its size was unique as of the last scan that covered it. For
a file of 10 MiB or more, `content` stays empty until the content phase of a
scan (see "`scan` mode" below) has read the file. A record with an empty
`content` is never part of a duplicate group, though it still defines the
file for tree reconstruction. The `files_signature` index lets `report` read
the records in signature order without sorting the table.
### Duplicate detection ### Duplicate detection
Two files are duplicates only when they agree on every rung of this ladder; a Two files are duplicates when they have the same size and the same three hashes.
mismatch at any rung means they are not duplicates. `scan` stores each file's `scan` computes the hashes and stores them in the database. `report` and `trees`
hashes, and `report` and `trees` group files by the whole signature — size, group the files whose size and hashes all match, whichever scan recorded them.
`head`, `tail`, and `content` — so the grouping is exactly this ladder applied
across everything scanned into the database, even across separate scans.
1. **Size.** Files of different sizes are never compared. Only files whose size `scan` gives every file it records three SHA-256 hashes:
at least one other file shares are hashed at all.
2. **Under 10 MiB: whole file.** A file smaller than 10 MiB is hashed in full
and compared directly, with no separate end-window step — small files are
cheap to read to the last byte, and doing so makes the comparison exact.
`head`, `tail`, and `content` all hold this whole-file SHA-256, so such a
file's signature is decided entirely by its size and its content.
3. **10 MiB and above: head and tail.** For a larger file, the SHA-256 of the
first 64 KiB (`head`) and of the last 64 KiB (`tail`) are a cheap gate that
eliminates most same-size pairs before any bulk reading: the content hash of
the next two rungs is computed only for a file whose size, `head`, and
`tail` match another file's, whether that file is scanned in the same run or
stored by an earlier scan. A stored file that first gains such a match in a
later scan gets its content hash then; until it has one, its `content` is
empty and it is not a duplicate. At 10 MiB and above the two windows never
overlap.
4. **10 MiB and above, content below 50 MiB.** The SHA-256 of the entire file.
Agreement here is proof of identical content (barring a SHA-256 collision).
5. **10 MiB and above, content 50 MiB and above.** A sampled SHA-256: the 1 MiB
window at each gigabyte-aligned offset (0, 1 GiB, 2 GiB, … while inside the
file, the final window truncated at end of file) is fed, in order, into one
hash. This is **deliberately probabilistic** — the gaps between samples are
never read, so two large files that agree on every sample are reported as
duplicates without being read in full. It is the price of never reading a
150 GB file end to end. Because size is already part of the signature, only
equal-size files reach this rung, so their sample boundaries always align.
`head`, `tail`, and `content` are one column each. A file below 10 MiB and one - `head`, the hash of the file's first 64 KiB.
at or above it never share a size, and neither do a file below 50 MiB and one at - `tail`, the hash of the file's last 64 KiB.
or above it, so a stored value is never ambiguous between the whole-file, - `content`, the content hash described below.
end-window, and sampled forms.
In a file of 64 KiB or less, the first and the last 64 KiB are both the whole
file, so all three hashes are the hash of the whole file.
The content hash depends on the file's size:
- Under 100 MiB, it is the SHA-256 of the whole file. Agreement on it is proof
of identical content, barring a SHA-256 collision.
- At 100 MiB and above, it is the SHA-256 of samples, so the file is never read
in full. A sample is the 1 MiB at each gigabyte-aligned offset: 0, 1 GiB, 2
GiB and so on. The samples are fed into one hash in order. A sample that would
run past the end of the file stops there.
The sampled hash is **deliberately probabilistic**. Bytes outside the samples
and outside the first and last 64 KiB are never read. Two large files that
differ only in such bytes are reported as duplicates. That is the price of never
reading a 150 GB file end to end.
Only files of the same size are compared, so their samples always lie at the
same offsets. A file under 100 MiB and one of 100 MiB or more never share a
size, so a whole-file hash is never compared with a sampled one.
`scan` reads a file only to hash it, and only when the file is new or has
changed since its record was written (see "`scan` mode").
### `scan` mode ### `scan` mode
@@ -479,35 +466,22 @@ FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
a one-line warning naming the path and what it is, counts it as skipped, and a one-line warning naming the path and what it is, counts it as skipped, and
drops it from the scanned operands before reading the database. Another operand drops it from the scanned operands before reading the database. Another operand
beneath it is still scanned. The records stored beneath it are not deleted: they beneath it is still scanned. The records stored beneath it are not deleted: they
are treated like any other record outside the scanned operands, including the are treated like any other record outside the scanned operands. If it lies under
content-phase exception below. If it lies under another operand, they are under another operand, they are under that operand instead, and are treated like any
that operand instead, and are treated like any other record there. This is not other record there. This is not an error: a scan whose every operand is dropped
an error: a scan whose every operand is dropped walks nothing and exits 0. An walks nothing and exits 0. An operand that is not on a persistent filesystem
operand that is not on a persistent filesystem (see "Rules for the walk") is (see "Rules for the walk") is dropped the same way. The line `scan` prints for
dropped the same way, except that the line `scan` prints for it is not a it is not a warning, though, and it is not counted as skipped.
warning, and it is not counted as skipped.
`scan` synchronizes the database with the filesystem state under the scanned `scan` synchronizes the database with the filesystem state under the scanned
operands: operands:
- Only a file whose size at least one other file shares is ever read: a - A file not yet in the database is hashed and inserted.
size-unique file cannot be a duplicate, so it is recorded without hashes
(`head`, `tail`, and `content` empty). The size census covers every file
walked this scan plus every database record outside the scanned operands, so a
possible duplicate of a separately scanned tree is still recognized.
- A file not yet in the database is inserted: hashed when its size is shared,
without hashes otherwise.
- A file already in the database is **skipped without reading its contents** - A file already in the database is **skipped without reading its contents**
when its lstat size equals the recorded size and its lstat mtime is not newer when its lstat size equals the recorded size and its lstat mtime is not newer
than the recorded mtime. This is what makes a daily rescan cheap. Exception: than the recorded mtime. This is what makes a daily rescan cheap.
an unchanged file whose record lacks hashes is hashed — and its record updated - A file whose mtime is newer than recorded, or whose size differs, is hashed
— once its size becomes shared, so hashing deferred by size-uniqueness happens again. Change detection compares the mtime to the nanosecond, as finely as the
as soon as it could matter. Likewise, an unchanged file of 10 MiB or more
whose record has no `content` hash is read for one by the content phase below
once its size, `head`, and `tail` match another record's.
- A file whose mtime is newer than recorded, or whose size differs, is processed
as if new: re-hashed, or recorded without hashes, per the shared-size rule.
Change detection compares the mtime to the nanosecond, as finely as the
filesystem records it, so a same-size rewrite counts as a change whenever the filesystem records it, so a same-size rewrite counts as a change whenever the
filesystem gives it a later mtime than recorded, even within the same second. filesystem gives it a later mtime than recorded, even within the same second.
- A database record whose path lies under one of the scanned operands but was - A database record whose path lies under one of the scanned operands but was
@@ -515,31 +489,26 @@ operands:
deleted files. It also removes records for paths that failed to stat or hash 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 this run: the database only ever contains signatures verified by the most
recent scan that covered them (a subsequent successful scan re-adds such recent scan that covered them (a subsequent successful scan re-adds such
files). A failure in the content phase below removes nothing: the record is files).
left as it is.
- Database records outside the scanned operands are untouched, so disjoint trees - Database records outside the scanned operands are untouched, so disjoint trees
can be scanned on different schedules into the same database. The one can be scanned on different schedules into the same database.
exception is the content phase below: a stored file of 10 MiB or more without - A record at or below a path the walk left out counts as outside the scanned
a `content` hash is read for one, wherever it lies, once its size, `head`, and operands, so it is not deleted. The walk leaves out a path that:
`tail` match another record's. If that file is gone, has changed since its - is a default exclude;
record was written, or is not on a persistent filesystem, it is not read and - is not on a persistent filesystem;
the record is left as it is. - with `-x`, is a directory on another filesystem than its operand.
- A record at or below a path the walk left out, because that path is a default
exclude, is not on a persistent filesystem, or, with `-x`, is a directory on
another filesystem than its operand, counts as outside the scanned operands:
it is not deleted, and its size joins the size census. A filesystem mounted
under an operand can therefore be scanned on its own schedule into the same
database. Records below an operand at or inside such a path are not kept this
way, since that operand's own walk verifies them.
`scan` runs **five sequential phases over the whole scan**. Parallelism lives A filesystem mounted under an operand can therefore be scanned on its own
inside the walk, hash and content phases; batched database writes begin during schedule into the same database. Records below an operand at or inside such
the hash phase: a path are not kept this way, since that operand's own walk verifies them.
`scan` runs **four sequential phases over the whole scan**. Parallelism lives
inside the walk and hash phases, and batched database writes begin during the
hash phase:
1. **load** — read the existing records. Each record under the operands is kept 1. **load** — read the existing records. Each record under the operands is kept
in memory, by path, with its size, its mtime and whether it has hashes, for in memory, by path, with its size and its mtime, for the walk to compare
the walk to compare each file with; the size of every other record joins the each file with.
size census.
2. **walk + stat** — enumerate the trees under all `PATH` operands concurrently 2. **walk + stat** — enumerate the trees under all `PATH` operands concurrently
with the walk worker pool: every operand seeds the shared queue, and each with the walk worker pool: every operand seeds the shared queue, and each
worker reads one directory at a time, handing discovered subdirectories back worker reads one directory at a time, handing discovered subdirectories back
@@ -547,49 +516,23 @@ the hash phase:
(while the directory's metadata is still hot). Sequential directory (while the directory's metadata is still hot). Sequential directory
enumeration is metadata-latency-bound and takes hours at tens of millions of 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 files; per-directory parallelism is what makes the walk tractable on large
or busy pools. The walk builds the size census and resolves unchanged or busy pools. The walk compares each file with its record as it goes, and
already-hashed files on the fly; every other file is carried to the hash an unchanged file needs nothing more. Every new or changed file is carried
phase as a (path, size, mtime) record. to the hash phase as a (path, size, mtime) record.
3. **hash** — with the census complete, each carried file's size decides its 3. **hash** — the worker pool hashes every carried file as described in
fate. Size-unique files are never read: new or changed ones are recorded "Duplicate detection" above, which gives the file its `head`, `tail` and
without hashes in the update phase, unchanged unhashed ones simply keep `content`. Zero-length files have constant hashes and are never opened.
their records. Every file with a shared size is hashed by the worker pool as Files are hashed in **inode order**, which minimizes seeks on spinning
described in "Duplicate detection" above: a file under 10 MiB in full, which disks. Paths that are hard links to the same inode are **read once**, and
gives its `head`, `tail`, and `content` alike, and a larger file only in its all share the one result. A hard-link backup farm therefore costs one read
end windows, which give its `head` and `tail`; its content hash is left to per inode, not per path. The phase total counts actual reads, so progress
the content phase. Zero-length files have constant hashes and are never and ETA are meaningful. Completed records are committed in batched
opened. Files are hashed in **inode order** (minimizing seeks on spinning transactions **while hashing runs**. A scan interrupted after hours
disks), and paths that are hard links to the same inode are **read once**, therefore keeps everything hashed so far, and the next scan resumes cheaply,
all sharing the one result — a hard-link backup farm costs one read per skipping records already written.
inode, not per path. The phase total counts actual reads, so progress and 4. **update** — commit the final partial batch of hashed records. Then delete
ETA are meaningful. Completed records are committed in batched transactions the records the scan did not verify: those of vanished files, and of paths
**while hashing runs**, so a scan interrupted after hours keeps everything that failed to stat or hash.
hashed so far and the next scan resumes cheaply, skipping records already
written.
4. **update** — commit the final partial batch, the hash-less records for
size-unique new and changed files, and the deletions for records the scan
did not verify (vanished files, plus paths that failed to stat or hash).
5. **content** — find every record of 10 MiB or more without a `content` hash
whose size, `head`, and `tail` equal another record's, anywhere in the
database: records from this scan and records stored by earlier scans, inside
or outside the scanned operands. SQLite finds them, so only the records to
be read are kept in memory, never every file's hashes. Every record sharing
their size, `head`, and `tail`, including one that already has a `content`
hash, has its file checked with `lstat` first. A file that is gone, is no
longer a regular file, has changed (a different size, or an mtime newer than
recorded), or is not on a persistent filesystem (see "Rules for the walk")
keeps its record as it is and does not count as a match for the others. Any
other `lstat` error is warned about and counted as skipped, with the same
result. If such a record has no `content` hash, it stays out of duplicate
groups; if it has one, it is still reported until a scan covering its own
tree updates or removes it. The files that pass and have no `content` hash
are read only if at least two of those records pass, so a file whose only
matches are stale costs no read; a file that already has a `content` hash is
never read again. They are read by a worker pool as in the hash phase, in
inode order and once per inode, and their content hashes are committed in
batches. A failed read is warned about and counted as skipped; its record
keeps an empty `content`, so it is not a duplicate, and a later scan tries
again.
Rules for the walk: Rules for the walk:
@@ -612,10 +555,10 @@ Rules for the walk:
- Filesystem boundaries are crossed by default. With `-x` (long form - Filesystem boundaries are crossed by default. With `-x` (long form
`--one-file-system`, following the GNU `du`/`rsync` convention), never descend `--one-file-system`, following the GNU `du`/`rsync` convention), never descend
into a directory on a different filesystem than its `PATH` operand; each into a directory on a different filesystem than its `PATH` operand; each
operand is bounded by its own filesystem. The records below such a directory, operand is bounded by its own filesystem. The records below such a directory
unless they lie below an operand at or inside that directory, count as outside count as outside the scanned operands, as described in "`scan` mode" above:
the scanned operands, as described in "`scan` mode" above: they are not they are not deleted. That does not hold below an operand at or inside that
deleted, and their sizes join the size census. directory.
- Never record anything that is not on a persistent filesystem. A filesystem is - Never record anything that is not on a persistent filesystem. A filesystem is
not persistent when the kernel makes it up or keeps it in memory: one of type not persistent when the kernel makes it up or keeps it in memory: one of type
`proc`, `sysfs`, `devtmpfs`, `devpts`, `tmpfs`, `ramfs`, `cgroup`, `cgroup2`, `proc`, `sysfs`, `devtmpfs`, `devpts`, `tmpfs`, `ramfs`, `cgroup`, `cgroup2`,
@@ -632,21 +575,19 @@ Rules for the walk:
`scan` cannot list them, it fails (exit 1). The walk then never enters a `scan` cannot list them, it fails (exit 1). The walk then never enters a
directory, and never records a file, on one of those devices, however it directory, and never records a file, on one of those devices, however it
reaches it: as an operand, below one, or through a bind mount, which keeps the reaches it: as an operand, below one, or through a bind mount, which keeps the
device ID of what it binds. The content phase never reads a file on one of device ID of what it binds. No flag turns this off. For each operand,
them either. No flag turns this off. For each operand, directory or file it directory or file it leaves out this way, `scan` prints a line on stderr
leaves out this way, `scan` prints a line on stderr naming it and the type of naming it and the type of its filesystem. It prints that line as it prints a
its filesystem, as it prints a warning, so it never lands inside a progress warning, so the line never lands inside a progress display:
display:
``` ```
scan: skipped /dev/shm (tmpfs, not a persistent filesystem) scan: skipped /dev/shm (tmpfs, not a persistent filesystem)
``` ```
That line is not a warning, and the path is not counted as skipped. Records That line is not a warning, and the path is not counted as skipped. An
that an earlier scan left at or below it, unless they lie below an operand earlier scan's records at or below it count as outside the scanned operands,
at or inside it, count as outside the scanned operands, as described in as described in "`scan` mode" above: they are not deleted. That does not
"`scan` mode" above: they are not deleted, and their sizes join the size hold below an operand at or inside it.
census.
- Below an operand, never descend into a default exclude: a path that holds the - Below an operand, never descend into a default exclude: a path that holds the
kernel's view of the running system, or temporary files, so that kernel's view of the running system, or temporary files, so that
@@ -678,32 +619,30 @@ Rules for the walk:
That line is not a warning, and the directory is not counted as skipped. A That line is not a warning, and the directory is not counted as skipped. A
default exclude that is not on a persistent filesystem, as `/proc` is, gets default exclude that is not on a persistent filesystem, as `/proc` is, gets
the line for its filesystem instead, and one that is itself an operand gets the line for its filesystem instead. One that is itself an operand gets no
no line, since it is walked as that operand. The records below a default line, since it is walked as that operand. When the walk does not enter a
exclude the walk does not enter, unless they lie below an operand at or default exclude, the records below it count as outside the scanned operands,
inside it, count as outside the scanned operands, as described in "`scan` as described in "`scan` mode" above: they are not deleted. That does not
mode" above: they are not deleted, and their sizes join the size census. hold below an operand at or inside it.
- On any per-path error (permission denied, file vanished between passes, - On any per-path error (permission denied, file vanished between passes,
unreadable): print a one-line warning to stderr, skip the path, and continue. unreadable): print a one-line warning to stderr, skip the path, and continue.
Per-file errors never abort the run; the final summary reports how many were Per-file errors never abort the run; the final summary reports how many were
skipped. As specified above, a skipped path that has a database record from an skipped. As specified above, a skipped path that has a database record from an
earlier scan loses that record, unless it failed only in the content phase, or earlier scan loses that record. An unreadable directory subtree likewise loses
is an operand dropped before the database was read whose record counts as its records. This is accepted: the database mirrors what the latest scan could
outside the scanned operands; an unreadable directory subtree likewise loses actually verify. The exception is an operand dropped before the database was
its records (accepted: the database mirrors what the latest scan could read, whose record counts as outside the scanned operands.
actually verify).
Concurrency: the walk phase (which also stats files), the hash phase, and the Concurrency: the walk phase, which also stats files, and the hash phase each use
content phase each use a worker pool of `--workers` workers (default a worker pool of `--workers` workers (default `runtime.NumCPU()`). The walk
`runtime.NumCPU()`); the walk parallelizes across directories, hashing across parallelizes across directories, and hashing across files. `--workers` must be
files. `--workers` must be at least 1: a smaller value is a usage error, at least 1: a smaller value is a usage error, reported in one line on stderr
reported in one line on stderr with exit 2 before anything is scanned. All three with exit 2 before anything is scanned. Both phases are seek-bound on spinning
phases are seek-bound on spinning disks, so raising `--workers` well past the disks, so raising `--workers` well past the core count can help on pools with
core count can help on pools with many spindles. The main goroutine owns many spindles. The main goroutine owns the database writes and the progress
partitioning, database writes, and the progress counts, which each progress counts. Each progress display draws the counts from a goroutine of its own, and
display draws from a goroutine of its own; progress display must never block the must never block the workers.
workers.
`scan` writes nothing to stdout. The summary line on stderr reports the files `scan` writes nothing to stdout. The summary line on stderr reports the files
seen this run broken down by disposition, plus skips: seen this run broken down by disposition, plus skips:
@@ -726,9 +665,7 @@ output whether or not the scanned filesystem is still mounted.
Processing: Processing:
- Records without a `content` hash (see "Database" above) are excluded: their - Group the records by the key `(size, head, tail, content)`.
content is unknown, so they are never reported as duplicates.
- Group the remaining records by the key `(size, head, tail, content)`.
- 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 first path - Within each group, sort paths lexicographically (byte order). The first path
is the group's `first`; every other path is a `dupe`. is the group's `first`; every other path is a `dupe`.
@@ -782,9 +719,7 @@ records, split on `/`.
Definitions: Definitions:
- A file's **signature** is `(size, head, tail, content)` — mtime is - A file's **signature** is `(size, head, tail, content)` — mtime is
informational and excluded. A record without a `content` hash has unknown informational and excluded.
content: its signature is treated as unique to that file, so a tree containing
such a file never compares equal to any other tree.
- A directory's **digest** is a SHA-256 Merkle digest computed bottom-up: - A directory's **digest** is a SHA-256 Merkle digest computed bottom-up:
serialize the directory's child entries — for a file child, its name and serialize the directory's child entries — for a file child, its name and
signature; for a subdirectory child, its name and that subdirectory's digest — signature; for a subdirectory child, its name and that subdirectory's digest —
@@ -858,8 +793,6 @@ database file size 2147483648 (2.0 GiB)
modified 2026-10-16T12:02:50Z modified 2026-10-16T12:02:50Z
schema version 1 schema version 1
records 12345678 records 12345678
records with head and tail hashes 1234567
records with content hash 123456
size of recorded files 150000000000000 (136.4 TiB) size of recorded files 150000000000000 (136.4 TiB)
``` ```
@@ -872,10 +805,8 @@ size of recorded files 150000000000000 (136.4 TiB)
database is in WAL mode, the newest changes may be in the `-wal` file alone. database is in WAL mode, the newest changes may be in the `-wal` file alone.
- `schema version` is the database's `PRAGMA user_version`. A database with - `schema version` is the database's `PRAGMA user_version`. A database with
another version than this build's is refused, as by `report`. another version than this build's is refused, as by `report`.
- `records` is the number of records. `records with head and tail hashes` and - `records` is the number of records. `size of recorded files` adds up the sizes
`records with content hash` count the records that have those hashes (see of all records, in bytes, then in human units.
"Database"), and `size of recorded files` adds up the sizes of all records, in
bytes, then in human units.
`info` runs in two phases, each with its own progress display on stderr (see `info` runs in two phases, each with its own progress display on stderr (see
"Progress"): `open` opens the database; `count` reads the schema version and "Progress"): `open` opens the database; `count` reads the schema version and
@@ -895,8 +826,8 @@ not hung. Each phase gets its own display from the moment it starts, naming the
phase and its place among the command's phases and showing its progress, its phase and its place among the command's phases and showing its progress, its
elapsed time and its estimated time remaining (ETA). elapsed time and its estimated time remaining (ETA).
A scan has five phases (`[1/5] load`, `[2/5] walk`, `[3/5] hash`, A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
`[4/5] update`, `[5/5] content`): `[4/4] update`):
- `load` counts the records first, then reads them against that count. While the - `load` counts the records first, then reads them against that count. While the
count runs, the display says it is counting the records. count runs, the display says it is counting the records.
@@ -906,13 +837,6 @@ A scan has five phases (`[1/5] load`, `[2/5] walk`, `[3/5] hash`,
scan, the ETA is unknown. scan, the ETA is unknown.
- `hash` and `update` have exact totals. Only files that actually need hashing - `hash` and `update` have exact totals. Only files that actually need hashing
appear in the hash total, so its ETA is meaningful. appear in the hash total, so its ETA is meaningful.
- `content` first counts the records whose files it checks with `lstat`, which
SQLite does by adding up the number of records with each matching size,
`head`, and `tail`, without fetching the records. It then says it is searching
the database until SQLite returns the first record; the checks, and their rate
and ETA, start there. The files to read then get a display of their own, with
their count as its total, and so does the writing of the last of their records
to the database, with the count of those records as its total.
`trees` has four phases (`[1/4] open`, `[2/4] read`, `[3/4] group`, `trees` has four phases (`[1/4] open`, `[2/4] read`, `[3/4] group`,
`[4/4] write`, see "`trees` mode"): `[4/4] write`, see "`trees` mode"):
@@ -958,23 +882,21 @@ Each display is one line holding:
Example shape (exact layout is flexible, content is not): Example shape (exact layout is flexible, content is not):
``` ```
[3/5] hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54 [3/4] hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54
[2/5] walk: [1234567/~1200000] 103%, 5120 files/s, elapsed 4m1s, eta unknown [2/4] walk: [1234567/~1200000] 103%, 5120 files/s, elapsed 4m1s, eta unknown
[3/5] hash: [12345/45678] 27%, stalled disk/db, elapsed 4m30s, eta 12m9s [3/4] hash: [12345/45678] 27%, stalled disk/db, elapsed 4m30s, eta 12m9s
``` ```
When no item has completed for 10 seconds, as while one large file is read, a When no item has completed for 10 seconds, as while one large file is read, a
disk is slow or the database is busy, the line says `stalled` and where the disk is slow or the database is busy, the line says `stalled` and where the
phase waits in place of the rate, which would read 0: `disk` for files, `db` for phase waits in place of the rate, which would read 0: `disk` for files, `db` for
the database, `out` for stdout, and two of them, as `disk/db`, for a phase that the database, `out` for stdout, and two of them, as `disk/db`, for a phase that
may wait on either. `walk` says `disk`. `hash` and the content phase's reads say may wait on either. `walk` says `disk`. `hash` says `disk/db`, as it reads files
`disk/db`, as they read files and write their records to the database between and writes their records to the database between them. `load`, `update` and the
them, and so do the content phase's checks, which `lstat` each record's file as `read` phase of `trees` say `db`. `trees`' `write` says `out`, and its `group`,
the database search returns it. `load`, `update`, the content phase's last write which works in memory, says `cpu`. The `read` phase of `report` writes the
and the `read` phase of `trees` say `db`. `trees`' `write` says `out`, and its report while it reads the records, so it says `db/out`. A phase still waiting
`group`, which works in memory, says `cpu`. The `read` phase of `report` writes for its total already says what it waits for, and says nothing more.
the report while it reads the records, so it says `db/out`. A phase still
waiting for its total already says what it waits for, and says nothing more.
Additional requirements: Additional requirements:
@@ -990,17 +912,15 @@ Additional requirements:
terminal is. A line too wide for the terminal first loses its bar, which gives terminal is. A line too wide for the terminal first loses its bar, which gives
way to the spinner, then its rate, the words of a stalled phase, or what the way to the spinner, then its rate, the words of a stalled phase, or what the
phase is waiting for. The phase and its place, the progress, the elapsed time phase is waiting for. The phase and its place, the progress, the elapsed time
and the ETA are kept whole. On an 80-column terminal, at counts like those of and the ETA are kept whole. On an 80-column terminal, at counts like those in
the stalled line in the example above, a stalled line keeps its words whenever the example above, a stalled line keeps its words whenever the elapsed time
the elapsed time and the ETA are both in minutes, except in the content and the ETA are both in minutes. Where the words and the ETA do not both fit,
phase's checks and reads. Their lines are the widest, and at most times in as at millions of files and times in hours, the words give way. Only when
minutes they lose the words. Where the words and the ETA do not both fit, as those four alone are too wide, as on a terminal narrower than about 70 columns
at millions of files and times in hours, the words give way. Only when those hours into a scan of millions of files, is the line cut short at the
four alone are too wide, as on a terminal narrower than about 70 columns hours terminal's edge, so the end of the ETA is lost first. A terminal made narrower
into a scan of millions of files, is the line cut short at the terminal's while a phase runs gets the same: the next draw erases the line within the new
edge, so the end of the ETA is lost first. A terminal made narrower while a width and fits the line to it, and the display goes on redrawing in place.
phase runs gets the same: the next draw erases the line within the new width
and fits the line to it, and the display goes on redrawing in place.
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI - When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
redraws: print the line the moment each phase starts, every 5 seconds while it redraws: print the line the moment each phase starts, every 5 seconds while it
runs (whether or not an item has completed, and never more often), and once runs (whether or not an item has completed, and never more often), and once
@@ -1232,8 +1152,8 @@ Tracked in [TODO.md](TODO.md).
## Non-goals ## Non-goals
- No byte-for-byte compare, and no deletion or linking of duplicates. Files that - No byte-for-byte compare, and no deletion or linking of duplicates. Files that
match are compared by a SHA-256 of the whole file below 50 MiB, and only by match are compared by a SHA-256 of the whole file below 100 MiB, and only by
samples at 50 MiB and over. The reports are advisory; acting on them is the samples at 100 MiB and over. The reports are advisory; acting on them is the
user's job. user's job.
- No persistence beyond the SQLite database described above; no export/import - No persistence beyond the SQLite database described above; no export/import
formats. formats.
+15
View File
@@ -29,6 +29,21 @@
# Completed Steps # Completed Steps
- the `head`, `tail` and `content` columns are BLOBs, each holding the raw 32
bytes of a SHA-256, half the size of the hex text they held. The hashes stay
raw from the hash phase through the database to `report` and `trees`. Reading
a record whose hash is not 32 bytes is an error (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/134)
- the hash phase gives every file the walk records three hashes: `head` and
`tail`, the SHA-256 of its first and last 64 KiB, and `content`, the SHA-256
of the whole file under 100 MiB and of 1 MiB at each gigabyte from 100 MiB up.
The 10 MiB and 50 MiB thresholds, the size census, the records stored without
hashes and the content phase are gone, so a scan has four phases and a rescan
reads only new and changed files. `info` no longer counts the records with
hashes, since every record has them (2026-10-25,
https://git.eeqj.de/sneak/sfdupes/issues/132)
- re-vendor `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` from - re-vendor `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` from
`sneak/prompts` `next` at `0653579`: in the `lint`, `test` and `builder` `sneak/prompts` `next` at `0653579`: in the `lint`, `test` and `builder`
stages of the `Dockerfile` the module download, and in `builder` the stages of the `Dockerfile` the module download, and in `builder` the
+19 -14
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -168,8 +169,8 @@ func assertRecordsIntact(t *testing.T, db *sql.DB, before []string) {
// TestSyncScanCancelledMidWalkKeepsRecords is the regression net under // TestSyncScanCancelledMidWalkKeepsRecords is the regression net under
// the post-walk guard. The scan is cancelled part-way through the // the post-walk guard. The scan is cancelled part-way through the
// walk, so it reaches the guard holding a genuinely partial size // walk, so it reaches the guard having walked a genuinely partial tree
// census and a still-populated index of records the walk never got to. // and holding a still-populated index of records the walk never got to.
// Every one of those records would look vanished to the update phase. // Every one of those records would look vanished to the update phase.
// The guard is what stops the scan there, and this test is what // The guard is what stops the scan there, and this test is what
// notices if it stops doing so: deleting the guard, or making it // notices if it stops doing so: deleting the guard, or making it
@@ -208,10 +209,10 @@ func TestSyncScanCancelledMidWalkKeepsRecords(t *testing.T) {
} }
// assertWalkGuardAborted checks that the scan stopped at the post-walk // assertWalkGuardAborted checks that the scan stopped at the post-walk
// guard: with a census that is neither empty (the walk really ran) // guard: with a walk that reached some files (it really ran) but not
// nor complete (it really was cut short), and with no record counted // all of them (it really was cut short), and with no record counted for
// for removal. A removal count means the partial census was carried // removal. A removal count means the partial walk was carried past
// past the guard into the update phase, which is the failure this test // the guard into the update phase, which is the failure this test
// exists to catch. // exists to catch.
func assertWalkGuardAborted(t *testing.T, st scanStats, err error) { func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
t.Helper() t.Helper()
@@ -222,30 +223,30 @@ func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
} }
if st.unchanged == 0 { if st.unchanged == 0 {
t.Fatalf("stats = %+v: the census is empty, so the walk never "+ t.Fatalf("stats = %+v: the walk reached no file, so it never "+
"ran and the guard was reached for the wrong reason", st) "ran and the guard was reached for the wrong reason", st)
} }
if st.unchanged >= walkCancelFiles { if st.unchanged >= walkCancelFiles {
t.Fatalf("stats = %+v: the census covers the whole tree, so the "+ t.Fatalf("stats = %+v: the walk reached the whole tree, so it "+
"walk was not cut short", st) "was not cut short", st)
} }
// The workers drop every directory still queued once the scan is // The workers drop every directory still queued once the scan is
// cancelled, so only the files in the directories already in flight // cancelled, so only the files in the directories already in flight
// can add to the census after the fact. A census beyond that bound // can still be reached after the fact. A walk beyond that bound
// would mean the cancellation was not observed where it should have // would mean the cancellation was not observed where it should have
// been. // been.
limit := walkCancelAtDone + walkCancelInFlightFiles limit := walkCancelAtDone + walkCancelInFlightFiles
if st.unchanged > limit { if st.unchanged > limit {
t.Errorf("census covers %d files, want at most %d: the walk kept "+ t.Errorf("the walk reached %d files, want at most %d: it kept "+
"taking directories off the queue after cancellation", "taking directories off the queue after cancellation",
st.unchanged, limit) st.unchanged, limit)
} }
if st.removed != 0 { if st.removed != 0 {
t.Errorf("stats = %+v: the scan counted records for removal from "+ t.Errorf("stats = %+v: the scan counted records for removal from "+
"a partial census", st) "a partial walk", st)
} }
} }
@@ -762,7 +763,9 @@ func TestHashWorkerDropsQueuedRuns(t *testing.T) {
var hashed atomic.Bool var hashed atomic.Bool
hash := func(path string, size int64) (string, string, string, error) { hash := func(path string, size int64) (
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
hashed.Store(true) hashed.Store(true)
return hashSignature(path, size) return hashSignature(path, size)
@@ -802,7 +805,9 @@ func TestHashWorkerAbandonsBlockedSend(t *testing.T) {
// The scan is cancelled while the worker hashes, so the worker has // The scan is cancelled while the worker hashes, so the worker has
// already passed the check that drops queued runs. // already passed the check that drops queued runs.
hash := func(path string, size int64) (string, string, string, error) { hash := func(path string, size int64) (
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
cancel() cancel()
return hashSignature(path, size) return hashSignature(path, size)
+55 -108
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -48,16 +49,17 @@ const lockFilePerm = 0o600
// createTableSQL is the schema applied to a fresh database. Paths are // createTableSQL is the schema applied to a fresh database. Paths are
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime // BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime
// holds whole Unix seconds and mtime_nsec the nanoseconds within that // holds whole Unix seconds and mtime_nsec the nanoseconds within that
// second. // second. head, tail and content each hold the raw 32 bytes of a
// SHA-256.
const createTableSQL = ` const createTableSQL = `
CREATE TABLE files ( CREATE TABLE files (
path BLOB PRIMARY KEY, path BLOB PRIMARY KEY,
size INTEGER NOT NULL, size INTEGER NOT NULL,
mtime INTEGER NOT NULL, mtime INTEGER NOT NULL,
mtime_nsec INTEGER NOT NULL, mtime_nsec INTEGER NOT NULL,
head TEXT NOT NULL, head BLOB NOT NULL,
tail TEXT NOT NULL, tail BLOB NOT NULL,
content TEXT NOT NULL content BLOB NOT NULL
) WITHOUT ROWID ) WITHOUT ROWID
` `
@@ -92,6 +94,10 @@ var errSchemaVersion = errors.New("unsupported database schema version")
// database. // database.
var errScanRunning = errors.New("another scan is running") var errScanRunning = errors.New("another scan is running")
// errHashSize reports a record whose head, tail or content is not the
// 32 bytes of a SHA-256.
var errHashSize = errors.New("hash is not 32 bytes")
// databasePath resolves the database location: SFDUPES_DATABASE when // databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the // set and non-empty, the compiled-in default otherwise. It returns the
// path, with the working directory, its symlinks resolved, put in front // path, with the working directory, its symlinks resolved, put in front
@@ -492,19 +498,24 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
for rows.Next() { for rows.Next() {
var ( var (
path []byte path, head, tail, content []byte
sec, nsec int64 sec, nsec int64
r scanRec r scanRec
) )
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail, err = rows.Scan(&path, &r.size, &sec, &nsec, &head, &tail, &content)
&r.content)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
r.path = string(path) r.path = string(path)
r.mtime = time.Unix(sec, nsec) r.mtime = time.Unix(sec, nsec)
err = r.setHashes(head, tail, content)
if err != nil {
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
}
fn(r) fn(r)
} }
@@ -561,17 +572,22 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
for rows.Next() { for rows.Next() {
var ( var (
path []byte path, head, tail, content []byte
r scanRec r scanRec
) )
err = rows.Scan(&r.size, &r.head, &r.tail, &r.content, &path) err = rows.Scan(&r.size, &head, &tail, &content, &path)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
r.path = string(path) r.path = string(path)
err = r.setHashes(head, tail, content)
if err != nil {
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
}
err = fn(r) err = fn(r)
if err != nil { if err != nil {
return err return err
@@ -586,6 +602,22 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
return nil return nil
} }
// setHashes sets r's head, tail and content to the values read from
// those columns. Each must be the raw 32 bytes of a SHA-256.
func (r *scanRec) setHashes(head, tail, content []byte) error {
for _, b := range [][]byte{head, tail, content} {
if len(b) != sha256.Size {
return fmt.Errorf("%w: found %d bytes", errHashSize, len(b))
}
}
r.head = [sha256.Size]byte(head)
r.tail = [sha256.Size]byte(tail)
r.content = [sha256.Size]byte(content)
return nil
}
// countRecords returns the number of records in the database. // countRecords returns the number of records in the database.
func countRecords(ctx context.Context, db *sql.DB) (int64, error) { func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
var n int64 var n int64
@@ -598,15 +630,11 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
return n, nil return n, nil
} }
// databaseCountsSQL counts the records, those with head and tail // databaseCountsSQL counts the records and adds up their sizes, in one
// hashes and those with a content hash, and adds up their sizes, in one
// pass over the table. A SUM over no rows is NULL, so on an empty table // pass over the table. A SUM over no rows is NULL, so on an empty table
// each SUM falls back to 0. // the SUM falls back to 0.
const databaseCountsSQL = ` const databaseCountsSQL = `
SELECT COUNT(*), SELECT COUNT(*), COALESCE(SUM(size), 0)
COALESCE(SUM(head <> '' AND tail <> ''), 0),
COALESCE(SUM(content <> ''), 0),
COALESCE(SUM(size), 0)
FROM files FROM files
` `
@@ -615,8 +643,6 @@ FROM files
type databaseCounts struct { type databaseCounts struct {
schemaVersion int schemaVersion int
records int64 // every record records int64 // every record
headTail int64 // records with head and tail hashes
content int64 // records with a content hash
size int64 // the sizes of all records added up size int64 // the sizes of all records added up
} }
@@ -644,7 +670,7 @@ func readDatabaseCounts(ctx context.Context,
} }
err = tx.QueryRowContext(ctx, databaseCountsSQL). err = tx.QueryRowContext(ctx, databaseCountsSQL).
Scan(&c.records, &c.headTail, &c.content, &c.size) Scan(&c.records, &c.size)
if err != nil { if err != nil {
return c, fmt.Errorf("count records: %w", err) return c, fmt.Errorf("count records: %w", err)
} }
@@ -652,15 +678,15 @@ func readDatabaseCounts(ctx context.Context,
return c, nil return c, nil
} }
// loadFileMeta streams every record's path, size, mtime, and whether // loadFileMeta streams every record's path, size and mtime to fn. Scan
// it carries hashes to fn. Scan change detection needs no hash // change detection needs no hash values, and skipping the hash columns
// values, and skipping the hash columns keeps the scan's in-memory // keeps the scan's in-memory index small on multi-million-file
// index small on multi-million-file databases. // databases.
func loadFileMeta(ctx context.Context, db *sql.DB, func loadFileMeta(ctx context.Context, db *sql.DB,
fn func(path string, size int64, mtime time.Time, hashed bool), fn func(path string, size int64, mtime time.Time),
) error { ) error {
rows, err := db.QueryContext(ctx, rows, err := db.QueryContext(ctx,
"SELECT path, size, mtime, mtime_nsec, head <> '' FROM files") "SELECT path, size, mtime, mtime_nsec FROM files")
if err != nil { if err != nil {
return fmt.Errorf("read records: %w", err) return fmt.Errorf("read records: %w", err)
} }
@@ -671,94 +697,14 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
var ( var (
path []byte path []byte
size, sec, nsec int64 size, sec, nsec int64
hashed int64
) )
err = rows.Scan(&path, &size, &sec, &nsec, &hashed) err = rows.Scan(&path, &size, &sec, &nsec)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
fn(string(path), size, time.Unix(sec, nsec), hashed != 0) fn(string(path), size, time.Unix(sec, nsec))
}
err = rows.Err()
if err != nil {
return fmt.Errorf("read records: %w", err)
}
return nil
}
// contentGroupsSQL selects each group (the records sharing a size,
// head, and tail) of records of at least headTailMin bytes that has more
// than one record and at least one record without a content hash, with
// the number of its records.
const contentGroupsSQL = `
SELECT size, head, tail, COUNT(*) AS records
FROM files
WHERE size >= ? AND head <> ''
GROUP BY size, head, tail
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
`
// contentCandidatesSQL selects every record of the groups
// contentGroupsSQL selects, with whether each record has a content
// hash. SQLite does the grouping, so no other record's hashes are
// loaded into memory; the rows come ordered by size, head, and tail, so
// each group's rows arrive together.
const contentCandidatesSQL = `
SELECT f.path, f.size, f.mtime, f.mtime_nsec, f.head, f.tail, f.content <> ''
FROM files AS f
JOIN (` + contentGroupsSQL + `) AS g USING (size, head, tail)
ORDER BY size, head, tail
`
// countContentCandidates returns the number of rows of
// contentCandidatesSQL, adding up the records of the groups
// contentGroupsSQL selects, so it neither joins nor sorts.
func countContentCandidates(ctx context.Context, db *sql.DB) (int64, error) {
var n int64
err := db.QueryRowContext(ctx,
"SELECT COALESCE(SUM(records), 0) FROM ("+contentGroupsSQL+")",
headTailMin).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count content candidates: %w", err)
}
return n, nil
}
// loadContentCandidates streams the rows of contentCandidatesSQL to fn:
// each record, without its content hash, and whether it has one.
func loadContentCandidates(ctx context.Context, db *sql.DB,
fn func(r scanRec, hashed bool),
) error {
rows, err := db.QueryContext(ctx, contentCandidatesSQL, headTailMin)
if err != nil {
return fmt.Errorf("read records: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
path []byte
sec, nsec int64
r scanRec
hashed int64
)
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
&hashed)
if err != nil {
return fmt.Errorf("read record: %w", err)
}
r.path = string(path)
r.mtime = time.Unix(sec, nsec)
fn(r, hashed != 0)
} }
err = rows.Err() err = rows.Err()
@@ -842,7 +788,8 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
for _, r := range upserts { for _, r := range upserts {
_, err = st.ExecContext(ctx, []byte(r.path), r.size, _, err = st.ExecContext(ctx, []byte(r.path), r.size,
r.mtime.Unix(), r.mtime.Nanosecond(), r.head, r.tail, r.content) r.mtime.Unix(), r.mtime.Nanosecond(),
r.head[:], r.tail[:], r.content[:])
if err != nil { if err != nil {
return fmt.Errorf("upsert %s: %w", r.path, err) return fmt.Errorf("upsert %s: %w", r.path, err)
} }
+71 -67
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -473,6 +474,67 @@ func TestLoadRowsCountAgreeDuringScan(t *testing.T) {
} }
} }
// TestLoadRowsRefuseWrongSizeHash stores a record whose head, tail or
// content is not the 32 bytes of a SHA-256: either one byte, or the 64
// bytes of a SHA-256 written as hex text. loadFileRows and
// loadSignatureRows must each fail on it with an error naming its path.
func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
t.Parallel()
hexText := strings.Repeat("ab", sha256.Size)
cases := []struct {
name string
update string
value any
}{
{"one-byte head", "UPDATE files SET head = ?", []byte{0}},
{"hex-text head", "UPDATE files SET head = ?", hexText},
{"one-byte tail", "UPDATE files SET tail = ?", []byte{0}},
{"hex-text tail", "UPDATE files SET tail = ?", hexText},
{"one-byte content", "UPDATE files SET content = ?", []byte{0}},
{"hex-text content", "UPDATE files SET content = ?", hexText},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
db := openTestDB(t)
err := applyChanges(t.Context(), db,
[]scanRec{{path: "/a/f"}}, nil, nil)
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(t.Context(), c.update, c.value)
if err != nil {
t.Fatal(err)
}
loads := map[string]func() error{
"loadFileRows": func() error {
return loadFileRows(t.Context(), db, func(int64) {},
func(scanRec) {})
},
"loadSignatureRows": func() error {
return loadSignatureRows(t.Context(), db, func(int64) {},
func(scanRec) error { return nil })
},
}
for name, load := range loads {
err := load()
if !errors.Is(err, errHashSize) ||
!strings.Contains(err.Error(), "/a/f") {
t.Errorf("%s: err = %v, want %v naming /a/f",
name, err, errHashSize)
}
}
})
}
}
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL // TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
// from the files_signature index alone, with nothing to sort: the // from the files_signature index alone, with nothing to sort: the
// first record comes at once, so report's read phase counts each // first record comes at once, so report's read phase counts each
@@ -527,12 +589,13 @@ func TestApplyChangesRoundTrip(t *testing.T) {
// written. // written.
recs := []scanRec{ recs := []scanRec{
{ {
size: 2, mtime: time.Unix(20, 999_999_999), head: "h2", tail: "t2", size: 2, mtime: time.Unix(20, 999_999_999), head: testHash("h2"),
content: "c2", path: "/a/tab\tnew\nline", tail: testHash("t2"), content: testHash("c2"),
path: "/a/tab\tnew\nline",
}, },
{ {
size: 1, mtime: time.Unix(10, 0), head: "h1", tail: "t1", size: 1, mtime: time.Unix(10, 0), head: testHash("h1"),
content: "c1", path: "/a/x", tail: testHash("t1"), content: testHash("c1"), path: "/a/x",
}, },
} }
@@ -553,8 +616,8 @@ func TestApplyChangesRoundTrip(t *testing.T) {
// An upsert for an existing path updates in place; a delete // An upsert for an existing path updates in place; a delete
// removes exactly its path. // removes exactly its path.
upd := scanRec{ upd := scanRec{
size: 3, mtime: time.Unix(30, 0), head: "h3", tail: "t3", content: "c3", size: 3, mtime: time.Unix(30, 0), head: testHash("h3"),
path: "/a/x", tail: testHash("t3"), content: testHash("c3"), path: "/a/x",
} }
err = applyChanges(t.Context(), db, []scanRec{upd}, err = applyChanges(t.Context(), db, []scanRec{upd},
@@ -581,8 +644,8 @@ func TestApplyChangesBatching(t *testing.T) {
recs := make([]scanRec, 0, n) recs := make([]scanRec, 0, n)
for i := range n { for i := range n {
recs = append(recs, scanRec{ recs = append(recs, scanRec{
size: int64(i), mtime: time.Unix(1, 0), head: "h", tail: "t", size: int64(i), mtime: time.Unix(1, 0), head: testHash("h"),
path: fmt.Sprintf("/batch/%07d", i), tail: testHash("t"), path: fmt.Sprintf("/batch/%07d", i),
}) })
} }
@@ -612,62 +675,3 @@ func TestApplyChangesBatching(t *testing.T) {
t.Fatalf("records = %d, want 0", len(got)) t.Fatalf("records = %d, want 0", len(got))
} }
} }
// TestCountContentCandidates checks that countContentCandidates counts
// the records the content phase's search returns: those of each group
// of two or more records of headTailMin or more with a head, where at
// least one has no content hash.
func TestCountContentCandidates(t *testing.T) {
t.Parallel()
db := openTestDB(t)
// Each group's records: size, head, and content, one per record.
groups := []struct {
size int64
head string
contents []string
}{
{headTailMin, "h", []string{"", "c", "c"}}, // returned
{headTailMin + 1, "h", []string{"", ""}}, // returned
{headTailMin + 2, "h", []string{"c", "c"}}, // all hashed
{headTailMin + 3, "h", []string{""}}, // one record
{headTailMin - 1, "h", []string{"", ""}}, // too small
{headTailMin + 4, "", []string{"", ""}}, // no head
}
var recs []scanRec
for _, g := range groups {
for _, content := range g.contents {
recs = append(recs, scanRec{
size: g.size, mtime: time.Unix(1, 0), head: g.head,
tail: "t", content: content,
path: fmt.Sprintf("/r/%d", len(recs)),
})
}
}
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
got, err := countContentCandidates(t.Context(), db)
if err != nil {
t.Fatalf("countContentCandidates: %v", err)
}
var returned int64
err = loadContentCandidates(t.Context(), db,
func(scanRec, bool) { returned++ })
if err != nil {
t.Fatalf("loadContentCandidates: %v", err)
}
if got != 5 || returned != 5 {
t.Errorf("count = %d, records returned = %d, want 5 and 5",
got, returned)
}
}
+4 -74
View File
@@ -164,9 +164,8 @@ func TestDefaultExcludesHelp(t *testing.T) {
// TestScanDoesNotEnterDefaultExclude checks that the walk does not // TestScanDoesNotEnterDefaultExclude checks that the walk does not
// enter a default exclude below an operand. It says so on a line of its // enter a default exclude below an operand. It says so on a line of its
// own and does not count it as skipped. The records below it count as // own and does not count it as skipped. The records below it count as
// outside the operands: the record of a file gone from it is kept, and // outside the operands: the record of a file gone from it is kept,
// its size joins the size census, while a file added to it is not // while a file added to it is not recorded.
// recorded.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestScanDoesNotEnterDefaultExclude(t *testing.T) { func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
@@ -186,10 +185,6 @@ func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
unseen := writeFile(t, excluded, "new", pattern(2, 10)) unseen := writeFile(t, excluded, "new", pattern(2, 10))
// The same size as gone, so it is hashed only if the census holds the
// size of gone's record.
added := writeFile(t, dir, "added", pattern(3, 100))
st, err := syncScan(t.Context(), db, []string{dir}, 1, false, st, err := syncScan(t.Context(), db, []string{dir}, 1, false,
walkRules{excludes: []string{excluded}}) walkRules{excludes: []string{excluded}})
if err != nil { if err != nil {
@@ -203,11 +198,6 @@ func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
recs := dbRecords(t, db) recs := dbRecords(t, db)
recordByPath(t, recs, gone) recordByPath(t, recs, gone)
if recordByPath(t, recs, added).head == "" {
t.Errorf("%s not hashed: the census lacks the size of %s",
added, gone)
}
if slices.Contains(recordPaths(recs), unseen) { if slices.Contains(recordPaths(recs), unseen) {
t.Errorf("%s recorded: the walk entered %s", unseen, excluded) t.Errorf("%s recorded: the walk entered %s", unseen, excluded)
} }
@@ -391,9 +381,8 @@ func TestRunScanSkipsNonPersistentOperand(t *testing.T) {
// TestScanKeepsRecordsOnNonPersistent scans a directory on a filesystem // TestScanKeepsRecordsOnNonPersistent scans a directory on a filesystem
// that is not persistent beside one on the tests' temporary directory. // that is not persistent beside one on the tests' temporary directory.
// The records an earlier scan left in it count as outside the operands: // The records an earlier scan left in it count as outside the operands:
// the record of a file gone from it is kept, and its size joins the size // the record of a file gone from it is kept, while a file added to it
// census, while a file added to it is not recorded, and its size does // is not recorded.
// not join the census.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestScanKeepsRecordsOnNonPersistent(t *testing.T) { func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
@@ -413,11 +402,6 @@ func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
unseen := writeFile(t, shm, "new", pattern(2, 10)) unseen := writeFile(t, shm, "new", pattern(2, 10))
// added has the size of gone, and lonely that of unseen: each is
// hashed only if the census holds that size.
added := writeFile(t, dir, "added", pattern(3, 100))
lonely := writeFile(t, dir, "lonely", pattern(4, 10))
st, err := syncScan(t.Context(), db, []string{shm, dir}, 1, false, rules) st, err := syncScan(t.Context(), db, []string{shm, dir}, 1, false, rules)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -430,15 +414,6 @@ func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
recs := dbRecords(t, db) recs := dbRecords(t, db)
recordByPath(t, recs, gone) recordByPath(t, recs, gone)
if recordByPath(t, recs, added).head == "" {
t.Errorf("%s not hashed: the census lacks the size of %s",
added, gone)
}
if recordByPath(t, recs, lonely).head != "" {
t.Errorf("%s hashed: the census holds the size of %s", lonely, unseen)
}
if slices.Contains(recordPaths(recs), unseen) { if slices.Contains(recordPaths(recs), unseen) {
t.Errorf("%s recorded", unseen) t.Errorf("%s recorded", unseen)
} }
@@ -450,51 +425,6 @@ func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
} }
} }
// TestScanContentSkipsNonPersistent checks that the content phase does
// not read a file on a filesystem that is not persistent, though an
// earlier scan left a record for it: it is treated as a changed file,
// so its record is left as it is and its identical copy in another
// tree, having no other match, is not read either.
func TestScanContentSkipsNonPersistent(t *testing.T) {
t.Parallel()
shm, _, rules := shmTempDir(t)
db := openTestDB(t)
// A scan that, without the list of filesystems, records a with a
// head and tail but no content hash.
a := sparseFileWithoutMatch(t, shm, "a", headTailMin)
syncTree(t, db, shm)
before := recordByPath(t, dbRecords(t, db), a)
if before.head == "" || before.content != "" {
t.Fatalf("after scanning %s: %+v, want head and tail only", shm, before)
}
b := sparseFile(t, t.TempDir(), "b", headTailMin)
st, err := syncScan(t.Context(), db, []string{filepath.Dir(b)}, 1, false,
rules)
if err != nil {
t.Fatal(err)
}
if st.skipped != 0 {
t.Errorf("skipped = %d, want 0", st.skipped)
}
recs := dbRecords(t, db)
if r := recordByPath(t, recs, a); r != before {
t.Errorf("record = %+v, want it left as %+v", r, before)
}
if r := recordByPath(t, recs, b); r.content != "" {
t.Errorf("%s: content = %q, want none: its only match is on %s",
b, r.content, shm)
}
}
// TestWalkLeavesOutNonPersistent starts the walk itself in a directory // TestWalkLeavesOutNonPersistent starts the walk itself in a directory
// on a filesystem that is not persistent, which a scan never does: it // on a filesystem that is not persistent, which a scan never does: it
// drops such an operand first. Below a directory on a persistent // drops such an operand first. Below a directory on a persistent
-3
View File
@@ -113,9 +113,6 @@ func writeInfo(stdout io.Writer, dbPath, source string,
dbFile.ModTime().UTC().Format(time.RFC3339)) dbFile.ModTime().UTC().Format(time.RFC3339))
fmt.Fprintf(&b, "schema version\t%d\n", counts.schemaVersion) fmt.Fprintf(&b, "schema version\t%d\n", counts.schemaVersion)
fmt.Fprintf(&b, "records\t%d\n", counts.records) fmt.Fprintf(&b, "records\t%d\n", counts.records)
fmt.Fprintf(&b, "records with head and tail hashes\t%d\n",
counts.headTail)
fmt.Fprintf(&b, "records with content hash\t%d\n", counts.content)
fmt.Fprintf(&b, "size of recorded files\t%d (%s)\n", fmt.Fprintf(&b, "size of recorded files\t%d (%s)\n",
counts.size, humanBytes(counts.size)) counts.size, humanBytes(counts.size))
+9 -11
View File
@@ -20,15 +20,17 @@ const infoOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " + const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " +
"eta unknown" "eta unknown"
// infoRecs are four records: a duplicate pair with every hash, a file // infoRecs are four records: a duplicate pair, a file of 20 MiB and
// of 20 MiB with head and tail hashes but no content hash yet, and a // another small file.
// file never hashed.
func infoRecs() []scanRec { func infoRecs() []scanRec {
h, tl, c, u := testHash("h"), testHash("t"), testHash("c"), testHash("u")
bigH, bigT, bigC := testHash("bh"), testHash("bt"), testHash("bc")
return []scanRec{ return []scanRec{
{size: 5, head: "h", tail: "t", content: "c", path: "/d/a"}, {size: 5, head: h, tail: tl, content: c, path: "/d/a"},
{size: 5, head: "h", tail: "t", content: "c", path: "/d/b"}, {size: 5, head: h, tail: tl, content: c, path: "/d/b"},
{size: 20 << 20, head: "bh", tail: "bt", path: "/d/big"}, {size: 20 << 20, head: bigH, tail: bigT, content: bigC, path: "/d/big"},
{size: 7, path: "/d/unique"}, {size: 7, head: u, tail: u, content: u, path: "/d/unique"},
} }
} }
@@ -45,16 +47,12 @@ func TestRunInfo(t *testing.T) {
"empty": { "empty": {
want: "schema version\t1\n" + want: "schema version\t1\n" +
"records\t0\n" + "records\t0\n" +
"records with head and tail hashes\t0\n" +
"records with content hash\t0\n" +
"size of recorded files\t0 (0 B)\n", "size of recorded files\t0 (0 B)\n",
}, },
"filled": { "filled": {
recs: infoRecs(), recs: infoRecs(),
want: "schema version\t1\n" + want: "schema version\t1\n" +
"records\t4\n" + "records\t4\n" +
"records with head and tail hashes\t3\n" +
"records with content hash\t2\n" +
"size of recorded files\t20971537 (20.0 MiB)\n", "size of recorded files\t20971537 (20.0 MiB)\n",
}, },
} }
+7 -9
View File
@@ -1,14 +1,12 @@
// Command sfdupes quickly identifies candidate duplicate files across // Command sfdupes quickly identifies candidate duplicate files across
// very large filesystems without reading every byte of every file. // very large filesystems without reading every byte of every file.
// Files are considered duplicates when their sizes are equal and they // Files are considered duplicates when their sizes are equal and they
// agree on a short ladder of SHA-256 hashes. A file under 10 MiB is // agree on three SHA-256 hashes: of the first 64 KiB, of the last 64
// hashed in full. A larger file is compared on the hashes of its first // KiB, and of the content. The content hash covers the whole file under
// and last 64 KiB, and only when those match another file's is its // 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large
// content hash computed and compared: of the whole file when it is // file is never read in full. scan maintains a persistent SQLite
// under 50 MiB, or of gigabyte-spaced 1 MiB samples when it is 50 MiB // database of file signatures (SFDUPES_DATABASE, default
// or larger. scan maintains a persistent SQLite database of file // /var/lib/sfdupes/db.sqlite) that the other subcommands read.
// signatures (SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite)
// that the other subcommands read.
// //
// Usage: // Usage:
// //
@@ -238,7 +236,7 @@ func newScanCommand() *cobra.Command {
}), }),
} }
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(), scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
"concurrent workers for the walk, hash, and content phases") "concurrent workers for the walk and hash phases")
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false, scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
"do not cross filesystem boundaries") "do not cross filesystem boundaries")
scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes", scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes",
+2 -2
View File
@@ -85,7 +85,7 @@ func terminalWidth() int {
return width return width
} }
// phaseLabel names phase with its place among phases, as "[2/5] walk". // phaseLabel names phase with its place among phases, as "[2/4] walk".
func phaseLabel(phases []string, phase string) string { func phaseLabel(phases []string, phase string) string {
return fmt.Sprintf("[%d/%d] %s", return fmt.Sprintf("[%d/%d] %s",
slices.Index(phases, phase)+1, len(phases), phase) slices.Index(phases, phase)+1, len(phases), phase)
@@ -438,7 +438,7 @@ func (p *progress) finish() {
// line formats the phase's progress as of now in at most room columns, // line formats the phase's progress as of now in at most room columns,
// as // as
// //
// [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s // [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
// //
// where "files" is the phase's unit, without the percent when percent // where "files" is the phase's unit, without the percent when percent
// is false, as before the library's bar, which shows its own. A phase // is false, as before the library's bar, which shows its own. A phase
+37 -46
View File
@@ -23,7 +23,7 @@ const testRedraw = time.Millisecond
const testDeadline = 5 * time.Second const testDeadline = 5 * time.Second
// hashStalledOn is where the hash phase says it waits once it has // hashStalledOn is where the hash phase says it waits once it has
// stalled, as readRuns gives it. // stalled, as hashPhase gives it.
const hashStalledOn = "disk/db" const hashStalledOn = "disk/db"
// testClock is a clock a test moves by hand, for a display that reads // testClock is a clock a test moves by hand, for a display that reads
@@ -117,38 +117,38 @@ func TestProgressLine(t *testing.T) {
label: scanPhase("load"), total: -1, label: scanPhase("load"), total: -1,
waitingFor: countingRecords, waitingFor: countingRecords,
}, },
want: "[1/5] load: counting the records, elapsed 10s, eta unknown", want: "[1/4] load: counting the records, elapsed 10s, eta unknown",
}, { }, {
p: &progress{ p: &progress{
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20, label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
}, },
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s", want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, { }, {
p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100}, p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100},
want: "[3/5] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown", want: "[3/4] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown",
}, { }, {
p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0}, p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0},
want: "[4/5] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s", want: "[4/4] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s",
}, { }, {
p: &progress{ p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: 100, label: scanPhase("walk"), unit: scanUnit, total: 100,
estimate: true, count: 20, estimate: true, count: 20,
}, },
want: "[2/5] walk: [20/~100] 20%, 2 files/s, elapsed 10s, eta 40s", want: "[2/4] walk: [20/~100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, { }, {
// Past the estimate the walk cannot tell how long it has left. // Past the estimate the walk cannot tell how long it has left.
p: &progress{ p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: 100, label: scanPhase("walk"), unit: scanUnit, total: 100,
estimate: true, count: 120, estimate: true, count: 120,
}, },
want: "[2/5] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " + want: "[2/4] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " +
"eta unknown", "eta unknown",
}, { }, {
// A first scan's walk has no estimate. // A first scan's walk has no estimate.
p: &progress{ p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: -1, count: 20, label: scanPhase("walk"), unit: scanUnit, total: -1, count: 20,
}, },
want: "[2/5] walk: 20 files, 2 files/s, elapsed 10s, eta unknown", want: "[2/4] walk: 20 files, 2 files/s, elapsed 10s, eta unknown",
}, { }, {
// The rate is in what the phase counts. // The rate is in what the phase counts.
p: &progress{ p: &progress{
@@ -184,9 +184,9 @@ func TestLineFitsRoom(t *testing.T) {
start: ago, rateStart: ago, lastItem: now, start: ago, rateStart: ago, lastItem: now,
drawn: []drawnCount{{at: ago}}, drawn: []drawnCount{{at: ago}},
} }
content := &progress{ load := &progress{
label: scanPhase("content"), total: -1, label: scanPhase("load"), total: -1,
waitingFor: "searching the database", start: ago, rateStart: ago, waitingFor: countingRecords, start: ago, rateStart: ago,
} }
started := now.Add(-50 * time.Second) started := now.Add(-50 * time.Second)
@@ -204,27 +204,27 @@ func TestLineFitsRoom(t *testing.T) {
want string want string
}{{ }{{
p: hash, room: 57, percent: true, p: hash, room: 57, percent: true,
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s", want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, { }, {
p: hash, room: 56, percent: true, p: hash, room: 56, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s", want: "[3/4] hash: [20/100] 20%, elapsed 10s, eta 40s",
}, { }, {
p: hash, room: 45, percent: true, p: hash, room: 45, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40", want: "[3/4] hash: [20/100] 20%, elapsed 10s, eta 40",
}, { }, {
// Before the library's bar, which shows the percent. // Before the library's bar, which shows the percent.
p: hash, room: 57, percent: false, p: hash, room: 57, percent: false,
want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s", want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, eta 40s",
}, { }, {
p: content, room: 40, percent: true, p: load, room: 40, percent: true,
want: "[5/5] content: elapsed 10s, eta unknown", want: "[1/4] load: elapsed 10s, eta unknown",
}, { }, {
p: stalled, room: 65, percent: true, p: stalled, room: 65, percent: true,
want: "[3/5] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " + want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
"eta 3m20s", "eta 3m20s",
}, { }, {
p: stalled, room: 64, percent: true, p: stalled, room: 64, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 50s, eta 3m20s", want: "[3/4] hash: [20/100] 20%, elapsed 50s, eta 3m20s",
}} }}
for _, tc := range cases { for _, tc := range cases {
@@ -255,14 +255,14 @@ func TestSetTotalStartsRate(t *testing.T) {
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
want := "[1/5] load: [0/100] 0%, 0 files/s, elapsed 25s, eta unknown" want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, eta unknown"
if got := p.line(clock.Now(), math.MaxInt, true); got != want { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
want = "[1/5] load: [0/100] 0%, stalled db, elapsed 30s, eta unknown" want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, eta unknown"
if got := p.line(clock.Now(), math.MaxInt, true); got != want { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
@@ -272,7 +272,7 @@ func TestSetTotalStartsRate(t *testing.T) {
p.increment() p.increment()
} }
want = "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s" want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
if got := p.line(clock.Now(), math.MaxInt, true); got != want { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
@@ -316,9 +316,9 @@ func TestPlainProgressPrintsAtStartAndEnd(t *testing.T) {
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n") lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
want := "[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown" want := "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown"
if len(lines) != 2 || lines[0] != want || if len(lines) != 2 || lines[0] != want ||
!strings.HasPrefix(lines[1], "[3/5] hash: [1000/1000] 100%, ") { !strings.HasPrefix(lines[1], "[3/4] hash: [1000/1000] 100%, ") {
t.Errorf("stderr lines = %q, want %q, then the line at 1000", t.Errorf("stderr lines = %q, want %q, then the line at 1000",
lines, want) lines, want)
} }
@@ -336,7 +336,7 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
}, nil, testRedraw, time.Now) }, nil, testRedraw, time.Now)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3) "[1/4] load: counting the records, elapsed 0s, eta unknown\n", 3)
p.finish() p.finish()
} }
@@ -366,15 +366,15 @@ func TestPlainProgressStalledProducer(t *testing.T) {
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[3/5] hash: [20/100] 20%, 4 files/s, elapsed 5s, eta 20s\n", 3) "[3/4] hash: [20/100] 20%, 4 files/s, elapsed 5s, eta 20s\n", 3)
clock.Add(time.Second) clock.Add(time.Second)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[3/5] hash: [20/100] 20%, 0 files/s, elapsed 6s, eta 24s\n", 3) "[3/4] hash: [20/100] 20%, 0 files/s, elapsed 6s, eta 24s\n", 3)
clock.Add(4 * time.Second) clock.Add(4 * time.Second)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[3/5] hash: [20/100] 20%, stalled disk/db, elapsed 10s, eta 40s\n", "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 10s, eta 40s\n",
3) 3)
} }
@@ -410,10 +410,10 @@ func TestPlainProgressRateAfterStall(t *testing.T) {
p.finish() p.finish()
want := []string{ want := []string{
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown", "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown",
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 5s, eta unknown", "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 5s, eta unknown",
"[3/5] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, eta unknown", "[3/4] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, eta unknown",
"[3/5] hash: [100/1000] 10%, 20 files/s, elapsed 15s, eta 2m15s", "[3/4] hash: [100/1000] 10%, 20 files/s, elapsed 15s, eta 2m15s",
} }
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n") lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
@@ -457,8 +457,8 @@ func TestProgressRateAfterStallRedrawn(t *testing.T) {
redraw(rateWindow + redrawInterval) redraw(rateWindow + redrawInterval)
p.finish() p.finish()
want := "[3/5] hash: [50/1000] 5%, 10 files/s, elapsed 15s, eta 4m45s\n" + want := "[3/4] hash: [50/1000] 5%, 10 files/s, elapsed 15s, eta 4m45s\n" +
"[3/5] hash: [50/1000] 5%, 0 files/s, elapsed 15s, eta 4m47s\n" "[3/4] hash: [50/1000] 5%, 0 files/s, elapsed 15s, eta 4m47s\n"
if !strings.Contains(stderr(), want) { if !strings.Contains(stderr(), want) {
t.Errorf("stderr = %q, want the lines %q", stderr(), want) t.Errorf("stderr = %q, want the lines %q", stderr(), want)
} }
@@ -570,19 +570,13 @@ func TestTerminalLinesKeepETA(t *testing.T) {
label: scanPhase("walk"), unit: scanUnit, total: 1200000, label: scanPhase("walk"), unit: scanUnit, total: 1200000,
estimate: true, estimate: true,
}, },
want: "[2/5] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " + want: "[2/4] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " +
"eta unknown", "eta unknown",
}, { }, {
params: progressParams{ params: progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543, label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}, },
want: "[3/5] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " + want: "[3/4] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"eta 17h30m0s",
}, {
params: progressParams{
label: scanPhase("content"), unit: scanUnit, total: 9876543,
},
want: "[5/5] content: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"eta 17h30m0s", "eta 17h30m0s",
}, { }, {
params: progressParams{ params: progressParams{
@@ -643,9 +637,6 @@ func TestTerminalStalledLines(t *testing.T) {
{label: scanPhase("walk"), estimate: true, stalledOn: "disk"}, {label: scanPhase("walk"), estimate: true, stalledOn: "disk"},
{label: scanPhase("hash"), stalledOn: hashStalledOn}, {label: scanPhase("hash"), stalledOn: hashStalledOn},
{label: scanPhase("update"), stalledOn: "db"}, {label: scanPhase("update"), stalledOn: "db"},
// The content phase's checks and reads, then its last write.
{label: scanPhase("content"), stalledOn: "disk/db"},
{label: scanPhase("content"), stalledOn: "db"},
{label: treesPhase("read"), stalledOn: "db"}, {label: treesPhase("read"), stalledOn: "db"},
{label: treesPhase("group"), stalledOn: "cpu"}, {label: treesPhase("group"), stalledOn: "cpu"},
{label: treesPhase("write"), stalledOn: "out"}, {label: treesPhase("write"), stalledOn: "out"},
@@ -693,7 +684,7 @@ func TestTerminalLinesFitWidth(t *testing.T) {
for _, width := range []int{120, 100, 80, 72, 60, 50, 40} { for _, width := range []int{120, 100, 80, 72, 60, 50, 40} {
for _, total := range []int64{9876543, 20000} { for _, total := range []int64{9876543, 20000} {
params := progressParams{ params := progressParams{
label: scanPhase("content"), unit: scanUnit, total: total, label: scanPhase("update"), unit: scanUnit, total: total,
} }
out := drawLate(stderr, params, width, total/8) out := drawLate(stderr, params, width, total/8)
+4 -9
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"io" "io"
@@ -24,9 +25,9 @@ const minGroupSize = 2
type scanRec struct { type scanRec struct {
size int64 size int64
mtime time.Time mtime time.Time
head string head [sha256.Size]byte
tail string tail [sha256.Size]byte
content string content [sha256.Size]byte
path string path string
} }
@@ -169,12 +170,6 @@ type reportWriter struct {
func (w *reportWriter) add(r scanRec) error { func (w *reportWriter) add(r scanRec) error {
w.records++ w.records++
// A record without a content hash has unknown content (README
// "Database"), so it is never a duplicate.
if r.content == "" {
return nil
}
if r.size == w.sig.size && r.head == w.sig.head && if r.size == w.sig.size && r.head == w.sig.head &&
r.tail == w.sig.tail && r.content == w.sig.content { r.tail == w.sig.tail && r.content == w.sig.content {
w.paths = append(w.paths, r.path) w.paths = append(w.paths, r.path)
+64 -45
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -18,14 +19,27 @@ import (
// awkwardDir is a directory name holding every byte the reports escape. // awkwardDir is a directory name holding every byte the reports escape.
const awkwardDir = "/d/\tone\ntwo\rthree\\four" const awkwardDir = "/d/\tone\ntwo\rthree\\four"
// testHash returns a stand-in hash for a test record: the bytes of s,
// then zeros. Stand-ins sort as their strings do, so a test can choose
// the order in which report reads its records.
func testHash(s string) [sha256.Size]byte {
var h [sha256.Size]byte
copy(h[:], s)
return h
}
// awkwardPairRecs is a duplicate pair in sibling directories /d/A and // awkwardPairRecs is a duplicate pair in sibling directories /d/A and
// awkwardDir. A raw tab sorts before "A" but its escaped form `\t` // awkwardDir. A raw tab sorts before "A" but its escaped form `\t`
// sorts after it, so awkwardDir coming first shows that sorting uses // sorts after it, so awkwardDir coming first shows that sorting uses
// the raw path. // the raw path.
func awkwardPairRecs() []scanRec { func awkwardPairRecs() []scanRec {
h, tl, c := testHash("h"), testHash("t"), testHash("c")
return []scanRec{ return []scanRec{
{size: 5, head: "h", tail: "t", content: "c", path: "/d/A/f"}, {size: 5, head: h, tail: tl, content: c, path: "/d/A/f"},
{size: 5, head: "h", tail: "t", content: "c", path: awkwardDir + "/f"}, {size: 5, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
} }
} }
@@ -143,8 +157,8 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
recs := make([]scanRec, 2*ioBufSize/len(dir)) recs := make([]scanRec, 2*ioBufSize/len(dir))
for i := range recs { for i := range recs {
recs[i] = scanRec{ recs[i] = scanRec{
size: int64(i/2) + 1, head: "h", tail: "t", content: "c", size: int64(i/2) + 1, head: testHash("h"), tail: testHash("t"),
path: fmt.Sprintf("%s/%d", dir, i), content: testHash("c"), path: fmt.Sprintf("%s/%d", dir, i),
} }
} }
@@ -277,13 +291,14 @@ func TestReadReportOnTerminal(t *testing.T) {
func TestRunReportsIgnoreInsertionOrder(t *testing.T) { func TestRunReportsIgnoreInsertionOrder(t *testing.T) {
// README §Constraints: identical database contents give identical // README §Constraints: identical database contents give identical
// output, whatever order the records were inserted in. // output, whatever order the records were inserted in.
a, b := testHash("a"), testHash("b")
recs := append(smokeTreeRecs(), awkwardPairRecs()...) recs := append(smokeTreeRecs(), awkwardPairRecs()...)
recs = append(recs, recs = append(recs,
scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/2"}, scanRec{size: 50, head: b, tail: b, content: b, path: "/y/2"},
scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/1"}, scanRec{size: 50, head: b, tail: b, content: b, path: "/y/1"},
scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/2"}, scanRec{size: 50, head: a, tail: a, content: a, path: "/x/2"},
scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/1"}, scanRec{size: 50, head: a, tail: a, content: a, path: "/x/1"},
scanRec{size: 50, path: "/x/unhashed"},
) )
reversed := slices.Clone(recs) reversed := slices.Clone(recs)
@@ -386,16 +401,19 @@ func TestWarnfEscapes(t *testing.T) {
func TestDupeGroups(t *testing.T) { func TestDupeGroups(t *testing.T) {
t.Parallel() t.Parallel()
h, tl, c := testHash("h"), testHash("t"), testHash("c")
bigH, bigT, bigC := testHash("H"), testHash("T"), testHash("C")
u := testHash("u")
recs := []scanRec{ recs := []scanRec{
{size: 100, head: "h", tail: "t", content: "c", path: "/z/b"}, {size: 100, head: h, tail: tl, content: c, path: "/z/b"},
{size: 100, head: "h", tail: "t", content: "c", path: "/z/a"}, {size: 100, head: h, tail: tl, content: c, path: "/z/a"},
{size: 100, head: "h", tail: "t", content: "c", path: "/z/c"}, {size: 100, head: h, tail: tl, content: c, path: "/z/c"},
{size: 4000, head: "H", tail: "T", content: "C", path: "/big/2"}, {size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/2"},
{size: 4000, head: "H", tail: "T", content: "C", path: "/big/1"}, {size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/1"},
// Same size as the /z group but a different head hash. // Same size as the /z group but a different head hash.
{size: 100, head: "other", tail: "t", content: "c", path: "/z/d"}, {size: 100, head: testHash("other"), tail: tl, content: c, path: "/z/d"},
// A singleton signature must not form a group. // A singleton signature must not form a group.
{size: 7, head: "u", tail: "u", content: "u", path: "/lonely"}, {size: 7, head: u, tail: u, content: u, path: "/lonely"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -419,16 +437,13 @@ func TestDupeGroups(t *testing.T) {
func TestDupeGroupsContentSeparates(t *testing.T) { func TestDupeGroupsContentSeparates(t *testing.T) {
t.Parallel() t.Parallel()
// Same size, head, and tail, but different content hashes: the final // Same size, head, and tail: /b differs from the others only in its
// rung keeps them apart, so no group forms. Matching content groups. // content hash, so it joins no group, while /a and /c group.
// Records without a content hash never group, not even with each h, tl := testHash("h"), testHash("t")
// other.
recs := []scanRec{ recs := []scanRec{
{size: 100, head: "h", tail: "t", content: "c1", path: "/a"}, {size: 100, head: h, tail: tl, content: testHash("c1"), path: "/a"},
{size: 100, head: "h", tail: "t", content: "c2", path: "/b"}, {size: 100, head: h, tail: tl, content: testHash("c2"), path: "/b"},
{size: 100, head: "h", tail: "t", content: "c1", path: "/c"}, {size: 100, head: h, tail: tl, content: testHash("c1"), path: "/c"},
{size: 100, head: "h", tail: "t", path: "/d"},
{size: 100, head: "h", tail: "t", path: "/e"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -447,14 +462,15 @@ func TestDupeGroupsMtimeExcluded(t *testing.T) {
// mtime is informational only; records differing only in mtime // mtime is informational only; records differing only in mtime
// still group together. // still group together.
h, tl, c := testHash("h"), testHash("t"), testHash("c")
recs := []scanRec{ recs := []scanRec{
{ {
size: 9, mtime: time.Unix(100, 0), head: "h", tail: "t", size: 9, mtime: time.Unix(100, 0), head: h, tail: tl,
content: "c", path: "/m/1", content: c, path: "/m/1",
}, },
{ {
size: 9, mtime: time.Unix(200, 0), head: "h", tail: "t", size: 9, mtime: time.Unix(200, 0), head: h, tail: tl,
content: "c", path: "/m/2", content: c, path: "/m/2",
}, },
} }
@@ -469,11 +485,12 @@ func TestDupeGroupsTieBreak(t *testing.T) {
// The hashes sort opposite to the first paths, so ordering the // The hashes sort opposite to the first paths, so ordering the
// groups by hash instead of by first path fails this test. // groups by hash instead of by first path fails this test.
a, b := testHash("a"), testHash("b")
recs := []scanRec{ recs := []scanRec{
{size: 50, head: "a", tail: "a", content: "a", path: "/beta/2"}, {size: 50, head: a, tail: a, content: a, path: "/beta/2"},
{size: 50, head: "a", tail: "a", content: "a", path: "/beta/1"}, {size: 50, head: a, tail: a, content: a, path: "/beta/1"},
{size: 50, head: "b", tail: "b", content: "b", path: "/alpha/2"}, {size: 50, head: b, tail: b, content: b, path: "/alpha/2"},
{size: 50, head: "b", tail: "b", content: "b", path: "/alpha/1"}, {size: 50, head: b, tail: b, content: b, path: "/alpha/1"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -495,15 +512,16 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
// /z, /k, which is neither the order of their first paths nor its // /z, /k, which is neither the order of their first paths nor its
// reverse, and must write them ordered by first path. The group of // reverse, and must write them ordered by first path. The group of
// size 7 comes after them all, though its first path sorts first. // size 7 comes after them all, though its first path sorts first.
a, b, c, d := testHash("a"), testHash("b"), testHash("c"), testHash("d")
recs := []scanRec{ recs := []scanRec{
{size: 50, head: "a", tail: "a", content: "a", path: "/k/2"}, {size: 50, head: a, tail: a, content: a, path: "/k/2"},
{size: 50, head: "a", tail: "a", content: "a", path: "/k/1"}, {size: 50, head: a, tail: a, content: a, path: "/k/1"},
{size: 50, head: "b", tail: "b", content: "b", path: "/z/1"}, {size: 50, head: b, tail: b, content: b, path: "/z/1"},
{size: 50, head: "b", tail: "b", content: "b", path: "/z/2"}, {size: 50, head: b, tail: b, content: b, path: "/z/2"},
{size: 50, head: "c", tail: "c", content: "c", path: "/a/2"}, {size: 50, head: c, tail: c, content: c, path: "/a/2"},
{size: 50, head: "c", tail: "c", content: "c", path: "/a/1"}, {size: 50, head: c, tail: c, content: c, path: "/a/1"},
{size: 7, head: "d", tail: "d", content: "d", path: "/0/2"}, {size: 7, head: d, tail: d, content: d, path: "/0/2"},
{size: 7, head: "d", tail: "d", content: "d", path: "/0/1"}, {size: 7, head: d, tail: d, content: d, path: "/0/1"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -522,11 +540,12 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
func TestDupeGroupsDeterministic(t *testing.T) { func TestDupeGroupsDeterministic(t *testing.T) {
t.Parallel() t.Parallel()
a, b := testHash("a"), testHash("b")
recs := []scanRec{ recs := []scanRec{
{size: 1, head: "a", tail: "a", content: "a", path: "/p/1"}, {size: 1, head: a, tail: a, content: a, path: "/p/1"},
{size: 1, head: "a", tail: "a", content: "a", path: "/p/2"}, {size: 1, head: a, tail: a, content: a, path: "/p/2"},
{size: 2, head: "b", tail: "b", content: "b", path: "/q/1"}, {size: 2, head: b, tail: b, content: b, path: "/q/1"},
{size: 2, head: "b", tail: "b", content: "b", path: "/q/2"}, {size: 2, head: b, tail: b, content: b, path: "/q/2"},
} }
forward := dupeGroupsOf(t, recs) forward := dupeGroupsOf(t, recs)
+142 -485
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -21,32 +20,21 @@ import (
"time" "time"
) )
// The duplicate ladder (see hashSignature and README "Duplicate // The hash phase gives every file it reads three hashes (see
// detection"). A same-size candidate below headTailMin is hashed in // hashSignature and README "Duplicate detection"): head and tail, of
// full and compared directly; a larger one is separated first by the // its first and last headTailWindow bytes, and content. The content
// hashes of its end windows, then by a content hash that is exact below // hash covers the whole file below wholeFileMax, and only sampled
// wholeFileMax and deliberately sampled at or above it. The hash phase // windows at or above it, so such a file is never read in full.
// reads only the end windows of a larger file; the content phase reads
// it for its content hash only once its size, head, and tail match
// another file's.
// headTailMin is the size threshold for the end-window gate. A file
// smaller than this is hashed in full directly, with no separate head
// and tail step: its head, tail, and content all carry the whole-file
// hash. A file this size or larger is separated first by its end
// windows.
const headTailMin = 10 * 1024 * 1024
// headTailWindow is the number of bytes hashed from each end of a file // headTailWindow is the number of bytes hashed from each end of a file
// at or above headTailMin (the head and tail rungs). Because // for its head and tail. In a file no larger than this, each window is
// headTailMin is far larger than two windows, the head and tail windows // the whole file.
// never overlap.
const headTailWindow = 64 * 1024 const headTailWindow = 64 * 1024
// wholeFileMax is the size boundary between the two content rungs: a // wholeFileMax is the size boundary between the two content hashes: a
// file strictly smaller than this is content-hashed in full; a file // file strictly smaller than this is content-hashed in full; a file
// this size or larger is content-hashed by sampling. // this size or larger is content-hashed by sampling.
const wholeFileMax = 50 * 1024 * 1024 const wholeFileMax = 100 * 1024 * 1024
// sampleStride is the spacing between content samples for large files: // sampleStride is the spacing between content samples for large files:
// one window is read at each gigabyte-aligned offset (0, 1 GiB, ...). // one window is read at each gigabyte-aligned offset (0, 1 GiB, ...).
@@ -76,13 +64,11 @@ type fileRec struct {
} }
// fileMeta is the in-memory index entry for one existing database // fileMeta is the in-memory index entry for one existing database
// record: just enough for change detection, plus whether the record // record: just enough for change detection. Hashes stay on disk; at
// carries hashes. Hashes stay on disk; at tens of millions of records // tens of millions of records they would dominate the scan's memory.
// they would dominate the scan's memory.
type fileMeta struct { type fileMeta struct {
size int64 size int64
mtime time.Time mtime time.Time
hashed bool
} }
// scanUnit is what every scan phase counts, as its progress display // scanUnit is what every scan phase counts, as its progress display
@@ -90,36 +76,31 @@ type fileMeta struct {
const scanUnit = "files" const scanUnit = "files"
// scanPhase names one phase of a scan with its place among all of them, // scanPhase names one phase of a scan with its place among all of them,
// as "[2/5] walk", for the phase's progress display. The list here is // as "[2/4] walk", for the phase's progress display. The list here is
// the one place the phases are counted. // the one place the phases are counted.
func scanPhase(name string) string { func scanPhase(name string) string {
return phaseLabel( return phaseLabel([]string{"load", "walk", "hash", "update"}, name)
[]string{"load", "walk", "hash", "update", "content"}, name)
} }
// runScan implements the scan subcommand: five sequential phases — // runScan implements the scan subcommand: four sequential phases —
// load (which reads the existing records), walk (which stats each file // load (which reads the existing records), walk (which stats each file
// as it is discovered), hash, update, content — that synchronize the // as it is discovered), hash, update — that synchronize the persistent
// persistent database with the filesystem state under the PATH // database with the filesystem state under the PATH operands. Flag
// operands. Only files whose size at least one other file shares are // parsing and the at-least-one-operand check are done by cobra. Before
// ever hashed: a size-unique file cannot be a duplicate. A file of // anything else, the scan names the database on stderr, saying whether
// headTailMin or more gets its content hash only when its size, head, // it is new. The scan holds the lock on the database for its whole run,
// and tail match another file's. Flag parsing and the // so a second scan fails before it walks the filesystem or opens the
// at-least-one-operand check are done by cobra. Before anything else, // database. Errors are returned rather than exiting, so that the
// the scan names the database on stderr, saying whether it is new. The // deferred close — which takes the database out of WAL mode — always
// scan holds the lock on the database for its whole run, so a second // runs, and the lock is released after it. When ctx is cancelled, as by
// scan fails before it walks the filesystem or opens the database. // the SIGINT or SIGTERM that interruptContext catches, the scan keeps
// Errors are returned rather than exiting, so that the deferred close — // what it has hashed (see syncScan), prints how many files its walk
// which takes the database out of WAL mode — always runs, and the lock // reached, and returns errInterrupted. workers must be at least 1; the
// is released after it. When ctx is cancelled, as by the SIGINT or // scan command rejects anything less. Before the lock is taken, the
// SIGTERM that interruptContext catches, the scan keeps what it has // scan lists the filesystems that are not persistent, on which the walk
// hashed (see syncScan), prints how many files its walk reached, and // records nothing; unless noDefaultExcludes is set, the walk does not
// returns errInterrupted. workers must be at least 1; the scan command // enter the default excludes either. Once the database is open, the
// rejects anything less. Before the lock is taken, the scan lists the // scan notes its files, which the walk passes over.
// filesystems that are not persistent, on which the walk records
// nothing; unless noDefaultExcludes is set, the walk does not enter the
// default excludes either. Once the database is open, the scan notes
// its files, which the walk passes over.
func runScan(ctx context.Context, roots []string, workers int, func runScan(ctx context.Context, roots []string, workers int,
oneFS, noDefaultExcludes bool, oneFS, noDefaultExcludes bool,
) error { ) error {
@@ -360,9 +341,7 @@ type scanStats struct {
type scanState struct { type scanState struct {
db *sql.DB db *sql.DB
existing map[string]fileMeta existing map[string]fileMeta
sizes []int64 // size census: every walked file, plus records outside the roots toHash []fileRec // new and changed files: must be read
toHash []fileRec // files whose size is shared: must be read
sentinels []fileRec // new/changed size-unique files: recorded without hashes
batch []scanRec batch []scanRec
st scanStats st scanStats
} }
@@ -391,19 +370,14 @@ func syncScan(ctx context.Context, db *sql.DB, roots []string,
} }
// runPhases synchronizes the database with the filesystem under roots // runPhases synchronizes the database with the filesystem under roots
// in five sequential phases: load (read the existing records), walk // in four sequential phases: load (read the existing records), walk
// (enumerate and stat every file, building a complete size census), // (enumerate and stat every file), hash (read only the new or changed
// hash (read only the new or changed — or previously unhashed — files // files, committing results in batches as they arrive), and update
// whose size at least one other file shares, committing results in // (delete the records the scan no longer verifies). Records outside the
// batches as they arrive), update (record the size-unique files without // roots are never touched. Operands the walk cannot start from, or that
// reading them, and delete the records the scan no longer verifies), // are not on a persistent filesystem, are dropped first, so the records
// and content (fill in the content hash of every record of headTailMin // beneath them are treated as if those operands had not been given.
// or more whose size, head, and tail match another record's). Records // Below an operand, the walk follows rules.
// outside the roots are never touched, except that the content phase
// fills in their content hash. Operands the walk cannot start from, or
// that are not on a persistent filesystem, are dropped first, so the
// records beneath them are treated as if those operands had not been
// given. Below an operand, the walk follows rules.
func (s *scanState) runPhases(ctx context.Context, roots []string, func (s *scanState) runPhases(ctx context.Context, roots []string,
workers int, oneFS bool, rules walkRules, workers int, oneFS bool, rules walkRules,
) error { ) error {
@@ -418,30 +392,22 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
return err return err
} }
changed, unhashed := s.walkPhase( s.walkPhase(startWalk(ctx, roots, oneFS, rules, workers), roots)
startWalk(ctx, roots, oneFS, rules, workers), roots)
// A cancelled walk stops early, so its size census covers only part // A cancelled walk stops early, so every file it never reached would
// of the roots, and every file it never reached would look vanished // look vanished to the update phase. Stop before anything is written
// to the update phase. Stop before anything is written or deleted. // or deleted.
err = ctx.Err() err = ctx.Err()
if err != nil { if err != nil {
return err return err
} }
s.partition(changed, unhashed)
err = s.hashPhase(ctx, workers) err = s.hashPhase(ctx, workers)
if err != nil { if err != nil {
return err return err
} }
err = s.updatePhase(ctx) return s.updatePhase(ctx)
if err != nil {
return err
}
return s.contentPhase(ctx, workers, rules)
} }
// walkableRoots returns the operands the walk can start from: regular // walkableRoots returns the operands the walk can start from: regular
@@ -483,10 +449,8 @@ func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
} }
// loadIndex indexes the database records under the scan roots for // loadIndex indexes the database records under the scan roots for
// change detection and collects the sizes of every record outside // change detection. It reads every record, those outside the roots
// them: out-of-scope records join the size census so a scanned file // included, so its display counts against the number of records.
// can be recognized as a possible duplicate of a tree scanned
// separately into the same database.
func (s *scanState) loadIndex(ctx context.Context, roots []string) error { func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
// Indexing tens of millions of records takes real time; without a // Indexing tens of millions of records takes real time; without a
// display the scan looks hung before the walk begins. Counting them // display the scan looks hung before the walk begins. Counting them
@@ -507,36 +471,24 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
s.existing = make(map[string]fileMeta) s.existing = make(map[string]fileMeta)
return loadFileMeta(ctx, s.db, return loadFileMeta(ctx, s.db,
func(path string, size int64, mtime time.Time, hashed bool) { func(path string, size int64, mtime time.Time) {
prog.increment() prog.increment()
if underAnyRoot(path, roots) { if underAnyRoot(path, roots) {
s.existing[path] = fileMeta{ s.existing[path] = fileMeta{size: size, mtime: mtime}
size: size, mtime: mtime, hashed: hashed,
} }
return
}
s.sizes = append(s.sizes, size)
}) })
} }
// walkPhase drains the walk, appending every walked file's size to // walkPhase drains the walk and queues every new or changed file for
// the census and resolving what it can immediately: an unchanged file // the hash phase. An unchanged file needs nothing more, and its record
// whose record already has hashes needs nothing from the hash phase // counts as verified. A record at or below a path the walk left out,
// (the content phase may still fill in its content hash). It returns // because -x stopped at it, it is a default exclude, or it is not on a
// the new-or-changed files and the unchanged files whose records lack // persistent filesystem, counts as outside the roots: it is not
// hashes; both remain candidates until the census decides whether // deleted. That does not hold below a root at or inside that path,
// their sizes are shared. A record at or below a path the walk left // since that root's own walk verifies the records below it; nor is a
// out, because -x stopped at it, it is a default exclude, or it is not // line printed for such a root.
// on a persistent filesystem, counts as outside the roots: it is not func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
// deleted, and its size joins the census. That does not hold below a
// root at or inside that path, since that root's own walk verifies the
// records below it; nor is a line printed for such a root.
func (s *scanState) walkPhase(
events <-chan walkEvent, roots []string,
) ([]fileRec, []fileRec) {
// The records the last scan left under the roots estimate how many // The records the last scan left under the roots estimate how many
// files the walk will find. A first scan has none, and no estimate. // files the walk will find. A first scan has none, and no estimate.
estimate := int64(len(s.existing)) estimate := int64(len(s.existing))
@@ -549,10 +501,7 @@ func (s *scanState) walkPhase(
estimate: true, stalledOn: "disk", estimate: true, stalledOn: "disk",
}) })
var ( var leftOut []string
changed, unhashed []fileRec
leftOut []string
)
for ev := range events { for ev := range events {
if ev.otherFSDir != "" { if ev.otherFSDir != "" {
@@ -582,28 +531,25 @@ func (s *scanState) walkPhase(
continue continue
} }
s.sizes = append(s.sizes, ev.rec.size)
s.st.walked++ s.st.walked++
prog.increment() prog.increment()
old, ok := s.existing[ev.rec.path] old, ok := s.existing[ev.rec.path]
if !ok || old.size != ev.rec.size || mtimeAfter(ev.rec.mtime, old.mtime) {
s.toHash = append(s.toHash, ev.rec)
continue
}
switch {
case !ok || old.size != ev.rec.size || mtimeAfter(ev.rec.mtime, old.mtime):
changed = append(changed, ev.rec)
case old.hashed:
delete(s.existing, ev.rec.path) delete(s.existing, ev.rec.path)
s.st.unchanged++ s.st.unchanged++
default:
unhashed = append(unhashed, ev.rec)
}
} }
prog.finish() prog.finish()
for path, old := range s.existing { for path := range s.existing {
// Kept only when a path the walk left out lies below the deepest // Kept only when a path the walk left out lies below the deepest
// root above the record, or is the record's own. A root at or // root above the record, or is the record's own. A root at or
// below that path was walked on its own and did not find the // below that path was walked on its own and did not find the
@@ -612,56 +558,8 @@ func (s *scanState) walkPhase(
left := deepestRoot(path, leftOut) left := deepestRoot(path, leftOut)
if len(left) > len(deepestRoot(path, roots)) { if len(left) > len(deepestRoot(path, roots)) {
delete(s.existing, path) delete(s.existing, path)
s.sizes = append(s.sizes, old.size)
} }
} }
return changed, unhashed
}
// partition decides each candidate's disposition now that the size
// census is complete. A file whose size no other file shares cannot
// be a duplicate and is never read: a new or changed one is recorded
// without hashes, an unchanged unhashed one keeps its record. Every
// file with a shared size queues for the hash phase.
func (s *scanState) partition(changed, unhashed []fileRec) {
slices.Sort(s.sizes)
for _, rec := range changed {
if s.sizeShared(rec.size) {
s.toHash = append(s.toHash, rec)
continue
}
s.sentinels = append(s.sentinels, rec)
s.resolve(rec.path)
}
for _, rec := range unhashed {
if s.sizeShared(rec.size) {
s.toHash = append(s.toHash, rec)
continue
}
delete(s.existing, rec.path)
s.st.unchanged++
}
s.sizes = nil
}
// sizeShared reports whether at least two census entries have this
// size. Every candidate's own size is in the census exactly once, so
// a second entry means another file (or a record outside the scan
// roots) could share its content.
func (s *scanState) sizeShared(size int64) bool {
i, found := slices.BinarySearch(s.sizes, size)
return found && i+1 < len(s.sizes) && s.sizes[i+1] == size
} }
// resolve counts one written record as added or updated and marks its // resolve counts one written record as added or updated and marks its
@@ -725,44 +623,30 @@ func isDatabaseFile(f fileRec, rules walkRules) bool {
}) })
} }
// hashPhase hashes every queued file with hashSignature — the head and // hashPhase hashes every queued file with hashSignature, one read per
// tail of a file of headTailMin or more, the whole file below that — // inode run, committing completed records to the database in batches
// committing completed records to the database in batches as results // as results arrive, so a long scan persists its progress as it goes
// arrive, so a long scan persists its progress as it goes (an // (an interrupted scan resumes cheaply: the next run skips everything
// interrupted scan resumes cheaply: the next run skips everything
// already recorded). A run that fails to hash is warned about and // already recorded). A run that fails to hash is warned about and
// skipped; stale records for its paths, if any, are deleted by the // counted as skipped; stale records for its paths, if any, are deleted
// update phase. // by the update phase. The total counts actual reads, so the display
func (s *scanState) hashPhase(ctx context.Context, workers int) error { // shows a real ETA.
runs := hashRuns(s.toHash)
s.toHash = nil
return s.readRuns(ctx, workers, "hash", runs, hashSignature, s.recordRun)
}
// readRuns reads runs with the worker pool, one read per inode run, in
// the order given, under the progress display of the phase named label.
// The workers compute each run's hashes with hash, and each result goes
// to record; a run that fails to read is warned about and counted as
// skipped instead. The total counts actual reads, so the display shows
// a real ETA.
// //
// Returning early — a failed database write, or a cancelled scan — must // Returning early — a failed database write, or a cancelled scan — must
// not strand the pool: the feeder would park forever on a full jobs // not strand the pool: the feeder would park forever on a full jobs
// channel and every worker on a full results channel. The deferred stop // channel and every worker on a full results channel. The deferred stop
// is what prevents that. // is what prevents that.
func (s *scanState) readRuns(ctx context.Context, workers int, func (s *scanState) hashPhase(ctx context.Context, workers int) error {
label string, runs [][]fileRec, runs := hashRuns(s.toHash)
hash func(path string, size int64) (string, string, string, error), s.toHash = nil
record func(ctx context.Context, r hashResult) error,
) error { pool := startHashPool(ctx, runs, workers, hashSignature)
pool := startHashPool(ctx, runs, workers, hash)
defer pool.stop() defer pool.stop()
// Between results the phase may wait on a file being read or on a // Between results the phase may wait on a file being read or on a
// batch of records being written. // batch of records being written.
prog := newProgress(progressParams{ prog := newProgress(progressParams{
label: scanPhase(label), unit: scanUnit, total: int64(len(runs)), label: scanPhase("hash"), unit: scanUnit, total: int64(len(runs)),
stalledOn: "disk/db", stalledOn: "disk/db",
}) })
defer prog.finish() defer prog.finish()
@@ -781,12 +665,12 @@ func (s *scanState) readRuns(ctx context.Context, workers int,
if r.err != nil { if r.err != nil {
s.st.skipped += len(r.run) s.st.skipped += len(r.run)
prog.warnf("%s %s: %v", label, r.run[0].path, r.err) prog.warnf("hash %s: %v", r.run[0].path, r.err)
continue continue
} }
err := record(ctx, r) err := s.recordRun(ctx, r)
if err != nil { if err != nil {
return err return err
} }
@@ -835,10 +719,9 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
} }
// updatePhase writes the scan's tail under one progress display: the // updatePhase writes the scan's tail under one progress display: the
// final partial batch of hashed records, a hash-less record for every // final partial batch of hashed records, and deletions for every record
// size-unique new or changed file, and deletions for every record the // the scan did not verify (vanished files, plus paths that failed to
// scan did not verify (vanished files, plus paths that failed to stat // stat or hash).
// or hash).
func (s *scanState) updatePhase(ctx context.Context) error { func (s *scanState) updatePhase(ctx context.Context) error {
deletes := make([]string, 0, len(s.existing)) deletes := make([]string, 0, len(s.existing))
for path := range s.existing { for path := range s.existing {
@@ -850,7 +733,7 @@ func (s *scanState) updatePhase(ctx context.Context) error {
s.st.removed = len(deletes) s.st.removed = len(deletes)
total := len(s.batch) + len(s.sentinels) + len(deletes) total := len(s.batch) + len(deletes)
prog := newProgress(progressParams{ prog := newProgress(progressParams{
label: scanPhase("update"), unit: scanUnit, total: int64(total), label: scanPhase("update"), unit: scanUnit, total: int64(total),
stalledOn: "db", stalledOn: "db",
@@ -865,209 +748,9 @@ func (s *scanState) updatePhase(ctx context.Context) error {
s.batch = nil s.batch = nil
// Sentinel records are converted in batch-sized chunks rather than
// materialized all at once; a first scan can have millions.
for chunk := range slices.Chunk(s.sentinels, updateBatchSize) {
recs := make([]scanRec, 0, len(chunk))
for _, rec := range chunk {
recs = append(recs, scanRec{
size: rec.size, mtime: rec.mtime, path: rec.path,
})
}
err = applyBatch(ctx, s.db, recs, nil, prog)
if err != nil {
return err
}
}
return applyChanges(ctx, s.db, nil, deletes, prog) return applyChanges(ctx, s.db, nil, deletes, prog)
} }
// contentPhase fills in the content hash of every record of headTailMin
// or more that lacks one and whose size, head, and tail equal another
// record's, anywhere in the database: records from this scan and
// records stored by earlier scans, inside or outside the roots. Only
// such a file can still be a duplicate, so no other file of headTailMin
// or more is read beyond its end windows. The files are read with the
// hash phase's worker pool and their records written back in batches. A
// failed read is warned about and counted as skipped; the record keeps
// its empty content, so it is never grouped, and a later scan tries
// again. No file on a filesystem in rules that is not persistent is
// read.
func (s *scanState) contentPhase(ctx context.Context, workers int,
rules walkRules,
) error {
toRead, recs, err := s.contentCandidates(ctx, rules)
if err != nil {
return err
}
err = s.readRuns(ctx, workers, "content", hashRuns(toRead),
hashContentOnly, func(ctx context.Context, r hashResult) error {
// Every path in the run keeps its record's head and tail
// and gains the one content hash read for the run.
for _, f := range r.run {
rec := recs[f.path]
rec.content = r.content
s.batch = append(s.batch, rec)
}
return s.commitFullBatch(ctx)
})
if err != nil {
return err
}
// The last partial batch is written under a display of its own, as
// the update phase writes the hash phase's.
prog := newProgress(progressParams{
label: scanPhase("content"), unit: scanUnit,
total: int64(len(s.batch)), stalledOn: "db",
})
defer prog.finish()
return applyChanges(ctx, s.db, s.batch, nil, prog)
}
// contentCandidates returns the files the content phase reads, and
// their records by path. Every record contentCandidatesSQL returns has
// its file checked with lstat, whether or not it already has a content
// hash: a file that is gone, is no longer a regular file, has changed
// by the walk's rule, or is on a filesystem in rules that is not
// persistent keeps its record as it is and does not count as a match
// for the others, and any other lstat error is warned about and counted
// as skipped, with the same result. If such a record has no content
// hash, it stays out of duplicate groups; if it has one, it is still
// reported until a scan covering its own tree updates or removes it.
// The files of a group that pass and have no content hash are read only
// if at least minGroupSize of the group's files pass, so a group whose
// other members are all stale costs no reads. Only the records to be
// read are kept.
func (s *scanState) contentCandidates(
ctx context.Context, rules walkRules,
) ([]fileRec, map[string]scanRec, error) {
// The search and the checks take real time on a large database;
// without a display the scan looks hung before the reads begin.
// The display says it is searching until SQLite returns the first
// record. The checks start there, against a count of the records
// the search returns, and so do their rate and ETA. Between records
// they may wait on a file's lstat or on the search.
prog := newProgress(progressParams{
label: scanPhase("content"), unit: scanUnit, total: -1,
waitingFor: "searching the database", stalledOn: "disk/db",
})
defer prog.finish()
total, err := countContentCandidates(ctx, s.db)
if err != nil {
return nil, nil, err
}
var (
searching = true // no record has been returned yet
toRead []fileRec
first scanRec // the current group's first record
passed int // the current group's files that passed the check
unread []fileRec // those of them without a content hash
)
recs := make(map[string]scanRec)
// endGroup queues the current group's files to read if at least
// minGroupSize of its files passed, and drops their records if not.
endGroup := func() {
if passed >= minGroupSize {
toRead = append(toRead, unread...)
} else {
for _, f := range unread {
delete(recs, f.path)
}
}
passed, unread = 0, nil
}
err = loadContentCandidates(ctx, s.db, func(r scanRec, hashed bool) {
if searching {
searching = false
prog.setTotal(total)
}
prog.increment()
if r.size != first.size || r.head != first.head || r.tail != first.tail {
endGroup()
first = r
}
f, ok, err := unchangedFile(r, rules)
if err != nil {
s.st.skipped++
prog.warnf("content %s: %v", r.path, err)
}
if !ok {
return
}
passed++
if !hashed {
unread = append(unread, f)
recs[r.path] = r
}
})
if err != nil {
return nil, nil, err
}
// A search that returned no record still ends showing its count.
if searching {
prog.setTotal(total)
}
endGroup()
return toRead, recs, nil
}
// unchangedFile lstats the file r names and returns it for reading if
// it is still the regular file r records: the same size, and an mtime
// no newer than recorded (the walk's change rule). A file that is gone,
// has changed, or is on a filesystem in rules that is not persistent
// reports false; any other lstat error is returned.
func unchangedFile(r scanRec, rules walkRules) (fileRec, bool, error) {
fi, err := os.Lstat(r.path)
if errors.Is(err, fs.ErrNotExist) {
return fileRec{}, false, nil
}
if err != nil {
return fileRec{}, false, err
}
if !fi.Mode().IsRegular() || fi.Size() != r.size ||
mtimeAfter(fi.ModTime(), r.mtime) {
return fileRec{}, false, nil
}
dev, ino := inodeOfInfo(fi)
// The scan reads nothing on a filesystem that is not persistent,
// whatever record an earlier scan left for the file.
if _, found := rules.nonPersistent[dev]; found {
return fileRec{}, false, nil
}
return fileRec{
path: r.path, size: r.size, mtime: r.mtime, dev: dev, ino: ino,
}, true, nil
}
// mtimeAfter reports whether mtime a is later than mtime b. // mtimeAfter reports whether mtime a is later than mtime b.
// Not a.After(b): time.Time wraps an mtime past year 292 billion; Unix() undoes it. // Not a.After(b): time.Time wraps an mtime past year 292 billion; Unix() undoes it.
func mtimeAfter(a, b time.Time) bool { func mtimeAfter(a, b time.Time) bool {
@@ -1512,13 +1195,12 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
// hashResult carries the hashes computed for one inode run (or the // hashResult carries the hashes computed for one inode run (or the
// error that prevented computing them) from the pool's workers to the // error that prevented computing them) from the pool's workers to the
// phase that started the pool: head, tail, and content from // hash phase.
// hashSignature, content alone from hashContentOnly.
type hashResult struct { type hashResult struct {
run []fileRec run []fileRec
head string head [sha256.Size]byte
tail string tail [sha256.Size]byte
content string content [sha256.Size]byte
err error err error
} }
@@ -1538,7 +1220,8 @@ type hashPool struct {
// hash each run's first path with hash (all paths in a run are hard // hash each run's first path with hash (all paths in a run are hard
// links to the same inode) and write one result per run. // links to the same inode) and write one result per run.
func startHashPool(ctx context.Context, runs [][]fileRec, workers int, func startHashPool(ctx context.Context, runs [][]fileRec, workers int,
hash func(path string, size int64) (string, string, string, error), hash func(path string, size int64) (
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error),
) *hashPool { ) *hashPool {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -1603,7 +1286,8 @@ func feedHashJobs(ctx context.Context, runs [][]fileRec,
// the hash of only delays that. // the hash of only delays that.
func hashWorker(ctx context.Context, jobs <-chan []fileRec, func hashWorker(ctx context.Context, jobs <-chan []fileRec,
results chan<- hashResult, results chan<- hashResult,
hash func(path string, size int64) (string, string, string, error), hash func(path string, size int64) (
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error),
) { ) {
for run := range jobs { for run := range jobs {
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -1654,100 +1338,73 @@ func openRegularFile(path string) (*os.File, error) {
return f, nil return f, nil
} }
// emptyHash is the lowercase-hex SHA-256 of the empty input: the head, // hashSignature computes the three hashes the hash phase records for a
// tail, and content hash of every zero-length file. // file: head and tail (see hashEnds) and content (see hashContent).
const emptyHash = "e3b0c44298fc1c149afbf4c8996fb924" + // With the file size they form its duplicate signature, and two files
"27ae41e4649b934ca495991b7852b855" // are duplicates only when all four agree. size is the value recorded
// when the file was statted; a zero-length file has constant hashes and
// is never opened. On an error every hash is left zero.
func hashSignature(path string, size int64) (
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
var zero [sha256.Size]byte
// hashSignature computes the hashes the hash phase records for a file
// whose size is shared; with the file size they form its duplicate
// signature. A file below headTailMin is hashed in full and its
// whole-file SHA-256 is returned as head, tail, and content alike —
// that range takes no separate end-window step. For a file at or above
// headTailMin only the head and tail are computed, the SHA-256 of its
// first and last headTailWindow bytes, and content is returned empty:
// the content phase computes it with hashContentOnly once the file's
// size, head, and tail match another file's. Two files are duplicates
// only when all four agree; any mismatch means not a duplicate. size
// is the value recorded when the file was statted; a zero-length file
// has constant hashes and is never opened.
func hashSignature(path string, size int64) (string, string, string, error) {
if size == 0 { if size == 0 {
return emptyHash, emptyHash, emptyHash, nil empty := sha256.Sum256(nil)
return empty, empty, empty, nil
} }
f, err := openRegularFile(path) f, err := openRegularFile(path)
if err != nil { if err != nil {
return "", "", "", err return zero, zero, zero, err
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
// Below the threshold the whole file is hashed directly, with no
// end-window step: head and tail both carry the whole-file hash.
if size < int64(headTailMin) {
content, err := hashWhole(f, size)
if err != nil {
return "", "", "", err
}
return content, content, content, nil
}
head, tail, err := hashEnds(f, size) head, tail, err := hashEnds(f, size)
if err != nil { if err != nil {
return "", "", "", err return zero, zero, zero, err
} }
return head, tail, "", nil
}
// hashContentOnly returns the content hash of the file at path, which
// is at least headTailMin bytes: the content phase's read. head and
// tail are returned empty, because the content phase keeps the ones its
// records already hold.
func hashContentOnly(path string, size int64) (string, string, string, error) {
f, err := openRegularFile(path)
if err != nil {
return "", "", "", err
}
defer func() { _ = f.Close() }()
content, err := hashContent(f, size) content, err := hashContent(f, size)
if err != nil {
return zero, zero, zero, err
}
return "", "", content, err return head, tail, content, nil
} }
// hashEnds returns the SHA-256 of the first and last headTailWindow // hashEnds returns the SHA-256 of the first and last headTailWindow
// bytes of f. It is called only for files at least headTailMin, which // bytes of f. In a file no larger than headTailWindow, each window is
// is far larger than two windows, so the windows never overlap and both // the whole file. Reading fewer bytes than asked means the file shrank
// reads are always full. // between the stat and the hash, and is an error.
func hashEnds(f *os.File, size int64) (string, string, error) { func hashEnds(f *os.File, size int64) (
buf := make([]byte, headTailWindow) [sha256.Size]byte, [sha256.Size]byte, error,
) {
var zero [sha256.Size]byte
buf := make([]byte, min(int64(headTailWindow), size))
_, err := f.ReadAt(buf, 0) _, err := f.ReadAt(buf, 0)
if err != nil { if err != nil {
return "", "", err return zero, zero, err
} }
h := sha256.Sum256(buf) head := sha256.Sum256(buf)
head := hex.EncodeToString(h[:])
_, err = f.ReadAt(buf, size-int64(headTailWindow)) _, err = f.ReadAt(buf, size-int64(len(buf)))
if err != nil { if err != nil {
return "", "", err return zero, zero, err
} }
t := sha256.Sum256(buf) return head, sha256.Sum256(buf), nil
return head, hex.EncodeToString(t[:]), nil
} }
// hashContent returns the content-rung hash of f: the SHA-256 of the // hashContent returns the content hash of f: the SHA-256 of the whole
// whole file when it is smaller than wholeFileMax, or of sampled // file when it is smaller than wholeFileMax, or of sampled windows when
// windows when it is that size or larger. // it is that size or larger.
func hashContent(f *os.File, size int64) (string, error) { func hashContent(f *os.File, size int64) ([sha256.Size]byte, error) {
if size >= int64(wholeFileMax) { if size >= int64(wholeFileMax) {
return hashSamples(f, size) return hashSamples(f, size)
} }
@@ -1760,30 +1417,30 @@ func hashContent(f *os.File, size int64) (string, error) {
// reads. Reading fewer than size bytes means the file shrank between // reads. Reading fewer than size bytes means the file shrank between
// the stat and the hash; that is an error rather than a hash of content // the stat and the hash; that is an error rather than a hash of content
// that no longer matches the recorded size. // that no longer matches the recorded size.
func hashWhole(f *os.File, size int64) (string, error) { func hashWhole(f *os.File, size int64) ([sha256.Size]byte, error) {
h := sha256.New() h := sha256.New()
n, err := io.Copy(h, io.NewSectionReader(f, 0, size)) n, err := io.Copy(h, io.NewSectionReader(f, 0, size))
if err != nil { if err != nil {
return "", err return [sha256.Size]byte{}, err
} }
if n != size { if n != size {
return "", fmt.Errorf("read %d of %d bytes: %w", n, size, return [sha256.Size]byte{}, fmt.Errorf("read %d of %d bytes: %w",
io.ErrUnexpectedEOF) n, size, io.ErrUnexpectedEOF)
} }
return hex.EncodeToString(h.Sum(nil)), nil return [sha256.Size]byte(h.Sum(nil)), nil
} }
// hashSamples feeds sampleWindow bytes at each gigabyte-aligned offset // hashSamples feeds sampleWindow bytes at each gigabyte-aligned offset
// (0, sampleStride, 2*sampleStride, ... while inside the file), in // (0, sampleStride, 2*sampleStride, ... while inside the file), in
// order, into one hash, each window truncated at end of file. This is // order, into one hash, each window truncated at end of file. Two files
// the probabilistic large-file rung: two files of equal size agreeing // of equal size agreeing on every sample are reported as duplicates
// on every sample are reported as duplicates without every byte being // without every byte being read. Because size is part of the signature,
// read. Because size is part of the signature, files of different sizes // only files of one size are compared, so their sample boundaries
// never reach this comparison, so the sample boundaries always align. // always align.
func hashSamples(f *os.File, size int64) (string, error) { func hashSamples(f *os.File, size int64) ([sha256.Size]byte, error) {
h := sha256.New() h := sha256.New()
buf := make([]byte, sampleWindow) buf := make([]byte, sampleWindow)
@@ -1792,11 +1449,11 @@ func hashSamples(f *os.File, size int64) (string, error) {
_, err := f.ReadAt(buf[:n], off) _, err := f.ReadAt(buf[:n], off)
if err != nil { if err != nil {
return "", err return [sha256.Size]byte{}, err
} }
h.Write(buf[:n]) h.Write(buf[:n])
} }
return hex.EncodeToString(h.Sum(nil)), nil return [sha256.Size]byte(h.Sum(nil)), nil
} }
+175 -697
View File
File diff suppressed because it is too large Load Diff
+4 -12
View File
@@ -286,18 +286,9 @@ func (b *treeBuilder) finish() (*treeNode, []*treeNode) {
// name and its signature (size, head, tail, content); mtime is // name and its signature (size, head, tail, content); mtime is
// excluded. // excluded.
func fileEntry(name string, r scanRec) string { func fileEntry(name string, r scanRec) string {
content := r.content
// A record without a content hash has unknown content (README
// "Database"): give it a signature no other file can share, so
// trees containing it never compare equal. Real hashes are hex, so
// the NUL-prefixed form cannot collide.
if content == "" {
content = "unhashed\x00" + r.path
}
return "f\x00" + name + "\x00" + strconv.FormatInt(r.size, 10) + return "f\x00" + name + "\x00" + strconv.FormatInt(r.size, 10) +
"\x00" + r.head + "\x00" + r.tail + "\x00" + content "\x00" + string(r.head[:]) + "\x00" + string(r.tail[:]) +
"\x00" + string(r.content[:])
} }
// collectTreeGroups groups directories by digest, counting each one on // collectTreeGroups groups directories by digest, counting each one on
@@ -347,7 +338,8 @@ func collectTreeGroups(allDirs []*treeNode, super *treeNode,
// digest is the SHA-256 of its child entries — files serialized with // digest is the SHA-256 of its child entries — files serialized with
// name and signature, subdirectories with name and recursive digest — // name and signature, subdirectories with name and recursive digest —
// sorted byte-lexicographically. Filenames cannot contain NUL or "/", // sorted byte-lexicographically. Filenames cannot contain NUL or "/",
// so NUL delimiters are unambiguous. // and every hash and digest is 32 bytes, so NUL delimiters are
// unambiguous.
func (n *treeNode) computeDigest() { func (n *treeNode) computeDigest() {
slices.Sort(n.entries) slices.Sort(n.entries)
+17 -22
View File
@@ -10,16 +10,6 @@ import (
"time" "time"
) )
// Signature hashes shared by the smoke-test records.
const (
f1Head = "f1h"
f1Tail = "f1t"
f1Content = "f1c"
f2Head = "f2h"
f2Tail = "f2t"
f2Content = "f2c"
)
// treesOpenLine is the plain line of trees' open phase when it opens // treesOpenLine is the plain line of trees' open phase when it opens
// the database at once; it prints it as the phase starts and as it ends. // the database at once; it prints it as the phase starts and as it ends.
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " + const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
@@ -28,6 +18,9 @@ const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and // smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
// /d/t2 are identical, /d/t3 differs from them only by one filename. // /d/t2 are identical, /d/t3 differs from them only by one filename.
func smokeTreeRecs() []scanRec { func smokeTreeRecs() []scanRec {
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
return []scanRec{ return []scanRec{
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"}, {size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"}, {size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
@@ -153,13 +146,14 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
for _, top := range []string{"/a", "/c"} { for _, top := range []string{"/a", "/c"} {
for _, p := range []string{"/b", "/b-x/f", "/b.txt", "/b/f"} { for _, p := range []string{"/b", "/b-x/f", "/b.txt", "/b/f"} {
content := "c" content := testHash("c")
if p == "/b-x/f" { if p == "/b-x/f" {
content = "other" content = testHash("other")
} }
recs = append(recs, scanRec{ recs = append(recs, scanRec{
size: 1, head: "h", tail: "t", content: content, path: top + p, size: 1, head: testHash("h"), tail: testHash("t"),
content: content, path: top + p,
}) })
} }
} }
@@ -332,11 +326,10 @@ func TestTreeDigests(t *testing.T) {
func TestTreeDigestContentSensitivity(t *testing.T) { func TestTreeDigestContentSensitivity(t *testing.T) {
t.Parallel() t.Parallel()
const sharedTail = "same" same, c := testHash("same"), testHash("c")
recs := []scanRec{ recs := []scanRec{
{size: 10, head: sharedTail, tail: sharedTail, content: "c", path: "/r/a/f"}, {size: 10, head: same, tail: same, content: c, path: "/r/a/f"},
{size: 10, head: "DIFF", tail: sharedTail, content: "c", path: "/r/b/f"}, {size: 10, head: testHash("DIFF"), tail: same, content: c, path: "/r/b/f"},
} }
_, dirs := treeOf(t, recs) _, dirs := treeOf(t, recs)
@@ -397,9 +390,10 @@ func TestCollectTreeGroupsSiblings(t *testing.T) {
// Identical sibling dirs share a parent, so their group cannot be // Identical sibling dirs share a parent, so their group cannot be
// implied by a parent group and must be reported. // implied by a parent group and must be reported.
h, tl, c := testHash("h"), testHash("t"), testHash("c")
recs := []scanRec{ recs := []scanRec{
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x1/f"}, {size: 10, head: h, tail: tl, content: c, path: "/p/x1/f"},
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x2/f"}, {size: 10, head: h, tail: tl, content: c, path: "/p/x2/f"},
} }
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)
@@ -418,10 +412,11 @@ func TestCollectTreeGroupsDifferingParents(t *testing.T) {
// /p/a and /q/b contain identical x subtrees, but /p/a has an // /p/a and /q/b contain identical x subtrees, but /p/a has an
// extra file, so the parents' digests differ and the x group must // extra file, so the parents' digests differ and the x group must
// be reported. // be reported.
h, tl, c, e := testHash("h"), testHash("t"), testHash("c"), testHash("e")
recs := []scanRec{ recs := []scanRec{
{size: 10, head: "h", tail: "t", content: "c", path: "/p/a/x/f"}, {size: 10, head: h, tail: tl, content: c, path: "/p/a/x/f"},
{size: 99, head: "e", tail: "e", content: "e", path: "/p/a/extra"}, {size: 99, head: e, tail: e, content: e, path: "/p/a/extra"},
{size: 10, head: "h", tail: "t", content: "c", path: "/q/b/x/f"}, {size: 10, head: h, tail: tl, content: c, path: "/q/b/x/f"},
} }
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)