Refuse an unversioned database that already has a files table (closes #11)
check / check (push) Failing after 2s
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:
@@ -209,7 +209,10 @@ All three subcommands operate on a single SQLite database file:
|
|||||||
`-shm` files beside it, which SQLite creates with the database
|
`-shm` files beside it, which SQLite creates with the database
|
||||||
file's permissions.
|
file's permissions.
|
||||||
- Schema (`PRAGMA user_version` is the schema version, currently 1; a
|
- 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
|
```sql
|
||||||
CREATE TABLE files (
|
CREATE TABLE files (
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
home and copies the sources with `--chown`, so no `chown -R` walks them
|
||||||
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/43)
|
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/43)
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ func openReportDatabase(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
v, err := userVersion(ctx, db)
|
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 {
|
if err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
|
|
||||||
@@ -245,6 +251,11 @@ func initSchema(ctx context.Context, db *sql.DB) error {
|
|||||||
|
|
||||||
switch v {
|
switch v {
|
||||||
case 0:
|
case 0:
|
||||||
|
err = checkUnversioned(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return createSchema(ctx, db)
|
return createSchema(ctx, db)
|
||||||
case schemaVersion:
|
case schemaVersion:
|
||||||
return nil
|
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
|
// createSchema applies the schema to a fresh database and stamps the
|
||||||
// schema version.
|
// schema version.
|
||||||
func createSchema(ctx context.Context, db *sql.DB) error {
|
func createSchema(ctx context.Context, db *sql.DB) error {
|
||||||
|
|||||||
+37
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestOpenReportDatabaseMissing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user