Refuse an unversioned database that already has a files table (closes #11)
check / check (push) Failing after 2s

A database at user_version 0 that already has a files table was not
created by sfdupes: a foreign or partially initialized file. scan used
to run its CREATE TABLE on it and fail with a raw SQLite error, and
report and trees gave only a bare version mismatch. All three now
check for a files table at version 0 and refuse such a database with
the schema-version error telling the operator to remove the file and
rescan. A genuinely empty database is unchanged: scan creates the
schema, report and trees refuse it as before.

Model: opus-4-8 (implementation); opus-5-5 (rebase)
This commit is contained in:
2026-10-04 08:02:43 +00:00
parent 2eeba3df6f
commit f2e71e4f41
4 changed files with 81 additions and 1 deletions
+4 -1
View File
@@ -209,7 +209,10 @@ All three subcommands operate on a single SQLite database file:
`-shm` files beside it, which SQLite creates with the database
file's permissions.
- Schema (`PRAGMA user_version` is the schema version, currently 1; a
database with any other version is a fatal error):
database with any other version is a fatal error. A database at
version 0 that already has a `files` table was not made by sfdupes;
every subcommand refuses it with an error telling the user to remove
the file and rescan):
```sql
CREATE TABLE files (
+4
View File
@@ -29,6 +29,10 @@
# Completed Steps
- refuse an unversioned database that already has a `files` table with
a clear schema-version error (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/11)
- the `Dockerfile` build stage keeps the Go module cache out of `builder`'s
home and copies the sources with `--chown`, so no `chown -R` walks them
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/43)
+36
View File
@@ -219,6 +219,12 @@ func openReportDatabase(ctx context.Context,
}
v, err := userVersion(ctx, db)
if err == nil && v == 0 {
// An empty database passes this check and fails the version
// check below.
err = checkUnversioned(ctx, db)
}
if err != nil {
_ = db.Close()
@@ -245,6 +251,11 @@ func initSchema(ctx context.Context, db *sql.DB) error {
switch v {
case 0:
err = checkUnversioned(ctx, db)
if err != nil {
return err
}
return createSchema(ctx, db)
case schemaVersion:
return nil
@@ -254,6 +265,31 @@ func initSchema(ctx context.Context, db *sql.DB) error {
}
}
// checkUnversioned checks a database at user_version 0 before it is
// taken for an empty one. A database that already has a files table
// was not created by this build: a foreign or partially initialized
// file. Adopting it could corrupt unrelated data, so that is a
// schema-version error telling the operator to remove the file and
// rescan.
func checkUnversioned(ctx context.Context, db *sql.DB) error {
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):
return nil
default:
return fmt.Errorf("check for files table: %w", err)
}
}
// createSchema applies the schema to a fresh database and stamps the
// schema version.
func createSchema(ctx context.Context, db *sql.DB) error {
+37
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
@@ -77,6 +78,42 @@ func TestOpenScanDatabaseCreates(t *testing.T) {
}
}
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 TestOpenReportDatabaseMissing(t *testing.T) {
t.Parallel()