check / check (push) Waiting to run
The head, tail and content columns of the files table are BLOB NOT NULL and hold the raw 32-byte SHA-256, half the size of the hex text, in the table and in the files_signature index. The hash phase, the records, report and trees carry each hash as a [sha256.Size]byte and compare it with ==; nothing is hex-encoded. Reading a record whose hash column is not 32 bytes is an error naming its path. In the tests, testHash is a stand-in hash: a string's bytes padded with zeros, so fixtures keep the sort order their strings give. Judgement call: a hash of the wrong length is refused on reading, not copied silently. Model: opus-5-5
2078 lines
54 KiB
Go
2078 lines
54 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// writeFile creates a file with the given content and returns its path.
|
|
func writeFile(t *testing.T, dir, name string, data []byte) string {
|
|
t.Helper()
|
|
|
|
p := filepath.Join(dir, name)
|
|
|
|
err := os.MkdirAll(filepath.Dir(p), 0o750)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.WriteFile(p, data, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return p
|
|
}
|
|
|
|
// pattern returns n bytes of deterministic content seeded by tag.
|
|
func pattern(tag byte, n int) []byte {
|
|
data := make([]byte, n)
|
|
for i := range data {
|
|
data[i] = tag ^ byte(i)
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
// sig returns a file's full signature (head, tail, content), failing the
|
|
// test on any error.
|
|
func sig(t *testing.T, path string, size int64) (
|
|
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte,
|
|
) {
|
|
t.Helper()
|
|
|
|
head, tail, content, err := hashSignature(path, size)
|
|
if err != nil {
|
|
t.Fatalf("hashSignature %s: %v", path, err)
|
|
}
|
|
|
|
return head, tail, content
|
|
}
|
|
|
|
// TestHashSignature checks the three hashes of a file under
|
|
// wholeFileMax: head and tail are the SHA-256 of its first and last
|
|
// headTailWindow bytes, and content that of the whole file. In a file
|
|
// no larger than headTailWindow each window is the whole file, so all
|
|
// three are the whole-file hash.
|
|
func TestHashSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
cases := []struct {
|
|
name string
|
|
data []byte
|
|
}{
|
|
{"one-byte", []byte("x")},
|
|
{"one-window", pattern(1, headTailWindow)},
|
|
{"several-windows", pattern(2, 3*headTailWindow+1)},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p := writeFile(t, dir, c.name, c.data)
|
|
|
|
head, tail, content := sig(t, p, int64(len(c.data)))
|
|
|
|
window := min(len(c.data), headTailWindow)
|
|
wantHead := sha256.Sum256(c.data[:window])
|
|
wantTail := sha256.Sum256(c.data[len(c.data)-window:])
|
|
wantContent := sha256.Sum256(c.data)
|
|
|
|
if head != wantHead || tail != wantTail || content != wantContent {
|
|
t.Errorf("head=%x tail=%x content=%x, want %x %x %x",
|
|
head, tail, content, wantHead, wantTail, wantContent)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHashSignatureEnds checks which hashes a one-byte difference
|
|
// changes in a file under wholeFileMax. Sparse files keep the fixtures
|
|
// cheap: a difference in the first window changes head but not tail,
|
|
// one in the last window changes tail but not head, and one between the
|
|
// windows changes neither but does change the whole-file content hash.
|
|
func TestHashSignatureEnds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
// Room for a byte between the two windows.
|
|
const size = int64(4 * headTailWindow)
|
|
|
|
base := sparseFile(t, dir, "ends-base", size)
|
|
headDiff := sparseFile(t, dir, "ends-head", size)
|
|
tailDiff := sparseFile(t, dir, "ends-tail", size)
|
|
midDiff := sparseFile(t, dir, "ends-mid", size)
|
|
|
|
pokeAt(t, headDiff, 0, []byte{1})
|
|
pokeAt(t, tailDiff, size-1, []byte{1})
|
|
pokeAt(t, midDiff, size/2, []byte{1})
|
|
|
|
bHead, bTail, bContent := sig(t, base, size)
|
|
|
|
h, tl, _ := sig(t, headDiff, size)
|
|
if h == bHead {
|
|
t.Error("a byte in the first window did not change head")
|
|
}
|
|
|
|
if tl != bTail {
|
|
t.Error("a byte in the first window changed tail")
|
|
}
|
|
|
|
h, tl, _ = sig(t, tailDiff, size)
|
|
if tl == bTail {
|
|
t.Error("a byte in the last window did not change tail")
|
|
}
|
|
|
|
if h != bHead {
|
|
t.Error("a byte in the last window changed head")
|
|
}
|
|
|
|
h, tl, c := sig(t, midDiff, size)
|
|
if h != bHead || tl != bTail {
|
|
t.Error("a byte between the windows changed an end hash")
|
|
}
|
|
|
|
if c == bContent {
|
|
t.Error("the whole-file content hash ignored a byte between the " +
|
|
"windows")
|
|
}
|
|
}
|
|
|
|
func TestHashSignatureErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
var zero [sha256.Size]byte
|
|
|
|
// A missing file: an error, and every hash left zero.
|
|
head, tail, content, err := hashSignature(filepath.Join(dir, "missing"), 1)
|
|
if err == nil {
|
|
t.Error("no error for a missing file")
|
|
}
|
|
|
|
if head != zero || tail != zero || content != zero {
|
|
t.Errorf("missing file returned hashes: %x %x %x", head, tail, content)
|
|
}
|
|
|
|
// A zero-length file has constant hashes and is never opened: even
|
|
// a missing path succeeds.
|
|
empty := sha256.Sum256(nil)
|
|
|
|
head, tail, content, err = hashSignature(filepath.Join(dir, "missing"), 0)
|
|
if err != nil || head != empty || tail != empty || content != empty {
|
|
t.Errorf("empty: head=%x tail=%x content=%x err=%v, "+
|
|
"want constant hashes", head, tail, content, err)
|
|
}
|
|
|
|
// A file that shrank between the stat and hash passes: reading at
|
|
// the stat-reported size must fail rather than emit wrong hashes.
|
|
p := writeFile(t, dir, "shrunk", []byte("tiny"))
|
|
|
|
head, tail, content, err = hashSignature(p, int64(2*headTailWindow))
|
|
if err == nil {
|
|
t.Error("no error when the stat size exceeds the file size")
|
|
}
|
|
|
|
if head != zero || tail != zero || content != zero {
|
|
t.Errorf("shrunk file returned hashes: %x %x %x", head, tail, content)
|
|
}
|
|
}
|
|
|
|
// sparseFile creates a file that is logically size bytes long without
|
|
// allocating blocks for the hole, so multi-gigabyte cases stay cheap.
|
|
func sparseFile(t *testing.T, dir, name string, size int64) string {
|
|
t.Helper()
|
|
|
|
p := filepath.Join(dir, name)
|
|
|
|
f, err := os.Create(p) //nolint:gosec // test-controlled path
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = f.Truncate(size)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = f.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return p
|
|
}
|
|
|
|
// pokeAt writes data into an existing file at off, leaving the rest of
|
|
// the file (a sparse hole) untouched.
|
|
func pokeAt(t *testing.T, path string, off int64, data []byte) {
|
|
t.Helper()
|
|
|
|
f, err := os.OpenFile(path, os.O_WRONLY, 0o600) //nolint:gosec // test path
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = f.WriteAt(data, off)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = f.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestContentHashBoundary checks the 100 MiB boundary between the two
|
|
// content hashes through a scan and report. The files of each pair
|
|
// differ in one byte that neither end window nor any sample covers.
|
|
// Just below the boundary the whole file is hashed, so that pair is not
|
|
// reported. At the boundary only the samples are hashed, so that pair
|
|
// is reported as duplicates.
|
|
func TestContentHashBoundary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
|
|
// A byte that lands outside the single [0, sampleWindow) sample a
|
|
// sub-gigabyte file has, and outside both end windows.
|
|
const off = 10 * 1024 * 1024
|
|
|
|
under := sparseFile(t, dir, "under-a", wholeFileMax-1)
|
|
underPoked := sparseFile(t, dir, "under-b", wholeFileMax-1)
|
|
at := sparseFile(t, dir, "at-a", wholeFileMax)
|
|
atPoked := sparseFile(t, dir, "at-b", wholeFileMax)
|
|
|
|
pokeAt(t, underPoked, off, []byte{1})
|
|
pokeAt(t, atPoked, off, []byte{1})
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
want := []string{at, atPoked}
|
|
|
|
groups := dupeGroups(t, db)
|
|
if len(groups) != 1 || !slices.Equal(groups[0].paths, want) {
|
|
t.Fatalf("groups = %+v, want only the pair %q, not %q", groups, want,
|
|
[]string{under, underPoked})
|
|
}
|
|
}
|
|
|
|
// TestContentHashMultiGigabyte exercises the sampled content hash
|
|
// across several gigabytes using sparse files: a difference inside the
|
|
// third sample window (at offset 2*sampleStride) changes the hash,
|
|
// while a difference in the gap after it does not.
|
|
func TestContentHashMultiGigabyte(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
// Three sample windows (offsets 0, 1 GiB, 2 GiB) plus a trailing gap
|
|
// that no sample covers.
|
|
size := int64(2*sampleStride + 2*sampleWindow)
|
|
thirdSample := int64(2 * sampleStride)
|
|
gap := thirdSample + int64(sampleWindow)
|
|
|
|
base := sparseFile(t, dir, "g-base", size)
|
|
inSample := sparseFile(t, dir, "g-insample", size)
|
|
inGap := sparseFile(t, dir, "g-ingap", size)
|
|
|
|
pokeAt(t, inSample, thirdSample, []byte{1})
|
|
pokeAt(t, inGap, gap, []byte{1})
|
|
|
|
_, _, cBase := sig(t, base, size)
|
|
_, _, cInSample := sig(t, inSample, size)
|
|
_, _, cInGap := sig(t, inGap, size)
|
|
|
|
if cInSample == cBase {
|
|
t.Error("sample at 2 GiB was not read: difference there was invisible")
|
|
}
|
|
|
|
if cInGap != cBase {
|
|
t.Error("a byte in an unsampled gap changed the content hash")
|
|
}
|
|
}
|
|
|
|
// collectWalk runs a walk over roots and returns the emitted records
|
|
// and the number of warning events.
|
|
func collectWalk(t *testing.T, roots []string, oneFS bool,
|
|
workers int,
|
|
) ([]fileRec, int) {
|
|
t.Helper()
|
|
|
|
var (
|
|
recs []fileRec
|
|
errs int
|
|
)
|
|
|
|
for ev := range startWalk(t.Context(), roots, oneFS, walkRules{},
|
|
workers) {
|
|
if ev.fail {
|
|
errs++
|
|
|
|
continue
|
|
}
|
|
|
|
recs = append(recs, ev.rec)
|
|
}
|
|
|
|
return recs, errs
|
|
}
|
|
|
|
// walkedPaths returns the sorted paths of the walked records.
|
|
func walkedPaths(recs []fileRec) []string {
|
|
paths := make([]string, 0, len(recs))
|
|
for _, r := range recs {
|
|
paths = append(paths, r.path)
|
|
}
|
|
|
|
slices.Sort(paths)
|
|
|
|
return paths
|
|
}
|
|
|
|
func TestWalk(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
want := []string{
|
|
writeFile(t, dir, "a.txt", []byte("a")),
|
|
writeFile(t, dir, "sub/b.txt", []byte("bb")),
|
|
writeFile(t, dir, "sub/deeper/c.txt", []byte("ccc")),
|
|
}
|
|
|
|
slices.Sort(want)
|
|
|
|
// Files under a .zfs directory must never be walked.
|
|
writeFile(t, dir, ".zfs/snapshot/hourly/a.txt", []byte("a"))
|
|
|
|
// Symlinks are skipped, not followed.
|
|
err := os.Symlink(want[0], filepath.Join(dir, "link"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
recs, errs := collectWalk(t, []string{dir}, false, 4)
|
|
if errs != 0 {
|
|
t.Fatalf("errs = %d, want 0", errs)
|
|
}
|
|
|
|
if got := walkedPaths(recs); !slices.Equal(got, want) {
|
|
t.Fatalf("paths = %q, want %q", got, want)
|
|
}
|
|
|
|
// The walk stats each file as it is discovered: every record must
|
|
// carry the real size and a plausible mtime.
|
|
for _, r := range recs {
|
|
if r.size < 1 || r.size > 3 {
|
|
t.Errorf("%s: size = %d, want 1..3", r.path, r.size)
|
|
}
|
|
|
|
if r.mtime.Unix() <= 0 {
|
|
t.Errorf("%s: mtime = %v, want after 1970", r.path, r.mtime)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWalkDeepAndWide(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Exercise the dispatcher with more directories than workers and
|
|
// with nesting deeper than the worker count.
|
|
dir := t.TempDir()
|
|
deep := "deep" + strings.Repeat("/d", 30)
|
|
|
|
want := make([]string, 0, 41)
|
|
want = append(want, writeFile(t, dir, deep+"/f", []byte("x")))
|
|
|
|
for i := range 40 {
|
|
want = append(want, writeFile(t, dir,
|
|
fmt.Sprintf("wide/%02d/f", i), []byte("y")))
|
|
}
|
|
|
|
slices.Sort(want)
|
|
|
|
recs, errs := collectWalk(t, []string{dir}, false, 8)
|
|
if errs != 0 {
|
|
t.Fatalf("errs = %d, want 0", errs)
|
|
}
|
|
|
|
if got := walkedPaths(recs); !slices.Equal(got, want) {
|
|
t.Fatalf("walked %d paths, want %d", len(got), len(want))
|
|
}
|
|
}
|
|
|
|
func TestWalkMultipleRoots(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootA := t.TempDir()
|
|
rootB := t.TempDir()
|
|
want := []string{
|
|
writeFile(t, rootA, "a1", []byte("1")),
|
|
writeFile(t, rootA, "sub/a2", []byte("2")),
|
|
writeFile(t, rootB, "b1", []byte("3")),
|
|
}
|
|
|
|
slices.Sort(want)
|
|
|
|
// Operands are enumerated concurrently by the shared pool; order
|
|
// is unspecified.
|
|
recs, errs := collectWalk(t, []string{rootA, rootB}, false, 4)
|
|
if errs != 0 {
|
|
t.Fatalf("errs = %d, want 0", errs)
|
|
}
|
|
|
|
if got := walkedPaths(recs); !slices.Equal(got, want) {
|
|
t.Fatalf("paths = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestWalkFileAndSymlinkOperands(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
f := writeFile(t, dir, "plain", []byte("data"))
|
|
|
|
link := filepath.Join(dir, "link")
|
|
|
|
err := os.Symlink(f, link)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A regular-file operand is emitted as itself, statted.
|
|
recs, errs := collectWalk(t, []string{f}, false, 2)
|
|
if errs != 0 || len(recs) != 1 || recs[0].path != f || recs[0].size != 4 {
|
|
t.Fatalf("file operand: recs = %+v, errs = %d", recs, errs)
|
|
}
|
|
|
|
// A symlink operand that reaches the walk (it became one after
|
|
// walkableRoots checked it) is not followed: it yields a warning and
|
|
// no records.
|
|
recs, errs = collectWalk(t, []string{link}, false, 2)
|
|
if errs != 1 || len(recs) != 0 {
|
|
t.Fatalf("symlink operand: recs = %+v, errs = %d", recs, errs)
|
|
}
|
|
}
|
|
|
|
func TestWalkOneFilesystemSameFS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Everything in one filesystem: -x must not skip anything.
|
|
dir := t.TempDir()
|
|
want := []string{
|
|
writeFile(t, dir, "a", []byte("a")),
|
|
writeFile(t, dir, "sub/deep/b", []byte("b")),
|
|
}
|
|
|
|
recs, errs := collectWalk(t, []string{dir}, true, 4)
|
|
if errs != 0 {
|
|
t.Fatalf("errs = %d, want 0", errs)
|
|
}
|
|
|
|
if got := walkedPaths(recs); !slices.Equal(got, want) {
|
|
t.Fatalf("paths = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDeviceOfInfo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
fi1, err := os.Lstat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fi2, err := os.Lstat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev1, ok1 := deviceOfInfo(fi1)
|
|
|
|
dev2, ok2 := deviceOfInfo(fi2)
|
|
if !ok1 || !ok2 || dev1 != dev2 {
|
|
t.Fatalf("deviceOfInfo unstable: %d/%v vs %d/%v",
|
|
dev1, ok1, dev2, ok2)
|
|
}
|
|
}
|
|
|
|
// dirEntryFor returns the fs.DirEntry for name within dir, obtained via
|
|
// the same os.ReadDir the walk uses, so it carries a real Info().
|
|
func dirEntryFor(t *testing.T, dir, name string) fs.DirEntry {
|
|
t.Helper()
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if e.Name() == name {
|
|
return e
|
|
}
|
|
}
|
|
|
|
t.Fatalf("entry %q not found in %q", name, dir)
|
|
|
|
return nil
|
|
}
|
|
|
|
// callSubdirJob runs subdirJob against e under parent, collecting any
|
|
// events it emits (subdirJob emits at most one).
|
|
func callSubdirJob(t *testing.T, p string, e fs.DirEntry,
|
|
parent dirJob, oneFS bool,
|
|
) (dirJob, bool, []walkEvent) {
|
|
t.Helper()
|
|
|
|
events := make(chan walkEvent, 1)
|
|
job, ok := subdirJob(t.Context(), p, e, parent, oneFS, walkRules{},
|
|
events)
|
|
close(events)
|
|
|
|
var evs []walkEvent
|
|
for ev := range events {
|
|
evs = append(evs, ev)
|
|
}
|
|
|
|
return job, ok, evs
|
|
}
|
|
|
|
// TestSubdirJobOneFilesystem exercises the -x boundary check in
|
|
// subdirJob directly, so no second real filesystem is needed. The
|
|
// subdirectory's real device is compared against a fabricated operand
|
|
// device.
|
|
func TestSubdirJobOneFilesystem(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
|
|
sub := filepath.Join(dir, "sub")
|
|
|
|
err := os.Mkdir(sub, 0o750)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := os.Lstat(sub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfInfo(info)
|
|
if !ok {
|
|
t.Skip("platform exposes no device id")
|
|
}
|
|
|
|
// A device the subdirectory is not on, standing in for an operand
|
|
// rooted on a different filesystem.
|
|
otherDev := dev + 1
|
|
e := dirEntryFor(t, dir, "sub")
|
|
|
|
cases := []struct {
|
|
name string
|
|
oneFS bool
|
|
parent dirJob
|
|
wantOK bool
|
|
}{
|
|
// -x on, subdirectory on a different device than its operand:
|
|
// descent is refused.
|
|
{"reject across boundary", true,
|
|
dirJob{rootDev: otherDev, rootDevOK: true}, false},
|
|
// -x on but the operand's own device is unknown: the boundary
|
|
// check is bypassed and descent proceeds.
|
|
{"bypass when root device unknown", true,
|
|
dirJob{rootDev: otherDev, rootDevOK: false}, true},
|
|
// Default (no -x): boundaries are crossed even onto a different
|
|
// device.
|
|
{"cross by default", false,
|
|
dirJob{rootDev: otherDev, rootDevOK: true}, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
job, ok, evs := callSubdirJob(t, sub, e, tc.parent, tc.oneFS)
|
|
if ok != tc.wantOK {
|
|
t.Fatalf("accepted = %v, want %v", ok, tc.wantOK)
|
|
}
|
|
|
|
// A refused descent names the directory, so the scan keeps
|
|
// the records below it.
|
|
var wantEvs []walkEvent
|
|
if !ok {
|
|
wantEvs = []walkEvent{{otherFSDir: sub}}
|
|
}
|
|
|
|
if !slices.Equal(evs, wantEvs) {
|
|
t.Fatalf("events = %+v, want %+v", evs, wantEvs)
|
|
}
|
|
|
|
// The accepted job must carry the operand's device down, or -x
|
|
// stops checking below the first level.
|
|
want := dirJob{
|
|
path: sub,
|
|
rootDev: tc.parent.rootDev,
|
|
rootDevOK: tc.parent.rootDevOK,
|
|
}
|
|
if ok && job != want {
|
|
t.Fatalf("job = %+v, want %+v", job, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// errInfoUnavailable is returned by errDirEntry.Info().
|
|
var errInfoUnavailable = errors.New("info unavailable")
|
|
|
|
// errDirEntry is a directory entry whose Info() always fails, driving
|
|
// subdirJob's stat-error branch deterministically.
|
|
type errDirEntry struct{ name string }
|
|
|
|
func (e errDirEntry) Name() string { return e.name }
|
|
func (errDirEntry) IsDir() bool { return true }
|
|
func (errDirEntry) Type() fs.FileMode {
|
|
return fs.ModeDir
|
|
}
|
|
|
|
func (errDirEntry) Info() (fs.FileInfo, error) {
|
|
return nil, errInfoUnavailable
|
|
}
|
|
|
|
// TestSubdirJobStatError asserts that when a subdirectory's Info()
|
|
// fails under -x, subdirJob warns and refuses descent.
|
|
func TestSubdirJobStatError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p := "/does/not/matter/sub"
|
|
|
|
_, ok, evs := callSubdirJob(t, p, errDirEntry{name: "sub"},
|
|
dirJob{rootDev: 1, rootDevOK: true}, true)
|
|
if ok {
|
|
t.Fatal("descent accepted after stat error, want refused")
|
|
}
|
|
|
|
if len(evs) != 1 || !evs[0].fail || !strings.Contains(evs[0].warn, p) {
|
|
t.Fatalf("want one warning naming the path, got %+v", evs)
|
|
}
|
|
}
|
|
|
|
// syncOneFilesystem scans roots into db with -x and returns the scan
|
|
// stats. Every device pruneRoots looks up, and the device each
|
|
// operand's walk compares its subdirectories with, comes from devices
|
|
// instead of from lstat, so a test can put a path on a filesystem that
|
|
// no directory here is on. Unlike runPhases, it skips walkableRoots and
|
|
// the check for a cancelled walk.
|
|
func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
|
devices map[string]uint64,
|
|
) scanStats {
|
|
t.Helper()
|
|
|
|
device := func(path string) (uint64, bool) {
|
|
dev, ok := devices[path]
|
|
|
|
return dev, ok
|
|
}
|
|
|
|
roots = pruneRoots(roots, true, walkRules{}, device)
|
|
|
|
ctx := t.Context()
|
|
s := &scanState{db: db}
|
|
|
|
err := s.loadIndex(ctx, roots)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
jobs, subdirs, events := startWalkWorkers(ctx, 2, true, walkRules{})
|
|
|
|
// seedRoot emits a regular-file operand itself and returns a job
|
|
// only for a directory operand.
|
|
var initial []dirJob
|
|
|
|
for _, root := range roots {
|
|
for _, job := range seedRoot(ctx, root, walkRules{}, events) {
|
|
job.rootDev = devices[root]
|
|
initial = append(initial, job)
|
|
}
|
|
}
|
|
|
|
dispatchDirs(ctx, initial, jobs, subdirs)
|
|
|
|
s.walkPhase(events, roots)
|
|
|
|
err = s.hashPhase(ctx, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = s.updatePhase(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return s.st
|
|
}
|
|
|
|
// TestScanOneFilesystemKeepsOtherFilesystemRecords checks that with -x a
|
|
// record below a directory the walk does not enter, because it is on
|
|
// another filesystem than its operand, counts as outside the operands:
|
|
// it is not deleted. The record of a gone file that is not below that
|
|
// directory is still deleted, even when its path begins with the
|
|
// directory's name. The operand is given a device that no directory
|
|
// here is on, so no second real filesystem is needed.
|
|
func TestScanOneFilesystemKeepsOtherFilesystemRecords(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
|
|
// A scan without -x records the file below mnt, and mnt2, which is
|
|
// beside mnt, not below it.
|
|
mounted := writeFile(t, dir, "mnt/f", pattern(1, 100))
|
|
gone := writeFile(t, dir, "mnt2", pattern(3, 50))
|
|
syncTree(t, db, dir)
|
|
|
|
err := os.Remove(gone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := os.Lstat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfInfo(info)
|
|
if !ok {
|
|
t.Skip("platform exposes no device id")
|
|
}
|
|
|
|
syncOneFilesystem(t, db, []string{dir}, map[string]uint64{dir: dev + 1})
|
|
|
|
recs := dbRecords(t, db)
|
|
recordByPath(t, recs, mounted)
|
|
|
|
if slices.Contains(recordPaths(recs), gone) {
|
|
t.Errorf("record for %s kept: the file is gone and not below mnt",
|
|
gone)
|
|
}
|
|
}
|
|
|
|
// TestScanOneFilesystemWalksNestedOperand checks that with -x an
|
|
// operand under another one, on a different filesystem than it, is
|
|
// walked on its own: a file added below it is recorded, and the record
|
|
// of a file gone from below it is deleted. A record below a directory
|
|
// that its own walk does not enter is still kept. Each operand is given
|
|
// a device that no directory here is on, so neither walk enters a
|
|
// subdirectory.
|
|
func TestScanOneFilesystemWalksNestedOperand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
|
|
// A scan without -x records both files below mnt.
|
|
gone := writeFile(t, dir, "mnt/gone", pattern(1, 10))
|
|
kept := writeFile(t, dir, "mnt/sub/kept", pattern(2, 20))
|
|
syncTree(t, db, dir)
|
|
|
|
err := os.Remove(gone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
added := writeFile(t, dir, "mnt/added", pattern(3, 30))
|
|
|
|
info, err := os.Lstat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfInfo(info)
|
|
if !ok {
|
|
t.Skip("platform exposes no device id")
|
|
}
|
|
|
|
mnt := filepath.Join(dir, "mnt")
|
|
syncOneFilesystem(t, db, []string{dir, mnt},
|
|
map[string]uint64{dir: dev + 1, mnt: dev + 2})
|
|
|
|
paths := recordPaths(dbRecords(t, db))
|
|
|
|
if !slices.Contains(paths, added) {
|
|
t.Errorf("no record for %s: the walk did not reach %s", added, mnt)
|
|
}
|
|
|
|
if slices.Contains(paths, gone) {
|
|
t.Errorf("record for %s kept: the file is gone", gone)
|
|
}
|
|
|
|
if !slices.Contains(paths, kept) {
|
|
t.Errorf("record for %s deleted: the walk of %s did not enter %s",
|
|
kept, mnt, filepath.Dir(kept))
|
|
}
|
|
}
|
|
|
|
// TestScanOneFilesystemWalksOperandBelowOtherFilesystem checks that
|
|
// with -x an operand on the same filesystem as the operand covering it
|
|
// is walked on its own when a directory between them is on another
|
|
// filesystem, since the walk of the covering operand does not enter
|
|
// that directory: a file added below the nested operand is recorded,
|
|
// and the record of a file gone from below it is deleted. A record
|
|
// below that directory but outside the nested operand is still kept.
|
|
// Both operands are given one device that no directory here is on,
|
|
// while mnt keeps its own.
|
|
func TestScanOneFilesystemWalksOperandBelowOtherFilesystem(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
|
|
// A scan without -x records both files below mnt.
|
|
gone := writeFile(t, dir, "mnt/back/gone", pattern(1, 10))
|
|
kept := writeFile(t, dir, "mnt/kept", pattern(2, 20))
|
|
syncTree(t, db, dir)
|
|
|
|
err := os.Remove(gone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
added := writeFile(t, dir, "mnt/back/added", pattern(3, 30))
|
|
|
|
info, err := os.Lstat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfInfo(info)
|
|
if !ok {
|
|
t.Skip("platform exposes no device id")
|
|
}
|
|
|
|
mnt := filepath.Join(dir, "mnt")
|
|
back := filepath.Join(mnt, "back")
|
|
syncOneFilesystem(t, db, []string{dir, back},
|
|
map[string]uint64{dir: dev + 1, mnt: dev, back: dev + 1})
|
|
|
|
paths := recordPaths(dbRecords(t, db))
|
|
|
|
if !slices.Contains(paths, added) {
|
|
t.Errorf("no record for %s: the walk did not reach %s", added, back)
|
|
}
|
|
|
|
if slices.Contains(paths, gone) {
|
|
t.Errorf("record for %s kept: the file is gone", gone)
|
|
}
|
|
|
|
if !slices.Contains(paths, kept) {
|
|
t.Errorf("record for %s deleted: the walk of %s did not enter %s",
|
|
kept, dir, mnt)
|
|
}
|
|
}
|
|
|
|
// TestScanOneFilesystemRecordsNestedFileOnce checks that with -x a
|
|
// regular-file operand under another operand is dropped when the file
|
|
// reports a different device than that operand but its directory does
|
|
// not, as a file bind-mounted into the tree does: the walk of the other
|
|
// operand enters the directory and reaches the file, so the file is
|
|
// recorded once.
|
|
func TestScanOneFilesystemRecordsNestedFileOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
file := writeFile(t, dir, "sub/f", pattern(1, 10))
|
|
|
|
info, err := os.Lstat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfInfo(info)
|
|
if !ok {
|
|
t.Skip("platform exposes no device id")
|
|
}
|
|
|
|
st := syncOneFilesystem(t, db, []string{dir, file}, map[string]uint64{
|
|
dir: dev, filepath.Dir(file): dev, file: dev + 1,
|
|
})
|
|
|
|
if st.added != 1 {
|
|
t.Errorf("%s recorded %d times, want once", file, st.added)
|
|
}
|
|
}
|
|
|
|
// TestScanOneFilesystemWalksNestedFileOperand checks that with -x a
|
|
// regular-file operand under another operand is kept when its directory
|
|
// is on a different filesystem than that operand: the walk of the other
|
|
// operand does not enter the directory, so the file is recorded once,
|
|
// as its own operand. The other operand is given a device that no
|
|
// directory here is on.
|
|
func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
file := writeFile(t, dir, "sub/f", pattern(1, 10))
|
|
|
|
info, err := os.Lstat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dev, ok := deviceOfInfo(info)
|
|
if !ok {
|
|
t.Skip("platform exposes no device id")
|
|
}
|
|
|
|
st := syncOneFilesystem(t, db, []string{dir, file}, map[string]uint64{
|
|
dir: dev + 1, filepath.Dir(file): dev,
|
|
})
|
|
|
|
if st.added != 1 {
|
|
t.Errorf("%s recorded %d times, want once", file, st.added)
|
|
}
|
|
}
|
|
|
|
// buildSmokeTree recreates the README smoke-test filesystem layout
|
|
// with deterministic content and returns the tree root.
|
|
func buildSmokeTree(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
one := pattern(10, 2000)
|
|
f1 := pattern(30, 3000)
|
|
f2 := pattern(40, 100)
|
|
|
|
writeFile(t, dir, "a/one.bin", one)
|
|
writeFile(t, dir, "b/copy.bin", one)
|
|
writeFile(t, dir, "b/copy2.bin", one)
|
|
// Same size as one.bin, different content.
|
|
writeFile(t, dir, "a/unique.bin", pattern(20, 2000))
|
|
writeFile(t, dir, "tiny1", []byte("x"))
|
|
writeFile(t, dir, "tiny2", []byte("x"))
|
|
writeFile(t, dir, "tiny3", []byte("y"))
|
|
writeFile(t, dir, "empty1", nil)
|
|
writeFile(t, dir, "empty2", nil)
|
|
writeFile(t, dir, "t1/f1", f1)
|
|
writeFile(t, dir, "t1/sub/f2", f2)
|
|
writeFile(t, dir, "t2/f1", f1)
|
|
writeFile(t, dir, "t2/sub/f2", f2)
|
|
writeFile(t, dir, "t3/f1", f1)
|
|
writeFile(t, dir, "t3/sub/f2renamed", f2)
|
|
|
|
return dir
|
|
}
|
|
|
|
// smokeTreeFiles is the number of regular files buildSmokeTree creates.
|
|
const smokeTreeFiles = 15
|
|
|
|
// syncTree synchronizes the database with the given roots and returns
|
|
// the scan stats.
|
|
func syncTree(t *testing.T, db *sql.DB, roots ...string) scanStats {
|
|
t.Helper()
|
|
|
|
st, err := syncScan(t.Context(), db, roots, 4, false, walkRules{})
|
|
if err != nil {
|
|
t.Fatalf("syncScan: %v", err)
|
|
}
|
|
|
|
return st
|
|
}
|
|
|
|
// dbRecords returns every record currently in the database, in path
|
|
// order.
|
|
func dbRecords(t *testing.T, db *sql.DB) []scanRec {
|
|
t.Helper()
|
|
|
|
var recs []scanRec
|
|
|
|
err := loadFileRows(t.Context(), db, func(int64) {}, func(r scanRec) {
|
|
recs = append(recs, r)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return recs
|
|
}
|
|
|
|
// recordByPath finds the record with the given path.
|
|
func recordByPath(t *testing.T, recs []scanRec, path string) scanRec {
|
|
t.Helper()
|
|
|
|
for _, r := range recs {
|
|
if r.path == path {
|
|
return r
|
|
}
|
|
}
|
|
|
|
t.Fatalf("no record for %q", path)
|
|
|
|
return scanRec{}
|
|
}
|
|
|
|
// recordPaths returns the sorted paths of recs.
|
|
func recordPaths(recs []scanRec) []string {
|
|
paths := make([]string, 0, len(recs))
|
|
for _, r := range recs {
|
|
paths = append(paths, r.path)
|
|
}
|
|
|
|
slices.Sort(paths)
|
|
|
|
return paths
|
|
}
|
|
|
|
// assertSmokeDupeGroups checks the file-level duplicate groups for the
|
|
// smoke tree rooted at dir.
|
|
func assertSmokeDupeGroups(t *testing.T, dir string, db *sql.DB) {
|
|
t.Helper()
|
|
|
|
groups := dupeGroups(t, db)
|
|
if len(groups) != 5 {
|
|
t.Fatalf("len(groups) = %d, want 5", len(groups))
|
|
}
|
|
|
|
wantSizes := []int64{3000, 2000, 100, 1, 0}
|
|
for i, g := range groups {
|
|
if g.size != wantSizes[i] {
|
|
t.Errorf("groups[%d].size = %d, want %d",
|
|
i, g.size, wantSizes[i])
|
|
}
|
|
}
|
|
|
|
wantF1 := []string{
|
|
filepath.Join(dir, "t1/f1"),
|
|
filepath.Join(dir, "t2/f1"),
|
|
filepath.Join(dir, "t3/f1"),
|
|
}
|
|
if !slices.Equal(groups[0].paths, wantF1) {
|
|
t.Errorf("groups[0].paths = %q, want %q", groups[0].paths, wantF1)
|
|
}
|
|
}
|
|
|
|
// assertSmokeTreeGroups checks the duplicate-tree groups for the smoke
|
|
// tree rooted at dir.
|
|
func assertSmokeTreeGroups(t *testing.T, dir string, db *sql.DB) {
|
|
t.Helper()
|
|
|
|
super, dirs := dbTree(t, db)
|
|
|
|
tg := collectTreeGroups(dirs, super, nil)
|
|
if len(tg) != 1 {
|
|
t.Fatalf("len(tree groups) = %d, want 1", len(tg))
|
|
}
|
|
|
|
wantTrees := []string{filepath.Join(dir, "t1"), filepath.Join(dir, "t2")}
|
|
if got := groupPaths(tg)[0]; !slices.Equal(got, wantTrees) {
|
|
t.Fatalf("tree group = %q, want %q", got, wantTrees)
|
|
}
|
|
|
|
if tg[0][0].fileCount != 2 || tg[0][0].totalSize != 3100 {
|
|
t.Fatalf("tree totals: %d files %d bytes, want 2 3100",
|
|
tg[0][0].fileCount, tg[0][0].totalSize)
|
|
}
|
|
}
|
|
|
|
func TestScanPipeline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := buildSmokeTree(t)
|
|
db := openTestDB(t)
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st != (scanStats{walked: smokeTreeFiles, added: smokeTreeFiles}) {
|
|
t.Fatalf("stats = %+v, want %d added only", st, smokeTreeFiles)
|
|
}
|
|
|
|
parsed := dbRecords(t, db)
|
|
if len(parsed) != smokeTreeFiles {
|
|
t.Fatalf("len(records) = %d, want %d", len(parsed), smokeTreeFiles)
|
|
}
|
|
|
|
assertSmokeDupeGroups(t, dir, db)
|
|
assertSmokeTreeGroups(t, dir, db)
|
|
}
|
|
|
|
// TestScanProgressNamesEachPhase checks the plain progress lines of a
|
|
// first scan, and of a rescan of one operand holding two of the
|
|
// database's records. Each phase's first line names it with its place
|
|
// among the four: load counts the records before reading them, and the
|
|
// walk estimates its total from the records under its operand, or says
|
|
// its ETA is unknown when there are none.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestScanProgressNamesEachPhase(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
dir := buildSmokeTree(t)
|
|
db := openTestDB(t)
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
first := stderr()
|
|
|
|
syncTree(t, db, filepath.Join(dir, "a"))
|
|
|
|
rescan := stderr()[len(first):]
|
|
load := "[1/4] load: counting the records, elapsed 0s, eta unknown"
|
|
cases := map[string][]string{
|
|
first: {
|
|
load, "[2/4] walk: 0 files, 0 files/s, elapsed 0s, eta unknown",
|
|
"[3/4] hash: [0/", "[4/4] update: [0/",
|
|
},
|
|
rescan: {
|
|
load, "[2/4] walk: [0/~2] 0%, 0 files/s, elapsed 0s, eta unknown",
|
|
"[3/4] hash: [0/", "[4/4] update: [0/",
|
|
},
|
|
}
|
|
|
|
for out, want := range cases {
|
|
var got []string
|
|
|
|
for line := range strings.Lines(out) {
|
|
label, _, _ := strings.Cut(line, ":")
|
|
if len(got) == 0 || !strings.HasPrefix(got[len(got)-1], label) {
|
|
got = append(got, line)
|
|
}
|
|
}
|
|
|
|
if len(got) != len(want) {
|
|
t.Fatalf("first lines of the phases = %q, want %q", got, want)
|
|
}
|
|
|
|
for i := range want {
|
|
if !strings.HasPrefix(got[i], want[i]) {
|
|
t.Errorf("phase %d starts %q, want %q", i+1, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// The load phase's total is the count of the records.
|
|
loaded := fmt.Sprintf("\n[1/4] load: [%d/%d] 100%%, ",
|
|
smokeTreeFiles, smokeTreeFiles)
|
|
if !strings.Contains(rescan, loaded) {
|
|
t.Errorf("rescan stderr = %q, want %q", rescan, loaded)
|
|
}
|
|
}
|
|
|
|
func TestSyncScanUnchangedReuse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
|
b := writeFile(t, dir, "b.bin", pattern(2, 600))
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st != (scanStats{walked: 2, added: 2}) {
|
|
t.Fatalf("first scan stats = %+v, want 2 added", st)
|
|
}
|
|
|
|
// An immediate rescan reuses every record without reading file
|
|
// contents. Prove it: make every file unreadable, so that a read
|
|
// would fail and count as skipped, and corrupt a stored hash, which
|
|
// must survive the rescan.
|
|
for _, p := range []string{a, b} {
|
|
err := os.Chmod(p, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
sentinel := sha256.Sum256([]byte("sentinel"))
|
|
|
|
_, err := db.ExecContext(context.Background(),
|
|
"UPDATE files SET head = ? WHERE path = ?", sentinel[:], []byte(a))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st = syncTree(t, db, dir)
|
|
if st != (scanStats{walked: 2, unchanged: 2}) {
|
|
t.Fatalf("rescan stats = %+v, want 2 unchanged and none skipped",
|
|
st)
|
|
}
|
|
|
|
if r := recordByPath(t, dbRecords(t, db), a); r.head != sentinel {
|
|
t.Fatalf("head = %x, want the sentinel %x (file must not be re-read)",
|
|
r.head, sentinel)
|
|
}
|
|
}
|
|
|
|
func TestSyncScanMtimeBump(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
// Bump the mtime forward: the file must be re-hashed even though
|
|
// its size is unchanged.
|
|
future := time.Now().Add(time.Hour)
|
|
|
|
err := os.Chtimes(a, future, future)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st != (scanStats{walked: 1, updated: 1}) {
|
|
t.Fatalf("mtime-bump stats = %+v, want 1 updated", st)
|
|
}
|
|
|
|
if r := recordByPath(t, dbRecords(t, db), a); !r.mtime.Equal(future) {
|
|
t.Fatalf("mtime = %v, want %v", r.mtime, future)
|
|
}
|
|
}
|
|
|
|
// assertWholeFileHashed fails unless the record for path holds the
|
|
// whole-file hash of data as its head, tail, and content.
|
|
func assertWholeFileHashed(t *testing.T, db *sql.DB, path string,
|
|
data []byte,
|
|
) {
|
|
t.Helper()
|
|
|
|
r := recordByPath(t, dbRecords(t, db), path)
|
|
if want := sha256.Sum256(data); r.head != want || r.tail != want ||
|
|
r.content != want {
|
|
t.Fatalf("head, tail, content = %x, %x, %x, want %x for each",
|
|
r.head, r.tail, r.content, want)
|
|
}
|
|
}
|
|
|
|
// TestScanStoresRawHashes checks what the database holds for a file
|
|
// larger than both end windows, whose three hashes all differ: head,
|
|
// tail and content are each a BLOB of the 32 bytes of the SHA-256 the
|
|
// scan computed.
|
|
func TestScanStoresRawHashes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
data := pattern(3, 3*headTailWindow)
|
|
p := writeFile(t, dir, "f.bin", data)
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
var (
|
|
blobs bool
|
|
head, tail, content []byte
|
|
)
|
|
|
|
err := db.QueryRowContext(t.Context(),
|
|
"SELECT typeof(head) = 'blob' AND typeof(tail) = 'blob' AND "+
|
|
"typeof(content) = 'blob', head, tail, content "+
|
|
"FROM files WHERE path = ?", []byte(p)).
|
|
Scan(&blobs, &head, &tail, &content)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !blobs {
|
|
t.Error("head, tail and content are not all BLOBs")
|
|
}
|
|
|
|
wantHead := sha256.Sum256(data[:headTailWindow])
|
|
wantTail := sha256.Sum256(data[len(data)-headTailWindow:])
|
|
wantContent := sha256.Sum256(data)
|
|
|
|
if !bytes.Equal(head, wantHead[:]) || !bytes.Equal(tail, wantTail[:]) ||
|
|
!bytes.Equal(content, wantContent[:]) {
|
|
t.Errorf("head, tail, content = %x, %x, %x, want %x, %x, %x",
|
|
head, tail, content, wantHead, wantTail, wantContent)
|
|
}
|
|
}
|
|
|
|
// TestSyncScanSameSecondRewrite rewrites a file in place at the same
|
|
// size with an mtime later in the same second as the recorded one: the
|
|
// next scan must notice the change and re-hash the file. The operand is
|
|
// the file's directory, or the file itself, which scan stats without
|
|
// reading its directory.
|
|
func TestSyncScanSameSecondRewrite(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, fileOperand := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("file operand %v", fileOperand), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
|
|
|
operand := dir
|
|
if fileOperand {
|
|
operand = a
|
|
}
|
|
|
|
first := time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC)
|
|
|
|
err := os.Chtimes(a, first, first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
syncTree(t, db, operand)
|
|
|
|
rewritten := pattern(3, 500)
|
|
writeFile(t, dir, "a.bin", rewritten)
|
|
|
|
later := first.Add(500 * time.Millisecond)
|
|
|
|
err = os.Chtimes(a, later, later)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, operand)
|
|
if st != (scanStats{walked: 1, updated: 1}) {
|
|
t.Fatalf("rescan stats = %+v, want 1 updated", st)
|
|
}
|
|
|
|
assertWholeFileHashed(t, db, a, rewritten)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSyncScanRewriteAfter2262 runs assertLateRewriteRehashed with an
|
|
// mtime after 2262, a time too late to count in nanoseconds in an int64.
|
|
func TestSyncScanRewriteAfter2262(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertLateRewriteRehashed(t, time.Date(2300, 1, 2, 3, 4, 5, 0, time.UTC))
|
|
}
|
|
|
|
// TestSyncScanRewritePastTimeLimit runs assertLateRewriteRehashed with an
|
|
// mtime one second past the latest a time.Time holds without wrapping it
|
|
// to a time far in the past.
|
|
func TestSyncScanRewritePastTimeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertLateRewriteRehashed(t, time.Unix(9223371974719179008, 0))
|
|
}
|
|
|
|
// assertLateRewriteRehashed scans a directory, rewrites a file in it in
|
|
// place at the same size, sets its mtime to late, and fails unless the
|
|
// next scan re-hashes the file. It skips where late does not fit the
|
|
// platform's timespec or the filesystem does not store it.
|
|
func assertLateRewriteRehashed(t *testing.T, late time.Time) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
rewritten := pattern(3, 500)
|
|
writeFile(t, dir, "a.bin", rewritten)
|
|
|
|
// os.Chtimes cannot set such a time: it converts through UnixNano.
|
|
ts, err := unix.TimeToTimespec(late)
|
|
if err != nil {
|
|
t.Skipf("an mtime %d seconds after 1970 does not fit this platform's "+
|
|
"timespec: %v", late.Unix(), err)
|
|
}
|
|
|
|
err = unix.UtimesNano(a, []unix.Timespec{ts, ts})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fi, err := os.Lstat(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if fi.ModTime().Unix() != late.Unix() {
|
|
t.Skipf("the filesystem stored the mtime as %d seconds after 1970, "+
|
|
"not %d", fi.ModTime().Unix(), late.Unix())
|
|
}
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st != (scanStats{walked: 1, updated: 1}) {
|
|
t.Fatalf("rescan stats = %+v, want 1 updated", st)
|
|
}
|
|
|
|
assertWholeFileHashed(t, db, a, rewritten)
|
|
}
|
|
|
|
func TestSyncScanAddRemove(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
a := writeFile(t, dir, "a.bin", pattern(1, 500))
|
|
|
|
writeFile(t, dir, "b.bin", pattern(2, 600))
|
|
syncTree(t, db, dir)
|
|
|
|
// Add one file, remove another.
|
|
c := writeFile(t, dir, "c.bin", pattern(3, 700))
|
|
|
|
err := os.Remove(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st != (scanStats{walked: 2, added: 1, removed: 1, unchanged: 1}) {
|
|
t.Fatalf("add/remove stats = %+v, want 1 added 1 removed 1 unchanged",
|
|
st)
|
|
}
|
|
|
|
want := []string{filepath.Join(dir, "b.bin"), c}
|
|
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, want) {
|
|
t.Fatalf("paths = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSyncScanSizeChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
p := writeFile(t, dir, "f", pattern(1, 100))
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
// Rewrite with a different size but force the mtime back to the
|
|
// recorded value: the size mismatch alone must trigger a re-hash.
|
|
old := recordByPath(t, dbRecords(t, db), p)
|
|
|
|
writeFile(t, dir, "f", pattern(1, 200))
|
|
|
|
err := os.Chtimes(p, old.mtime, old.mtime)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st.updated != 1 {
|
|
t.Fatalf("stats = %+v, want 1 updated", st)
|
|
}
|
|
|
|
if got := recordByPath(t, dbRecords(t, db), p); got.size != 200 {
|
|
t.Fatalf("size = %d, want 200", got.size)
|
|
}
|
|
}
|
|
|
|
func TestSyncScanScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
|
|
writeFile(t, dir, "a/keep", pattern(1, 10))
|
|
|
|
gone := writeFile(t, dir, "b/gone", pattern(2, 10))
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
// Deleting a file outside the rescanned root must not remove its
|
|
// record: records outside the scanned operands are untouched.
|
|
err := os.Remove(gone)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, filepath.Join(dir, "a"))
|
|
if st.removed != 0 || st.unchanged != 1 {
|
|
t.Fatalf("subtree stats = %+v, want 0 removed 1 unchanged", st)
|
|
}
|
|
|
|
if got := recordPaths(dbRecords(t, db)); len(got) != 2 {
|
|
t.Fatalf("records = %q, want both retained", got)
|
|
}
|
|
|
|
// Rescanning the parent now removes the vanished file's record.
|
|
st = syncTree(t, db, dir)
|
|
if st.removed != 1 {
|
|
t.Fatalf("parent stats = %+v, want 1 removed", st)
|
|
}
|
|
}
|
|
|
|
func TestSyncScanRemovesNonRegular(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
p := writeFile(t, dir, "f", pattern(1, 10))
|
|
keep := writeFile(t, dir, "g", pattern(2, 10))
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
// Replace the file with a symlink: it is no longer walked, so its
|
|
// record must be deleted.
|
|
err := os.Remove(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.Symlink(keep, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st.removed != 1 || st.unchanged != 1 {
|
|
t.Fatalf("stats = %+v, want 1 removed 1 unchanged", st)
|
|
}
|
|
}
|
|
|
|
func TestSyncScanOverlappingRoots(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
|
|
writeFile(t, dir, "sub/f", pattern(1, 10))
|
|
|
|
// A file reachable via two overlapping operands is deduplicated
|
|
// by path in the shared walk and processed once.
|
|
st := syncTree(t, db, dir, filepath.Join(dir, "sub"))
|
|
if st != (scanStats{walked: 1, added: 1}) {
|
|
t.Fatalf("stats = %+v, want 1 added", st)
|
|
}
|
|
|
|
if got := recordPaths(dbRecords(t, db)); len(got) != 1 {
|
|
t.Fatalf("records = %q, want exactly one", got)
|
|
}
|
|
}
|
|
|
|
// TestScanHashesUniqueSize checks that a file whose size no other file
|
|
// shares is hashed like every other file.
|
|
func TestScanHashesUniqueSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
data := pattern(1, 500)
|
|
a := writeFile(t, dir, "a.bin", data)
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
assertWholeFileHashed(t, db, a, data)
|
|
}
|
|
|
|
func TestScanHardlinksReadOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
a := writeFile(t, dir, "a.bin", pattern(1, 300))
|
|
b := filepath.Join(dir, "b.bin")
|
|
|
|
err := os.Link(a, b)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st != (scanStats{walked: 2, added: 2}) {
|
|
t.Fatalf("stats = %+v, want 2 added", st)
|
|
}
|
|
|
|
// Both paths share the single read's hashes and group together.
|
|
recs := dbRecords(t, db)
|
|
|
|
ra := recordByPath(t, recs, a)
|
|
rb := recordByPath(t, recs, b)
|
|
|
|
if ra.head == [sha256.Size]byte{} || ra.head != rb.head ||
|
|
ra.tail != rb.tail {
|
|
t.Fatalf("hardlink hashes differ: %+v vs %+v", ra, rb)
|
|
}
|
|
|
|
if groups := dupeGroups(t, db); len(groups) != 1 {
|
|
t.Fatalf("groups = %+v, want the hardlink pair", groups)
|
|
}
|
|
}
|
|
|
|
func TestScanHardlinkRunFailsTogether(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
db := openTestDB(t)
|
|
a := writeFile(t, dir, "a.bin", pattern(1, 300))
|
|
|
|
err := os.Link(a, filepath.Join(dir, "b.bin"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Unreadable inode: the run's single read fails, so both paths are
|
|
// skipped — proof that hard links are read once, not per path.
|
|
err = os.Chmod(a, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st.skipped != 2 || st.added != 0 {
|
|
t.Fatalf("stats = %+v, want both hardlink paths skipped", st)
|
|
}
|
|
}
|
|
|
|
// TestHashPhaseSkipsFileReplacedByFIFO replaces a walked regular file
|
|
// with a FIFO before the hash phase reads it. Opening a FIFO that has
|
|
// no writer waits for one, so a blocking open would hang the phase. The
|
|
// path must be warned about once and skipped, with no record written.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestHashPhaseSkipsFileReplacedByFIFO(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
dir := t.TempDir()
|
|
p := writeFile(t, dir, "a.bin", pattern(1, 300))
|
|
|
|
walked, _ := collectWalk(t, []string{dir}, false, 1)
|
|
|
|
err := os.Remove(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = unix.Mkfifo(p, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := &scanState{db: openTestDB(t), toHash: walked}
|
|
|
|
err = s.hashPhase(t.Context(), 1)
|
|
if err != nil {
|
|
t.Fatalf("hashPhase = %v, want nil", err)
|
|
}
|
|
|
|
if s.st.skipped != 1 || len(s.batch) != 0 {
|
|
t.Errorf("skipped = %d, records = %d, want 1 skipped and none",
|
|
s.st.skipped, len(s.batch))
|
|
}
|
|
|
|
warning := "hash " + p + ": not a regular file\n"
|
|
if got := stderr(); strings.Count(got, warning) != 1 {
|
|
t.Errorf("stderr = %q, want %q once", got, warning)
|
|
}
|
|
}
|
|
|
|
// injectedWriteFailure is the message the injected database trigger
|
|
// aborts with, so the test can recognize its own failure in the error
|
|
// the scan reports.
|
|
const injectedWriteFailure = "injected write failure"
|
|
|
|
// hashLeakFiles is the size of the fixture for the hash-phase failure
|
|
// test. The batch commit inside the hash phase is what fails, so the
|
|
// tree must hold more than updateBatchSize files for the failure to
|
|
// happen at all, and the surplus over that is what is still queued
|
|
// when it does. That surplus is 2*workQueueDepth, which jobs, results
|
|
// and the workers in flight between them absorb exactly, so the feeder
|
|
// itself drains and exits; what an abandoned pool leaves parked is
|
|
// every worker, each holding a result nobody will ever receive, plus
|
|
// the goroutine waiting on them. That is what this test detects, and
|
|
// its margin over detecting nothing at all is the worker count —
|
|
// worth knowing before changing hashLeakWorkers or workQueueDepth.
|
|
const hashLeakFiles = updateBatchSize + 2*workQueueDepth
|
|
|
|
// hashLeakWorkers is the worker count for that scan: a fixed, modest
|
|
// number keeps the leak deterministic on any machine.
|
|
const hashLeakWorkers = 4
|
|
|
|
// goroutineSettle bounds how long a goroutine count is given to come
|
|
// back down to its target. Only a failing run ever waits this long.
|
|
const goroutineSettle = 5 * time.Second
|
|
|
|
// goroutinePoll is the interval between goroutine-count samples.
|
|
const goroutinePoll = 10 * time.Millisecond
|
|
|
|
// writeEmptyFiles creates n empty files directly in dir. Zero-length
|
|
// files are never opened by the hasher — their hashes are constant —
|
|
// so a fixture this size costs directory entries and no read I/O,
|
|
// while still queueing n runs through the hash pool.
|
|
func writeEmptyFiles(t *testing.T, dir string, n int) {
|
|
t.Helper()
|
|
|
|
for i := range n {
|
|
err := os.WriteFile(
|
|
filepath.Join(dir, strconv.Itoa(i)), nil, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// injectWriteFailure creates a scan database at path carrying the real
|
|
// schema plus a trigger that aborts every insert. Reads are untouched,
|
|
// so a scan loads its index and walks normally and then fails on the
|
|
// first record it tries to commit — a genuine database write failure
|
|
// partway through the hash phase.
|
|
func injectWriteFailure(t *testing.T, path string) {
|
|
t.Helper()
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(t.Context(),
|
|
"CREATE TRIGGER refuse_insert BEFORE INSERT ON files "+
|
|
"BEGIN SELECT RAISE(ABORT, '"+injectedWriteFailure+"'); END")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// baselineGoroutines waits for the goroutine count to stop moving and
|
|
// returns it. Handles closed by earlier tests take a moment to reap
|
|
// their driver goroutines, so a single sample would make the baseline
|
|
// itself flaky.
|
|
func baselineGoroutines(t *testing.T) int {
|
|
t.Helper()
|
|
|
|
// The first scan command in a process starts os/signal's goroutine,
|
|
// which never exits. Start it now, so the baseline counts it
|
|
// instead of the scan seeming to leave it behind.
|
|
ch := make(chan os.Signal, 1)
|
|
signal.Notify(ch, syscall.SIGINT)
|
|
signal.Stop(ch)
|
|
|
|
deadline := time.Now().Add(goroutineSettle)
|
|
last := runtime.NumGoroutine()
|
|
|
|
for time.Now().Before(deadline) {
|
|
time.Sleep(goroutinePoll)
|
|
|
|
n := runtime.NumGoroutine()
|
|
if n == last {
|
|
return n
|
|
}
|
|
|
|
last = n
|
|
}
|
|
|
|
return last
|
|
}
|
|
|
|
// settledGoroutines polls runtime.NumGoroutine until it is back at or
|
|
// below want and returns the last count seen. Polling, rather than one
|
|
// sample after a fixed sleep, is what keeps this from being a race
|
|
// between the assertion and goroutines that are already exiting.
|
|
func settledGoroutines(t *testing.T, want int) int {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(goroutineSettle)
|
|
|
|
for {
|
|
n := runtime.NumGoroutine()
|
|
if n <= want || time.Now().After(deadline) {
|
|
return n
|
|
}
|
|
|
|
time.Sleep(goroutinePoll)
|
|
}
|
|
}
|
|
|
|
// TestScanHashWriteFailureUnwindsPool drives the scan entry point
|
|
// against a database that refuses every write. The hash phase gives up
|
|
// partway through with thousands of runs still queued, which used to
|
|
// leave the feeder parked on a full job channel and every worker parked
|
|
// on a full result channel for the life of the process.
|
|
func TestScanHashWriteFailureUnwindsPool(t *testing.T) {
|
|
path := testDBPath(t)
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
dir := t.TempDir()
|
|
|
|
writeEmptyFiles(t, dir, hashLeakFiles)
|
|
injectWriteFailure(t, path)
|
|
|
|
base := baselineGoroutines(t)
|
|
|
|
var stderr bytes.Buffer
|
|
|
|
code := run([]string{
|
|
cmdScan, "--workers", strconv.Itoa(hashLeakWorkers), dir,
|
|
}, io.Discard, &stderr)
|
|
if code != exitFatal {
|
|
t.Fatalf("run(scan) = %d, want %d; stderr: %s",
|
|
code, exitFatal, stderr.String())
|
|
}
|
|
|
|
if !strings.Contains(stderr.String(), injectedWriteFailure) {
|
|
t.Errorf("stderr = %q, want the injected write failure",
|
|
stderr.String())
|
|
}
|
|
|
|
if got := settledGoroutines(t, base); got > base {
|
|
t.Errorf("goroutines = %d after the failed scan, want %d back",
|
|
got, base)
|
|
}
|
|
}
|
|
|
|
func TestHashRuns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := func(path string, dev, ino uint64) fileRec {
|
|
return fileRec{path: path, dev: dev, ino: ino}
|
|
}
|
|
|
|
runs := hashRuns([]fileRec{
|
|
rec("/c", 1, 7),
|
|
rec("/a", 1, 7),
|
|
rec("/b", 1, 9),
|
|
// No inode identity: never merged, even with matching zeros.
|
|
rec("/z1", 0, 0),
|
|
rec("/z2", 0, 0),
|
|
})
|
|
|
|
got := make([][]string, 0, len(runs))
|
|
for _, run := range runs {
|
|
paths := make([]string, 0, len(run))
|
|
for _, r := range run {
|
|
paths = append(paths, r.path)
|
|
}
|
|
|
|
got = append(got, paths)
|
|
}
|
|
|
|
want := [][]string{{"/z1"}, {"/z2"}, {"/a", "/c"}, {"/b"}}
|
|
if !slices.EqualFunc(got, want, slices.Equal) {
|
|
t.Fatalf("runs = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestPruneRoots(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const pqRoot = "/p/q"
|
|
|
|
// /p/q is on another filesystem than /p; /p/q/r is on the same one as
|
|
// /p/q, /p/s as /p, and /p/t has no known device.
|
|
devices := map[string]uint64{"/p": 1, pqRoot: 2, "/p/q/r": 2, "/p/s": 1}
|
|
device := func(path string) (uint64, bool) {
|
|
dev, ok := devices[path]
|
|
|
|
return dev, ok
|
|
}
|
|
|
|
// Duplicates and operands under other operands are dropped; /cc is
|
|
// not under /c (sibling with a shared prefix).
|
|
got := pruneRoots([]string{"/a/b", "/a", "/c", "/a", "/a/b/c", "/cc"},
|
|
false, walkRules{}, device)
|
|
|
|
want := []string{"/a", "/c", "/cc"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("pruneRoots = %q, want %q", got, want)
|
|
}
|
|
|
|
nested := []string{"/p/q/r", pqRoot, "/p", "/p", "/p/s", "/p/t"}
|
|
|
|
got = pruneRoots(nested, false, walkRules{}, device)
|
|
|
|
want = []string{"/p"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("pruneRoots without -x = %q, want %q", got, want)
|
|
}
|
|
|
|
// With -x, /p/q, on another filesystem than /p, is kept as well.
|
|
got = pruneRoots(nested, true, walkRules{}, device)
|
|
|
|
want = []string{"/p", pqRoot}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("pruneRoots with -x = %q, want %q", got, want)
|
|
}
|
|
|
|
// The walk of /p does not enter /p/q when it is a default exclude, so
|
|
// /p/q is kept, without -x too; /p/q/r lies under it and is dropped.
|
|
got = pruneRoots(nested, false, walkRules{excludes: []string{pqRoot}},
|
|
device)
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("pruneRoots excluding %s = %q, want %q", pqRoot, got, want)
|
|
}
|
|
|
|
// Nor does it enter /p/q when the filesystem of /p/q is not
|
|
// persistent, so /p/q/r/m, of no known device, is kept.
|
|
got = pruneRoots([]string{"/p", "/p/q/r/m"}, false,
|
|
walkRules{nonPersistent: map[uint64]string{2: "tmpfs"}}, device)
|
|
|
|
want = []string{"/p", "/p/q/r/m"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
|
|
pqRoot, got, want)
|
|
}
|
|
}
|
|
|
|
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := buildSmokeTree(t)
|
|
db := openTestDB(t)
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
// Remove the scanned tree entirely; the analysis must be
|
|
// unaffected because it reads the database alone.
|
|
err := os.RemoveAll(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
assertSmokeDupeGroups(t, dir, db)
|
|
assertSmokeTreeGroups(t, dir, db)
|
|
}
|
|
|
|
func TestUnderRoot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const abRoot = "/a/b"
|
|
|
|
cases := []struct {
|
|
path string
|
|
root string
|
|
want bool
|
|
}{
|
|
{"/a/b/c", abRoot, true},
|
|
{abRoot, abRoot, true},
|
|
{"/a/bc", abRoot, false},
|
|
{"/a", abRoot, false},
|
|
{"/x/y", "/", true},
|
|
{"/", "/", true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := underRoot(c.path, c.root); got != c.want {
|
|
t.Errorf("underRoot(%q, %q) = %v, want %v",
|
|
c.path, c.root, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunScanPassesOverDatabase scans a tree that holds the scan's own
|
|
// database, reached directly and through a symlinked directory. The
|
|
// walk passes over the database file, its -wal and -shm files and its
|
|
// .lock file with no line and no count, and the record an earlier scan
|
|
// left for the .lock file is deleted. A rescan of the unchanged tree
|
|
// updates nothing, and report lists none of the database's files.
|
|
func TestRunScanPassesOverDatabase(t *testing.T) {
|
|
for _, viaSymlink := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("via symlink %v", viaSymlink), func(t *testing.T) {
|
|
base := t.TempDir()
|
|
tree := filepath.Join(base, "real", "tree")
|
|
operand := tree
|
|
|
|
writeFile(t, tree, "a", pattern(1, 100))
|
|
writeFile(t, tree, "b", pattern(1, 100))
|
|
writeFile(t, tree, "empty1", nil)
|
|
writeFile(t, tree, "empty2", nil)
|
|
|
|
if viaSymlink {
|
|
err := os.Symlink(filepath.Join(base, "real"),
|
|
filepath.Join(base, "link"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
operand = filepath.Join(base, "link", "tree")
|
|
}
|
|
|
|
dbPath := filepath.Join(tree, "db.sqlite")
|
|
t.Setenv(databaseEnv, dbPath)
|
|
|
|
db, err := openScanDatabase(t.Context(), dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = applyChanges(t.Context(), db, []scanRec{
|
|
{path: filepath.Join(operand, "db.sqlite.lock")},
|
|
}, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
closeScanDatabase(t.Context(), db, dbPath)
|
|
|
|
assertScanSummary(t,
|
|
"4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)",
|
|
operand)
|
|
assertScanSummary(t,
|
|
"4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)",
|
|
operand)
|
|
|
|
report := runStdout(t, cmdReport)
|
|
if strings.Contains(report, "db.sqlite") ||
|
|
!strings.Contains(report, filepath.Join(operand, "empty2")) {
|
|
t.Errorf("report = %q, want the empty files and none of "+
|
|
"the database's files", report)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunScanPassesOverDatabaseOperands scans the database file and its
|
|
// .lock file given as operands themselves: neither is recorded or
|
|
// counted.
|
|
func TestRunScanPassesOverDatabaseOperands(t *testing.T) {
|
|
dbPath := testDBPath(t)
|
|
t.Setenv(databaseEnv, dbPath)
|
|
|
|
// The first scan creates both files.
|
|
scanOK(t, t.TempDir())
|
|
|
|
assertScanSummary(t,
|
|
"0 files seen (0 added, 0 updated, 0 removed, 0 unchanged)",
|
|
dbPath, dbPath+".lock")
|
|
}
|
|
|
|
// assertScanSummary scans operands and fails the test unless the
|
|
// summary line gives counts with nothing skipped, and the line naming
|
|
// the database is the only one that names one of its files.
|
|
func assertScanSummary(t *testing.T, counts string, operands ...string) {
|
|
t.Helper()
|
|
|
|
stderr := scanOK(t, operands...)
|
|
|
|
want := "scan: " + counts + ", 0 skipped\n"
|
|
if !strings.Contains(stderr, want) {
|
|
t.Errorf("scan %q: stderr = %q, want %q", operands, stderr, want)
|
|
}
|
|
|
|
if strings.Count(stderr, "db.sqlite") != 1 {
|
|
t.Errorf("scan %q: stderr = %q, want only the line naming the "+
|
|
"database to name one of its files", operands, stderr)
|
|
}
|
|
}
|