Store each hash as its raw 32 bytes in a BLOB column (closes #134)
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:
clawbot
2026-10-26 01:14:23 +00:00
parent f7a746f912
commit 894603059b
12 changed files with 310 additions and 157 deletions
+71 -28
View File
@@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -43,13 +42,6 @@ func writeFile(t *testing.T, dir, name string, data []byte) string {
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.
func pattern(tag byte, n int) []byte {
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
// 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()
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)))
window := min(len(c.data), headTailWindow)
wantHead := hexSum(c.data[:window])
wantTail := hexSum(c.data[len(c.data)-window:])
wantContent := hexSum(c.data)
wantHead := sha256.Sum256(c.data[:window])
wantTail := sha256.Sum256(c.data[len(c.data)-window:])
wantContent := sha256.Sum256(c.data)
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)
}
})
@@ -170,22 +164,25 @@ func TestHashSignatureErrors(t *testing.T) {
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)
if err == nil {
t.Error("no error for a missing file")
}
if head != "" || tail != "" || content != "" {
t.Errorf("missing file returned hashes: %q %q %q", head, tail, content)
if head != zero || tail != zero || content != zero {
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 missing path succeeds.
empty := sha256.Sum256(nil)
head, tail, content, err = hashSignature(filepath.Join(dir, "missing"), 0)
if err != nil ||
head != emptyHash || tail != emptyHash || content != emptyHash {
t.Errorf("empty: head=%q tail=%q content=%q err=%v, "+
if err != nil || head != empty || tail != empty || content != empty {
t.Errorf("empty: head=%x tail=%x content=%x err=%v, "+
"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")
}
if head != "" || tail != "" || content != "" {
t.Errorf("shrunk file returned hashes: %q %q %q", head, tail, content)
if head != zero || tail != zero || content != zero {
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(),
"UPDATE files SET head = 'sentinel' WHERE path = ?", []byte(a))
"UPDATE files SET head = ? WHERE path = ?", sentinel[:], []byte(a))
if err != nil {
t.Fatal(err)
}
@@ -1223,9 +1222,9 @@ func TestSyncScanUnchangedReuse(t *testing.T) {
st)
}
if r := recordByPath(t, dbRecords(t, db), a); r.head != "sentinel" {
t.Fatalf("head = %q, want sentinel (file must not be re-read)",
r.head)
if r := recordByPath(t, dbRecords(t, db), a); r.head != sentinel {
t.Fatalf("head = %x, want the sentinel %x (file must not be re-read)",
r.head, sentinel)
}
}
@@ -1265,13 +1264,56 @@ func assertWholeFileHashed(t *testing.T, db *sql.DB, path string,
t.Helper()
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 {
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)
}
}
// 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
// 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
@@ -1567,7 +1609,8 @@ func TestScanHardlinksReadOnce(t *testing.T) {
ra := recordByPath(t, recs, a)
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)
}