Implement persistent SQLite scan database
All checks were successful
check / check (push) Successful in 3s
All checks were successful
check / check (push) Successful in 3s
scan now synchronizes a database that survives between runs (SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite) instead of emitting a stream: operands are resolved to absolute paths, unchanged files (same size, mtime not newer than recorded) are never re-read, new and changed files are hashed, and records under the scanned operands that were not verified this run are deleted; records outside the operands are untouched. All changes commit in a single transaction, and WAL journaling with a busy timeout keeps a report run during a cron scan safe. report and trees read the database (no positional arguments); the NUL-terminated stream format, its parser, and the malformed-record handling are gone. The driver is modernc.org/sqlite (pure Go), so builds keep cgo disabled.
This commit is contained in:
408
scan_test.go
408
scan_test.go
@@ -1,15 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeFile creates a file with the given content and returns its path.
|
||||
@@ -254,38 +255,6 @@ func TestStatPass(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout runs fn with os.Stdout redirected to a temp file and
|
||||
// returns everything fn wrote to it.
|
||||
func captureStdout(t *testing.T, fn func()) []byte {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orig := os.Stdout
|
||||
os.Stdout = f
|
||||
|
||||
defer func() { os.Stdout = orig }()
|
||||
|
||||
fn()
|
||||
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = f.Close()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// buildSmokeTree recreates the README smoke-test filesystem layout
|
||||
// with deterministic content and returns the tree root.
|
||||
func buildSmokeTree(t *testing.T) string {
|
||||
@@ -316,45 +285,65 @@ func buildSmokeTree(t *testing.T) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
// scanToRecords runs the walk, stat, and hash passes over dir and
|
||||
// parses the emitted stream back into records.
|
||||
func scanToRecords(t *testing.T, dir string) []scanRec {
|
||||
// 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()
|
||||
|
||||
paths, walkErrs := walkPass([]string{dir}, false)
|
||||
if walkErrs != 0 {
|
||||
t.Fatalf("walk errors: %d", walkErrs)
|
||||
st, err := syncScan(db, roots, 4, false)
|
||||
if err != nil {
|
||||
t.Fatalf("syncScan: %v", err)
|
||||
}
|
||||
|
||||
recs, statErrs := statPass(paths, 4)
|
||||
if statErrs != 0 {
|
||||
t.Fatalf("stat errors: %d", statErrs)
|
||||
}
|
||||
|
||||
var emitted, hashErrs int
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
emitted, hashErrs = hashPass(recs, 4)
|
||||
})
|
||||
|
||||
if emitted != len(paths) || hashErrs != 0 {
|
||||
t.Fatalf("emitted %d of %d, %d hash errors",
|
||||
emitted, len(paths), hashErrs)
|
||||
}
|
||||
|
||||
parsed, malformed := parseScanStream(bytes.NewReader(out), "pipe")
|
||||
if malformed != 0 || len(parsed) != len(paths) {
|
||||
t.Fatalf("parsed %d records, %d malformed, want %d, 0",
|
||||
len(parsed), malformed, len(paths))
|
||||
}
|
||||
|
||||
return parsed
|
||||
return st
|
||||
}
|
||||
|
||||
//nolint:paralleltest // redirects the process-wide os.Stdout
|
||||
func TestScanPipeline(t *testing.T) {
|
||||
dir := buildSmokeTree(t)
|
||||
parsed := scanToRecords(t, dir)
|
||||
// dbRecords returns every record currently in the database.
|
||||
func dbRecords(t *testing.T, db *sql.DB) []scanRec {
|
||||
t.Helper()
|
||||
|
||||
recs, err := loadFileRows(db)
|
||||
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, parsed []scanRec) {
|
||||
t.Helper()
|
||||
|
||||
groups := collectDupeGroups(parsed)
|
||||
if len(groups) != 5 {
|
||||
@@ -377,6 +366,12 @@ func TestScanPipeline(t *testing.T) {
|
||||
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, parsed []scanRec) {
|
||||
t.Helper()
|
||||
|
||||
super, dirs := buildHierarchy(parsed)
|
||||
super.compute()
|
||||
@@ -396,3 +391,282 @@ func TestScanPipeline(t *testing.T) {
|
||||
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{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, parsed)
|
||||
assertSmokeTreeGroups(t, dir, parsed)
|
||||
}
|
||||
|
||||
func TestSyncScanUnchangedReuse(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))
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
if st != (scanStats{added: 2}) {
|
||||
t.Fatalf("first scan stats = %+v, want 2 added", st)
|
||||
}
|
||||
|
||||
// An immediate rescan reuses every record without reading file
|
||||
// contents. Prove the files are not re-read by corrupting a stored
|
||||
// hash and observing that it survives the rescan.
|
||||
_, err := db.ExecContext(context.Background(),
|
||||
"UPDATE files SET head = 'sentinel' WHERE path = ?", []byte(a))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st = syncTree(t, db, dir)
|
||||
if st != (scanStats{unchanged: 2}) {
|
||||
t.Fatalf("rescan stats = %+v, want 2 unchanged", st)
|
||||
}
|
||||
|
||||
if r := recordByPath(t, dbRecords(t, db), a); r.head != "sentinel" {
|
||||
t.Fatalf("head = %q, want sentinel (file must not be re-read)",
|
||||
r.head)
|
||||
}
|
||||
}
|
||||
|
||||
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{updated: 1}) {
|
||||
t.Fatalf("mtime-bump stats = %+v, want 1 updated", st)
|
||||
}
|
||||
|
||||
if r := recordByPath(t, dbRecords(t, db), a); r.mtime != future.Unix() {
|
||||
t.Fatalf("mtime = %d, want %d", r.mtime, future.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
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{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))
|
||||
|
||||
mt := time.Unix(old.mtime, 0)
|
||||
|
||||
err := os.Chtimes(p, mt, mt)
|
||||
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 yields one record.
|
||||
st := syncTree(t, db, dir, filepath.Join(dir, "sub"))
|
||||
if st.added != 1 {
|
||||
t.Fatalf("stats = %+v, want 1 added", st)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
recs := dbRecords(t, db)
|
||||
|
||||
assertSmokeDupeGroups(t, dir, recs)
|
||||
assertSmokeTreeGroups(t, dir, recs)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniquePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := uniquePaths([]string{"/a", "/b", "/a", "/c", "/b"})
|
||||
|
||||
want := []string{"/a", "/b", "/c"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("uniquePaths = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user