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()