Skip to content

Commit 18c8dff

Browse files
committed
sqlite: Codex round 3 — audit completeness, temp tables, intent, cancel
Five P2 review findings on commit 6118ec3 (the round-2 P1 TOCTOU fix was accepted). Bounded fixes: - Committed-audit completeness: a committed `DELETE FROM t` (no WHERE) was missing from the committed stream because the truncate optimization skips sqlite3_update_hook (sqlite3.h documents this). The authorizer now returns SQLITE_IGNORE for a user-table SQLITE_DELETE, which SQLite defines as "proceed, but delete row-by-row" — disabling the truncate optimization so the hook fires. DDL/internal `sqlite_*` deletes stay OK (DDL is reported as a DELETE on sqlite_master). `allowed` is now `decision != SQLITE_DENY` so IGNORE still records an allowed attempt. ON CONFLICT REPLACE deletes remain hook-invisible — documented as preupdate-hook territory (open-knob #4). - `.tables` now unions `sqlite_temp_schema`, so TEMP tables/views (which the authorizer allows) are listed, matching the real sqlite3 shell. - Open intent: a non-read-only open of an *existing* DB now reports `.write` (was always `.create`), so a Kit `authorize` closure can permit writes to an existing file while denying new-file creation. - Cancellation: `run` now calls `Task.checkCancellation()` before the first step. A task cancelled before any sqlite3_step would otherwise run to completion (onCancel's interrupt() is a no-op when nothing is in flight). Documented, not fixed (bounded): unbounded stdin is buffered before the SQL-length cap applies (open-knob #5; needs an incremental capped read). Tests: committed DELETE FROM t appears in the committed stream; `.tables` lists a TEMP table.
1 parent 6118ec3 commit 18c8dff

6 files changed

Lines changed: 80 additions & 7 deletions

File tree

PLAN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,8 @@ public extension Shell {
317317
1. **Journal mode** — WAL (best concurrency; `-wal`/`-shm` siblings) vs rollback (`-journal`; simplest). Both fine on native I/O.
318318
2. **M7 shim VFS** — ship now vs defer (recommended: defer; §5 confinement is closed/enumerable without it). Partial symlink-race hardening is already in place: the **DB open** canonicalizes before `authorize` and opens with `SQLITE_OPEN_NOFOLLOW`, which is race-free (SQLite rejects the open if any component became a symlink after authorization). Two residual TOCTOU gaps are explicitly **M7-scope**: (a) the **audit-log append** uses leaf-only `O_NOFOLLOW`, so a *parent-directory* swapped to a symlink after authorization is still followed (needs `openat`-style walking from a trusted root fd); and (b) **WAL `-wal`/`-shm` sidecars** aren't separately authorized, so a Kit caller whose `authorize` grants a single file rather than its directory could see siblings created next to it.
319319
3. **`:memory:`** — the supported answer for in-memory/non-identity-mount callers that can't use a host file URL.
320-
4. **Value-level audit** — enable `SQLITE_ENABLE_PREUPDATE_HOOK` if old/new row values are needed (vs. table+rowid only).
320+
4. **Value-level audit** — enable `SQLITE_ENABLE_PREUPDATE_HOOK` if old/new row values are needed (vs. table+rowid only). It is also the way to capture the remaining committed-DELETE case: the authorizer returns `SQLITE_IGNORE` for `SQLITE_DELETE` so `DELETE FROM t` is deleted row-by-row (defeating the truncate optimization) and thus seen by `sqlite3_update_hook`, but rows deleted via `ON CONFLICT REPLACE` are still not reported by the update hook — only the preupdate hook sees those.
321+
5. **Bounded stdin** — SQL piped on stdin is currently read fully into memory before `SQLITE_LIMIT_SQL_LENGTH` (a prepare-time cap) applies. An incremental, capped stdin read (rejecting input past `EnginePolicy.maxSQLLength` as it's consumed) is a follow-up for hardening against a large-pipe memory DoS.
321322

322323
## 14. References (SwiftBash files this design mirrors)
323324

Sources/SwiftSQLiteBash/DotCommands.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,18 @@ enum DotCommandRunner {
3535

3636
switch command {
3737
case ".tables":
38+
// Union in `sqlite_temp_schema` so TEMP tables/views (which the
39+
// authorizer allows) show up too, matching the real sqlite3 shell.
3840
return await listSchema(
3941
connection,
40-
"SELECT name FROM sqlite_schema WHERE type IN ('table','view') "
41-
+ "AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name;")
42+
"SELECT name FROM sqlite_schema "
43+
+ "WHERE type IN ('table','view') "
44+
+ "AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' "
45+
+ "UNION "
46+
+ "SELECT name FROM sqlite_temp_schema "
47+
+ "WHERE type IN ('table','view') "
48+
+ "AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' "
49+
+ "ORDER BY name;")
4250

4351
case ".indexes", ".indices":
4452
var sql = "SELECT name FROM sqlite_schema WHERE type='index' "

Sources/SwiftSQLiteKit/EngineContext.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ final class EngineContext: @unchecked Sendable {
4141
events.append(.attempted(
4242
action: Self.actionName(action),
4343
table: guardedObjectName(action: action, arg1: arg1, arg2: arg2),
44-
allowed: decision == SQLITE_OK))
44+
allowed: decision != SQLITE_DENY))
4545
return decision
4646
}
4747

@@ -78,7 +78,26 @@ final class EngineContext: @unchecked Sendable {
7878
// them. (`_audit*` was already denied above.)
7979
return SQLITE_OK
8080

81-
case SQLITE_INSERT, SQLITE_UPDATE, SQLITE_DELETE,
81+
case SQLITE_DELETE:
82+
if readOnly { return SQLITE_DENY }
83+
// DDL is reported as a DELETE on `sqlite_master` (see the schema
84+
// note below) and other internal bookkeeping touches `sqlite_*`
85+
// tables — leave all those as OK, unperturbed. For a real
86+
// *user-table* delete, return IGNORE so SQLite disables the
87+
// truncate optimization and deletes rows individually. Otherwise
88+
// `DELETE FROM t` (no WHERE) skips sqlite3_update_hook and the
89+
// committed-audit stream would miss those rows (sqlite3.h: the
90+
// update hook is "not invoked when rows are deleted using the
91+
// truncate optimization"). For SQLITE_DELETE, IGNORE means
92+
// "proceed, but row-by-row" (sqlite3.h authorizer docs) — it does
93+
// NOT skip the delete. (ON CONFLICT REPLACE deletes are still
94+
// missed; capturing those needs the preupdate hook — open-knob #4.)
95+
if (object ?? "").lowercased().hasPrefix("sqlite_") {
96+
return SQLITE_OK
97+
}
98+
return SQLITE_IGNORE
99+
100+
case SQLITE_INSERT, SQLITE_UPDATE,
82101
SQLITE_CREATE_TABLE, SQLITE_CREATE_TEMP_TABLE,
83102
SQLITE_CREATE_INDEX, SQLITE_CREATE_TEMP_INDEX,
84103
SQLITE_CREATE_VIEW, SQLITE_CREATE_TEMP_VIEW,

Sources/SwiftSQLiteKit/SQLiteConnection.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,16 @@ public actor SQLiteConnection {
6464
let canonicalPath = ConnectionHandle.canonicalize(url.path)
6565
self.handle = ConnectionHandle(location: canonicalPath, policy: policy, ctx: ctx)
6666

67-
let intent: AccessIntent = policy.readOnly ? .read : .create
67+
// Distinguish opening an existing DB (.write) from creating a new one
68+
// (.create) so a caller's authorize closure can permit writes to an
69+
// existing file while still denying new-file creation.
70+
let intent: AccessIntent
71+
if policy.readOnly {
72+
intent = .read
73+
} else {
74+
intent = FileManager.default.fileExists(atPath: canonicalPath)
75+
? .write : .create
76+
}
6877
try await authorize(URL(fileURLWithPath: canonicalPath), intent)
6978
try handle.open()
7079
try handle.configure()
@@ -109,7 +118,11 @@ public actor SQLiteConnection {
109118
let handle = self.handle
110119
do {
111120
let result = try await withTaskCancellationHandler {
112-
try handle.runScript(sql)
121+
// A task cancelled *before* the first sqlite3_step would
122+
// otherwise run to completion: onCancel's interrupt() is a
123+
// no-op when no statement is in flight. Refuse to start.
124+
try Task.checkCancellation()
125+
return try handle.runScript(sql)
113126
} onCancel: {
114127
// sqlite3_interrupt is thread-safe (SQLITE_THREADSAFE=1) and
115128
// makes the in-flight step return SQLITE_INTERRUPT. No shared

Tests/SwiftSQLiteBashTests/CommandBehaviorTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,18 @@ struct CommandBehaviorTests {
7777
#expect(result.status.isSuccess)
7878
#expect(result.stdout.contains("42"))
7979
}
80+
81+
@Test func dotTablesIncludesTempTables() async throws {
82+
let shell = Shell()
83+
shell.installShellBuiltin(SqliteCommand.self)
84+
let script = """
85+
CREATE TABLE perm(x);
86+
CREATE TEMP TABLE tmp(y);
87+
.tables
88+
"""
89+
let result = try await runCapturing(shell, "sqlite3 :memory:", stdin: script)
90+
#expect(result.status.isSuccess, "stderr: \(result.stderr)")
91+
#expect(result.stdout.contains("perm"))
92+
#expect(result.stdout.contains("tmp"))
93+
}
8094
}

Tests/SwiftSQLiteKitTests/AuditTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,22 @@ struct AuditTests {
7777
#expect(text.contains("\"action\":\"INSERT\""))
7878
#expect(text.contains("\"action\":\"DROP_TABLE\""))
7979
}
80+
81+
/// `DELETE FROM t` (no WHERE) is a truncate-optimization candidate that
82+
/// would skip `sqlite3_update_hook`; the authorizer returns IGNORE for
83+
/// DELETE so rows are removed individually and the committed stream still
84+
/// records them.
85+
@Test func committedDeleteAppearsInCommittedStream() async throws {
86+
let sink = InMemoryAuditSink()
87+
let db = try await SQLiteConnection(inMemory: .default, audit: sink)
88+
try await db.execute("CREATE TABLE t(x); INSERT INTO t(x) VALUES (1),(2),(3);")
89+
try await db.execute("DELETE FROM t;")
90+
await db.close()
91+
92+
let committed = await sink.committed
93+
#expect(committed.contains {
94+
if case .committed(_, _, "DELETE") = $0 { return true }
95+
return false
96+
}, "committed DELETE rows should be audited, got \(committed)")
97+
}
8098
}

0 commit comments

Comments
 (0)