aboutsummaryrefslogtreecommitdiff
path: root/internal/dup
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
commit3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch)
treeecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/dup
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/dup')
-rw-r--r--internal/dup/dup.go398
-rw-r--r--internal/dup/dup_test.go331
2 files changed, 729 insertions, 0 deletions
diff --git a/internal/dup/dup.go b/internal/dup/dup.go
new file mode 100644
index 0000000..502ef31
--- /dev/null
+++ b/internal/dup/dup.go
@@ -0,0 +1,398 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package dup answers "is this file a duplicate, and of which original?"
+// cheaply: candidates are grouped by size, and only hashed when sizes
+// collide — a partial hash first, a full hash only on a partial collision.
+package dup
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "krino/internal/scan"
+)
+
+// partialChunk is the size of the head and tail read for the partial hash.
+const partialChunk = 64 << 10 // 64 KiB
+
+// candidate is one file the index knows about: a scanned file, or a file
+// found under one of the extra directories.
+type candidate struct {
+ path string
+ size int64
+ modTime time.Time
+ name string
+ extra bool // found under one of the extra directories, not scanned
+}
+
+// Index finds files with identical content among the scanned files and,
+// optionally, every regular file under some extra directories.
+type Index struct {
+ candidates []candidate
+ bySize map[int64][]int // size -> indexes into candidates
+ scanned map[string]int // scanned file path -> index into candidates
+
+ mu sync.Mutex
+ partial map[string][sha256.Size]byte // memoised partial hash, by path
+ full map[string][sha256.Size]byte // memoised full hash, by path
+
+ candErrs []CandidateError
+ candErrSeen map[string]bool // path already recorded in candErrs
+}
+
+// CandidateError is one candidate (never the file Lookup was asked about)
+// that could not be hashed, so it was skipped rather than comparing it.
+type CandidateError struct {
+ Path string
+ Err error
+}
+
+func (e CandidateError) Error() string { return e.Path + ": " + e.Err.Error() }
+
+// NewIndex stats the extra directories (recursively, symlinks skipped). A
+// missing or unreadable extra directory is reported in the error list and
+// otherwise ignored; an unreadable subdirectory found while walking an
+// otherwise-readable extra directory adds its own error but does not stop
+// the rest of that directory from being indexed. An extra directory that is
+// itself a symlink (A4) is not followed either — filepath.WalkDir Lstats
+// its root, so left unchecked it would be indexed as silently empty — and
+// is reported in the error list instead. Nothing is hashed yet.
+func NewIndex(files []scan.File, extra []string) (*Index, []error) {
+ x := &Index{
+ bySize: make(map[int64][]int),
+ scanned: make(map[string]int, len(files)),
+ partial: make(map[string][sha256.Size]byte),
+ full: make(map[string][sha256.Size]byte),
+ }
+ for _, f := range files {
+ x.scanned[f.Path] = x.add(candidate{path: f.Path, size: f.Size, modTime: f.ModTime, name: f.Name})
+ }
+
+ var errs []error
+ for _, dir := range extra {
+ errs = append(errs, x.addExtraDir(dir)...)
+ }
+ return x, errs
+}
+
+// add appends c to the candidate list and its size group, and returns its
+// index.
+func (x *Index) add(c candidate) int {
+ idx := len(x.candidates)
+ x.candidates = append(x.candidates, c)
+ x.bySize[c.size] = append(x.bySize[c.size], idx)
+ return idx
+}
+
+// addExtraDir walks dir, adding every regular file found (symlinks, both to
+// files and to directories, are skipped: filepath.WalkDir never follows
+// them, so it is enough not to add or descend into one). An error on dir
+// itself (missing, or unreadable) aborts the walk and is the sole error
+// returned; an unreadable subdirectory deeper in the tree adds one error
+// naming it and the walk continues, so files elsewhere in dir are still
+// indexed. A file whose own Info() fails (A3) is dropped from the index the
+// same way: silently if it has simply vanished (fs.ErrNotExist), otherwise
+// with its own error added to errs.
+func (x *Index) addExtraDir(dir string) []error {
+ var errs []error
+ err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ if path == dir {
+ return err
+ }
+ errs = append(errs, fmt.Errorf("%s: %w", path, err))
+ return nil
+ }
+ if path == dir && d.Type()&fs.ModeSymlink != 0 {
+ // filepath.WalkDir Lstats its root: a symlinked extra directory
+ // (A4) would otherwise be silently treated as empty rather than
+ // followed, with no sign anything was wrong.
+ errs = append(errs, fmt.Errorf("%s is a symlink; not followed", path))
+ return nil
+ }
+ if d.Type()&fs.ModeSymlink != 0 || d.IsDir() || !d.Type().IsRegular() {
+ return nil
+ }
+ x.addEntry(path, d, &errs)
+ return nil
+ })
+ if err != nil {
+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
+ }
+ return errs
+}
+
+// addEntry indexes one regular-file entry found while walking an extra
+// directory: A3, a file whose own Info() fails is dropped from the index —
+// silently if it has simply vanished (fs.ErrNotExist), otherwise with its
+// own error appended to errs. Factored out of addExtraDir's WalkDir
+// callback so a test can drive it directly with a fabricated fs.DirEntry,
+// since filepath.WalkDir gives no way to inject a canned Info() failure on
+// a real walk.
+func (x *Index) addEntry(path string, d fs.DirEntry, errs *[]error) {
+ info, err := d.Info()
+ if err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ *errs = append(*errs, fmt.Errorf("%s: %w", path, err))
+ }
+ return
+ }
+ x.add(candidate{path: path, size: info.Size(), modTime: info.ModTime(), name: d.Name(), extra: true})
+}
+
+// Lookup reports whether path (one of the scanned files) duplicates another
+// file, and which file is the original. Safe for concurrent use.
+func (x *Index) Lookup(path string) (original string, dup bool, err error) {
+ idx, ok := x.scanned[path]
+ if !ok {
+ return "", false, fmt.Errorf("dup: %s was not scanned", path)
+ }
+ c := x.candidates[idx]
+ if c.size == 0 {
+ // Empty files are never duplicates, and are never read.
+ return path, false, nil
+ }
+ group := x.bySize[c.size]
+ if len(group) < 2 {
+ // Alone in its size group: not a duplicate, never read.
+ return path, false, nil
+ }
+
+ identical, err := x.identicalTo(idx, group)
+ if err != nil {
+ return "", false, err
+ }
+ if len(identical) < 2 {
+ return path, false, nil
+ }
+ orig := x.candidates[x.original(identical)].path
+ return orig, orig != path, nil
+}
+
+// identicalTo returns the indexes in group (which all share idx's size,
+// idx included) whose content matches candidates[idx]: same partial hash,
+// then, only for those that collide, the same full hash. idx is the file
+// Lookup was asked about; a failure hashing it propagates, since Lookup can
+// answer nothing without it. A failure hashing any other candidate in group
+// only removes that candidate from consideration: a vanished candidate
+// (errors.Is fs.ErrNotExist) is dropped silently, any other failure is
+// recorded on the Index (see recordCandidateError) so the caller can warn
+// about it once matching is done.
+func (x *Index) identicalTo(idx int, group []int) ([]int, error) {
+ idxPartial, err := x.partialHash(x.candidates[idx].path)
+ if err != nil {
+ return nil, err
+ }
+
+ same := []int{idx}
+ var idxFull [sha256.Size]byte
+ haveIdxFull := false
+ for _, j := range group {
+ if j == idx {
+ continue
+ }
+ jPartial, err := x.partialHash(x.candidates[j].path)
+ if err != nil {
+ x.recordCandidateError(x.candidates[j].path, err)
+ continue
+ }
+ if jPartial != idxPartial {
+ continue
+ }
+ if !haveIdxFull {
+ idxFull, err = x.fullHash(x.candidates[idx].path)
+ if err != nil {
+ return nil, err
+ }
+ haveIdxFull = true
+ }
+ jFull, err := x.fullHash(x.candidates[j].path)
+ if err != nil {
+ x.recordCandidateError(x.candidates[j].path, err)
+ continue
+ }
+ if jFull == idxFull {
+ same = append(same, j)
+ }
+ }
+ return same, nil
+}
+
+// recordCandidateError records that path (never the file Lookup was asked
+// about) could not be hashed and so was skipped, unless it simply vanished
+// (fs.ErrNotExist), which is not worth reporting, or was already recorded.
+// Safe for concurrent use.
+func (x *Index) recordCandidateError(path string, err error) {
+ if errors.Is(err, fs.ErrNotExist) {
+ return
+ }
+ x.mu.Lock()
+ defer x.mu.Unlock()
+ if x.candErrSeen == nil {
+ x.candErrSeen = make(map[string]bool)
+ }
+ if x.candErrSeen[path] {
+ return
+ }
+ x.candErrSeen[path] = true
+ x.candErrs = append(x.candErrs, CandidateError{Path: path, Err: err})
+}
+
+// Errors returns every candidate-hashing failure recorded so far,
+// deduplicated by path, in first-recorded order. Safe for concurrent use.
+func (x *Index) Errors() []CandidateError {
+ x.mu.Lock()
+ defer x.mu.Unlock()
+ return append([]CandidateError(nil), x.candErrs...)
+}
+
+// original picks, among a set of identical candidates, the index that is
+// the original. Spec §5.5: one flat comparison, in order — a file under an
+// extra directory beats one that is not; then the oldest by ModTime; then
+// the shortest base name; then the base name that sorts first; then (the
+// final, always-deterministic tie-break) the full path that sorts first.
+// This one chain applies to every pair alike; extra-vs-extra candidates
+// are not a special case broken by path alone.
+func (x *Index) original(idxs []int) int {
+ best := idxs[0]
+ for _, j := range idxs[1:] {
+ if x.preferred(j, best) {
+ best = j
+ }
+ }
+ return best
+}
+
+// preferred reports whether candidate a should be chosen as the original
+// over candidate b.
+func (x *Index) preferred(a, b int) bool {
+ ca, cb := x.candidates[a], x.candidates[b]
+ if ca.extra != cb.extra {
+ return ca.extra
+ }
+ if !ca.modTime.Equal(cb.modTime) {
+ return ca.modTime.Before(cb.modTime)
+ }
+ if len(ca.name) != len(cb.name) {
+ return len(ca.name) < len(cb.name)
+ }
+ if ca.name != cb.name {
+ return ca.name < cb.name
+ }
+ return ca.path < cb.path
+}
+
+// partialHash returns the memoised partial hash for path, computing and
+// storing it on first use. The hash is computed outside the lock; only the
+// memo access is guarded.
+func (x *Index) partialHash(path string) ([sha256.Size]byte, error) {
+ x.mu.Lock()
+ h, ok := x.partial[path]
+ x.mu.Unlock()
+ if ok {
+ return h, nil
+ }
+ h, err := computePartialHash(path)
+ if err != nil {
+ return h, err
+ }
+ x.mu.Lock()
+ x.partial[path] = h
+ x.mu.Unlock()
+ return h, nil
+}
+
+// fullHash returns the memoised full-file hash for path, computing and
+// storing it on first use.
+func (x *Index) fullHash(path string) ([sha256.Size]byte, error) {
+ x.mu.Lock()
+ h, ok := x.full[path]
+ x.mu.Unlock()
+ if ok {
+ return h, nil
+ }
+ h, err := computeFullHash(path)
+ if err != nil {
+ return h, err
+ }
+ x.mu.Lock()
+ x.full[path] = h
+ x.mu.Unlock()
+ return h, nil
+}
+
+// computePartialHash hashes the file's size, its first 64 KiB and its last
+// 64 KiB (the two overlap, or repeat the whole file, when it is smaller
+// than 64 KiB).
+func computePartialHash(path string) ([sha256.Size]byte, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ size := info.Size()
+
+ h := sha256.New()
+ var sizeBuf [8]byte
+ binary.BigEndian.PutUint64(sizeBuf[:], uint64(size))
+ h.Write(sizeBuf[:])
+
+ head, err := readAt(f, 0)
+ if err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ h.Write(head)
+
+ tailOff := size - partialChunk
+ if tailOff < 0 {
+ tailOff = 0
+ }
+ tail, err := readAt(f, tailOff)
+ if err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ h.Write(tail)
+
+ var out [sha256.Size]byte
+ copy(out[:], h.Sum(nil))
+ return out, nil
+}
+
+// readAt reads up to partialChunk bytes starting at off, without disturbing
+// f's current offset.
+func readAt(f *os.File, off int64) ([]byte, error) {
+ buf := make([]byte, partialChunk)
+ n, err := f.ReadAt(buf, off)
+ if err != nil && err != io.EOF {
+ return nil, err
+ }
+ return buf[:n], nil
+}
+
+// computeFullHash hashes the whole file.
+func computeFullHash(path string) ([sha256.Size]byte, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ defer f.Close()
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ var out [sha256.Size]byte
+ copy(out[:], h.Sum(nil))
+ return out, nil
+}
diff --git a/internal/dup/dup_test.go b/internal/dup/dup_test.go
new file mode 100644
index 0000000..fb4e64d
--- /dev/null
+++ b/internal/dup/dup_test.go
@@ -0,0 +1,331 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package dup
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/scan"
+)
+
+var base = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+
+// put writes content at dir/name with an mtime `age` hours after base.
+func put(t *testing.T, dir, name string, content []byte, hours int) scan.File {
+ t.Helper()
+ p := filepath.Join(dir, name)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, content, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ mt := base.Add(time.Duration(hours) * time.Hour)
+ if err := os.Chtimes(p, mt, mt); err != nil {
+ t.Fatal(err)
+ }
+ return scan.File{Path: p, Rel: name, Name: filepath.Base(name), Size: int64(len(content)), ModTime: mt}
+}
+
+func lookup(t *testing.T, x *Index, f scan.File) (string, bool) {
+ t.Helper()
+ orig, dup, err := x.Lookup(f.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return orig, dup
+}
+
+func TestDuplicatesInScan(t *testing.T) {
+ d := t.TempDir()
+ a := put(t, d, "report.pdf", []byte("same content"), 1)
+ b := put(t, d, "report (1).pdf", []byte("same content"), 5)
+ c := put(t, d, "other.pdf", []byte("diff content"), 0) // same size, different bytes
+ e1 := put(t, d, "empty1", nil, 0)
+ e2 := put(t, d, "empty2", nil, 1)
+ x, errs := NewIndex([]scan.File{a, b, c, e1, e2}, nil)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ if orig, dup := lookup(t, x, b); !dup || orig != a.Path {
+ t.Errorf("copy: dup=%v orig=%s, want dup of %s", dup, orig, a.Path)
+ }
+ if _, dup := lookup(t, x, a); dup {
+ t.Error("the original reported as a duplicate")
+ }
+ if _, dup := lookup(t, x, c); dup {
+ t.Error("same size, different content reported as a duplicate")
+ }
+ if _, dup := lookup(t, x, e2); dup {
+ t.Error("empty files reported as duplicates")
+ }
+}
+
+func TestExtraDirHoldsTheOriginal(t *testing.T) {
+ scanned, filed := t.TempDir(), t.TempDir()
+ dl := put(t, scanned, "invoice.pdf", []byte("invoice 42"), 0) // older than the filed copy
+ put(t, filed, "2026/invoice-42.pdf", []byte("invoice 42"), 9)
+ x, errs := NewIndex([]scan.File{dl}, []string{filed, filepath.Join(filed, "missing")})
+ if len(errs) != 1 {
+ t.Errorf("want one error for the missing extra dir, got %v", errs)
+ }
+ if orig, dup := lookup(t, x, dl); !dup || orig != filepath.Join(filed, "2026/invoice-42.pdf") {
+ t.Errorf("dup=%v orig=%s, want the filed copy as original", dup, orig)
+ }
+}
+
+func TestTieBreaks(t *testing.T) {
+ d := t.TempDir()
+ long := put(t, d, "longer-name.txt", []byte("x"), 0)
+ short := put(t, d, "b.txt", []byte("x"), 0)
+ same := put(t, d, "a.txt", []byte("x"), 0)
+ x, _ := NewIndex([]scan.File{long, short, same}, nil)
+ for _, f := range []scan.File{long, short} {
+ if orig, dup := lookup(t, x, f); !dup || orig != same.Path {
+ t.Errorf("%s: dup=%v orig=%s, want %s (shortest name, then path order)", f.Name, dup, orig, same.Path)
+ }
+ }
+}
+
+func TestPartialHashCollisionResolvedByFullHash(t *testing.T) {
+ d := t.TempDir()
+ head, tail := bytes.Repeat([]byte("h"), 70<<10), bytes.Repeat([]byte("t"), 70<<10)
+ one := append(append(append([]byte{}, head...), []byte("MIDDLE-ONE")...), tail...)
+ two := append(append(append([]byte{}, head...), []byte("MIDDLE-TWO")...), tail...)
+ a := put(t, d, "a.bin", one, 0)
+ b := put(t, d, "b.bin", two, 1)
+ x, _ := NewIndex([]scan.File{a, b}, nil)
+ if _, dup := lookup(t, x, b); dup {
+ t.Error("files differing only in the middle reported as duplicates")
+ }
+}
+
+func TestLookupUnknownPath(t *testing.T) {
+ x, _ := NewIndex(nil, nil)
+ if _, _, err := x.Lookup("/nowhere"); err == nil {
+ t.Fatal("no error for a path that was not scanned")
+ }
+}
+
+// TestTieBreakNameBeforePath: same-length names, same mtime, in directories
+// that sort in the opposite order from the names — the base name decides,
+// not the full path (spec §5.5's flat chain, not a path-only fallback).
+func TestTieBreakNameBeforePath(t *testing.T) {
+ d := t.TempDir()
+ catInZzz := put(t, d, "zzz/cat.txt", []byte("x"), 0)
+ dogInAaa := put(t, d, "aaa/dog.txt", []byte("x"), 0)
+ x, _ := NewIndex([]scan.File{catInZzz, dogInAaa}, nil)
+ for _, f := range []scan.File{catInZzz, dogInAaa} {
+ if orig, dup := lookup(t, x, f); orig != catInZzz.Path || dup != (f.Path != catInZzz.Path) {
+ t.Errorf("%s: orig=%s dup=%v, want %s (name sorts before path)", f.Rel, orig, dup, catInZzz.Path)
+ }
+ }
+}
+
+// TestTieBreakExtraVsExtra: two extra directories hold identical copies;
+// the one under the lexically later directory is older and must still win
+// on ModTime — extra-vs-extra ties are not resolved by path alone.
+func TestTieBreakExtraVsExtra(t *testing.T) {
+ common := t.TempDir()
+ aaa, zzz := filepath.Join(common, "aaa"), filepath.Join(common, "zzz")
+ newer := put(t, aaa, "copy.txt", []byte("invoice 42"), 5) // lexically first, newer
+ older := put(t, zzz, "copy.txt", []byte("invoice 42"), 0) // lexically last, older
+ scanned := t.TempDir()
+ dl := put(t, scanned, "download.txt", []byte("invoice 42"), 3)
+ x, errs := NewIndex([]scan.File{dl}, []string{aaa, zzz})
+ if len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if orig, dup := lookup(t, x, dl); !dup || orig != older.Path {
+ t.Errorf("dup=%v orig=%s, want %s (older extra copy, despite sorting after %s)", dup, orig, older.Path, newer.Path)
+ }
+}
+
+// TestUnreadableSubdirReported: an unreadable subdirectory under an extra
+// directory is reported as its own error, and the rest of that extra
+// directory is still indexed.
+func TestUnreadableSubdirReported(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permissions are not enforced running as root")
+ }
+ filed := t.TempDir()
+ blocked := filepath.Join(filed, "blocked")
+ if err := os.MkdirAll(blocked, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ put(t, blocked, "secret.pdf", []byte("secret 42"), 0)
+ put(t, filed, "visible.pdf", []byte("visible content"), 0)
+ if err := os.Chmod(blocked, 0o000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := os.Chmod(blocked, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ scanned := t.TempDir()
+ dl := put(t, scanned, "visible.pdf", []byte("visible content"), 1)
+
+ x, errs := NewIndex([]scan.File{dl}, []string{filed})
+ if len(errs) != 1 || !strings.Contains(errs[0].Error(), blocked) {
+ t.Fatalf("want one error naming %s, got %v", blocked, errs)
+ }
+ if orig, dup := lookup(t, x, dl); !dup || orig != filepath.Join(filed, "visible.pdf") {
+ t.Errorf("dup=%v orig=%s, want the filed copy (visible.pdf still indexed despite the unreadable sibling)", dup, orig)
+ }
+}
+
+// TestUnreadableCandidateSkipped: three files share a size; one of them
+// (not the subject of either Lookup call) is unreadable. A1: the other two
+// are still reported as a duplicate pair, and the unreadable one is
+// recorded exactly once as a candidate error, not returned as a Lookup
+// error.
+func TestUnreadableCandidateSkipped(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permissions are not enforced running as root")
+ }
+ d := t.TempDir()
+ a := put(t, d, "a.bin", []byte("same content"), 0)
+ b := put(t, d, "b.bin", []byte("same content"), 1)
+ c := put(t, d, "c.bin", []byte("diff content"), 2) // same size, different bytes
+ if err := os.Chmod(c.Path, 0o000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(c.Path, 0o644) })
+
+ x, errs := NewIndex([]scan.File{a, b, c}, nil)
+ if len(errs) != 0 {
+ t.Fatalf("NewIndex errors: %v", errs)
+ }
+ if orig, dup := lookup(t, x, b); !dup || orig != a.Path {
+ t.Errorf("a/b duplicate pair broken by unreadable sibling: dup=%v orig=%s", dup, orig)
+ }
+ if orig, dup := lookup(t, x, a); dup {
+ t.Errorf("a reported as a duplicate: orig=%s", orig)
+ }
+ cerrs := x.Errors()
+ if len(cerrs) != 1 {
+ t.Fatalf("got %d candidate errors, want 1: %v", len(cerrs), cerrs)
+ }
+ if cerrs[0].Path != c.Path {
+ t.Errorf("candidate error names %q, want %q", cerrs[0].Path, c.Path)
+ }
+}
+
+// TestVanishedCandidateSkippedSilently: a candidate that vanishes between
+// being indexed and being hashed is dropped with no error recorded at all.
+func TestVanishedCandidateSkippedSilently(t *testing.T) {
+ d := t.TempDir()
+ a := put(t, d, "a.bin", []byte("same content"), 0)
+ b := put(t, d, "b.bin", []byte("same content"), 1)
+ c := put(t, d, "c.bin", []byte("diff content"), 2)
+ x, errs := NewIndex([]scan.File{a, b, c}, nil)
+ if len(errs) != 0 {
+ t.Fatalf("NewIndex errors: %v", errs)
+ }
+ if err := os.Remove(c.Path); err != nil {
+ t.Fatal(err)
+ }
+ if orig, dup := lookup(t, x, b); !dup || orig != a.Path {
+ t.Errorf("a/b duplicate pair broken by vanished sibling: dup=%v orig=%s", dup, orig)
+ }
+ if got := x.Errors(); len(got) != 0 {
+ t.Errorf("vanished candidate recorded as an error: %v", got)
+ }
+}
+
+// TestLookupFailsWhenSubjectUnreadable: Lookup still fails outright when
+// the file it was asked about (not some other candidate) cannot be read.
+func TestLookupFailsWhenSubjectUnreadable(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permissions are not enforced running as root")
+ }
+ d := t.TempDir()
+ a := put(t, d, "a.bin", []byte("same content"), 0)
+ b := put(t, d, "b.bin", []byte("same content"), 1)
+ if err := os.Chmod(a.Path, 0o000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(a.Path, 0o644) })
+ x, _ := NewIndex([]scan.File{a, b}, nil)
+ if _, _, err := x.Lookup(a.Path); err == nil {
+ t.Fatal("no error looking up an unreadable subject")
+ }
+}
+
+// fakeDirEntry is an fs.DirEntry whose Info() returns a canned result, for
+// exercising addEntry's Info()-failure handling directly (A3) — a real
+// filepath.WalkDir gives no hook to inject a stat failure deterministically
+// and without root.
+type fakeDirEntry struct {
+ name string
+ info fs.FileInfo
+ infoErr error
+}
+
+func (f fakeDirEntry) Name() string { return f.name }
+func (f fakeDirEntry) IsDir() bool { return false }
+func (f fakeDirEntry) Type() fs.FileMode { return 0 }
+func (f fakeDirEntry) Info() (fs.FileInfo, error) { return f.info, f.infoErr }
+
+// TestAddEntryInfoFailure: A3. A vanished entry's Info() failure
+// (fs.ErrNotExist) is dropped with no error recorded; any other Info()
+// failure is dropped too, but recorded in errs, naming the entry.
+func TestAddEntryInfoFailure(t *testing.T) {
+ x := &Index{bySize: make(map[int64][]int), scanned: make(map[string]int)}
+ var errs []error
+
+ x.addEntry("/extra/vanished.txt", fakeDirEntry{
+ name: "vanished.txt", infoErr: fmt.Errorf("stat vanished.txt: %w", fs.ErrNotExist),
+ }, &errs)
+ if len(errs) != 0 {
+ t.Fatalf("vanished entry recorded an error: %v", errs)
+ }
+ if len(x.candidates) != 0 {
+ t.Fatalf("vanished entry was indexed: %v", x.candidates)
+ }
+
+ x.addEntry("/extra/denied.txt", fakeDirEntry{
+ name: "denied.txt", infoErr: errors.New("permission denied"),
+ }, &errs)
+ if len(errs) != 1 || !strings.Contains(errs[0].Error(), "/extra/denied.txt") {
+ t.Fatalf("want one error naming /extra/denied.txt, got %v", errs)
+ }
+ if len(x.candidates) != 0 {
+ t.Fatalf("denied entry was indexed: %v", x.candidates)
+ }
+}
+
+// TestExtraDirSymlinkNotFollowed: A4. An extra directory that is itself a
+// symlink to a real directory is not silently treated as empty:
+// filepath.WalkDir Lstats its root, so without a check for this the walk
+// would report no error and index nothing, misleading the user into
+// thinking an archive was consulted when it never was.
+func TestExtraDirSymlinkNotFollowed(t *testing.T) {
+ real := t.TempDir()
+ put(t, real, "invoice.pdf", []byte("invoice 42"), 0)
+ link := filepath.Join(t.TempDir(), "link")
+ if err := os.Symlink(real, link); err != nil {
+ t.Fatal(err)
+ }
+
+ scanned := t.TempDir()
+ dl := put(t, scanned, "download.pdf", []byte("invoice 42"), 1)
+
+ x, errs := NewIndex([]scan.File{dl}, []string{link})
+ if len(errs) != 1 || !strings.Contains(errs[0].Error(), link) || !strings.Contains(errs[0].Error(), "symlink") {
+ t.Fatalf("want one error naming %s as a symlink, got %v", link, errs)
+ }
+ if orig, dup := lookup(t, x, dl); dup {
+ t.Errorf("symlinked extra dir was indexed despite the error: dup=%v orig=%s", dup, orig)
+ }
+}