diff options
Diffstat (limited to 'internal/dup')
| -rw-r--r-- | internal/dup/dup.go | 398 | ||||
| -rw-r--r-- | internal/dup/dup_test.go | 331 |
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) + } +} |
