mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
85c49cf2fd
* feat(appstore): FencedTx, the appstore skeleton, and the boundary and write-capture guards (TASK-3388)
SPEC-6 U1 (DOC-3371 v12 §4): the door every app write will go through, and
the two tests that guard it. No app mutations or routes yet; U2 adds them
under these guards.
- store.FencedTx:
- BeginFenced's first statement reads app_installs (auth_epoch, state,
workspace), FOR SHARE on Postgres, and refuses unless the admitted epoch
is current, the install is active and the workspace matches.
- There is no raw Exec and no free-form query. The lock primitives
(LockWorkspaceSeq, NextItemNumber, NextSeq) take the human item path's
own workspace lock.
- Companion guards: requireCompanionCollection, and requireCompanionItem
for derived rows.
- Every field is unexported, and a zero value refuses every method.
- internal/appstore: holds *store.Store concretely; withFence runs one
mutation in one FencedTx; CheckFence is the live path until U2.
- The boundary test (go/packages + go/types) walks every module function
the package can reach. It fails on:
- a *store.Store method off a reviewed allow-list (stronger than the
spec's deny-list)
- database/sql calls outside the door
- Store.db or (*Store).DB
- an interface call or declaration an internal/store type answers
- reflect or unsafe
- the ordinary attachments Put
One fixture per rule under testdata is asserted to be reported.
- The reverse test: no package outside internal/store and internal/appstore
references BeginFenced.
- storetest.CaptureWrites:
- SQLite: modernc's pre-update hook on a single connection, with a
sentinel write proving the hook is still on it.
- Postgres: statement-level audit triggers installed by the harness.
The harness is proven red on a human create (items plus FTS5 shadow
tables) and on a test-only trigger write at depth > 0, and silent on a
read. The blind inventory is asserted exactly: items_fts, comments_fts,
documents_fts and sqlite_sequence.
- guardConn.Unwrap exposes the driver connection to the harness.
- Migration: app_installs (SQLite 112 / PG 086); its columns are added to
the NUL baseline, since no writer exists before U8, which must validate
origin.
Moved to U2, where they are first used: DebounceActivity, CheckItemCap and
the via_app columns.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): close the boundary checker's gaps and the harness's coverage holes (TASK-3388)
Codex round 1. Each gap now has a testdata fixture asserted to be reported,
and the six P1 fixtures were checked against the round-0 checker:
- package-var: package-level var initializers and init functions are walked
for the root and every module package it brings in that the human store's
own closure does not
- generic: instantiated generic methods are normalized through Origin()
- embedded-sql: rules check a method's DECLARING receiver, so a method
promoted through an embedded *sql.DB or *store.Store is caught at the call
- func-field: a call through a function value is refused (dynamic-call)
unless it is a parameter of an unexported root function that every call
site passes a literal or a named function
- linkname: a go:linkname directive or an unsafe import in a walked package
is refused
- whole module: the walk covers the whole module, not only internal/
- embedded-store: the old checker caught this only by walking into
DeleteItem's body; it is now caught at the call
Against the round-0 checker, all six P1 fixtures fail (pkgvar, helperinit,
generic, embeddedsql, funcfield, linkname); the extra embeddedstore fixture
passed there, for the reason above.
Harness:
- SQLite: PRAGMA data_version, read on the hooked connection at the start
and end, refuses a capture during which another connection committed. A
replaced connection is refused at the sentinel. Both branches are tested;
the data_version test passes silently without the check.
- Postgres: triggers are ensured at every capture, so tables created later
are covered; a table created during a capture is refused; captures are
serialized per database.
- The Postgres lock tests wait until pg_stat_activity shows a backend
waiting on a Lock, instead of treating 500ms as proof of blocking.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): refuse every function value instead of verifying callbacks; working-trigger check (TASK-3388)
Codex round 2 found five P1s in the checker and one P2 in the harness.
Four of the P1s were bypasses of one mechanism: verifying where a callback
parameter came from. They were reassignment, aliasing through a pointer, a
method expression shifting the argument index, and interface dispatch past
the call-site census. This is a guard, not a proof, so the mechanism is
removed rather than patched again:
- withFence(fn) is gone; app mutations open and commit their FencedTx
directly.
- Every call through a function value is refused (dynamic-call).
- Any use of a variable, parameter or field of function type is refused
(func-value). This also closes the fifth P1: an exported function-typed
package var in the store's own import closure, run through
sync.Once.Do, whose initializer the walk excludes.
Interface checks now judge the INSTANTIATED receiver, since Mutator[string]
can be implemented where Mutator[T] is not; Origin() is used only for
indexing.
Harness P2: a pad_write_audit_t trigger counts only when it is enabled,
statement-level, AFTER, on INSERT/UPDATE/DELETE, and calls
pad_write_audit_fn. Otherwise it is dropped and recreated.
Fixtures genericiface, callbackparam, methodexprcallback and oncevar each
pass against the round-1 checker and are caught now. A Postgres test
disables the trigger and expects the next capture to see the write.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): refuse interface dispatch on unbound type arguments (TASK-3388)
Codex round 3: a generic helper run[T](m Mutator[T]) is walked once, with T
unbound, so Mutator[T] never matched *store.Store and the call went
unflagged. An interface call whose receiver is, or is instantiated with, a
type parameter is now refused, rather than propagating type arguments
through the walk. Fixture genericifacebody passes the round-2 checker and is
caught now.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(appstore): no interface dispatch inside a generic body (TASK-3388)
Codex round 4 found two more ways a type parameter hides from the
round-3 check:
- an anonymous interface constraint (or a generic alias)
- a type parameter nested inside a func, slice, map or alias type argument
Chasing type parameters through every type form is open-ended, so the class
is closed outright: a generic function body, walked once with its parameters
unbound, may make no interface method call except on error. A non-generic
function cannot hold an unbound type parameter, so the ordinary
implemented-by-store check stays exact there. Fixtures genericconstraint and
genericnested both pass the round-3 checker and are caught now.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
273 lines
9.2 KiB
Go
273 lines
9.2 KiB
Go
package storetest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql/driver"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"testing"
|
|
|
|
"modernc.org/sqlite"
|
|
sqlite3 "modernc.org/sqlite/lib"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// Write is one write the database recorded while a capture was running.
|
|
type Write struct {
|
|
Table string
|
|
Op string // INSERT, UPDATE or DELETE
|
|
// Depth is SQLite's sqlite3_preupdate_depth: 0 for a statement the
|
|
// caller ran, above 0 for a write a trigger made. Postgres reports 0.
|
|
Depth int
|
|
}
|
|
|
|
// CaptureWrites runs fn and returns every table write it caused, as the
|
|
// DATABASE saw it, triggers included (SPEC-6 §4, TASK-3388). It is the
|
|
// app store's write census instrument: a GUARD, not a proof.
|
|
//
|
|
// SQLite: the store is pinned to ONE connection (MaxOpenConns(1), no
|
|
// lifetime or idle expiry), and modernc's pre-update hook is registered on
|
|
// it, so every statement fn runs is observed. The hook does NOT fire for
|
|
// virtual tables or SQLite's own system tables. That blind inventory is
|
|
// asserted exactly by TestBlindInventory; FTS5's shadow tables are ordinary
|
|
// tables and ARE observed. A sentinel write into a temp table before and
|
|
// after fn proves the hook was still on the connection fn used. The store is
|
|
// left on one connection afterwards: a test that captures must not then
|
|
// expect concurrent store work.
|
|
//
|
|
// Postgres: a statement-level AFTER trigger on every table (installed by
|
|
// this harness, never by a shipped migration) appends to pad_write_audit,
|
|
// and the rows fn added are returned. A statement that changes zero rows
|
|
// still fires, so Postgres over-reports rather than under-reports.
|
|
func CaptureWrites(t testing.TB, s *store.Store, fn func()) []Write {
|
|
t.Helper()
|
|
if s.D().Driver() == store.DriverPostgres {
|
|
return capturePostgres(t, s, fn)
|
|
}
|
|
return captureSQLite(t, s, fn)
|
|
}
|
|
|
|
// Tables returns the distinct tables in ws, sorted.
|
|
func Tables(ws []Write) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, w := range ws {
|
|
if !seen[w.Table] {
|
|
seen[w.Table] = true
|
|
out = append(out, w.Table)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
const sentinelTable = "pad_capture_sentinel"
|
|
|
|
func captureSQLite(t testing.TB, s *store.Store, fn func()) []Write {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
db := s.DB()
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
db.SetConnMaxLifetime(0)
|
|
db.SetConnMaxIdleTime(0)
|
|
|
|
var mu sync.Mutex
|
|
var writes []Write
|
|
hook := func(d sqlite.SQLitePreUpdateData) {
|
|
op := "?"
|
|
switch d.Op {
|
|
case sqlite3.SQLITE_INSERT:
|
|
op = "INSERT"
|
|
case sqlite3.SQLITE_UPDATE:
|
|
op = "UPDATE"
|
|
case sqlite3.SQLITE_DELETE:
|
|
op = "DELETE"
|
|
}
|
|
mu.Lock()
|
|
writes = append(writes, Write{Table: d.TableName, Op: op, Depth: d.Depth()})
|
|
mu.Unlock()
|
|
}
|
|
setHook := func(h sqlite.PreUpdateHookFn) {
|
|
conn, err := db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("capture: conn: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
if err := conn.Raw(func(dc any) error {
|
|
// The store wraps the driver connection in its NUL guard.
|
|
if u, ok := dc.(interface{ Unwrap() driver.Conn }); ok {
|
|
dc = u.Unwrap()
|
|
}
|
|
r, ok := dc.(sqlite.HookRegisterer)
|
|
if !ok {
|
|
return fmt.Errorf("driver connection %T has no pre-update hook", dc)
|
|
}
|
|
r.RegisterPreUpdateHook(h)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("capture: %v", err)
|
|
}
|
|
}
|
|
sentinel := func(when string) {
|
|
mu.Lock()
|
|
before := len(writes)
|
|
mu.Unlock()
|
|
if _, err := db.Exec(`INSERT INTO temp.`+sentinelTable+` (at) VALUES (?)`, when); err != nil {
|
|
// The temp table exists only on the hooked connection.
|
|
t.Fatalf("capture: the %s sentinel failed (%v): the store is no longer on the hooked connection", when, err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(writes) != before+1 || writes[len(writes)-1].Table != sentinelTable {
|
|
t.Fatalf("capture: the %s sentinel was not observed; the hook is not on the connection the store used", when)
|
|
}
|
|
writes = writes[:before]
|
|
}
|
|
|
|
// PRAGMA data_version, read on the hooked connection, changes when ANY
|
|
// OTHER connection commits to the database. Equal values at start and end
|
|
// mean every write fn committed went through the hooked connection, even
|
|
// if something widened the pool in between.
|
|
dataVersion := func() int64 {
|
|
var v int64
|
|
if err := db.QueryRow(`PRAGMA data_version`).Scan(&v); err != nil {
|
|
t.Fatalf("capture: data_version: %v", err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
if _, err := db.Exec(`CREATE TEMP TABLE IF NOT EXISTS ` + sentinelTable + ` (at TEXT)`); err != nil {
|
|
t.Fatalf("capture: sentinel table: %v", err)
|
|
}
|
|
setHook(hook)
|
|
defer setHook(nil)
|
|
sentinel("start")
|
|
dvStart := dataVersion()
|
|
fn()
|
|
// Pin the pool back to one connection before reading, so the read and
|
|
// the end sentinel land on the hooked connection if it still exists.
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
sentinel("end")
|
|
if dvEnd := dataVersion(); dvEnd != dvStart {
|
|
t.Fatalf("capture: another connection committed during capture (data_version %d -> %d); its writes were not observed", dvStart, dvEnd)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]Write(nil), writes...)
|
|
}
|
|
|
|
// pgCaptureLocks serializes captures per database: the audit window is a
|
|
// range of audit ids, so two captures running at once would read each other's
|
|
// writes, and one installing triggers could race the other's callback.
|
|
var pgCaptureLocks sync.Map // database name -> *sync.Mutex
|
|
|
|
func capturePostgres(t testing.TB, s *store.Store, fn func()) []Write {
|
|
t.Helper()
|
|
db := s.DB()
|
|
var dbName string
|
|
if err := db.QueryRow(`SELECT current_database()`).Scan(&dbName); err != nil {
|
|
t.Fatalf("capture: %v", err)
|
|
}
|
|
mu, _ := pgCaptureLocks.LoadOrStore(dbName, &sync.Mutex{})
|
|
mu.(*sync.Mutex).Lock()
|
|
defer mu.(*sync.Mutex).Unlock()
|
|
// Every capture re-checks coverage: a table created since the last one
|
|
// gets its trigger now.
|
|
installPGAudit(t, s)
|
|
var start int64
|
|
if err := db.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM pad_write_audit`).Scan(&start); err != nil {
|
|
t.Fatalf("capture: audit start: %v", err)
|
|
}
|
|
fn()
|
|
rows, err := db.Query(`SELECT tbl, op FROM pad_write_audit WHERE id > $1 ORDER BY id`, start)
|
|
if err != nil {
|
|
t.Fatalf("capture: audit read: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var writes []Write
|
|
for rows.Next() {
|
|
var w Write
|
|
if err := rows.Scan(&w.Table, &w.Op); err != nil {
|
|
t.Fatalf("capture: audit scan: %v", err)
|
|
}
|
|
writes = append(writes, w)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("capture: audit rows: %v", err)
|
|
}
|
|
// A table created DURING fn has no trigger, so its writes were not seen.
|
|
if missing := unauditedTables(t, s); len(missing) > 0 {
|
|
t.Fatalf("capture: tables created during capture are unaudited: %v", missing)
|
|
}
|
|
return writes
|
|
}
|
|
|
|
// unauditedTables lists base tables in the store's schema with no WORKING
|
|
// audit trigger. A same-named trigger only counts when it is enabled,
|
|
// statement-level, AFTER, fires on INSERT, UPDATE and DELETE, and calls
|
|
// pad_write_audit_fn (tgtype bits: ROW 1, BEFORE 2, INSERT 4, DELETE 8,
|
|
// UPDATE 16, INSTEAD 64). A disabled or repointed one is not coverage.
|
|
func unauditedTables(t testing.TB, s *store.Store) []string {
|
|
t.Helper()
|
|
rows, err := s.DB().Query(`SELECT c.relname FROM pg_class c
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p') AND c.relname <> 'pad_write_audit'
|
|
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg
|
|
WHERE tg.tgrelid = c.oid AND tg.tgname = 'pad_write_audit_t'
|
|
AND tg.tgenabled IN ('O', 'A')
|
|
AND (tg.tgtype & (1 | 2 | 64)) = 0
|
|
AND (tg.tgtype & (4 | 8 | 16)) = (4 | 8 | 16)
|
|
AND tg.tgfoid = (SELECT p.oid FROM pg_proc p JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
|
WHERE p.proname = 'pad_write_audit_fn' AND pn.nspname = current_schema()))`)
|
|
if err != nil {
|
|
t.Fatalf("capture: list unaudited: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
t.Fatalf("capture: list unaudited: %v", err)
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// installPGAudit puts a statement-level AFTER trigger on every base table in
|
|
// the store's schema that does not have one yet. It runs at the start of
|
|
// every capture, so a table created after an earlier capture is covered.
|
|
func installPGAudit(t testing.TB, s *store.Store) {
|
|
t.Helper()
|
|
db := s.DB()
|
|
for _, stmt := range []string{
|
|
`CREATE TABLE IF NOT EXISTS pad_write_audit (id BIGSERIAL PRIMARY KEY, txid BIGINT NOT NULL, tbl TEXT NOT NULL, op TEXT NOT NULL)`,
|
|
`CREATE OR REPLACE FUNCTION pad_write_audit_fn() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
INSERT INTO pad_write_audit (txid, tbl, op) VALUES (txid_current(), TG_TABLE_NAME, TG_OP);
|
|
RETURN NULL;
|
|
END $$`,
|
|
} {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
t.Fatalf("capture: install audit: %v", err)
|
|
}
|
|
}
|
|
// A table without a working trigger gets one, replacing any same-named
|
|
// trigger that is disabled or calls something else.
|
|
for _, tbl := range unauditedTables(t, s) {
|
|
for _, q := range []string{
|
|
fmt.Sprintf(`DROP TRIGGER IF EXISTS pad_write_audit_t ON %q`, tbl),
|
|
fmt.Sprintf(`CREATE TRIGGER pad_write_audit_t AFTER INSERT OR UPDATE OR DELETE ON %q FOR EACH STATEMENT EXECUTE FUNCTION pad_write_audit_fn()`, tbl),
|
|
} {
|
|
if _, err := db.Exec(q); err != nil {
|
|
t.Fatalf("capture: trigger on %s: %v", tbl, err)
|
|
}
|
|
}
|
|
}
|
|
}
|