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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user