aboutsummaryrefslogtreecommitdiff
path: root/internal/dup/dup_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/dup/dup_test.go')
-rw-r--r--internal/dup/dup_test.go331
1 files changed, 331 insertions, 0 deletions
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)
+ }
+}