Hash files under 10 MiB in full, gate larger files on head/tail
check / check (push) Successful in 59s

Amends the issue 61 ladder per the owner's design change. A file under
10 MiB (new headTailMin) is now hashed in full and compared directly,
with no end-window step: its head, tail, and content all carry the
whole-file SHA-256, so its signature is decided by size and content
alone. A file at 10 MiB or above keeps the 64 KiB head/tail gate, then
the whole-file content hash below 50 MiB or gigabyte-spaced 1 MiB
samples at or above. hashEnds drops its now-unreachable single-window
branch, and hashWhole errors if the file shrank below its recorded
size (the only read for the sub-10-MiB range). README, TODO, tests,
and the schema-version note updated to match.

Model: opus-4-8
This commit is contained in:
2026-09-22 14:22:17 +00:00
parent b80c7e805e
commit dc30ad9f31
5 changed files with 203 additions and 107 deletions
+49 -37
View File
@@ -7,12 +7,14 @@
duplicate files — and, ultimately, entire duplicate directory trees — duplicate files — and, ultimately, entire duplicate directory trees —
across very large filesystems without reading every byte of every file. across 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 hashes: the SHA-256 of their first 64 KiB and agree on a short ladder of hashes. A file under 10 MiB is hashed in full
of their last 64 KiB, and then a content hash — the SHA-256 of the whole and compared directly. A larger file is gated first on the SHA-256 of
file when it is under 50 MiB, or of gigabyte-spaced 1 MiB samples when it its first 64 KiB and of its last 64 KiB, and then compared on a content
is 50 MiB or larger. Below 50 MiB this is proof of identical content; at hash — the SHA-256 of the whole file when it is under 50 MiB, or of
or above 50 MiB it is a strong candidate signal rather than proof, gigabyte-spaced 1 MiB samples when it is 50 MiB or larger. Below 50 MiB
because the gaps between samples are never read. The intended use is the content hash is proof of identical content; at or above 50 MiB it is
a strong candidate signal rather than proof, because the gaps between
samples are never read. The intended use is
finding duplicate downloads and duplicated directory trees on finding duplicate downloads and duplicated directory trees on
multi-terabyte ZFS servers where reading every byte of every file is multi-terabyte ZFS servers where reading every byte of every file is
prohibitively expensive. `scan` maintains a persistent SQLite database of file prohibitively expensive. `scan` maintains a persistent SQLite database of file
@@ -78,7 +80,8 @@ Goals, in order:
2. **Spend I/O in proportion to duplicate likelihood.** Only files 2. **Spend I/O in proportion to duplicate likelihood.** Only files
whose size at least one other file shares are read at all — a whose size at least one other file shares are read at all — a
size-unique file cannot be a duplicate. Those are compared by the size-unique file cannot be a duplicate. Those are compared by the
ladder in "Duplicate detection" below: cheap 64 KiB end windows ladder in "Duplicate detection" below: a file under 10 MiB is hashed
in full, while a larger file is gated on cheap 64 KiB end windows
first, then a content hash that reads the whole file below 50 MiB first, then a content hash that reads the whole file below 50 MiB
but only gigabyte-spaced 1 MiB samples at or above it, so the very 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 largest files are still never read in full. Scale target: tens of
@@ -153,28 +156,31 @@ All three subcommands operate on a single SQLite database file:
toward the filesystem. toward the filesystem.
- Schema (`PRAGMA user_version` is the schema version, currently 2; a - Schema (`PRAGMA user_version` is the schema version, currently 2; a
database with any other version is a fatal error). Version 2 added database with any other version is a fatal error). Version 2 added
the `content` column and widened the end windows from 1 KiB to the `content` column and the 64 KiB head/tail signature (replacing
64 KiB, so a version 1 database cannot be reused: it is rejected and the version 1 1 KiB end windows), so a version 1 database cannot be
the tree must be rescanned from scratch. reused: it is rejected and the tree must be rescanned from scratch.
```sql ```sql
CREATE TABLE files ( CREATE TABLE files (
path BLOB PRIMARY KEY, -- absolute path, raw bytes path BLOB PRIMARY KEY, -- absolute path, raw bytes
size INTEGER NOT NULL, -- bytes, from lstat size INTEGER NOT NULL, -- bytes, from lstat
mtime INTEGER NOT NULL, -- Unix seconds, from lstat mtime INTEGER NOT NULL, -- Unix seconds, from lstat
head TEXT NOT NULL, -- lowercase-hex SHA-256, first 64 KiB head TEXT NOT NULL, -- lowercase-hex SHA-256; first 64 KiB, or whole file under 10 MiB
tail TEXT NOT NULL, -- lowercase-hex SHA-256, last 64 KiB tail TEXT NOT NULL, -- lowercase-hex SHA-256; last 64 KiB, or whole file under 10 MiB
content TEXT NOT NULL -- lowercase-hex SHA-256, whole file or samples content TEXT NOT NULL -- lowercase-hex SHA-256, whole file or samples
) WITHOUT ROWID; ) WITHOUT ROWID;
``` ```
Paths are stored as BLOBs because Unix paths are raw bytes, not Paths are stored as BLOBs because Unix paths are raw bytes, not
guaranteed UTF-8. `mtime` is used only for change detection; it is guaranteed UTF-8. `mtime` is used only for change detection; it is
not part of the duplicate key. `head`, `tail`, and `content` are not part of the duplicate key. For a file under 10 MiB `head`, `tail`,
empty strings when the file has never been hashed because its size and `content` all hold the whole-file hash (that range is hashed in
was unique as of the last scan that covered it; such records still full, with no end windows); for a larger file `head` and `tail` hold
define the file for tree reconstruction but never participate in the first- and last-64 KiB hashes and `content` the whole-file or
duplicate groups. 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; such records still define the file for tree
reconstruction but never participate in duplicate groups.
### Duplicate detection ### Duplicate detection
@@ -187,27 +193,33 @@ the database, even across separate scans.
1. **Size.** Files of different sizes are never compared. Only files 1. **Size.** Files of different sizes are never compared. Only files
whose size at least one other file shares are hashed at all. whose size at least one other file shares are hashed at all.
2. **Head and tail.** The SHA-256 of the first 64 KiB (`head`) and of 2. **Under 10 MiB: whole file.** A file smaller than 10 MiB is hashed
the last 64 KiB (`tail`). When a file is 64 KiB or smaller the two in full and compared directly, with no separate end-window step —
windows are the whole file and coincide, so `head` and `tail` are small files are cheap to read to the last byte, and doing so makes
equal and only one read is issued; when it is between one and two the comparison exact. `head`, `tail`, and `content` all hold this
windows the two windows overlap, which is harmless. These reads are whole-file SHA-256, so such a file's signature is decided entirely
cheap and eliminate most same-size pairs before any bulk reading. by its size and its content.
3. **Content, below 50 MiB.** The SHA-256 of the entire file. Agreement 3. **10 MiB and above: head and tail.** For a larger file, the SHA-256
here is proof of identical content (barring a SHA-256 collision). of the first 64 KiB (`head`) and of the last 64 KiB (`tail`) are a
4. **Content, 50 MiB and above.** A sampled SHA-256: the 1 MiB window cheap gate that eliminates most same-size pairs before any bulk
at each gigabyte-aligned offset (0, 1 GiB, 2 GiB, … while inside the reading. At 10 MiB and above the two windows never overlap.
file, the final window truncated at end of file) is fed, in order, 4. **10 MiB and above, content below 50 MiB.** The SHA-256 of the
into one hash. This is **deliberately probabilistic** — the gaps entire file. Agreement here is proof of identical content (barring a
between samples are never read, so two large files that agree on SHA-256 collision).
every sample are reported as duplicates without being read in full. 5. **10 MiB and above, content 50 MiB and above.** A sampled SHA-256:
It is the price of never reading a 150 GB file end to end. Because the 1 MiB window at each gigabyte-aligned offset (0, 1 GiB, 2 GiB, …
size is already part of the signature, only equal-size files reach while inside the file, the final window truncated at end of file) is
this rung, so their sample boundaries always align. 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 50 MiB `head`, `tail`, and `content` are one column each. A file below 10 MiB
and a file at or above it never share a size, so a `content` value is and one at or above it never share a size, and neither do a file below
never ambiguous between the whole-file and sampled forms. 50 MiB and one at or above it, so a stored value is never ambiguous
between the whole-file, end-window, and sampled forms.
### `scan` mode ### `scan` mode
+12 -10
View File
@@ -29,17 +29,19 @@
# Completed Steps # Completed Steps
- replace the 1 KiB end-window sampling with the 64 KiB head/tail plus - replace the 1 KiB end-window sampling with the head/tail plus
content-hash ladder (2026-09-22, branch `next`, closes content-hash ladder (2026-09-22, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/61): the duplicate signature https://git.eeqj.de/sneak/sfdupes/issues/61): a file under 10 MiB is
gains a `content` hash — the whole file below 50 MiB, gigabyte-spaced hashed in full and compared directly, with no end-window step — its
1 MiB samples at or above — and the end windows widen from 1 KiB to `head`, `tail`, and `content` all hold the whole-file hash. A file at
64 KiB. Schema bumps to version 2 (new `content` column); a version 1 10 MiB or above is gated on the 64 KiB `head` and `tail`, then
database is rejected and must be rescanned, which is required anyway compared on a `content` hash — the whole file below 50 MiB,
since every stored hash changed. `report` and `trees` group by the gigabyte-spaced 1 MiB samples at or above. Schema bumps to version 2
extended signature, so the ladder is applied across the whole (new `content` column); a version 1 database is rejected and must be
database. README "Duplicate detection" documents every rung including rescanned, which is required anyway since every stored hash changed.
the probabilistic large-file path. `report` and `trees` group by the extended signature, so the ladder is
applied across the whole database. README "Duplicate detection"
documents every rung including the probabilistic large-file path.
- remove the dead `files.dat` references from `Makefile`, `.gitignore` - remove the dead `files.dat` references from `Makefile`, `.gitignore`
and `.dockerignore` (2026-09-21, branch `next`, closes and `.dockerignore` (2026-09-21, branch `next`, closes
+3 -2
View File
@@ -26,8 +26,9 @@ const databaseEnv = "SFDUPES_DATABASE"
// schemaVersion is the database schema version this build reads and // schemaVersion is the database schema version this build reads and
// writes, stored in PRAGMA user_version. Version 2 adds the content // writes, stored in PRAGMA user_version. Version 2 adds the content
// column and stores 65 KiB (rather than 1 KiB) end-window hashes, so a // column and the head/tail/content signature (replacing the version 1
// version 1 database is rejected and must be rescanned. // 1 KiB end windows), so a version 1 database is rejected and must be
// rescanned.
const schemaVersion = 2 const schemaVersion = 2
// dbDirPerm is the mode for a database parent directory created by // dbDirPerm is the mode for a database parent directory created by
+51 -28
View File
@@ -18,13 +18,22 @@ import (
) )
// The duplicate ladder (see hashSignature and README "Duplicate // The duplicate ladder (see hashSignature and README "Duplicate
// detection"). Same-size candidates are separated first by the hashes // detection"). A same-size candidate below headTailMin is hashed in
// of their end windows, then by a content hash that is exact for // full and compared directly; a larger one is separated first by the
// smaller files and deliberately sampled for large ones. // hashes of its end windows, then by a content hash that is exact below
// wholeFileMax and deliberately sampled at or above it.
// 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
// (the head and tail rungs). A file no larger than one window has head // at or above headTailMin (the head and tail rungs). Because
// and tail equal to the hash of its whole content. // headTailMin is far larger than two windows, the head and tail windows
// 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 rungs: a
@@ -972,14 +981,17 @@ const emptyHash = "e3b0c44298fc1c149afbf4c8996fb924" +
"27ae41e4649b934ca495991b7852b855" "27ae41e4649b934ca495991b7852b855"
// hashSignature computes the three content hashes that, with the file // hashSignature computes the three content hashes that, with the file
// size, form its duplicate signature: the SHA-256 of the first and last // size, form its duplicate signature. A file below headTailMin is
// headTailWindow bytes (the head and tail rungs), and a content hash // hashed in full and its whole-file SHA-256 is returned as head, tail,
// that is the SHA-256 of the whole file below wholeFileMax (the exact // and content alike — that range takes no separate end-window step. For
// rung) or of gigabyte-spaced samples at or above it (the sampled, // a file at or above headTailMin the head and tail are the SHA-256 of
// deliberately probabilistic rung). Two files are duplicates only when // its first and last headTailWindow bytes, and content is the SHA-256
// all four agree; any mismatch means not a duplicate. size is the value // of the whole file below wholeFileMax (the exact rung) or of
// recorded when the file was statted; a zero-length file has constant // gigabyte-spaced samples at or above it (the sampled, deliberately
// hashes and is never opened. // probabilistic rung). 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) { func hashSignature(path string, size int64) (string, string, string, error) {
if size == 0 { if size == 0 {
return emptyHash, emptyHash, emptyHash, nil return emptyHash, emptyHash, emptyHash, nil
@@ -993,6 +1005,17 @@ func hashSignature(path string, size int64) (string, string, string, error) {
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 "", "", "", err
@@ -1007,14 +1030,11 @@ func hashSignature(path string, size int64) (string, string, string, error) {
} }
// 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. The two windows overlap when the file is between one and // bytes of f. It is called only for files at least headTailMin, which
// two windows in size; when it is no larger than one window they // is far larger than two windows, so the windows never overlap and both
// coincide, so the head hash is reused as the tail and only one read is // reads are always full.
// issued.
func hashEnds(f *os.File, size int64) (string, string, error) { func hashEnds(f *os.File, size int64) (string, string, error) {
n := min(int64(headTailWindow), size) buf := make([]byte, headTailWindow)
buf := make([]byte, n)
_, err := f.ReadAt(buf, 0) _, err := f.ReadAt(buf, 0)
if err != nil { if err != nil {
@@ -1024,11 +1044,7 @@ func hashEnds(f *os.File, size int64) (string, string, error) {
h := sha256.Sum256(buf) h := sha256.Sum256(buf)
head := hex.EncodeToString(h[:]) head := hex.EncodeToString(h[:])
if size <= int64(headTailWindow) { _, err = f.ReadAt(buf, size-int64(headTailWindow))
return head, head, nil
}
_, err = f.ReadAt(buf, size-n)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -1050,16 +1066,23 @@ func hashContent(f *os.File, size int64) (string, error) {
} }
// hashWhole returns the SHA-256 of the entire file. A SectionReader is // hashWhole returns the SHA-256 of the entire file. A SectionReader is
// used so the read is independent of the offset left by the end-window // used so the read is independent of the offset left by any end-window
// reads. // 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
// that no longer matches the recorded size.
func hashWhole(f *os.File, size int64) (string, error) { func hashWhole(f *os.File, size int64) (string, error) {
h := sha256.New() h := sha256.New()
_, 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 "", err
} }
if n != size {
return "", fmt.Errorf("read %d of %d bytes: %w", n, size,
io.ErrUnexpectedEOF)
}
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
+88 -30
View File
@@ -53,10 +53,23 @@ func pattern(tag byte, n int) []byte {
return data return data
} }
// TestHashSignatureEnds exercises the head and tail rungs across the // sig returns a file's full signature (head, tail, content), failing the
// window boundaries. Every file here is below wholeFileMax, so the // test on any error.
// content rung is a whole-file hash. func sig(t *testing.T, path string, size int64) (string, string, string) {
func TestHashSignatureEnds(t *testing.T) { t.Helper()
head, tail, content, err := hashSignature(path, size)
if err != nil {
t.Fatalf("hashSignature %s: %v", path, err)
}
return head, tail, content
}
// TestHashSignatureBelowThreshold verifies that a file below headTailMin
// is hashed in full and compared directly: head, tail, and content all
// carry the whole-file SHA-256, with no separate end-window step.
func TestHashSignatureBelowThreshold(t *testing.T) {
t.Parallel() t.Parallel()
dir := t.TempDir() dir := t.TempDir()
@@ -65,13 +78,10 @@ func TestHashSignatureEnds(t *testing.T) {
name string name string
data []byte data []byte
}{ }{
{"empty", nil},
{"one-byte", []byte("x")}, {"one-byte", []byte("x")},
{"under-one-window", pattern(1, headTailWindow-1)}, {"one-window", pattern(1, headTailWindow)},
{"exactly-one-window", pattern(2, headTailWindow)}, {"several-windows", pattern(2, 3*headTailWindow)},
{"overlapping-windows", pattern(3, headTailWindow+headTailWindow/2)}, {"near-threshold", pattern(3, headTailMin-1)},
{"exactly-two-windows", pattern(4, 2*headTailWindow)},
{"beyond-two-windows", pattern(5, 3*headTailWindow)},
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
@@ -79,28 +89,79 @@ func TestHashSignatureEnds(t *testing.T) {
p := writeFile(t, dir, c.name, c.data) p := writeFile(t, dir, c.name, c.data)
head, tail, content, err := hashSignature(p, int64(len(c.data))) head, tail, content := sig(t, p, int64(len(c.data)))
if err != nil {
t.Fatalf("hashSignature: %v", err)
}
n := min(headTailWindow, len(c.data)) whole := hexSum(c.data)
if want := hexSum(c.data[:n]); head != want { if head != whole || tail != whole || content != whole {
t.Errorf("head = %s, want %s", head, want) t.Errorf("head=%s tail=%s content=%s, want all whole-file %s",
} head, tail, content, whole)
if want := hexSum(c.data[len(c.data)-n:]); tail != want {
t.Errorf("tail = %s, want %s", tail, want)
}
// Below wholeFileMax the content rung hashes the whole file.
if want := hexSum(c.data); content != want {
t.Errorf("content = %s, want whole-file %s", content, want)
} }
}) })
} }
} }
// TestHashSignatureEnds exercises the head and tail rungs, which apply
// only to files at least headTailMin. Sparse files keep the fixtures
// cheap: a difference in the first window changes only head, a
// difference in the last window changes only tail, and a difference
// between the windows changes neither end hash but does change the
// whole-file content rung (the file is below wholeFileMax).
func TestHashSignatureEnds(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Between headTailMin and wholeFileMax: the end-window gate is active
// and the content rung is a whole-file hash.
const size = int64(headTailMin + 2*1024*1024)
base := sparseFile(t, dir, "ends-base", size)
headDiff := sparseFile(t, dir, "ends-head", size)
tailDiff := sparseFile(t, dir, "ends-tail", size)
midDiff := sparseFile(t, dir, "ends-mid", size)
pokeAt(t, headDiff, 0, []byte{1})
pokeAt(t, tailDiff, size-1, []byte{1})
pokeAt(t, midDiff, size/2, []byte{1})
bHead, bTail, bContent := sig(t, base, size)
h, tl, c := sig(t, headDiff, size)
if h == bHead {
t.Error("a byte in the first window did not change head")
}
if tl != bTail {
t.Error("a byte in the first window changed tail")
}
if c == bContent {
t.Error("a byte in the first window did not change content")
}
h, tl, c = sig(t, tailDiff, size)
if tl == bTail {
t.Error("a byte in the last window did not change tail")
}
if h != bHead {
t.Error("a byte in the last window changed head")
}
if c == bContent {
t.Error("a byte in the last window did not change content")
}
h, tl, c = sig(t, midDiff, size)
if h != bHead || tl != bTail {
t.Error("a byte between the windows changed an end hash")
}
if c == bContent {
t.Error("whole-file content rung ignored a byte between the windows")
}
}
func TestHashSignatureErrors(t *testing.T) { func TestHashSignatureErrors(t *testing.T) {
t.Parallel() t.Parallel()
@@ -189,10 +250,7 @@ func pokeAt(t *testing.T, path string, off int64, data []byte) {
func contentHash(t *testing.T, path string, size int64) string { func contentHash(t *testing.T, path string, size int64) string {
t.Helper() t.Helper()
_, _, content, err := hashSignature(path, size) _, _, content := sig(t, path, size)
if err != nil {
t.Fatalf("hashSignature %s: %v", path, err)
}
return content return content
} }