check / check (push) Canceled after 0s
scan, report and trees now print one line on stderr before anything else, naming the database and whether its path is the default or comes from SFDUPES_DATABASE; scan also says when the file is new. report and trees print it before opening the database, so a missing-database error follows it. A relative path gets the working directory put in front of it, with its symlinks resolved as the kernel reports it, and is not cleaned: a ".." after a symlink names another file than the cleaned path does. README "Database" lists when scan writes each file. Unverified: the line on a terminal has no test of its own. Model: opus-5-5
385 lines
9.2 KiB
Go
385 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testDBPath returns a database path inside a fresh temp dir, with
|
|
// symlinks resolved as the scan lock resolves them, so the lock file
|
|
// is this path plus ".lock" even where the temp dir is reached through
|
|
// a symlink.
|
|
func testDBPath(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return filepath.Join(dir, "db.sqlite")
|
|
}
|
|
|
|
// openTestDB creates a fresh scan database in a temp dir.
|
|
func openTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
|
|
db, err := openScanDatabase(t.Context(), testDBPath(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db
|
|
}
|
|
|
|
func TestDatabasePath(t *testing.T) {
|
|
// README §Database: SFDUPES_DATABASE when set and non-empty, the
|
|
// default otherwise, with the working directory, its symlinks
|
|
// resolved, put in front of a relative path. Nothing is cleaned, so
|
|
// a ".." stays.
|
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Chdir(dir)
|
|
|
|
fromEnv := "from " + databaseEnv
|
|
cases := []struct {
|
|
env, path, source string
|
|
}{
|
|
{"", defaultDatabasePath, "default"},
|
|
{"/custom/place.sqlite", "/custom/place.sqlite", fromEnv},
|
|
{"/custom/link/../place.sqlite", "/custom/link/../place.sqlite",
|
|
fromEnv},
|
|
{"sub/place.sqlite", dir + "/sub/place.sqlite", fromEnv},
|
|
{"link/../place.sqlite", dir + "/link/../place.sqlite", fromEnv},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Setenv(databaseEnv, tc.env)
|
|
|
|
path, source, err := databasePath()
|
|
if err != nil || path != tc.path || source != tc.source {
|
|
t.Errorf("databasePath() with %s=%q = %q, %q, %v; want %q, %q",
|
|
databaseEnv, tc.env, path, source, err, tc.path, tc.source)
|
|
}
|
|
}
|
|
|
|
// In the root directory the path gets one slash in front, not two.
|
|
t.Chdir("/")
|
|
t.Setenv(databaseEnv, "place.sqlite")
|
|
|
|
path, _, err := databasePath()
|
|
if err != nil || path != "/place.sqlite" {
|
|
t.Errorf("databasePath() in / = %q, %v; want %q",
|
|
path, err, "/place.sqlite")
|
|
}
|
|
}
|
|
|
|
func TestOpenScanDatabaseCreates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The parent directory does not exist yet; scan must create it.
|
|
path := filepath.Join(t.TempDir(), "nested", "dir", "db.sqlite")
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openScanDatabase: %v", err)
|
|
}
|
|
|
|
v, err := userVersion(t.Context(), db)
|
|
if err != nil || v != schemaVersion {
|
|
t.Fatalf("userVersion = %d, %v; want %d, nil", v, err, schemaVersion)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
// Reopening an existing database must succeed and find the schema.
|
|
db, err = openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
if recs := dbRecords(t, db); len(recs) != 0 {
|
|
t.Fatalf("records = %v, want none", recs)
|
|
}
|
|
}
|
|
|
|
func TestOpenDatabaseUnversionedForeign(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A database that has a files table but user_version 0, written by
|
|
// some other tool. report, trees and scan must refuse it with the
|
|
// schema-version error, not adopt it and not emit a raw SQLite
|
|
// "table files already exists".
|
|
path := testDBPath(t)
|
|
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(t.Context(), "CREATE TABLE files (x INTEGER)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
_, err = openReportDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) ||
|
|
!strings.Contains(err.Error(), "remove the file and rescan") {
|
|
t.Fatalf("report: err = %v, want errSchemaVersion telling the "+
|
|
"operator to remove the file and rescan", err)
|
|
}
|
|
|
|
_, err = openScanDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) ||
|
|
!strings.Contains(err.Error(), "remove the file and rescan") {
|
|
t.Fatalf("scan: err = %v, want errSchemaVersion telling the "+
|
|
"operator to remove the file and rescan", err)
|
|
}
|
|
}
|
|
|
|
func TestSchemaCreationStoppedPartway(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A first scan stopped while creating the schema must leave a
|
|
// database the next scan accepts. max_page_count(2) leaves room for
|
|
// the files table but not its index, so schema creation fails right
|
|
// after CREATE TABLE, a point an interrupt could also stop it at.
|
|
path := testDBPath(t)
|
|
|
|
db, err := openDB(path, scanParams+"&_pragma=max_page_count(2)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = initSchema(t.Context(), db)
|
|
_ = db.Close()
|
|
|
|
if err == nil {
|
|
t.Fatal("initSchema with no room for the index succeeded")
|
|
}
|
|
|
|
db, err = openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("next scan: %v", err)
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
v, err := userVersion(t.Context(), db)
|
|
if err != nil || v != schemaVersion {
|
|
t.Fatalf("userVersion = %d, %v; want %d, nil", v, err, schemaVersion)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := openReportDatabase(t.Context(), testDBPath(t))
|
|
if !errors.Is(err, errNoDatabase) {
|
|
t.Fatalf("err = %v, want errNoDatabase", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenDatabaseVersionMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A database stamped with a schema version other than 0 and
|
|
// schemaVersion. report, trees and scan must all refuse it.
|
|
path := testDBPath(t)
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = db.ExecContext(context.Background(), "PRAGMA user_version = 99")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
_, err = openReportDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) {
|
|
t.Fatalf("report: err = %v, want errSchemaVersion", err)
|
|
}
|
|
|
|
_, err = openScanDatabase(t.Context(), path)
|
|
if !errors.Is(err, errSchemaVersion) {
|
|
t.Fatalf("scan: err = %v, want errSchemaVersion", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenReportDatabaseOK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := testDBPath(t)
|
|
|
|
db, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
|
|
db, err = openReportDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openReportDatabase: %v", err)
|
|
}
|
|
|
|
_ = db.Close()
|
|
}
|
|
|
|
func TestCloseScanDatabaseWhileReportOpen(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A report holding the database open stops scan from taking it out
|
|
// of WAL mode. The -wal and -shm files must then stay beside it, so
|
|
// that a later report still needs only read access.
|
|
path := testDBPath(t)
|
|
|
|
scanDB, err := openScanDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
reportDB, err := openReportDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
closeScanDatabase(t.Context(), scanDB, path)
|
|
|
|
_ = reportDB.Close()
|
|
|
|
_, err = os.Stat(path + "-wal")
|
|
if err != nil {
|
|
t.Fatalf("no -wal left: the switch out of WAL mode was not "+
|
|
"stopped: %v", err)
|
|
}
|
|
|
|
makeReadOnly(t, path)
|
|
|
|
reportDB, err = openReportDatabase(t.Context(), path)
|
|
if err != nil {
|
|
t.Fatalf("openReportDatabase: %v", err)
|
|
}
|
|
|
|
defer func() { _ = reportDB.Close() }()
|
|
|
|
err = loadFileRows(t.Context(), reportDB, func(scanRec) {})
|
|
if err != nil {
|
|
t.Fatalf("loadFileRows: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyChangesRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openTestDB(t)
|
|
|
|
// Paths may contain tabs and newlines; the database must store
|
|
// them byte-exactly. Every hash, content included, comes back as
|
|
// written.
|
|
recs := []scanRec{
|
|
{
|
|
size: 2, mtime: time.Unix(20, 999_999_999), head: "h2", tail: "t2",
|
|
content: "c2", path: "/a/tab\tnew\nline",
|
|
},
|
|
{
|
|
size: 1, mtime: time.Unix(10, 0), head: "h1", tail: "t1",
|
|
content: "c1", path: "/a/x",
|
|
},
|
|
}
|
|
|
|
err := applyChanges(t.Context(), db, recs, nil,
|
|
newProgress("update", 2))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
// The records come back in path order, which is the order of recs.
|
|
got := dbRecords(t, db)
|
|
if !slices.Equal(got, recs) {
|
|
t.Fatalf("rows = %+v, want %+v", got, recs)
|
|
}
|
|
|
|
// An upsert for an existing path updates in place; a delete
|
|
// removes exactly its path.
|
|
upd := scanRec{
|
|
size: 3, mtime: time.Unix(30, 0), head: "h3", tail: "t3", content: "c3",
|
|
path: "/a/x",
|
|
}
|
|
|
|
err = applyChanges(t.Context(), db, []scanRec{upd},
|
|
[]string{"/a/tab\tnew\nline"}, newProgress("update", 2))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
got = dbRecords(t, db)
|
|
if len(got) != 1 || got[0] != upd {
|
|
t.Fatalf("rows = %+v, want just %+v", got, upd)
|
|
}
|
|
}
|
|
|
|
func TestApplyChangesBatching(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := openTestDB(t)
|
|
|
|
// One more change than the batch size, so the update spans two
|
|
// transactions.
|
|
n := updateBatchSize + 1
|
|
|
|
recs := make([]scanRec, 0, n)
|
|
for i := range n {
|
|
recs = append(recs, scanRec{
|
|
size: int64(i), mtime: time.Unix(1, 0), head: "h", tail: "t",
|
|
path: fmt.Sprintf("/batch/%07d", i),
|
|
})
|
|
}
|
|
|
|
err := applyChanges(t.Context(), db, recs, nil,
|
|
newProgress("update", int64(n)))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges: %v", err)
|
|
}
|
|
|
|
if got := dbRecords(t, db); len(got) != n {
|
|
t.Fatalf("records = %d, want %d", len(got), n)
|
|
}
|
|
|
|
deletes := make([]string, 0, n)
|
|
for _, r := range recs {
|
|
deletes = append(deletes, r.path)
|
|
}
|
|
|
|
err = applyChanges(t.Context(), db, nil, deletes,
|
|
newProgress("update", int64(n)))
|
|
if err != nil {
|
|
t.Fatalf("applyChanges deletes: %v", err)
|
|
}
|
|
|
|
if got := dbRecords(t, db); len(got) != 0 {
|
|
t.Fatalf("records = %d, want 0", len(got))
|
|
}
|
|
}
|