Pin the accepted database inode through VFS registration
6 files changed,
+370
−14
internal/vfsspike/lock_failure_linux_test.go+3 −0internal/vfsspike/main_pin_linux_test.go+315 −0internal/vfsspike/process_linux_test.go+13 −4internal/vfsspike/review_boundaries_linux_test.go+2 −2internal/vfsspike/vfs_linux.go+35 −6internal/vfsspike/vfs_linux_test.go+2 −2
diff --git a/internal/vfsspike/lock_failure_linux_test.go b/internal/vfsspike/lock_failure_linux_test.go| index 732485beb..1d5466071 100644 |
| --- a/internal/vfsspike/lock_failure_linux_test.go |
| +++ b/internal/vfsspike/lock_failure_linux_test.go |
| @@ -109,6 +109,7 @@ func TestPoisonedCloseRetainsOwnershipUntilFinalFile(t *testing.T) { |
| a := openMainCallback(t, v) |
| b := openMainCallback(t, v) |
| afd := getFile(a).fd |
| + requireMainCensus(t, v, 3) |
| path := filepath.Join(dir, "main.db") |
| start := pending + 2 |
| length := int64(510) |
| @@ -147,10 +148,12 @@ func TestPoisonedCloseRetainsOwnershipUntilFinalFile(t *testing.T) { |
| if err := v.Close(); err != unix.EBUSY { |
| t.Fatalf("unregister with live file: %v", err) |
| } |
| + requireMainCensus(t, v, 3) |
| if rc := closeFile(v.tls, b); rc != sqlite.SQLITE_OK { |
| t.Fatal(rc) |
| } |
| assertKernelLock(t, path, start, length, unix.F_UNLCK) |
| + requireMainCensus(t, v, 1) |
| if len(v.retired) != 0 || len(v.db.deferred) != 0 || v.shm != nil { |
| t.Fatal("final cleanup retained ownership") |
| } |
diff --git a/internal/vfsspike/main_pin_linux_test.go b/internal/vfsspike/main_pin_linux_test.go| new file mode 100644 |
| index 000000000..8c85806e5 |
| --- /dev/null |
| +++ b/internal/vfsspike/main_pin_linux_test.go |
| @@ -0,0 +1,315 @@ |
| +//go:build linux && (amd64 || arm64) |
| + |
| +package vfsspike |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/authority" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// acceptedMain observes fixture identity without opening the main inode. |
| +func acceptedMain(t *testing.T, data *os.File, name string) authority.Facts { |
| + t.Helper() |
| + var st unix.Stat_t |
| + if err := unix.Fstatat(int(data.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + t.Fatal(err) |
| + } |
| + return authority.Facts{Dev: st.Dev, Ino: st.Ino, Mode: st.Mode, Nlink: st.Nlink} |
| +} |
| + |
| +// Stat follows proc's fd links without creating a same-inode descriptor. |
| +func mainCensus(t *testing.T, id identity) int { |
| + t.Helper() |
| + entries, err := os.ReadDir("/proc/self/fd") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + count := 0 |
| + for _, entry := range entries { |
| + var st unix.Stat_t |
| + err := unix.Stat(filepath.Join("/proc/self/fd", entry.Name()), &st) |
| + if err == unix.ENOENT { |
| + continue |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.Dev == id.dev && st.Ino == id.ino { |
| + count++ |
| + } |
| + } |
| + return count |
| +} |
| +func requireMainCensus(t *testing.T, v *VFS, want int) { |
| + t.Helper() |
| + if got := mainCensus(t, v.expected); got != want { |
| + t.Fatalf("main descriptors = %d, want %d", got, want) |
| + } |
| +} |
| + |
| +func TestMainPinZeroHandleLifetime(t *testing.T) { |
| + v, dir := setup(t) |
| + flags, err := unix.FcntlInt(uintptr(v.pin), unix.F_GETFL, 0) |
| + if err != nil || flags&unix.O_ACCMODE != unix.O_RDONLY || flags&unix.O_NONBLOCK == 0 { |
| + t.Fatalf("pin flags %x: %v", flags, err) |
| + } |
| + fdFlags, err := unix.FcntlInt(uintptr(v.pin), unix.F_GETFD, 0) |
| + if err != nil || fdFlags&unix.FD_CLOEXEC == 0 { |
| + t.Fatalf("pin fd flags %x: %v", fdFlags, err) |
| + } |
| + db := connect(t, v) |
| + execSQL(t, db, "CREATE TABLE data(value); INSERT INTO data VALUES(7)") |
| + if err := db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireMainCensus(t, v, 1) |
| + path := filepath.Join(dir, "main.db") |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("replacement"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireMainCensus(t, v, 1) |
| + var st unix.Stat_t |
| + if err := unix.Fstat(v.pin, &st); err != nil || !same(&st, v.expected) || st.Nlink != 0 { |
| + t.Fatalf("pin identity %+v: %v", st, err) |
| + } |
| + header := make([]byte, 16) |
| + if n, err := unix.Pread(v.pin, header, 0); err != nil || n != 16 || !bytes.Equal(header, []byte("SQLite format 3\x00")) { |
| + t.Fatalf("pinned header %q: %v", header, err) |
| + } |
| + next := connect(t, v) |
| + if err := next.Ping(); err == nil { |
| + t.Fatal("replacement accepted") |
| + } |
| + if err := next.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := v.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireMainCensus(t, v, 0) |
| +} |
| + |
| +func TestMainPinBusyClose(t *testing.T) { |
| + v, _ := setup(t) |
| + db := connect(t, v) |
| + execSQL(t, db, "CREATE TABLE data(value)") |
| + baseline := mainCensus(t, v.expected) |
| + if baseline != 2 { |
| + t.Fatalf("pin plus SQLite fd = %d", baseline) |
| + } |
| + if err := v.Close(); err != unix.EBUSY { |
| + t.Fatal(err) |
| + } |
| + requireMainCensus(t, v, baseline) |
| + if err := db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireMainCensus(t, v, 1) |
| + if err := v.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireMainCensus(t, v, 0) |
| +} |
| + |
| +func TestMainPinSecondRegistrationPreservesLocks(t *testing.T) { |
| + v, dir := setup(t) |
| + db := connect(t, v) |
| + execSQL(t, db, "CREATE TABLE data(value); BEGIN IMMEDIATE") |
| + path := filepath.Join(dir, "main.db") |
| + assertReserved(t, path) |
| + d, err := os.Open(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + baseline := mainCensus(t, v.expected) |
| + second, err := Register(d, d, "main.db", acceptedMain(t, d, "main.db")) |
| + if second != nil { |
| + second.Close() |
| + t.Fatal("second registration succeeded") |
| + } |
| + if err != unix.EBUSY { |
| + t.Fatal(err) |
| + } |
| + // Count before the child probe, without relying on GC to close leaked fds. |
| + requireMainCensus(t, v, baseline) |
| + assertReserved(t, path) |
| + execSQL(t, db, "COMMIT") |
| +} |
| + |
| +func pinFixture(t *testing.T) (*os.File, string, authority.Facts) { |
| + t.Helper() |
| + 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) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + return d, dir, acceptedMain(t, d, "main.db") |
| +} |
| +func requireReservationFree(t *testing.T, d *os.File) { |
| + t.Helper() |
| + v, err := Register(d, d, "main.db", acceptedMain(t, d, "main.db")) |
| + if err != nil { |
| + t.Fatal("reservation retained", err) |
| + } |
| + if err := v.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestMainPinAcceptedFacts(t *testing.T) { |
| + d, dir, accepted := pinFixture(t) |
| + if err := os.WriteFile(filepath.Join(dir, "other.db"), nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + other := acceptedMain(t, d, "other.db") |
| + v, err := Register(d, d, "main.db", other) |
| + if v != nil { |
| + v.Close() |
| + t.Fatal("wrong accepted identity registered") |
| + } |
| + if err != unix.ESTALE { |
| + t.Fatal(err) |
| + } |
| + for _, f := range []authority.Facts{accepted, other} { |
| + if n := mainCensus(t, identity{f.Dev, f.Ino}); n != 0 { |
| + t.Fatal("mismatch opened main", n) |
| + } |
| + } |
| + requireReservationFree(t, d) |
| +} |
| + |
| +func TestMainPinBeforeOpenRace(t *testing.T) { |
| + for _, kind := range []string{"replacement", "unlink", "symlink", "hardlink", "directory", "fifo"} { |
| + t.Run(kind, func(t *testing.T) { |
| + d, dir, accepted := pinFixture(t) |
| + path := filepath.Join(dir, "main.db") |
| + beforePin = func() { |
| + if err := os.Rename(path, filepath.Join(dir, "original.db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + var err error |
| + switch kind { |
| + case "replacement": |
| + err = os.WriteFile(path, nil, 0600) |
| + case "symlink": |
| + err = os.Symlink("original.db", path) |
| + case "hardlink": |
| + err = os.Link(filepath.Join(dir, "original.db"), path) |
| + case "directory": |
| + err = os.Mkdir(path, 0700) |
| + case "fifo": |
| + err = unix.Mkfifo(path, 0600) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + t.Cleanup(func() { beforePin = nil }) |
| + v, err := Register(d, d, "main.db", accepted) |
| + beforePin = nil |
| + if v != nil { |
| + v.Close() |
| + t.Fatal("raced pin accepted", kind) |
| + } |
| + if err == nil { |
| + t.Fatal("missing refusal") |
| + } |
| + if got := mainCensus(t, identity{accepted.Dev, accepted.Ino}); got != 0 { |
| + t.Fatal("refused pin leaked", got) |
| + } |
| + if _, err := unix.FcntlInt(0, unix.F_GETFD, 0); err != nil { |
| + t.Fatal("stdin closed", err) |
| + } |
| + if kind == "unlink" { |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("created missing main", err) |
| + } |
| + } else { |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.Rename(filepath.Join(dir, "original.db"), path); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireReservationFree(t, d) |
| + }) |
| + } |
| +} |
| +func TestMainPinEarlyFailureDoesNotCloseStdin(t *testing.T) { |
| + d, dir, accepted := pinFixture(t) |
| + closed, err := os.Open(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + closed.Close() |
| + if _, err := unix.FcntlInt(0, unix.F_GETFD, 0); err != nil { |
| + t.Fatal("fixture stdin", err) |
| + } |
| + v, err := Register(d, closed, "main.db", accepted) |
| + if v != nil { |
| + v.Close() |
| + t.Fatal("closed meta accepted") |
| + } |
| + if err == nil { |
| + t.Fatal("missing failure") |
| + } |
| + if _, err := unix.FcntlInt(0, unix.F_GETFD, 0); err != nil { |
| + t.Fatal("failure closed stdin", err) |
| + } |
| + requireReservationFree(t, d) |
| +} |
| +func TestMainPinReleaseOrdering(t *testing.T) { |
| + v, _ := setup(t) |
| + called, held, published := false, false, false |
| + count := -1 |
| + v.beforeRelease = func() { |
| + called = true |
| + count = mainCensus(t, v.expected) |
| + registry.RLock() |
| + held = registry.inodes[v.expected] |
| + _, published = registry.vfs[v.ptr] |
| + registry.RUnlock() |
| + } |
| + if err := v.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Assert after Close returns so a failed assertion cannot interrupt disposal. |
| + if !called || count != 0 || !held || published { |
| + t.Fatalf("release ordering: called=%v descriptors=%d held=%v published=%v", called, count, held, published) |
| + } |
| +} |
| +func TestMainPinPoisonedCleanupError(t *testing.T) { |
| + v, _ := setup(t) |
| + // An unregistered mapping makes Munmap refuse, exercising the real cleanup |
| + // error path without closing any lock-bearing descriptor outside the VFS. |
| + v.poisoned.Store(true) |
| + v.shm = &sharedMemory{fd: -1, regions: map[int32][]byte{0: {1}}} |
| + if err := v.Close(); err != unix.EIO { |
| + t.Fatal("cleanup refusal", err) |
| + } |
| + requireMainCensus(t, v, 1) |
| + registry.RLock() |
| + held := registry.inodes[v.expected] |
| + registry.RUnlock() |
| + if !held { |
| + t.Fatal("poison released reservation") |
| + } |
| + v.shm = nil |
| + if err := v.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + requireMainCensus(t, v, 0) |
| +} |
diff --git a/internal/vfsspike/process_linux_test.go b/internal/vfsspike/process_linux_test.go| index 07ec3824d..3dc1ac2a9 100644 |
| --- a/internal/vfsspike/process_linux_test.go |
| +++ b/internal/vfsspike/process_linux_test.go |
| @@ -17,6 +17,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/authority" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -36,9 +37,9 @@ func TestVFSProcessChild(t *testing.T) { |
| } |
| socket := os.NewFile(3, "rights") |
| defer socket.Close() |
| - payload, control := make([]byte, 1), make([]byte, unix.CmsgSpace(8)) |
| + payload, control := make([]byte, 4096), make([]byte, unix.CmsgSpace(8)) |
| n, oobn, flags, _, err := unix.Recvmsg(3, payload, control, unix.MSG_CMSG_CLOEXEC) |
| - if err != nil || n != 1 || flags&unix.MSG_CTRUNC != 0 { |
| + if err != nil || n == 0 || flags&(unix.MSG_CTRUNC|unix.MSG_TRUNC) != 0 { |
| t.Fatalf("receive: %d %d %v", n, flags, err) |
| } |
| messages, err := unix.ParseSocketControlMessage(control[:oobn]) |
| @@ -68,7 +69,11 @@ func TestVFSProcessChild(t *testing.T) { |
| } |
| ids = append(ids, st.Dev, st.Ino) |
| } |
| - v, err := Register(data, meta, "main.db") |
| + var accepted authority.Facts |
| + if err := json.Unmarshal(payload[:n], &accepted); err != nil { |
| + t.Fatal(err) |
| + } |
| + v, err := Register(data, meta, "main.db", accepted) |
| data.Close() |
| meta.Close() |
| if err != nil { |
| @@ -260,7 +265,11 @@ func launchVFSProcess(t *testing.T, data, meta string) *vfsProcess { |
| t.Fatal(err) |
| } |
| defer mf.Close() |
| - if err := unix.Sendmsg(int(parent.Fd()), []byte{1}, unix.UnixRights(int(df.Fd()), int(mf.Fd())), nil, 0); err != nil { |
| + payload, err := json.Marshal(acceptedMain(t, df, "main.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Sendmsg(int(parent.Fd()), payload, unix.UnixRights(int(df.Fd()), int(mf.Fd())), nil, 0); err != nil { |
| t.Fatal(err) |
| } |
| ready := p.receive() |
diff --git a/internal/vfsspike/review_boundaries_linux_test.go b/internal/vfsspike/review_boundaries_linux_test.go| index 2134eaa91..5bb17985c 100644 |
| --- a/internal/vfsspike/review_boundaries_linux_test.go |
| +++ b/internal/vfsspike/review_boundaries_linux_test.go |
| @@ -51,7 +51,7 @@ func TestUnsupportedKernelPageSizeRefused(t *testing.T) { |
| old := pageSize |
| pageSize = func() int { return 65536 } |
| defer func() { pageSize = old }() |
| - v, err := Register(d, d, "main.db") |
| + v, err := Register(d, d, "main.db", acceptedMain(t, d, "main.db")) |
| if v != nil { |
| v.Close() |
| } |
| @@ -59,7 +59,7 @@ func TestUnsupportedKernelPageSizeRefused(t *testing.T) { |
| t.Fatalf("64KiB page kernel accepted: %v", err) |
| } |
| pageSize = old |
| - v, err = Register(d, d, "main.db") |
| + v, err = Register(d, d, "main.db", acceptedMain(t, d, "main.db")) |
| if err != nil { |
| t.Fatalf("refusal retained registration: %v", err) |
| } |
diff --git a/internal/vfsspike/vfs_linux.go b/internal/vfsspike/vfs_linux.go| index 69c2f85ee..760694f27 100644 |
| --- a/internal/vfsspike/vfs_linux.go |
| +++ b/internal/vfsspike/vfs_linux.go |
| @@ -18,6 +18,7 @@ import ( |
| "time" |
| "unsafe" |
| + "github.com/carlosframework/platform/internal/authority" |
| "golang.org/x/sys/unix" |
| "modernc.org/libc" |
| sqlite "modernc.org/sqlite/lib" |
| @@ -27,7 +28,7 @@ type identity struct{ dev, ino uint64 } |
| type VFS struct { |
| mu sync.Mutex |
| Name string |
| - data, meta int |
| + data, meta, pin int |
| main string |
| expected identity |
| ptr, cname, methods uintptr |
| @@ -36,6 +37,7 @@ type VFS struct { |
| closed bool |
| rejected []int |
| beforeOpen func(string) |
| + beforeRelease func() |
| shmAllocate func(int, int64) error |
| lockHook func(int, int16, int64, int64) error |
| poisoned atomic.Bool |
| @@ -67,6 +69,7 @@ var registry = struct { |
| var barrier atomic.Uint64 |
| var pageSize = unix.Getpagesize |
| +var beforePin func() |
| func fp[T any](f T) uintptr { return *(*uintptr)(unsafe.Pointer(&f)) } |
| @@ -80,8 +83,10 @@ func plain(name string) bool { |
| } |
| // Register borrows two already-open directory descriptors and retains its own |
| -// copies. The main file must already exist. Only one custom VFS may be registered in the process; a second is refused before opening any additional main descriptor. |
| -func Register(data, meta *os.File, name string) (*VFS, error) { |
| +// copies. After reserving the process's single registration, it opens and pins |
| +// the existing regular main file described by accepted. A refused second |
| +// registration never opens main: closing that fd would drop the live POSIX locks. |
| +func Register(data, meta *os.File, name string, accepted authority.Facts) (*VFS, error) { |
| if !plain(name) { |
| return nil, unix.EINVAL |
| } |
| @@ -95,7 +100,10 @@ func Register(data, meta *os.File, name string) (*VFS, error) { |
| if s.Mode&unix.S_IFMT != unix.S_IFREG || s.Nlink != 1 { |
| return nil, unix.EINVAL |
| } |
| - id := identity{s.Dev, s.Ino} |
| + id := identity{accepted.Dev, accepted.Ino} |
| + if !same(&s, id) { |
| + return nil, unix.ESTALE |
| + } |
| registry.Lock() |
| if len(registry.inodes) != 0 { |
| registry.Unlock() |
| @@ -103,7 +111,7 @@ func Register(data, meta *os.File, name string) (*VFS, error) { |
| } |
| registry.inodes[id] = true |
| registry.Unlock() |
| - v := &VFS{data: -1, meta: -1, main: name, expected: id, files: make(map[uintptr]*file), tls: libc.NewTLS()} |
| + v := &VFS{data: -1, meta: -1, pin: -1, main: name, expected: id, files: make(map[uintptr]*file), tls: libc.NewTLS()} |
| var err error |
| defer func() { |
| if err != nil { |
| @@ -118,6 +126,17 @@ func Register(data, meta *os.File, name string) (*VFS, error) { |
| if err != nil { |
| return nil, err |
| } |
| + if beforePin != nil { |
| + beforePin() |
| + } |
| + v.pin, err = unix.Openat(v.data, name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_NOCTTY|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err = unix.Fstat(v.pin, &s); err != nil || s.Nlink != 1 || !same(&s, id) { |
| + err = unix.ESTALE |
| + return nil, err |
| + } |
| nonce := make([]byte, 8) |
| if _, err = rand.Read(nonce); err != nil { |
| return nil, err |
| @@ -180,8 +199,11 @@ func (v *VFS) Close() error { |
| func (v *VFS) dispose() { |
| registry.Lock() |
| delete(registry.vfs, v.ptr) |
| - delete(registry.inodes, v.expected) |
| registry.Unlock() |
| + if v.pin >= 0 { |
| + unix.Close(v.pin) |
| + v.pin = -1 |
| + } |
| if v.data >= 0 { |
| unix.Close(v.data) |
| } |
| @@ -198,6 +220,13 @@ func (v *VFS) dispose() { |
| libc.Xfree(v.tls, v.cname) |
| } |
| v.tls.Close() |
| + // Keep the reservation until no owned descriptor can release another VFS's locks. |
| + if v.beforeRelease != nil { |
| + v.beforeRelease() |
| + } |
| + registry.Lock() |
| + delete(registry.inodes, v.expected) |
| + registry.Unlock() |
| } |
| func (v *VFS) allowed(name string) bool { |
| return name == v.main || name == v.main+"-wal" || name == v.main+"-journal" || name == v.main+"-shm" |
diff --git a/internal/vfsspike/vfs_linux_test.go b/internal/vfsspike/vfs_linux_test.go| index 9fa72836e..c4cf81965 100644 |
| --- a/internal/vfsspike/vfs_linux_test.go |
| +++ b/internal/vfsspike/vfs_linux_test.go |
| @@ -27,7 +27,7 @@ func setup(t *testing.T) (*VFS, string) { |
| t.Fatal(err) |
| } |
| defer d.Close() |
| - v, err := Register(d, d, "main.db") |
| + v, err := Register(d, d, "main.db", acceptedMain(t, d, "main.db")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -290,7 +290,7 @@ func TestSecondRegistrationRefused(t *testing.T) { |
| t.Fatal(err) |
| } |
| defer d.Close() |
| - if other, err := Register(d, d, "other.db"); err == nil { |
| + if other, err := Register(d, d, "other.db", acceptedMain(t, d, "other.db")); err == nil { |
| other.Close() |
| t.Fatal("second registration accepted") |
| } |