From b2a4f748b7039de3552ee8ad102571031d82156c Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 07:23:09 +0000 Subject: [PATCH] Refuse an unversioned database that already has a files table (closes #11) scan opened any database whose user_version was 0 as empty and ran an unconditional CREATE TABLE files, so a foreign or partially initialized file with a files table failed with the raw SQLite "table files already exists", which explains nothing. createSchema now checks for an existing files table first and, when one is present at version 0, reports the schema-version error telling the operator to remove the file and rescan, matching the README rule that an unrecognized database is fatal. Model: opus-4-8 --- TODO.md | 5 +++++ db.go | 25 +++++++++++++++++++++++-- db_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 3da0b6c..47373ca 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,11 @@ # Completed Steps +- make `scan` refuse an unversioned database that already has a `files` + table with a clear schema-version error instead of a raw SQLite + "table files already exists" (2026-09-21, branch `next`, closes + https://git.eeqj.de/sneak/sfdupes/issues/11) + - fix the lint-image pin comments and `FROM` form in `Dockerfile` and `Dockerfile.lint` (2026-08-10, branch `next`, closes https://git.eeqj.de/sneak/sfdupes/issues/25): dropped the false 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()