Refuse unsafe VFS opens and verify custom-first crash recovery
4 files changed,
+205
−13
internal/vfsspike/locks_linux.go+12 −7internal/vfsspike/process_linux_test.go+21 −4internal/vfsspike/review_boundaries_linux_test.go+164 −0internal/vfsspike/vfs_linux.go+8 −2
CI passed — run details
diff --git a/internal/vfsspike/locks_linux.go b/internal/vfsspike/locks_linux.go| index 761aebd1c..0ee3b9cde 100644 |
| --- a/internal/vfsspike/locks_linux.go |
| +++ b/internal/vfsspike/locks_linux.go |
| @@ -97,13 +97,18 @@ func shmMap(t *libc.TLS, p uintptr, page, size, extend int32, out uintptr) int32 |
| return sqlite.SQLITE_IOERR_SHMSIZE |
| } |
| end := (int64(page) + 1) * int64(size) |
| - if s.Size < end { |
| - if extend == 0 { |
| - return sqlite.SQLITE_OK |
| - } |
| - if unix.Ftruncate(f.shm, end) != nil { |
| - return sqlite.SQLITE_IOERR_SHMSIZE |
| - } |
| + if s.Size < end && extend == 0 { |
| + return sqlite.SQLITE_OK |
| + } |
| + // File length is not proof of backing: a failed allocation may have |
| + // extended the file while leaving holes. Reserve every unmapped extent |
| + // before mmap, preserving existing bytes. Unsupported filesystems refuse. |
| + allocate := f.v.shmAllocate |
| + if allocate == nil { |
| + allocate = func(fd int, end int64) error { return unix.Fallocate(fd, 0, 0, end) } |
| + } |
| + if allocate(f.shm, end) != nil { |
| + return sqlite.SQLITE_IOERR_SHMSIZE |
| } |
| b, err := unix.Mmap(f.shm, int64(page)*int64(size), int(size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) |
| if err != nil { |
diff --git a/internal/vfsspike/process_linux_test.go b/internal/vfsspike/process_linux_test.go| index 7de94a420..07ec3824d 100644 |
| --- a/internal/vfsspike/process_linux_test.go |
| +++ b/internal/vfsspike/process_linux_test.go |
| @@ -27,6 +27,7 @@ type processResponse struct { |
| Rows [][]int64 |
| Inodes []uint64 |
| TempFiles []string |
| + Integrity string |
| } |
| func TestVFSProcessChild(t *testing.T) { |
| @@ -118,6 +119,8 @@ func TestVFSProcessChild(t *testing.T) { |
| } |
| v.mu.Unlock() |
| } |
| + case "integrity": |
| + err = db.QueryRow("PRAGMA integrity_check").Scan(&response.Integrity) |
| case "query": |
| var rows *sql.Rows |
| rows, err = db.Query(req.SQL) |
| @@ -519,16 +522,30 @@ func TestVFSProcessCrashRecovery(t *testing.T) { |
| t.Fatalf("uncommitted transaction did not spill: before=%d after=%d", before.Size(), after.Size()) |
| } |
| t.Logf("uncommitted %s spill grew %s from %d to %d bytes before SIGKILL", mode, filepath.Base(spillPath), before.Size(), after.Size()) |
| + // No ordinary connection may recover or retain a SHM dead-man lock |
| + // before the new custom process gets the first post-crash read. |
| + if err := db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| p.stop(true) |
| + next := launchVFSProcess(t, data, meta) |
| + next.ok("open", "") |
| + next.count(1) |
| + if got := next.ok("integrity", "").Integrity; got != "ok" { |
| + t.Fatalf("custom recovery integrity %q", got) |
| + } |
| + if mode == "DELETE" { |
| + if _, err := os.Stat(filepath.Join(data, "main.db-journal")); !os.IsNotExist(err) { |
| + t.Fatalf("custom recovery retained hot journal: %v", err) |
| + } |
| + } |
| + db = processOrdinary(t, filepath.Join(data, "main.db")) |
| ordinaryCount(t, db, 1) |
| var check string |
| if err := db.QueryRow("PRAGMA integrity_check").Scan(&check); err != nil || check != "ok" { |
| - t.Fatalf("integrity %q: %v", check, err) |
| + t.Fatalf("ordinary confirmation integrity %q: %v", check, err) |
| } |
| execSQL(t, db, "INSERT INTO data VALUES(2,NULL)") |
| - ordinaryCount(t, db, 2) |
| - next := launchVFSProcess(t, data, meta) |
| - next.ok("open", "") |
| next.count(2) |
| next.stop(false) |
| }) |
diff --git a/internal/vfsspike/review_boundaries_linux_test.go b/internal/vfsspike/review_boundaries_linux_test.go| new file mode 100644 |
| index 000000000..2134eaa91 |
| --- /dev/null |
| +++ b/internal/vfsspike/review_boundaries_linux_test.go |
| @@ -0,0 +1,164 @@ |
| +//go:build linux && (amd64 || arm64) |
| + |
| +package vfsspike |
| + |
| +import ( |
| + "bytes" |
| + "database/sql" |
| + "golang.org/x/sys/unix" |
| + "modernc.org/libc" |
| + sqlite "modernc.org/sqlite/lib" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestReadOnlyWALMainRefusedWithoutPoison(t *testing.T) { |
| + v, _ := setup(t) |
| + a := connect(t, v) |
| + execSQL(t, a, "PRAGMA journal_mode=WAL; CREATE TABLE data(value); INSERT INTO data VALUES(1)") |
| + b, err := sql.Open("sqlite", "file:main.db?vfs="+v.Name+"&mode=ro") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b.SetMaxOpenConns(1) |
| + var count int |
| + readErr := b.QueryRow("SELECT count(*) FROM data").Scan(&count) |
| + closeErr := b.Close() |
| + _, writeErr := a.Exec("INSERT INTO data VALUES(2)") |
| + t.Logf("read-only read=%v close=%v poisoned=%v surviving writer=%v", readErr, closeErr, v.poisoned.Load(), writeErr) |
| + if readErr == nil { |
| + t.Error("read-only main was accepted") |
| + } |
| + if v.poisoned.Load() || writeErr != nil { |
| + t.Fatal("read-only attempt damaged existing writer") |
| + } |
| + if scalar(t, a, "SELECT count(*) FROM data") != 2 { |
| + t.Fatal("surviving writer lost row") |
| + } |
| +} |
| + |
| +func TestUnsupportedKernelPageSizeRefused(t *testing.T) { |
| + dir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(dir, "main.db"), nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + d, err := os.Open(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + old := pageSize |
| + pageSize = func() int { return 65536 } |
| + defer func() { pageSize = old }() |
| + v, err := Register(d, d, "main.db") |
| + if v != nil { |
| + v.Close() |
| + } |
| + if err != unix.ENOTSUP { |
| + t.Fatalf("64KiB page kernel accepted: %v", err) |
| + } |
| + pageSize = old |
| + v, err = Register(d, d, "main.db") |
| + if err != nil { |
| + t.Fatalf("refusal retained registration: %v", err) |
| + } |
| + if err := v.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestMainUnlinkBeforeOpenDoesNotCreate(t *testing.T) { |
| + v, dir := setup(t) |
| + v.beforeOpen = func(name string) { |
| + if name == "main.db" { |
| + if err := os.Remove(filepath.Join(dir, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + name, err := libc.CString("main.db") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer libc.Xfree(v.tls, name) |
| + out := sqliteBuffer(t, v.tls, 32) |
| + rc := openFile(v.tls, v.ptr, name, out, sqlite.SQLITE_OPEN_MAIN_DB|sqlite.SQLITE_OPEN_READWRITE|sqlite.SQLITE_OPEN_CREATE, 0) |
| + if rc != sqlite.SQLITE_CANTOPEN { |
| + if rc == sqlite.SQLITE_OK { |
| + closeFile(v.tls, out) |
| + } |
| + t.Fatalf("unlinked main opened: %d", rc) |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "main.db")); !os.IsNotExist(err) { |
| + t.Fatalf("stray main created: %v", err) |
| + } |
| +} |
| + |
| +func TestSHMMappingAllocatesBacking(t *testing.T) { |
| + v, _ := setup(t) |
| + p := openMainCallback(t, v) |
| + out := sqliteBuffer(t, v.tls, 8) |
| + if rc := shmMap(v.tls, p, 0, 32768, 0, out); rc != 0 || foreignLoad[uintptr](v.tls, out) != 0 { |
| + t.Fatalf("nonextending absent region: %d", rc) |
| + } |
| + if rc := shmMap(v.tls, p, 0, 32768, 1, out); rc != 0 { |
| + t.Fatal(rc) |
| + } |
| + var st unix.Stat_t |
| + if err := unix.Fstat(v.shm.fd, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.Size != 32768 || st.Blocks*512 < 32768 { |
| + t.Fatalf("SHM has sparse backing: size=%d allocated=%d", st.Size, st.Blocks*512) |
| + } |
| +} |
| + |
| +func TestSHMAllocationFailureRetryPreservesBytes(t *testing.T) { |
| + for _, failure := range []error{unix.ENOSPC, unix.EOPNOTSUPP} { |
| + t.Run(failure.Error(), func(t *testing.T) { |
| + v, _ := setup(t) |
| + p := openMainCallback(t, v) |
| + out := sqliteBuffer(t, v.tls, 8) |
| + if rc := shmMap(v.tls, p, 0, 32768, 1, out); rc != 0 { |
| + t.Fatal(rc) |
| + } |
| + sentinel := bytes.Repeat([]byte{0xa5}, 32768) |
| + if n, err := unix.Pwrite(v.shm.fd, sentinel, 0); err != nil || n != len(sentinel) { |
| + t.Fatalf("sentinel: %d %v", n, err) |
| + } |
| + calls := 0 |
| + v.shmAllocate = func(fd int, end int64) error { |
| + calls++ |
| + if calls == 1 { |
| + if err := unix.Ftruncate(fd, end); err != nil { |
| + t.Fatal(err) |
| + } |
| + return failure |
| + } |
| + return unix.Fallocate(fd, 0, 0, end) |
| + } |
| + foreignStore(v.tls, out, uintptr(123)) |
| + if rc := shmMap(v.tls, p, 1, 32768, 1, out); rc != sqlite.SQLITE_IOERR_SHMSIZE || foreignLoad[uintptr](v.tls, out) != 0 || len(v.shm.regions) != 1 { |
| + t.Fatalf("allocation failure mapped: rc=%d regions=%d", rc, len(v.shm.regions)) |
| + } |
| + // The failed allocator grew the logical size. Even extend=0 must now |
| + // prove backing before returning a mapping for that existing extent. |
| + if rc := shmMap(v.tls, p, 1, 32768, 0, out); rc != 0 || foreignLoad[uintptr](v.tls, out) == 0 || calls != 2 { |
| + t.Fatalf("retry skipped backing: rc=%d calls=%d", rc, calls) |
| + } |
| + got := make([]byte, len(sentinel)) |
| + if n, err := unix.Pread(v.shm.fd, got, 0); err != nil || n != len(got) || !bytes.Equal(got, sentinel) { |
| + t.Fatal("allocation changed existing SHM bytes") |
| + } |
| + var st unix.Stat_t |
| + if err := unix.Fstat(v.shm.fd, &st); err != nil || st.Blocks*512 < 65536 { |
| + t.Fatalf("retry left sparse backing: %+v %v", st, err) |
| + } |
| + v.shmAllocate = func(int, int64) error { t.Error("cached map allocated again"); return unix.ENOSPC } |
| + if rc := shmMap(v.tls, p, 1, 32768, 0, out); rc != 0 { |
| + t.Fatal(rc) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/vfsspike/vfs_linux.go b/internal/vfsspike/vfs_linux.go| index d1ee8a7e7..69c2f85ee 100644 |
| --- a/internal/vfsspike/vfs_linux.go |
| +++ b/internal/vfsspike/vfs_linux.go |
| @@ -36,6 +36,7 @@ type VFS struct { |
| closed bool |
| rejected []int |
| beforeOpen func(string) |
| + shmAllocate func(int, int64) error |
| lockHook func(int, int16, int64, int64) error |
| poisoned atomic.Bool |
| retired []*file |
| @@ -65,6 +66,8 @@ var registry = struct { |
| }{vfs: make(map[uintptr]*VFS), files: make(map[uintptr]*file), inodes: make(map[identity]bool)} |
| var barrier atomic.Uint64 |
| +var pageSize = unix.Getpagesize |
| + |
| func fp[T any](f T) uintptr { return *(*uintptr)(unsafe.Pointer(&f)) } |
| func getVFS(p uintptr) *VFS { registry.RLock(); defer registry.RUnlock(); return registry.vfs[p] } |
| @@ -82,6 +85,9 @@ func Register(data, meta *os.File, name string) (*VFS, error) { |
| if !plain(name) { |
| return nil, unix.EINVAL |
| } |
| + if pageSize() > 32768 { |
| + return nil, unix.ENOTSUP |
| + } |
| var s unix.Stat_t |
| if err := unix.Fstatat(int(data.Fd()), name, &s, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| return nil, err |
| @@ -231,7 +237,7 @@ func openFile(tls *libc.TLS, p, z, out uintptr, flags int32, outFlags uintptr) i |
| return sqlite.SQLITE_CANTOPEN |
| } |
| isMain := flags&sqlite.SQLITE_OPEN_MAIN_DB != 0 |
| - if isMain && name != v.main { |
| + if isMain && (name != v.main || flags&sqlite.SQLITE_OPEN_READWRITE == 0 || flags&sqlite.SQLITE_OPEN_READONLY != 0) { |
| return sqlite.SQLITE_CANTOPEN |
| } |
| if !isMain && !temporary && name == v.main { |
| @@ -243,7 +249,7 @@ func openFile(tls *libc.TLS, p, z, out uintptr, flags int32, outFlags uintptr) i |
| } else { |
| oflags |= unix.O_RDONLY |
| } |
| - if flags&sqlite.SQLITE_OPEN_CREATE != 0 { |
| + if !isMain && flags&sqlite.SQLITE_OPEN_CREATE != 0 { |
| oflags |= unix.O_CREAT |
| } |
| if temporary || flags&sqlite.SQLITE_OPEN_EXCLUSIVE != 0 { |