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