From dd405054fe4cc9eb87c5750fb94d3ba7215be836 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 22:49:12 +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 --- 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 2044a7d..fb35c9c 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-09-21, closes + https://git.eeqj.de/sneak/sfdupes/issues/11) + - remove the dead `files.dat` references from `Makefile`, `.gitignore` and `.dockerignore` (2026-09-21, branch `next`, closes https://git.eeqj.de/sneak/sfdupes/issues/22) diff --git a/db.go b/db.go index 573d944..a79c660 100644 --- a/db.go +++ b/db.go @@ -174,9 +174,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 41ab6f2..29f7e26 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()