Store each hash as its raw 32 bytes in a BLOB column (closes #134)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
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
This commit is contained in:
@@ -388,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 of the first 64 KiB
|
head BLOB NOT NULL, -- raw 32-byte SHA-256 of the first 64 KiB
|
||||||
tail TEXT NOT NULL, -- lowercase-hex SHA-256 of the last 64 KiB
|
tail BLOB NOT NULL, -- raw 32-byte SHA-256 of the last 64 KiB
|
||||||
content TEXT NOT NULL -- lowercase-hex SHA-256; whole file under 100 MiB, else 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);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
|
|
||||||
# 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
|
- 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
|
`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.
|
of the whole file under 100 MiB and of 1 MiB at each gigabyte from 100 MiB up.
|
||||||
|
|||||||
+7
-2
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -756,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)
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-8
@@ -473,6 +473,43 @@ func TestLoadRowsCountAgreeDuringScan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadRowsRefuseShortHash stores a record whose head is one byte,
|
||||||
|
// not the 32 bytes of a SHA-256: loadFileRows and loadSignatureRows
|
||||||
|
// must each fail on it with an error naming its path.
|
||||||
|
func TestLoadRowsRefuseShortHash(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(), "UPDATE files SET head = x'00'")
|
||||||
|
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 +564,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 +591,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 +619,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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -23,11 +23,14 @@ const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " +
|
|||||||
// infoRecs are four records: a duplicate pair, a file of 20 MiB and
|
// infoRecs are four records: a duplicate pair, a file of 20 MiB and
|
||||||
// another small file.
|
// another small file.
|
||||||
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", content: "bc", path: "/d/big"},
|
{size: 20 << 20, head: bigH, tail: bigT, content: bigC, path: "/d/big"},
|
||||||
{size: 7, head: "u", tail: "u", content: "u", path: "/d/unique"},
|
{size: 7, head: u, tail: u, content: u, path: "/d/unique"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+62
-38
@@ -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,12 +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"},
|
||||||
)
|
)
|
||||||
|
|
||||||
reversed := slices.Clone(recs)
|
reversed := slices.Clone(recs)
|
||||||
@@ -385,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)
|
||||||
@@ -420,10 +439,11 @@ func TestDupeGroupsContentSeparates(t *testing.T) {
|
|||||||
|
|
||||||
// Same size, head, and tail: /b differs from the others only in its
|
// Same size, head, and tail: /b differs from the others only in its
|
||||||
// content hash, so it joins no group, while /a and /c group.
|
// content hash, so it joins no group, while /a and /c group.
|
||||||
|
h, tl := testHash("h"), testHash("t")
|
||||||
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"},
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := dupeGroupsOf(t, recs)
|
groups := dupeGroupsOf(t, recs)
|
||||||
@@ -442,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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,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)
|
||||||
@@ -490,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)
|
||||||
@@ -517,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)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -1199,9 +1198,9 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
|
|||||||
// hash phase.
|
// hash phase.
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1221,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)
|
||||||
|
|
||||||
@@ -1286,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 {
|
||||||
@@ -1337,37 +1338,38 @@ 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,
|
|
||||||
// tail, and content hash of every zero-length file.
|
|
||||||
const emptyHash = "e3b0c44298fc1c149afbf4c8996fb924" +
|
|
||||||
"27ae41e4649b934ca495991b7852b855"
|
|
||||||
|
|
||||||
// hashSignature computes the three hashes the hash phase records for a
|
// hashSignature computes the three hashes the hash phase records for a
|
||||||
// file: head and tail (see hashEnds) and content (see hashContent).
|
// file: head and tail (see hashEnds) and content (see hashContent).
|
||||||
// With the file size they form its duplicate signature, and two files
|
// With the file size they form its duplicate signature, and two files
|
||||||
// are duplicates only when all four agree. size is the value recorded
|
// 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
|
// when the file was statted; a zero-length file has constant hashes and
|
||||||
// is never opened.
|
// is never opened. On an error every hash is left zero.
|
||||||
func hashSignature(path string, size int64) (string, string, string, error) {
|
func hashSignature(path string, size int64) (
|
||||||
|
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
|
||||||
|
) {
|
||||||
|
var zero [sha256.Size]byte
|
||||||
|
|
||||||
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() }()
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := hashContent(f, size)
|
content, err := hashContent(f, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", err
|
return zero, zero, zero, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return head, tail, content, nil
|
return head, tail, content, nil
|
||||||
@@ -1377,31 +1379,32 @@ func hashSignature(path string, size int64) (string, string, string, error) {
|
|||||||
// bytes of f. In a file no larger than headTailWindow, each window is
|
// bytes of f. In a file no larger than headTailWindow, each window is
|
||||||
// the whole file. Reading fewer bytes than asked means the file shrank
|
// the whole file. Reading fewer bytes than asked means the file shrank
|
||||||
// between the stat and the hash, and is an error.
|
// 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) (
|
||||||
|
[sha256.Size]byte, [sha256.Size]byte, error,
|
||||||
|
) {
|
||||||
|
var zero [sha256.Size]byte
|
||||||
|
|
||||||
buf := make([]byte, min(int64(headTailWindow), size))
|
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(len(buf)))
|
_, 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 hash of f: the SHA-256 of the whole
|
// hashContent returns the content hash of f: the SHA-256 of the whole
|
||||||
// file when it is smaller than wholeFileMax, or of sampled windows when
|
// file when it is smaller than wholeFileMax, or of sampled 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)
|
||||||
}
|
}
|
||||||
@@ -1414,20 +1417,20 @@ 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
|
||||||
@@ -1437,7 +1440,7 @@ func hashWhole(f *os.File, size int64) (string, error) {
|
|||||||
// without every byte being read. Because size is part of the signature,
|
// without every byte being read. Because size is part of the signature,
|
||||||
// only files of one size are compared, so their sample boundaries
|
// only files of one size are compared, so their 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)
|
||||||
|
|
||||||
@@ -1446,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
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-28
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -43,13 +42,6 @@ func writeFile(t *testing.T, dir, name string, data []byte) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// hexSum returns the lowercase-hex SHA-256 of data.
|
|
||||||
func hexSum(data []byte) string {
|
|
||||||
s := sha256.Sum256(data)
|
|
||||||
|
|
||||||
return hex.EncodeToString(s[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// pattern returns n bytes of deterministic content seeded by tag.
|
// pattern returns n bytes of deterministic content seeded by tag.
|
||||||
func pattern(tag byte, n int) []byte {
|
func pattern(tag byte, n int) []byte {
|
||||||
data := make([]byte, n)
|
data := make([]byte, n)
|
||||||
@@ -62,7 +54,9 @@ func pattern(tag byte, n int) []byte {
|
|||||||
|
|
||||||
// sig returns a file's full signature (head, tail, content), failing the
|
// sig returns a file's full signature (head, tail, content), failing the
|
||||||
// test on any error.
|
// test on any error.
|
||||||
func sig(t *testing.T, path string, size int64) (string, string, string) {
|
func sig(t *testing.T, path string, size int64) (
|
||||||
|
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
head, tail, content, err := hashSignature(path, size)
|
head, tail, content, err := hashSignature(path, size)
|
||||||
@@ -100,12 +94,12 @@ func TestHashSignature(t *testing.T) {
|
|||||||
head, tail, content := sig(t, p, int64(len(c.data)))
|
head, tail, content := sig(t, p, int64(len(c.data)))
|
||||||
|
|
||||||
window := min(len(c.data), headTailWindow)
|
window := min(len(c.data), headTailWindow)
|
||||||
wantHead := hexSum(c.data[:window])
|
wantHead := sha256.Sum256(c.data[:window])
|
||||||
wantTail := hexSum(c.data[len(c.data)-window:])
|
wantTail := sha256.Sum256(c.data[len(c.data)-window:])
|
||||||
wantContent := hexSum(c.data)
|
wantContent := sha256.Sum256(c.data)
|
||||||
|
|
||||||
if head != wantHead || tail != wantTail || content != wantContent {
|
if head != wantHead || tail != wantTail || content != wantContent {
|
||||||
t.Errorf("head=%s tail=%s content=%s, want %s %s %s",
|
t.Errorf("head=%x tail=%x content=%x, want %x %x %x",
|
||||||
head, tail, content, wantHead, wantTail, wantContent)
|
head, tail, content, wantHead, wantTail, wantContent)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -170,22 +164,25 @@ func TestHashSignatureErrors(t *testing.T) {
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
// A missing file: an error, and every hash left empty.
|
var zero [sha256.Size]byte
|
||||||
|
|
||||||
|
// A missing file: an error, and every hash left zero.
|
||||||
head, tail, content, err := hashSignature(filepath.Join(dir, "missing"), 1)
|
head, tail, content, err := hashSignature(filepath.Join(dir, "missing"), 1)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("no error for a missing file")
|
t.Error("no error for a missing file")
|
||||||
}
|
}
|
||||||
|
|
||||||
if head != "" || tail != "" || content != "" {
|
if head != zero || tail != zero || content != zero {
|
||||||
t.Errorf("missing file returned hashes: %q %q %q", head, tail, content)
|
t.Errorf("missing file returned hashes: %x %x %x", head, tail, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A zero-length file has constant hashes and is never opened: even
|
// A zero-length file has constant hashes and is never opened: even
|
||||||
// a missing path succeeds.
|
// a missing path succeeds.
|
||||||
|
empty := sha256.Sum256(nil)
|
||||||
|
|
||||||
head, tail, content, err = hashSignature(filepath.Join(dir, "missing"), 0)
|
head, tail, content, err = hashSignature(filepath.Join(dir, "missing"), 0)
|
||||||
if err != nil ||
|
if err != nil || head != empty || tail != empty || content != empty {
|
||||||
head != emptyHash || tail != emptyHash || content != emptyHash {
|
t.Errorf("empty: head=%x tail=%x content=%x err=%v, "+
|
||||||
t.Errorf("empty: head=%q tail=%q content=%q err=%v, "+
|
|
||||||
"want constant hashes", head, tail, content, err)
|
"want constant hashes", head, tail, content, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,8 +195,8 @@ func TestHashSignatureErrors(t *testing.T) {
|
|||||||
t.Error("no error when the stat size exceeds the file size")
|
t.Error("no error when the stat size exceeds the file size")
|
||||||
}
|
}
|
||||||
|
|
||||||
if head != "" || tail != "" || content != "" {
|
if head != zero || tail != zero || content != zero {
|
||||||
t.Errorf("shrunk file returned hashes: %q %q %q", head, tail, content)
|
t.Errorf("shrunk file returned hashes: %x %x %x", head, tail, content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,8 +1208,10 @@ func TestSyncScanUnchangedReuse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sentinel := sha256.Sum256([]byte("sentinel"))
|
||||||
|
|
||||||
_, err := db.ExecContext(context.Background(),
|
_, err := db.ExecContext(context.Background(),
|
||||||
"UPDATE files SET head = 'sentinel' WHERE path = ?", []byte(a))
|
"UPDATE files SET head = ? WHERE path = ?", sentinel[:], []byte(a))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1223,9 +1222,9 @@ func TestSyncScanUnchangedReuse(t *testing.T) {
|
|||||||
st)
|
st)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r := recordByPath(t, dbRecords(t, db), a); r.head != "sentinel" {
|
if r := recordByPath(t, dbRecords(t, db), a); r.head != sentinel {
|
||||||
t.Fatalf("head = %q, want sentinel (file must not be re-read)",
|
t.Fatalf("head = %x, want the sentinel %x (file must not be re-read)",
|
||||||
r.head)
|
r.head, sentinel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1265,13 +1264,56 @@ func assertWholeFileHashed(t *testing.T, db *sql.DB, path string,
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
r := recordByPath(t, dbRecords(t, db), path)
|
r := recordByPath(t, dbRecords(t, db), path)
|
||||||
if want := hexSum(data); r.head != want || r.tail != want ||
|
if want := sha256.Sum256(data); r.head != want || r.tail != want ||
|
||||||
r.content != want {
|
r.content != want {
|
||||||
t.Fatalf("head, tail, content = %q, %q, %q, want %q for each",
|
t.Fatalf("head, tail, content = %x, %x, %x, want %x for each",
|
||||||
r.head, r.tail, r.content, want)
|
r.head, r.tail, r.content, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScanStoresRawHashes checks what the database holds for a file
|
||||||
|
// larger than both end windows, whose three hashes all differ: head,
|
||||||
|
// tail and content are each a BLOB of the 32 bytes of the SHA-256 the
|
||||||
|
// scan computed.
|
||||||
|
func TestScanStoresRawHashes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t)
|
||||||
|
data := pattern(3, 3*headTailWindow)
|
||||||
|
p := writeFile(t, dir, "f.bin", data)
|
||||||
|
|
||||||
|
syncTree(t, db, dir)
|
||||||
|
|
||||||
|
var (
|
||||||
|
blobs bool
|
||||||
|
head, tail, content []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
err := db.QueryRowContext(t.Context(),
|
||||||
|
"SELECT typeof(head) = 'blob' AND typeof(tail) = 'blob' AND "+
|
||||||
|
"typeof(content) = 'blob', head, tail, content "+
|
||||||
|
"FROM files WHERE path = ?", []byte(p)).
|
||||||
|
Scan(&blobs, &head, &tail, &content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !blobs {
|
||||||
|
t.Error("head, tail and content are not all BLOBs")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantHead := sha256.Sum256(data[:headTailWindow])
|
||||||
|
wantTail := sha256.Sum256(data[len(data)-headTailWindow:])
|
||||||
|
wantContent := sha256.Sum256(data)
|
||||||
|
|
||||||
|
if !bytes.Equal(head, wantHead[:]) || !bytes.Equal(tail, wantTail[:]) ||
|
||||||
|
!bytes.Equal(content, wantContent[:]) {
|
||||||
|
t.Errorf("head, tail, content = %x, %x, %x, want %x, %x, %x",
|
||||||
|
head, tail, content, wantHead, wantTail, wantContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSyncScanSameSecondRewrite rewrites a file in place at the same
|
// TestSyncScanSameSecondRewrite rewrites a file in place at the same
|
||||||
// size with an mtime later in the same second as the recorded one: the
|
// size with an mtime later in the same second as the recorded one: the
|
||||||
// next scan must notice the change and re-hash the file. The operand is
|
// next scan must notice the change and re-hash the file. The operand is
|
||||||
@@ -1567,7 +1609,8 @@ func TestScanHardlinksReadOnce(t *testing.T) {
|
|||||||
ra := recordByPath(t, recs, a)
|
ra := recordByPath(t, recs, a)
|
||||||
rb := recordByPath(t, recs, b)
|
rb := recordByPath(t, recs, b)
|
||||||
|
|
||||||
if ra.head == "" || ra.head != rb.head || ra.tail != rb.tail {
|
if ra.head == [sha256.Size]byte{} || ra.head != rb.head ||
|
||||||
|
ra.tail != rb.tail {
|
||||||
t.Fatalf("hardlink hashes differ: %+v vs %+v", ra, rb)
|
t.Fatalf("hardlink hashes differ: %+v vs %+v", ra, rb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ func (b *treeBuilder) finish() (*treeNode, []*treeNode) {
|
|||||||
// excluded.
|
// excluded.
|
||||||
func fileEntry(name string, r scanRec) string {
|
func fileEntry(name string, r scanRec) string {
|
||||||
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" + r.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
|
||||||
@@ -337,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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user