Files
sfdupes/scan.go
T
sneak b80c7e805e
check / check (push) Successful in 1m0s
Add 64 KiB head/tail and content-hash duplicate ladder (closes #61)
Replace the 1 KiB end sampling with a ladder for same-size candidates:
SHA-256 of the first and last 64 KiB, then a content hash that is the
whole file below 50 MiB (proof of identity) and gigabyte-spaced 1 MiB
samples at or above (deliberately probabilistic). Two files are
duplicates only when size, head, tail, and content all agree.

The signature gains a content column; schema bumps to version 2, so a
version 1 database is rejected and must be rescanned (unavoidable — every
stored hash changed). Because report and trees group stored signatures
across separate scans, content is computed for every shared-size file,
not only within-run head/tail collisions; size remains the sole read
gate. README "Duplicate detection" documents each rung; tests cover the
window boundaries, the 50 MiB boundary, and a multi-gigabyte sampled
case with sparse temp files.

Model: opus-4-8
2026-09-22 13:58:57 +00:00

1090 lines
29 KiB
Go

package main
import (
"cmp"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
)
// The duplicate ladder (see hashSignature and README "Duplicate
// detection"). Same-size candidates are separated first by the hashes
// of their end windows, then by a content hash that is exact for
// smaller files and deliberately sampled for large ones.
// 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
// and tail equal to the hash of its whole content.
const headTailWindow = 64 * 1024
// wholeFileMax is the size boundary between the two content rungs: a
// file strictly smaller than this is content-hashed in full; a file
// this size or larger is content-hashed by sampling.
const wholeFileMax = 50 * 1024 * 1024
// sampleStride is the spacing between content samples for large files:
// one window is read at each gigabyte-aligned offset (0, 1 GiB, ...).
const sampleStride = 1024 * 1024 * 1024
// sampleWindow is the number of bytes read at each large-file sample
// offset, truncated at end of file.
const sampleWindow = 1024 * 1024
// workQueueDepth bounds the job and result channels feeding the walk
// and hash worker pools.
const workQueueDepth = 1024
// fileRec carries one statted file between the scan phases. dev and
// ino identify the underlying inode so hard-linked paths can share
// one read; both are zero when the platform exposes no inode.
type fileRec struct {
path string
size int64
mtime int64
dev uint64
ino uint64
}
// fileMeta is the in-memory index entry for one existing database
// record: just enough for change detection, plus whether the record
// carries hashes. Hashes stay on disk; at tens of millions of records
// they would dominate the scan's memory.
type fileMeta struct {
size int64
mtime int64
hashed bool
}
// runScan implements the scan subcommand: three sequential phases —
// walk (which stats each file as it is discovered), hash, update —
// that synchronize the persistent database with the filesystem state
// under the PATH operands. Only files whose size at least one other
// file shares are ever hashed: a size-unique file cannot be a
// duplicate. Flag parsing and the at-least-one-operand check are done
// by cobra. Errors are returned rather than exiting, so that the
// deferred close — which checkpoints the SQLite WAL — always runs.
// Cancelling ctx unwinds the worker pools and aborts the scan with the
// context's error.
func runScan(ctx context.Context, roots []string, workers int,
oneFS bool,
) error {
if workers < 1 {
workers = 1
}
roots, err := resolveRoots(roots)
if err != nil {
return err
}
dbPath := databasePath()
db, err := openScanDatabase(ctx, dbPath)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
st, err := syncScan(ctx, db, roots, workers, oneFS)
if err != nil {
return fmt.Errorf("update database %s: %w", dbPath, err)
}
fmt.Fprintf(os.Stderr,
"scan: %d files seen (%d added, %d updated, %d removed, "+
"%d unchanged), %d skipped\n",
st.added+st.updated+st.unchanged, st.added, st.updated,
st.removed, st.unchanged, st.skipped)
return nil
}
// resolveRoots converts each PATH operand to an absolute, lexically
// cleaned path (symlinks are not resolved) and verifies that it
// exists. Database records are keyed by absolute path, so scan results
// must not depend on the working directory.
func resolveRoots(roots []string) ([]string, error) {
abs := make([]string, 0, len(roots))
for _, root := range roots {
a, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err)
}
// A nonexistent operand is a fatal error before any scanning.
_, err = os.Lstat(a)
if err != nil {
return nil, err
}
abs = append(abs, a)
}
return abs, nil
}
// pruneRoots drops operands already covered by another operand:
// duplicates and any operand lying under another one. Every remaining
// file is then reachable through exactly one root, so walked paths are
// unique without keeping a scan-wide set of every path seen.
func pruneRoots(roots []string) []string {
// Shorter-first ordering guarantees an ancestor is kept before any
// operand under it is considered.
sorted := slices.Clone(roots)
slices.SortFunc(sorted, func(a, b string) int {
if len(a) != len(b) {
return len(a) - len(b)
}
return strings.Compare(a, b)
})
kept := make([]string, 0, len(sorted))
for _, r := range sorted {
if !underAnyRoot(r, kept) {
kept = append(kept, r)
}
}
return kept
}
// scanStats summarizes one scan's database synchronization for the
// final stderr summary.
type scanStats struct {
added int
updated int
removed int
unchanged int
skipped int
}
// scanState carries one scan's evolving state across its phases.
// Entries consumed from existing mark files verified this run;
// whatever remains after the walk and hash phases is deleted by the
// update phase.
type scanState struct {
db *sql.DB
existing map[string]fileMeta
sizes []int64 // size census: every walked file, plus records outside the roots
toHash []fileRec // files whose size is shared: must be read
sentinels []fileRec // new/changed size-unique files: recorded without hashes
batch []scanRec
st scanStats
}
// syncScan synchronizes the database with the filesystem under roots
// in three sequential phases: walk (enumerate and stat every file,
// building a complete size census), hash (read only the new or
// changed — or previously unhashed — files whose size at least one
// other file shares, committing results in batches as they arrive),
// and update (record the size-unique files without reading them, and
// delete the records the scan no longer verifies). Records outside
// the roots are never touched.
func syncScan(ctx context.Context, db *sql.DB, roots []string,
workers int, oneFS bool,
) (scanStats, error) {
roots = pruneRoots(roots)
s := &scanState{db: db}
err := s.loadIndex(ctx, roots)
if err != nil {
return s.st, err
}
changed, unhashed := s.walkPhase(startWalk(ctx, roots, oneFS, workers))
// A cancelled walk stops early, so its size census covers only part
// of the roots, and every file it never reached looks vanished to
// the update phase. Defence in depth rather than the only barrier:
// that phase would today fail on its first BeginTx with the same
// cancelled context before deleting anything. But it is the barrier
// that survives a later decision to let an interrupted scan commit
// what it has, and it turns a confusing failure deep in the update
// phase into a clean abort at the phase boundary.
err = ctx.Err()
if err != nil {
return s.st, err
}
s.partition(changed, unhashed)
err = s.hashPhase(ctx, workers)
if err != nil {
return s.st, err
}
return s.st, s.updatePhase(ctx)
}
// loadIndex indexes the database records under the scan roots for
// change detection and collects the sizes of every record outside
// them: out-of-scope records join the size census so a scanned file
// can be recognized as a possible duplicate of a tree scanned
// separately into the same database.
func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
// Indexing tens of millions of records takes real time; without a
// display the scan looks hung before the walk begins.
prog := newProgress("load", -1)
defer prog.finish()
s.existing = make(map[string]fileMeta)
return loadFileMeta(ctx, s.db,
func(path string, size, mtime int64, hashed bool) {
prog.increment()
if underAnyRoot(path, roots) {
s.existing[path] = fileMeta{
size: size, mtime: mtime, hashed: hashed,
}
return
}
s.sizes = append(s.sizes, size)
})
}
// walkPhase drains the walk, appending every walked file's size to
// the census and resolving what it can immediately: an unchanged file
// whose record already has hashes needs nothing further. It returns
// the new-or-changed files and the unchanged files whose records lack
// hashes; both remain candidates until the census decides whether
// their sizes are shared.
func (s *scanState) walkPhase(
events <-chan walkEvent,
) ([]fileRec, []fileRec) {
prog := newProgress("walk", -1)
var changed, unhashed []fileRec
for ev := range events {
if ev.fail {
s.st.skipped++
prog.warnf("%s", ev.warn)
continue
}
s.sizes = append(s.sizes, ev.rec.size)
prog.increment()
old, ok := s.existing[ev.rec.path]
switch {
case !ok || old.size != ev.rec.size || old.mtime < ev.rec.mtime:
changed = append(changed, ev.rec)
case old.hashed:
delete(s.existing, ev.rec.path)
s.st.unchanged++
default:
unhashed = append(unhashed, ev.rec)
}
}
prog.finish()
return changed, unhashed
}
// partition decides each candidate's disposition now that the size
// census is complete. A file whose size no other file shares cannot
// be a duplicate and is never read: a new or changed one is recorded
// without hashes, an unchanged unhashed one keeps its record. Every
// file with a shared size queues for the hash phase.
func (s *scanState) partition(changed, unhashed []fileRec) {
slices.Sort(s.sizes)
for _, rec := range changed {
if s.sizeShared(rec.size) {
s.toHash = append(s.toHash, rec)
continue
}
s.sentinels = append(s.sentinels, rec)
s.resolve(rec.path)
}
for _, rec := range unhashed {
if s.sizeShared(rec.size) {
s.toHash = append(s.toHash, rec)
continue
}
delete(s.existing, rec.path)
s.st.unchanged++
}
s.sizes = nil
}
// sizeShared reports whether at least two census entries have this
// size. Every candidate's own size is in the census exactly once, so
// a second entry means another file (or a record outside the scan
// roots) could share its content.
func (s *scanState) sizeShared(size int64) bool {
i, found := slices.BinarySearch(s.sizes, size)
return found && i+1 < len(s.sizes) && s.sizes[i+1] == size
}
// resolve counts one written record as added or updated and marks its
// path verified.
func (s *scanState) resolve(path string) {
if _, ok := s.existing[path]; ok {
s.st.updated++
delete(s.existing, path)
return
}
s.st.added++
}
// hashRuns sorts the queued files into inode order and groups paths
// sharing an inode into runs. Inode-ordered reads minimize seeking on
// spinning disks, and each run of hard-linked paths is read once,
// with every path sharing the result. Files without an inode identity
// are never merged.
func hashRuns(toHash []fileRec) [][]fileRec {
slices.SortFunc(toHash, func(a, b fileRec) int {
if c := cmp.Compare(a.dev, b.dev); c != 0 {
return c
}
if c := cmp.Compare(a.ino, b.ino); c != 0 {
return c
}
return strings.Compare(a.path, b.path)
})
var runs [][]fileRec
start := 0
for i := 1; i <= len(toHash); i++ {
if i == len(toHash) || !sameInode(toHash[i-1], toHash[i]) {
runs = append(runs, toHash[start:i])
start = i
}
}
return runs
}
// sameInode reports whether two records name the same underlying
// inode. Records without an inode identity never match.
func sameInode(a, b fileRec) bool {
return (a.dev != 0 || a.ino != 0) && a.dev == b.dev && a.ino == b.ino
}
// hashPhase hashes every queued file with the worker pool — one read
// per inode run, in inode order — committing completed records to the
// database in batches as results arrive, so a long scan persists its
// progress as it goes (an interrupted scan resumes cheaply: the next
// run skips everything already recorded). The total counts actual
// reads, so the bar shows a real ETA. A run that fails to hash is
// warned about and skipped; stale records for its paths, if any, are
// deleted by the update phase.
//
// Returning early — a failed database write, or a cancelled scan — must
// not strand the pool: the feeder would park forever on a full jobs
// channel and every worker on a full results channel. The deferred stop
// is what prevents that.
func (s *scanState) hashPhase(ctx context.Context, workers int) error {
runs := hashRuns(s.toHash)
s.toHash = nil
pool := startHashPool(ctx, runs, workers)
defer pool.stop()
prog := newProgress("hash", int64(len(runs)))
defer prog.finish()
for range runs {
var r hashResult
select {
case r = <-pool.results:
case <-ctx.Done():
return ctx.Err()
}
prog.increment()
if r.err != nil {
s.st.skipped += len(r.run)
prog.warnf("hash %s: %v", r.run[0].path, r.err)
continue
}
err := s.recordRun(ctx, r)
if err != nil {
return err
}
}
return nil
}
// recordRun folds one hash result into the running batch: every path
// in the run (one file, or several hard links to it) gets a record
// with the shared hashes.
func (s *scanState) recordRun(ctx context.Context, r hashResult) error {
for _, rec := range r.run {
s.resolve(rec.path)
s.batch = append(s.batch, scanRec{
size: rec.size,
mtime: rec.mtime,
head: r.head,
tail: r.tail,
content: r.content,
path: rec.path,
})
}
if len(s.batch) < updateBatchSize {
return nil
}
err := applyBatch(ctx, s.db, s.batch, nil, nil)
s.batch = s.batch[:0]
return err
}
// updatePhase writes the scan's tail under one progress display: the
// final partial batch of hashed records, a hash-less record for every
// size-unique new or changed file, and deletions for every record the
// scan did not verify (vanished files, plus paths that failed to stat
// or hash).
func (s *scanState) updatePhase(ctx context.Context) error {
deletes := make([]string, 0, len(s.existing))
for path := range s.existing {
deletes = append(deletes, path)
}
// Sorted deletes keep the update phase deterministic.
slices.Sort(deletes)
s.st.removed = len(deletes)
total := len(s.batch) + len(s.sentinels) + len(deletes)
prog := newProgress("update", int64(total))
defer prog.finish()
err := applyChanges(ctx, s.db, s.batch, nil, prog)
if err != nil {
return err
}
s.batch = nil
// Sentinel records are converted in batch-sized chunks rather than
// materialized all at once; a first scan can have millions.
for chunk := range slices.Chunk(s.sentinels, updateBatchSize) {
recs := make([]scanRec, 0, len(chunk))
for _, rec := range chunk {
recs = append(recs, scanRec{
size: rec.size, mtime: rec.mtime, path: rec.path,
})
}
err = applyBatch(ctx, s.db, recs, nil, prog)
if err != nil {
return err
}
}
return applyChanges(ctx, s.db, nil, deletes, prog)
}
// underAnyRoot reports whether path is any of the roots or lies under
// one of them.
func underAnyRoot(path string, roots []string) bool {
for _, root := range roots {
if underRoot(path, root) {
return true
}
}
return false
}
// underRoot reports whether path is root itself or lies under it. Both
// must be absolute and lexically clean.
func underRoot(path, root string) bool {
if path == root {
return true
}
prefix := root
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
return strings.HasPrefix(path, prefix)
}
// dirJob is one directory awaiting traversal by the walk workers. It
// carries its operand's filesystem device so -x can stop at
// filesystem boundaries.
type dirJob struct {
path string
rootDev uint64
rootDevOK bool
}
// walkEvent is one walk result delivered to the walk phase: a regular
// file's statted record, or a warning when fail is set.
type walkEvent struct {
rec fileRec
warn string
fail bool
}
// startWalk seeds every root into the shared walk worker pool and
// returns the event stream: one record per regular file, one warning
// event per per-path error. The channel is closed when the walk
// completes, and also when ctx is cancelled — every goroutine in the
// pool abandons its blocking send in that case, so the consumer sees a
// truncated but properly terminated stream instead of a stalled one.
func startWalk(ctx context.Context, roots []string, oneFS bool,
workers int,
) <-chan walkEvent {
jobs, subdirs, events := startWalkWorkers(ctx, workers, oneFS)
go func() {
initial := make([]dirJob, 0, len(roots))
for _, root := range roots {
initial = append(initial, seedRoot(ctx, root, events)...)
}
dispatchDirs(ctx, initial, jobs, subdirs)
}()
return events
}
// sendEvent delivers one walk event, abandoning the send when the scan
// is cancelled. Every walk goroutine reaches the consumer through this
// one channel, so this is where a cancelled walk unwinds rather than
// parking on a buffer nobody is draining.
func sendEvent(ctx context.Context, events chan<- walkEvent,
ev walkEvent,
) {
select {
case events <- ev:
case <-ctx.Done():
}
}
// seedRoot turns one PATH operand into the walk's starting state: a
// regular-file operand is statted and emitted directly, a directory
// operand becomes an initial job, and a symlink or other non-regular
// operand yields nothing (symlinks are never followed, including as
// operands).
func seedRoot(ctx context.Context, root string,
events chan<- walkEvent,
) []dirJob {
fi, err := os.Lstat(root)
if err != nil {
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", root, err),
fail: true,
})
return nil
}
switch {
case fi.IsDir():
if filepath.Base(root) == ".zfs" {
return nil
}
dev, ok := deviceOfInfo(fi)
return []dirJob{{path: root, rootDev: dev, rootDevOK: ok}}
case fi.Mode().IsRegular():
dev, ino := inodeOfInfo(fi)
sendEvent(ctx, events, walkEvent{rec: fileRec{
path: root,
size: fi.Size(),
mtime: fi.ModTime().Unix(),
dev: dev,
ino: ino,
}})
return nil
default:
return nil
}
}
// startWalkWorkers starts the walk worker pool. Each worker processes
// one directory at a time, emitting an event per regular file and
// handing discovered subdirectories back to the dispatcher; events is
// closed once every worker has finished. A cancelled scan makes the
// workers drop the directories still queued rather than stop reading
// jobs, so the range always runs out and the pool always tears down.
func startWalkWorkers(ctx context.Context, workers int,
oneFS bool,
) (chan dirJob, chan []dirJob, chan walkEvent) {
jobs := make(chan dirJob, workQueueDepth)
subdirs := make(chan []dirJob, workers)
events := make(chan walkEvent, workQueueDepth)
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for job := range jobs {
if ctx.Err() != nil {
continue
}
select {
case subdirs <- walkOneDir(ctx, job, oneFS, events):
case <-ctx.Done():
}
}
})
}
go func() {
wg.Wait()
close(events)
}()
return jobs, subdirs, events
}
// dispatchDirs feeds directory jobs to the walk workers, queueing
// newly discovered subdirectories (newest first, which keeps the
// frontier small) until every directory has been processed, then
// closes jobs. jobs is closed on every path out, cancellation
// included: the workers range over it, and a dispatcher that returned
// without closing would strand all of them.
func dispatchDirs(ctx context.Context, initial []dirJob,
jobs chan<- dirJob, subdirs <-chan []dirJob,
) {
go func() {
defer close(jobs)
queue := slices.Clone(initial)
pending := len(queue)
for pending > 0 {
var (
out chan<- dirJob
next dirJob
)
if len(queue) > 0 {
out = jobs
next = queue[len(queue)-1]
}
select {
case out <- next:
queue = queue[:len(queue)-1]
case subs := <-subdirs:
pending += len(subs) - 1
queue = append(queue, subs...)
case <-ctx.Done():
return
}
}
}()
}
// walkOneDir reads one directory, emitting an event per regular-file
// entry and a warning event per unreadable one, and returns the
// subdirectories to descend into.
func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
events chan<- walkEvent,
) []dirJob {
entries, err := os.ReadDir(job.path)
if err != nil {
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", job.path, err),
fail: true,
})
return nil
}
var subs []dirJob
for _, e := range entries {
p := filepath.Join(job.path, e.Name())
if e.IsDir() {
if sub, ok := subdirJob(ctx, p, e, job, oneFS, events); ok {
subs = append(subs, sub)
}
continue
}
// Regular files only: skip symlinks, sockets, FIFOs, and
// device nodes.
if !e.Type().IsRegular() {
continue
}
emitFile(ctx, p, e, events)
}
return subs
}
// emitFile stats one regular directory entry and emits its record.
// The lstat happens here in the walk worker, while the directory's
// metadata is still hot; a path that fails to stat (or stops being a
// regular file) between the directory read and the lstat is warned
// about and skipped.
func emitFile(ctx context.Context, p string, e fs.DirEntry,
events chan<- walkEvent,
) {
info, err := e.Info()
if err != nil {
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("stat %s: %v", p, err),
fail: true,
})
return
}
if !info.Mode().IsRegular() {
return
}
dev, ino := inodeOfInfo(info)
sendEvent(ctx, events, walkEvent{rec: fileRec{
path: p,
size: info.Size(),
mtime: info.ModTime().Unix(),
dev: dev,
ino: ino,
}})
}
// subdirJob applies the descent rules to directory p: never enter
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
// snapshot), and with -x never enter a directory on a different
// filesystem than its operand.
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
parent dirJob, oneFS bool, events chan<- walkEvent,
) (dirJob, bool) {
if e.Name() == ".zfs" {
return dirJob{}, false
}
job := dirJob{path: p, rootDev: parent.rootDev, rootDevOK: parent.rootDevOK}
if !oneFS || !parent.rootDevOK {
return job, true
}
info, err := e.Info()
if err != nil {
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", p, err),
fail: true,
})
return dirJob{}, false
}
if dev, ok := deviceOfInfo(info); ok && dev != parent.rootDev {
return dirJob{}, false
}
return job, true
}
// deviceOfInfo extracts the filesystem device ID from a FileInfo, when
// the platform exposes one.
func deviceOfInfo(fi fs.FileInfo) (uint64, bool) {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return statDev(st), true
}
// inodeOfInfo extracts the (device, inode) pair identifying a file's
// underlying inode; (0, 0) when the platform exposes none (such files
// are never merged as hard links).
func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return 0, 0
}
return statDev(st), st.Ino
}
// hashResult carries one inode run's signature hashes — head, tail, and
// content — (or the error that prevented hashing it) from the hash
// workers to the hash phase.
type hashResult struct {
run []fileRec
head string
tail string
content string
err error
}
// hashPool owns every goroutine of the hash worker pool: the feeder
// that queues the inode runs and the workers that read them. Both block
// on channel sends, so both are cancellable — the pool's context is
// derived from the scan's, and stop cancels it and waits the goroutines
// out. The consumer must call stop on every path out of the phase, not
// just the happy one.
type hashPool struct {
results <-chan hashResult
cancel context.CancelFunc
done <-chan struct{}
}
// startHashPool starts the feeder and the workers over runs. Workers
// hash each run's first path (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,
) *hashPool {
ctx, cancel := context.WithCancel(ctx)
jobs := make(chan []fileRec, workQueueDepth)
results := make(chan hashResult, workQueueDepth)
var wg sync.WaitGroup
wg.Go(func() { feedHashJobs(ctx, runs, jobs) })
for range workers {
wg.Go(func() { hashWorker(ctx, jobs, results) })
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
return &hashPool{results: results, cancel: cancel, done: done}
}
// stop cancels the pool and blocks until every one of its goroutines
// has exited, draining results while it waits: a worker already parked
// on a send observes the cancellation only once a receiver frees it.
// Calling stop more than once is safe.
func (p *hashPool) stop() {
p.cancel()
for {
select {
case <-p.results:
case <-p.done:
return
}
}
}
// feedHashJobs queues every run for the workers, closing jobs on the
// way out — including when the scan is cancelled mid-queue, so that the
// workers' range over jobs always terminates.
func feedHashJobs(ctx context.Context, runs [][]fileRec,
jobs chan<- []fileRec,
) {
defer close(jobs)
for _, run := range runs {
select {
case jobs <- run:
case <-ctx.Done():
return
}
}
}
// hashWorker hashes one inode run at a time until jobs is closed or the
// scan is cancelled. A cancelled worker drops the runs still queued
// instead of stopping its reads of jobs: the range must run out for the
// pool to tear down, and reading a file nobody wants the hash of only
// delays that.
func hashWorker(ctx context.Context, jobs <-chan []fileRec,
results chan<- hashResult,
) {
for run := range jobs {
if ctx.Err() != nil {
continue
}
head, tail, content, err := hashSignature(run[0].path, run[0].size)
select {
case results <- hashResult{
run: run, head: head, tail: tail, content: content, err: err,
}:
case <-ctx.Done():
return
}
}
}
// 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 content hashes that, with the file
// size, form its duplicate signature: the SHA-256 of the first and last
// headTailWindow bytes (the head and tail rungs), and a content hash
// that is the SHA-256 of the whole file below wholeFileMax (the exact
// rung) or of gigabyte-spaced samples at or above it (the sampled,
// deliberately 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) {
if size == 0 {
return emptyHash, emptyHash, emptyHash, nil
}
//nolint:gosec // hashing operator-supplied paths is the tool's purpose
f, err := os.Open(path)
if err != nil {
return "", "", "", err
}
defer func() { _ = f.Close() }()
head, tail, err := hashEnds(f, size)
if err != nil {
return "", "", "", err
}
content, err := hashContent(f, size)
if err != nil {
return "", "", "", err
}
return head, tail, content, nil
}
// 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
// two windows in size; when it is no larger than one window they
// coincide, so the head hash is reused as the tail and only one read is
// issued.
func hashEnds(f *os.File, size int64) (string, string, error) {
n := min(int64(headTailWindow), size)
buf := make([]byte, n)
_, err := f.ReadAt(buf, 0)
if err != nil {
return "", "", err
}
h := sha256.Sum256(buf)
head := hex.EncodeToString(h[:])
if size <= int64(headTailWindow) {
return head, head, nil
}
_, err = f.ReadAt(buf, size-n)
if err != nil {
return "", "", err
}
t := sha256.Sum256(buf)
return head, hex.EncodeToString(t[:]), nil
}
// hashContent returns the content-rung 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) {
if size >= int64(wholeFileMax) {
return hashSamples(f, size)
}
return hashWhole(f, size)
}
// 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
// reads.
func hashWhole(f *os.File, size int64) (string, error) {
h := sha256.New()
_, err := io.Copy(h, io.NewSectionReader(f, 0, size))
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// hashSamples feeds sampleWindow bytes at each gigabyte-aligned offset
// (0, sampleStride, 2*sampleStride, ... while inside the file), in
// order, into one hash, each window truncated at end of file. This is
// the probabilistic large-file rung: two files of equal size agreeing
// on every sample are reported as duplicates without every byte being
// read. Because size is part of the signature, files of different sizes
// never reach this comparison, so the sample boundaries always align.
func hashSamples(f *os.File, size int64) (string, error) {
h := sha256.New()
buf := make([]byte, sampleWindow)
for off := int64(0); off < size; off += int64(sampleStride) {
n := min(int64(sampleWindow), size-off)
_, err := f.ReadAt(buf[:n], off)
if err != nil {
return "", err
}
h.Write(buf[:n])
}
return hex.EncodeToString(h.Sum(nil)), nil
}