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
+37 -34
View File
@@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -1199,9 +1198,9 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
// hash phase.
type hashResult struct {
run []fileRec
head string
tail string
content string
head [sha256.Size]byte
tail [sha256.Size]byte
content [sha256.Size]byte
err error
}
@@ -1221,7 +1220,8 @@ type hashPool struct {
// 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.
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 {
ctx, cancel := context.WithCancel(ctx)
@@ -1286,7 +1286,8 @@ func feedHashJobs(ctx context.Context, runs [][]fileRec,
// the hash of only delays that.
func hashWorker(ctx context.Context, jobs <-chan []fileRec,
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 {
if ctx.Err() != nil {
@@ -1337,37 +1338,38 @@ func openRegularFile(path string) (*os.File, error) {
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
// file: head and tail (see hashEnds) and content (see hashContent).
// With the file size they form its duplicate signature, and two files
// 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.
func hashSignature(path string, size int64) (string, string, string, error) {
// 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
if size == 0 {
return emptyHash, emptyHash, emptyHash, nil
empty := sha256.Sum256(nil)
return empty, empty, empty, nil
}
f, err := openRegularFile(path)
if err != nil {
return "", "", "", err
return zero, zero, zero, err
}
defer func() { _ = f.Close() }()
head, tail, err := hashEnds(f, size)
if err != nil {
return "", "", "", err
return zero, zero, zero, err
}
content, err := hashContent(f, size)
if err != nil {
return "", "", "", err
return zero, zero, zero, err
}
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
// the whole file. Reading fewer bytes than asked means the file shrank
// 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))
_, err := f.ReadAt(buf, 0)
if err != nil {
return "", "", err
return zero, zero, err
}
h := sha256.Sum256(buf)
head := hex.EncodeToString(h[:])
head := sha256.Sum256(buf)
_, err = f.ReadAt(buf, size-int64(len(buf)))
if err != nil {
return "", "", err
return zero, zero, err
}
t := sha256.Sum256(buf)
return head, hex.EncodeToString(t[:]), nil
return head, sha256.Sum256(buf), nil
}
// 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
// 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) {
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
// 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) ([sha256.Size]byte, error) {
h := sha256.New()
n, err := io.Copy(h, io.NewSectionReader(f, 0, size))
if err != nil {
return "", err
return [sha256.Size]byte{}, err
}
if n != size {
return "", fmt.Errorf("read %d of %d bytes: %w", n, size,
io.ErrUnexpectedEOF)
return [sha256.Size]byte{}, fmt.Errorf("read %d of %d bytes: %w",
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
@@ -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,
// only files of one size are compared, so their sample boundaries
// 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()
buf := make([]byte, sampleWindow)
@@ -1446,11 +1449,11 @@ func hashSamples(f *os.File, size int64) (string, error) {
_, err := f.ReadAt(buf[:n], off)
if err != nil {
return "", err
return [sha256.Size]byte{}, err
}
h.Write(buf[:n])
}
return hex.EncodeToString(h.Sum(nil)), nil
return [sha256.Size]byte(h.Sum(nil)), nil
}