From 933a7ced69262a1327a212c4f151a1f0fae31a22 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 3 Oct 2026 13:32:17 +0000 Subject: [PATCH] Refuse an unversioned database that already has a files table (closes #11) An unversioned database (user_version 0) that already contains a files table was not created by this build; it is a foreign or partially initialized file. Adopting it silently could corrupt unrelated data, so createSchema now checks for a files table first and, when one exists, returns the schema-version error telling the operator to remove the file and rescan. A genuinely empty database is still created and stamped as before. Model: opus-4-8 (implementation); opus-5-5 (rebase) --- TODO.md | 4 ++++ db.go | 25 +++++++++++++++++++++++-- db_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 7724ba8..c07ea24 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,10 @@ # Completed Steps +- refuse an unversioned database that already has a `files` table with + a clear schema-version error (2026-10-03, + https://git.eeqj.de/sneak/sfdupes/issues/11) + - escape tabs, newlines, carriage returns and backslashes in report, trees and warning paths; the root directory's path is `/` (2026-10-03, https://git.eeqj.de/sneak/sfdupes/issues/7) diff --git a/db.go b/db.go index f732608..4643029 100644 --- a/db.go +++ b/db.go @@ -176,9 +176,30 @@ func initSchema(ctx context.Context, db *sql.DB) error { } // createSchema applies the schema to a fresh database and stamps the -// schema version. +// schema version. A database with user_version 0 that already has a +// files table was not created by this build — a foreign or partially +// initialized file. Adopting it silently could corrupt unrelated data, +// so that is a fatal schema-version error telling the operator to +// remove the file and rescan. func createSchema(ctx context.Context, db *sql.DB) error { - _, err := db.ExecContext(ctx, createTableSQL) + var name string + + err := db.QueryRowContext(ctx, + "SELECT name FROM sqlite_master "+ + "WHERE type = 'table' AND name = 'files'").Scan(&name) + + switch { + case err == nil: + return fmt.Errorf( + "has a files table but no schema version; "+ + "remove the file and rescan: %w", errSchemaVersion) + case errors.Is(err, sql.ErrNoRows): + // Genuinely empty: create the schema below. + default: + return fmt.Errorf("check for files table: %w", err) + } + + _, err = db.ExecContext(ctx, createTableSQL) if err != nil { return fmt.Errorf("create schema: %w", err) } diff --git a/db_test.go b/db_test.go index b68bddd..03b0994 100644 --- a/db_test.go +++ b/db_test.go @@ -78,6 +78,38 @@ func TestOpenScanDatabaseCreates(t *testing.T) { } } +func TestOpenScanDatabaseUnversionedForeign(t *testing.T) { + t.Parallel() + + path := testDBPath(t) + + // A database that has a files table but user_version 0 — a foreign + // or partially initialized file. scan must refuse it with a clear + // schema-version error, not adopt it and not emit a raw SQLite + // "table files already exists". + db, err := openDB(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 = openScanDatabase(t.Context(), path) + if !errors.Is(err, errSchemaVersion) { + t.Fatalf("err = %v, want errSchemaVersion", err) + } + + if !strings.Contains(err.Error(), "remove the file and rescan") { + t.Fatalf("err = %v, want it to tell the operator to remove and rescan", + err) + } +} + func TestOpenReportDatabaseMissing(t *testing.T) { t.Parallel()