Skip to content

Commit 669d8e7

Browse files
committed
sqlite: audit savepoint boundaries — no over-report on ROLLBACK TO
The committed-audit stream over-reported when SQL rolled back to a savepoint inside an open transaction (`BEGIN; SAVEPOINT s; INSERT…; ROLLBACK TO s; COMMIT;`): SQLite fires no rollback hook for `ROLLBACK TO`, so rows written after the savepoint stayed pending and were promoted to the committed stream on the outer COMMIT — recording an insert that never landed. EngineContext now tracks open savepoints as (name, pending-count) markers, driven by the SQLITE_SAVEPOINT authorizer events (arg1 = BEGIN/RELEASE/ ROLLBACK, arg2 = name; verified against the amalgamation). Because the authorizer fires at prepare time and runScript prepares+steps one statement at a time, by the time `ROLLBACK TO s` is prepared the intervening rows are already pending, so the marker trims them precisely. RELEASE merges into the parent; commit()/rollback()/discardPending() clear the stack. Tests: a rolled-back-savepoint insert is absent from the committed stream (and the row doesn't land); a RELEASEd-then-committed insert is present.
1 parent 0f42881 commit 669d8e7

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

PLAN.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,6 @@ public extension Shell {
320320
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.
321321
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.
322322
6. **`.dump` non-UTF-8 TEXT** — SQLite doesn't enforce UTF-8; bytes from e.g. `CAST(x'80' AS TEXT)` are lossily decoded to U+FFFD at read time, so `.dump` can't recover them. Preserving them needs the value model to carry raw bytes for TEXT (vs. a Swift `String`); an advanced edge, follow-up. (Generated columns *are* handled now — the dump emits an explicit insertable-column list.)
323-
7. **Audit savepoint boundaries** — the committed-audit stream **over-reports** when SQL rolls back to a SAVEPOINT inside an open transaction (`BEGIN; SAVEPOINT s; INSERT…; ROLLBACK TO s; COMMIT;`): the rollback hook isn't invoked for `ROLLBACK TO`, so rows written after the savepoint stay pending and are promoted on the outer `COMMIT`. Correct handling needs savepoint-aware pending boundaries (a marker stack keyed off the `SQLITE_SAVEPOINT` authorizer events). Full COMMIT/ROLLBACK is already accurate; this is a best-effort edge alongside the REPLACE-delete case (knob #4).
324323

325324
## 14. References (SwiftBash files this design mirrors)
326325

Sources/SwiftSQLiteKit/EngineContext.swift

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ final class EngineContext: @unchecked Sendable {
2323
/// Per-row update-hook records awaiting a commit. Promoted to
2424
/// `committed` events on `commit()`, dropped on `rollback()`.
2525
private var pending: [(table: String, rowid: Int64, op: String)] = []
26+
/// Open savepoints as `(name, pending.count when created)`. Lets a
27+
/// `ROLLBACK TO s` discard the pending rows recorded after `s` (SQLite
28+
/// fires no rollback hook for `ROLLBACK TO`).
29+
private var savepoints: [(name: String, mark: Int)] = []
2630

2731
var deadlineNanos: UInt64?
2832

@@ -42,9 +46,41 @@ final class EngineContext: @unchecked Sendable {
4246
action: Self.actionName(action),
4347
table: guardedObjectName(action: action, arg1: arg1, arg2: arg2),
4448
allowed: decision != SQLITE_DENY))
49+
// Track savepoint boundaries so `ROLLBACK TO s` drops the pending
50+
// committed-audit rows recorded after `s`. arg1 is the operation
51+
// ("BEGIN"/"RELEASE"/"ROLLBACK"), arg2 the savepoint name. The
52+
// authorizer fires at prepare time and runScript prepares+steps one
53+
// statement at a time, so when `ROLLBACK TO s` is prepared the
54+
// intervening rows are already in `pending`.
55+
if action == SQLITE_SAVEPOINT, decision == SQLITE_OK {
56+
applySavepoint(operation: arg1, name: arg2)
57+
}
4558
return decision
4659
}
4760

61+
private func applySavepoint(operation: String?, name: String?) {
62+
guard let name else { return }
63+
switch operation {
64+
case "BEGIN":
65+
savepoints.append((name: name, mark: pending.count))
66+
case "ROLLBACK":
67+
// Discard pending rows recorded after `s`, and drop nested
68+
// savepoints above it; `s` itself stays active.
69+
guard let idx = savepoints.lastIndex(where: { $0.name == name })
70+
else { return }
71+
let mark = savepoints[idx].mark
72+
if mark < pending.count { pending.removeLast(pending.count - mark) }
73+
savepoints.removeSubrange((idx + 1)...)
74+
case "RELEASE":
75+
// `s` (and nested savepoints) merge into the parent; rows remain.
76+
if let idx = savepoints.lastIndex(where: { $0.name == name }) {
77+
savepoints.removeSubrange(idx...)
78+
}
79+
default:
80+
break
81+
}
82+
}
83+
4884
private func decide(action: Int32, arg1: String?, arg2: String?) -> Int32 {
4985
let object = guardedObjectName(action: action, arg1: arg1, arg2: arg2)
5086

@@ -189,19 +225,20 @@ final class EngineContext: @unchecked Sendable {
189225
events.append(.committed(table: record.table, rowid: record.rowid, op: record.op))
190226
}
191227
pending.removeAll(keepingCapacity: true)
228+
savepoints.removeAll(keepingCapacity: true)
192229
}
193230

194231
/// Drop pending rows — the transaction rolled back, so they never
195232
/// committed. They remain in the *attempted* stream (recorded by the
196233
/// authorizer), never the committed stream.
197234
///
198-
/// Known limitation: this fires only on a full transaction rollback, not
199-
/// on `ROLLBACK TO SAVEPOINT` (SQLite exposes no savepoint-execution
200-
/// hook), and the pending buffer has no savepoint boundaries. So rows
201-
/// written after a savepoint that is later rolled back to may still be
202-
/// promoted to the committed stream. The attempted stream is unaffected.
235+
/// `ROLLBACK TO SAVEPOINT` (which fires no rollback hook) is handled
236+
/// separately via the savepoint markers in `applySavepoint` — a partial
237+
/// rollback trims `pending` back to the savepoint boundary, so this full
238+
/// rollback only has to clear what remains.
203239
func rollback() {
204240
pending.removeAll(keepingCapacity: true)
241+
savepoints.removeAll(keepingCapacity: true)
205242
}
206243

207244
// MARK: Timeout / cancellation
@@ -229,6 +266,7 @@ final class EngineContext: @unchecked Sendable {
229266

230267
func discardPending() {
231268
pending.removeAll(keepingCapacity: false)
269+
savepoints.removeAll(keepingCapacity: false)
232270
}
233271

234272
// MARK: Action-code names (for the attempted stream)

Tests/SwiftSQLiteKitTests/AuditTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,43 @@ struct AuditTests {
9595
return false
9696
}, "committed DELETE rows should be audited, got \(committed)")
9797
}
98+
99+
/// A row inserted inside a savepoint that is rolled back before COMMIT
100+
/// never lands, so it must not appear in the committed stream (the
101+
/// rollback hook doesn't fire for `ROLLBACK TO` — savepoint markers handle
102+
/// it).
103+
@Test func rolledBackSavepointInsertIsNotCommitted() async throws {
104+
let sink = InMemoryAuditSink()
105+
let db = try await SQLiteConnection(inMemory: .default, audit: sink)
106+
try await db.execute("CREATE TABLE t(x);")
107+
try await db.run(
108+
"BEGIN; SAVEPOINT s; INSERT INTO t(x) VALUES (1); ROLLBACK TO s; COMMIT;")
109+
110+
let count = try await db.query("SELECT count(*) FROM t;")
111+
#expect(count.rows[0][0] == .integer(0))
112+
await db.close()
113+
114+
let committed = await sink.committed
115+
#expect(!committed.contains {
116+
if case .committed(_, _, "INSERT") = $0 { return true }
117+
return false
118+
}, "rolled-back savepoint insert leaked into committed: \(committed)")
119+
}
120+
121+
/// Control: an insert in a savepoint that is RELEASEd (merged into the
122+
/// outer transaction) and committed *does* appear in the committed stream.
123+
@Test func releasedSavepointInsertIsCommitted() async throws {
124+
let sink = InMemoryAuditSink()
125+
let db = try await SQLiteConnection(inMemory: .default, audit: sink)
126+
try await db.execute("CREATE TABLE t(x);")
127+
try await db.run(
128+
"BEGIN; SAVEPOINT s; INSERT INTO t(x) VALUES (1); RELEASE s; COMMIT;")
129+
await db.close()
130+
131+
let committed = await sink.committed
132+
#expect(committed.contains {
133+
if case .committed(_, _, "INSERT") = $0 { return true }
134+
return false
135+
}, "released savepoint insert should be committed, got \(committed)")
136+
}
98137
}

0 commit comments

Comments
 (0)