| @@ -0,0 +1,1207 @@ |
| 1 | +//! The local-database actions of the `mobile` namespace: `dbExecute`, |
| 2 | +//! `dbQuery`. |
| 3 | +//! |
| 4 | +//! The database is `<sandbox>/Documents/craft.db`, the exact file the Swift |
| 5 | +//! coordinator's `setupDatabase` opens — same directory lookup |
| 6 | +//! (`NSDocumentDirectory` in `NSUserDomainMask`), same filename, same plain |
| 7 | +//! `sqlite3_open`. It is deliberately **not** opened through `database.zig`'s |
| 8 | +//! `Database`, whose init applies pragmas Swift never sets (WAL, |
| 9 | +//! `foreign_keys=ON`, cache size, busy timeout); wrapping it would silently |
| 10 | +//! flip an existing app's `craft.db` into WAL mode mid-migration. Raw externs, |
| 11 | +//! no pragmas, opened lazily on the first db action and kept for the process |
| 12 | +//! lifetime, as Swift keeps its coordinator-lifetime handle. The externs are |
| 13 | +//! this file's own copies — `database.zig`'s are file-private by design, and |
| 14 | +//! `extern` declarations are free to repeat. |
| 15 | +//! |
| 16 | +//! The payload fields are `sql` (string, required) and `params` (array, |
| 17 | +//! optional) — the names the Swift dispatcher reads (`body["sql"]`, |
| 18 | +//! `body["params"]`) and the shim hand-off proves ride inside `d`. The Android |
| 19 | +//! surface posts the same pair (`CraftBridge.kt.template`'s `db.execute` |
| 20 | +//! stringifies `params || []`), so the field names are pinned on three sides. |
| 21 | +//! |
| 22 | +//! ## What is carried across exactly, because it is the observable contract |
| 23 | +//! |
| 24 | +//! - **`dbExecute` resolves `{"rowsAffected":<int>,"lastInsertId":<int>}`** — |
| 25 | +//! `sqlite3_changes` and `sqlite3_last_insert_rowid`, both fields always |
| 26 | +//! present. |
| 27 | +//! - **`dbQuery` resolves a bare JSON array of row objects keyed by column |
| 28 | +//! name** — Swift's `.fragmentsAllowed` makes the bare `[]`/`[{...}]` legal, |
| 29 | +//! and `test-bridges.html` stringifies the value straight into the page, so |
| 30 | +//! keys and value types are all visible contract. An empty result set is |
| 31 | +//! `[]`; a non-SELECT statement sent through `dbQuery` executes and |
| 32 | +//! resolves `[]`. |
| 33 | +//! - **A `BLOB` column's key is entirely absent from its row object** — not |
| 34 | +//! `null`. Swift's column switch has `default: break`, so a row that is all |
| 35 | +//! BLOBs serializes as `{}`. Anything else changes the shape. |
| 36 | +//! - **`NULL` columns are JSON `null`**, Swift's `NSNull`. |
| 37 | +//! - **A SQLite failure rejects with `sqlite3_errmsg`'s text as the |
| 38 | +//! message** — "no such table: x" is what the page displays (`'Error: ' + |
| 39 | +//! e.message`), so the raw message text is the contract and travels intact |
| 40 | +//! through `ErrorContext`. `dbExecute` of a SELECT that returns rows steps |
| 41 | +//! to `SQLITE_ROW`, not `SQLITE_DONE`, and therefore rejects — with |
| 42 | +//! whatever `sqlite3_errmsg` says at that point (typically "not an error"). |
| 43 | +//! Bug-compatible on purpose: the two implementations must be |
| 44 | +//! interchangeable while the shim exists. |
| 45 | +//! - **A row value SQLite holds but JSON cannot carry (an infinite REAL, |
| 46 | +//! e.g. a stored `9e999`) rejects with "Native result could not be |
| 47 | +//! serialized"** — the exact message Swift's `JSONSerialization` catch |
| 48 | +//! produces when the Double reaches it. |
| 49 | +//! |
| 50 | +//! ## What is deliberately not carried across |
| 51 | +//! |
| 52 | +//! **Swift's silent hang.** The dispatcher arms are gate + `if let sql` with |
| 53 | +//! no `else`: a missing or non-string `sql`, or `enableLocalDatabase` at its |
| 54 | +//! default `false`, replies nothing at all. Malformed input errors here |
| 55 | +//! instead, and the config gate is left plumbable the way clipboard's is: a |
| 56 | +//! host calls `craft_ios_set_local_database_enabled(false)` and the actions |
| 57 | +//! reject `PermissionDenied` — never silence. It defaults to enabled, because |
| 58 | +//! default-off would answer every app with PERMISSION_DENIED until a host that |
| 59 | +//! does not exist yet is written (the clipboard module records the same |
| 60 | +//! trade). |
| 61 | +//! |
| 62 | +//! **The `Int32` funnels.** Swift binds integer params through `Int32(int)`, |
| 63 | +//! which *traps* — crashes the app — for |v| >= 2^31, and reads INTEGER |
| 64 | +//! columns through `sqlite3_column_int`, which silently wraps large values. |
| 65 | +//! Both go through the 64-bit calls here; a crash is not a contract, and a |
| 66 | +//! wrapped rowid is a corrupted answer. |
| 67 | +//! |
| 68 | +//! **Text truncation at NUL.** Swift's `sqlite3_bind_text(_, _, str, -1, nil)` |
| 69 | +//! measures to the first NUL *and* passes SQLITE_STATIC on a temporary — a |
| 70 | +//! latent use-after-free. Strings bind here with their byte length and |
| 71 | +//! SQLITE_TRANSIENT, exactly as the desktop `bindText` does, so an embedded |
| 72 | +//! NUL in a *param* survives the round trip. Reads use `column_bytes` rather |
| 73 | +//! than `String(cString:)` for the same reason. The `sql` string itself is |
| 74 | +//! different: SQLite stops parsing at a NUL, so a NUL there would silently |
| 75 | +//! execute a prefix of what the page sent — refused as `InvalidParameter` |
| 76 | +//! (rule: truncation that changes meaning is refused, not performed). |
| 77 | +//! |
| 78 | +//! **Silently dropped payload.** Swift binds NULL for a param it cannot |
| 79 | +//! downcast (an object or array), ignores a `params` value that is not an |
| 80 | +//! array, never checks a bind's return code (excess params vanish into |
| 81 | +//! `SQLITE_RANGE`), and `prepare_v2` executes only the first statement of a |
| 82 | +//! multi-statement string, discarding the rest. Every one of those is a page |
| 83 | +//! payload quietly not doing what the page said. Here: non-bindable params and |
| 84 | +//! a non-array `params` are `InvalidParameter`; bind failures reject with the |
| 85 | +//! SQLite message; SQL that continues past the first statement (more than |
| 86 | +//! whitespace/semicolons — a trailing `--` comment is refused too, |
| 87 | +//! conservatively) is `InvalidParameter`. |
| 88 | +//! |
| 89 | +//! **Partial rows on a mid-iteration step failure.** Swift's query loop is |
| 90 | +//! `while step == SQLITE_ROW` with the terminating code unexamined: an |
| 91 | +//! `SQLITE_BUSY` or corruption error just ends the loop and *resolves* the |
| 92 | +//! rows collected so far, indistinguishable from a complete result. That is |
| 93 | +//! fabricated success; a step that ends with anything but `SQLITE_DONE` |
| 94 | +//! rejects here, with the SQLite message. |
| 95 | +//! |
| 96 | +//! **The error `code` label.** Swift rejected with its catch-all |
| 97 | +//! `"CRAFT_ERROR"`; a Zig-served rejection carries `NATIVE_CALL_FAILED` |
| 98 | +//! (`ErrorContext` owns the code vocabulary). The message text — the part the |
| 99 | +//! page displays and the part that names the failing table — is preserved |
| 100 | +//! verbatim. |
| 101 | +//! |
| 102 | +//! **A failed open is closed.** Swift's `setupDatabase` ignores that |
| 103 | +//! `sqlite3_open` hands back a handle even on failure, and leaves it set — so |
| 104 | +//! later actions run against a half-open handle. Here a failed open is closed |
| 105 | +//! and the action rejects "Database not initialized", the message Swift's |
| 106 | +//! `guard let db` produces for exactly this condition. The open is retried on |
| 107 | +//! the next action rather than latched broken. |
| 108 | +//! |
| 109 | +//! ## Host-test safety, named because it is invisible |
| 110 | +//! |
| 111 | +//! On a macOS host, the lazy open would land in the *developer's* |
| 112 | +//! `~/Documents/craft.db`. Every test below therefore either fails validation |
| 113 | +//! before `ensureDatabase` can run, or drives `runExecute`/`runQuery` directly |
| 114 | +//! against its own `:memory:` handle. No test may hand `handleMessage` a |
| 115 | +//! payload with a valid `sql`. |
| 116 | + |
| 117 | +const std = ; |
| 118 | +const builtin = ; |
| 119 | +const capabilities = ; |
| 120 | +const bridge_error = ; |
| 121 | +const request_context = ; |
| 122 | +const memory = ; |
| 123 | +const objc_runtime = ; |
| 124 | + |
| 125 | +const objc = objc_runtime.objc; |
| 126 | + |
| 127 | +// SQLite, spelled exactly as `database.zig` spells it (those declarations are |
| 128 | +// file-private by design; `extern` declarations are free to repeat). The |
| 129 | +// vendored amalgamation is compiled into every artifact that links this |
| 130 | +// module on Darwin — `ios_surface_tests`, `ios_module_tests`, the device lib — |
| 131 | +// per `build.zig`. |
| 132 | +const sqlite3 = opaque {}; |
| 133 | +const sqlite3_stmt = opaque {}; |
| 134 | + |
| 135 | +const SQLITE_OK = 0; |
| 136 | +const SQLITE_ROW = 100; |
| 137 | +const SQLITE_DONE = 101; |
| 138 | + |
| 139 | +const SQLITE_INTEGER = 1; |
| 140 | +const SQLITE_FLOAT = 2; |
| 141 | +const SQLITE_TEXT = 3; |
| 142 | +const SQLITE_BLOB = 4; |
| 143 | +const SQLITE_NULL = 5; |
| 144 | + |
| 145 | +extern fn sqlite3_open(filename: [*:0]const u8, ppDb: *?*sqlite3) c_int; |
| 146 | +extern fn sqlite3_close(db: ?*sqlite3) c_int; |
| 147 | +extern fn sqlite3_errmsg(db: ?*sqlite3) ?[*:0]const u8; |
| 148 | +extern fn sqlite3_changes(db: ?*sqlite3) c_int; |
| 149 | +extern fn sqlite3_last_insert_rowid(db: ?*sqlite3) i64; |
| 150 | +extern fn sqlite3_prepare_v2(db: ?*sqlite3, zSql: [*]const u8, nByte: c_int, ppStmt: *?*sqlite3_stmt, pzTail: ?*[*]const u8) c_int; |
| 151 | +extern fn sqlite3_finalize(stmt: ?*sqlite3_stmt) c_int; |
| 152 | +extern fn sqlite3_step(stmt: ?*sqlite3_stmt) c_int; |
| 153 | +extern fn sqlite3_bind_null(stmt: ?*sqlite3_stmt, idx: c_int) c_int; |
| 154 | +extern fn sqlite3_bind_int64(stmt: ?*sqlite3_stmt, idx: c_int, value: i64) c_int; |
| 155 | +extern fn sqlite3_bind_double(stmt: ?*sqlite3_stmt, idx: c_int, value: f64) c_int; |
| 156 | +extern fn sqlite3_bind_text(stmt: ?*sqlite3_stmt, idx: c_int, text: [*]const u8, len: c_int, destructor: ?*const anyopaque) c_int; |
| 157 | +extern fn sqlite3_column_count(stmt: ?*sqlite3_stmt) c_int; |
| 158 | +extern fn sqlite3_column_name(stmt: ?*sqlite3_stmt, idx: c_int) ?[*:0]const u8; |
| 159 | +extern fn sqlite3_column_type(stmt: ?*sqlite3_stmt, idx: c_int) c_int; |
| 160 | +extern fn sqlite3_column_int64(stmt: ?*sqlite3_stmt, idx: c_int) i64; |
| 161 | +extern fn sqlite3_column_double(stmt: ?*sqlite3_stmt, idx: c_int) f64; |
| 162 | +extern fn sqlite3_column_text(stmt: ?*sqlite3_stmt, idx: c_int) ?[*]const u8; |
| 163 | +extern fn sqlite3_column_bytes(stmt: ?*sqlite3_stmt, idx: c_int) c_int; |
| 164 | + |
| 165 | +/// `((sqlite3_destructor_type)-1)`: copy the bound bytes rather than borrow |
| 166 | +/// them. An opaque pointer, never a typed fn pointer — the all-ones address |
| 167 | +/// cannot satisfy fn-pointer alignment, and the long comment in `database.zig` |
| 168 | +/// records the two Linux link breakages the typed spelling caused. |
| 169 | +const SQLITE_TRANSIENT: ?*const anyopaque = @ptrFromInt(std.math.maxInt(usize)); |
| 170 | + |
| 171 | +/// The action names, spelled exactly as the Swift `case` labels spell them. |
| 172 | +/// `test/ios_conformance_test.zig` matches the two lists by string in both |
| 173 | +/// directions. |
| 174 | +pub const A = struct { |
| 175 | + pub const db_execute = "dbExecute"; |
| 176 | + pub const db_query = "dbQuery"; |
| 177 | +}; |
| 178 | + |
| 179 | +/// Both `.result`: each Swift path resolves or rejects a callback, and both |
| 180 | +/// are awaited by the page. Both `.live`: plain SQLite C calls, no UI, no |
| 181 | +/// permission prompt, no completion handler — the amalgamation is compiled |
| 182 | +/// into the iOS artifacts, so nothing warrants `.unavailable`. |
| 183 | +pub const capability_actions = [_]capabilities.ActionDecl{ |
| 184 | + .{ .name = A.db_execute, .reply = .result }, |
| 185 | + .{ .name = A.db_query, .reply = .result }, |
| 186 | +}; |
| 187 | + |
| 188 | +/// The `enableLocalDatabase` gate, kept plumbable per the clipboard precedent |
| 189 | +/// (`craft_ios_set_clipboard_enabled`). Swift's gate at `false` replied |
| 190 | +/// *nothing*; off here means `PermissionDenied`, never silence. Main-thread |
| 191 | +/// only, like every gate in the chain: the WKScriptMessageHandler callback is |
| 192 | +/// the sole reader, a host at startup the sole writer. |
| 193 | +var local_database_enabled: bool = true; |
| 194 | + |
| 195 | +pub fn setEnabled(value: bool) void { |
| 196 | + local_database_enabled = value; |
| 197 | +} |
| 198 | + |
| 199 | +export fn craft_ios_set_local_database_enabled(value: bool) callconv(.c) void { |
| 200 | + local_database_enabled = value; |
| 201 | +} |
| 202 | + |
| 203 | +/// The one process-lifetime handle to `Documents/craft.db`, mirroring Swift's |
| 204 | +/// coordinator-lifetime `db`. Opened lazily on the first db action rather than |
| 205 | +/// at init, because `DbBridge` is constructed per-message by the dispatch loop |
| 206 | +/// and must stay free to construct. Never populated by tests — see the module |
| 207 | +/// comment on host-test safety. |
| 208 | +var global_db: ?*sqlite3 = null; |
| 209 | + |
| 210 | +/// Which handler an action selects, split from `handleMessage` so the |
| 211 | +/// table-versus-dispatch agreement is assertable on a host without invoking a |
| 212 | +/// handler whose success path would open the developer's own `craft.db`. |
| 213 | +const Route = enum { execute, query }; |
| 214 | + |
| 215 | +fn routeFor(action: []const u8) ?Route { |
| 216 | + if (std.mem.eql(u8, action, A.db_execute)) return .execute; |
| 217 | + if (std.mem.eql(u8, action, A.db_query)) return .query; |
| 218 | + return null; |
| 219 | +} |
| 220 | + |
| 221 | +pub const DbBridge = struct { |
| 222 | + allocator: std.mem.Allocator, |
| 223 | + |
| 224 | + const Self = @This(); |
| 225 | + |
| 226 | + pub fn init(allocator: std.mem.Allocator) Self { |
| 227 | + return .{ .allocator = allocator }; |
| 228 | + } |
| 229 | + |
| 230 | + pub fn deinit(_: *Self) void {} |
| 231 | + |
| 232 | + pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { |
| 233 | + const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction; |
| 234 | + // Exhaustive, so a `Route` without a handler is a compile error. |
| 235 | + return switch (route) { |
| 236 | + .execute => self.serve(.execute, data), |
| 237 | + .query => self.serve(.query, data), |
| 238 | + }; |
| 239 | + } |
| 240 | + |
| 241 | + /// One body for both actions — they share the gate, the payload shape, the |
| 242 | + /// database handle, and the two reply routes; only the runner differs. |
| 243 | + fn serve(self: *Self, route: Route, data: []const u8) !void { |
| 244 | + const action: []const u8 = switch (route) { |
| 245 | + .execute => A.db_execute, |
| 246 | + .query => A.db_query, |
| 247 | + }; |
| 248 | + |
| 249 | + if (!local_database_enabled) return bridge_error.BridgeError.PermissionDenied; |
| 250 | + |
| 251 | + var parsed = try parsePayload(self.allocator, data); |
| 252 | + defer parsed.deinit(); |
| 253 | + const request = try parseRequest(parsed.value); |
| 254 | + |
| 255 | + // Validation strictly precedes the open: this ordering is what lets |
| 256 | + // the host tests exercise every refusal above without ever creating |
| 257 | + // `~/Documents/craft.db` on the machine running them. |
| 258 | + // `if` comparisons rather than a `switch`: on Darwin the comptime |
| 259 | + // platform gate is pruned, so `error.UnsupportedPlatform` is not in |
| 260 | + // the inferred error set and a switch arm naming it would not compile. |
| 261 | + const db = ensureDatabase(self.allocator) catch |err| { |
| 262 | + if (err == error.OutOfMemory) return err; |
| 263 | + if (err == error.UnsupportedPlatform) return err; |
| 264 | + // Swift's `guard let db` message, verbatim — the observable |
| 265 | + // reply for "there is no database to run this against". |
| 266 | + rejectWithMessage(self.allocator, action, "Database not initialized"); |
| 267 | + return; |
| 268 | + }; |
| 269 | + |
| 270 | + const outcome = switch (route) { |
| 271 | + .execute => try runExecute(self.allocator, db, request), |
| 272 | + .query => try runQuery(self.allocator, db, request), |
| 273 | + }; |
| 274 | + switch (outcome) { |
| 275 | + .ok => |fragment| { |
| 276 | + defer self.allocator.free(fragment); |
| 277 | + bridge_error.sendResultToJS(self.allocator, action, fragment); |
| 278 | + }, |
| 279 | + .failed => |message| { |
| 280 | + defer self.allocator.free(message); |
| 281 | + rejectWithMessage(self.allocator, action, message); |
| 282 | + }, |
| 283 | + } |
| 284 | + } |
| 285 | +}; |
| 286 | + |
| 287 | +/// Parse `d`, distinguishing a bad payload from a failed allocation — telling |
| 288 | +/// the page INVALID_JSON about its own good JSON sends whoever debugs it to |
| 289 | +/// the wrong side of the bridge. |
| 290 | +fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) { |
| 291 | + return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) { |
| 292 | + error.OutOfMemory => return err, |
| 293 | + else => return bridge_error.BridgeError.InvalidJSON, |
| 294 | + }; |
| 295 | +} |
| 296 | + |
| 297 | +/// The payload after validation. `sql` and `params` are the names the injected |
| 298 | +/// surfaces post and the Swift dispatcher reads — pinned on both sides of the |
| 299 | +/// migration. `params` borrows the parsed JSON's array items; the caller keeps |
| 300 | +/// the parse alive until the run completes. |
| 301 | +const Request = struct { |
| 302 | + sql: []const u8, |
| 303 | + params: []const std.json.Value, |
| 304 | +}; |
| 305 | + |
| 306 | +fn parseRequest(payload: std.json.Value) !Request { |
| 307 | + const object = switch (payload) { |
| 308 | + .object => |o| o, |
| 309 | + else => return bridge_error.BridgeError.InvalidJSON, |
| 310 | + }; |
| 311 | + |
| 312 | + const sql_field = object.get("sql") orelse return bridge_error.BridgeError.MissingData; |
| 313 | + const sql = switch (sql_field) { |
| 314 | + .string => |s| s, |
| 315 | + // Swift's `as? String` fails here and replies nothing at all. |
| 316 | + else => return bridge_error.BridgeError.InvalidParameter, |
| 317 | + }; |
| 318 | + // the \\u0000 escape is legal JSON, so a page can reach this. SQLite stops |
| 319 | + // parsing SQL at a NUL: an unchecked one would silently execute a prefix |
| 320 | + // of what the page sent and report success for the whole. |
| 321 | + if (std.mem.indexOfScalar(u8, sql, 0) != null) return bridge_error.BridgeError.InvalidParameter; |
| 322 | + // `prepare_v2` takes the byte count as a c_int; a longer `sql` would trip |
| 323 | + // the `@intCast` safety panic — the same crash-instead-of-answer the |
| 324 | + // module doc rejects in Swift's `Int32(int)` funnel. (SQLite itself caps |
| 325 | + // statements at SQLITE_LIMIT_LENGTH, far below this, so nothing honest is |
| 326 | + // refused.) |
| 327 | + if (sql.len > std.math.maxInt(c_int)) return bridge_error.BridgeError.InvalidParameter; |
| 328 | + |
| 329 | + const params: []const std.json.Value = if (object.get("params")) |params_field| switch (params_field) { |
| 330 | + .array => |a| a.items, |
| 331 | + // `craft.db.execute(sql)` with params omitted serializes `null` on |
| 332 | + // some surfaces; absent and null are both "no params", as in Swift. |
| 333 | + .null => &[_]std.json.Value{}, |
| 334 | + // Swift's `as? [Any]` quietly yields nil here and executes without |
| 335 | + // the params the page sent — a dropped payload, refused instead. |
| 336 | + else => return bridge_error.BridgeError.InvalidParameter, |
| 337 | + } else &[_]std.json.Value{}; |
| 338 | + |
| 339 | + for (params) |param| switch (param) { |
| 340 | + // `bind_text` takes the byte length as a c_int — same `@intCast` |
| 341 | + // panic as `sql` above for a param of 2^31 bytes or more; refused |
| 342 | + // here so a page payload can never crash instead of erroring. |
| 343 | + .string => |s| if (s.len > std.math.maxInt(c_int)) return bridge_error.BridgeError.InvalidParameter, |
| 344 | + .integer, .float, .bool, .null => {}, |
| 345 | + // Swift binds NULL for an object or array param — the value the page |
| 346 | + // sent, silently replaced with a different one. `.number_string` |
| 347 | + // cannot occur under default parse options; refusing it beats |
| 348 | + // guessing at its binding. |
| 349 | + else => return bridge_error.BridgeError.InvalidParameter, |
| 350 | + }; |
| 351 | + |
| 352 | + return .{ .sql = sql, .params = params }; |
| 353 | +} |
| 354 | + |
| 355 | +/// What a runner hands back: the resolve fragment, or the message the page's |
| 356 | +/// rejection carries (SQLite's own text — the observable contract). Exactly |
| 357 | +/// one of the two, and the caller owns either slice. Validation failures are |
| 358 | +/// errors instead, and take the dispatcher's fixed-message route. |
| 359 | +const RunOutcome = union(enum) { |
| 360 | + ok: []u8, |
| 361 | + failed: []u8, |
| 362 | +}; |
| 363 | + |
| 364 | +/// The `dbExecute` resolve fragment. Key order is fixed here where Swift's |
| 365 | +/// dictionary serialization left it arbitrary; both fields are the contract. |
| 366 | +fn executeFragment(allocator: std.mem.Allocator, rows_affected: c_int, last_insert_id: i64) ![]u8 { |
| 367 | + return std.fmt.allocPrint( |
| 368 | + allocator, |
| 369 | + "", |
| 370 | + .{ rows_affected, last_insert_id }, |
| 371 | + ); |
| 372 | +} |
| 373 | + |
| 374 | +/// True when `sql` continues past the first prepared statement with anything |
| 375 | +/// but whitespace and semicolons. `prepare_v2` points `tail` one past the end |
| 376 | +/// of the first statement; Swift never looks, and silently discards whatever |
| 377 | +/// follows. Conservative on purpose: a trailing `-- comment` is refused too, |
| 378 | +/// and the page gets INVALID_PARAMETER instead of a silent partial execution. |
| 379 | +fn hasTrailingStatement(sql: []const u8, tail: [*]const u8) bool { |
| 380 | + const consumed = @intFromPtr(tail) - @intFromPtr(sql.ptr); |
| 381 | + if (consumed >= sql.len) return false; |
| 382 | + const rest = std.mem.trim(u8, sql[consumed..], " \t\r\n;"); |
| 383 | + return rest.len != 0; |
| 384 | +} |
| 385 | + |
| 386 | +/// `sqlite3_errmsg`'s current text, copied immediately — the pointer is only |
| 387 | +/// valid until the next call on this connection, and `finalize` runs before |
| 388 | +/// the message reaches the page. |
| 389 | +fn copyErrMsg(allocator: std.mem.Allocator, db: *sqlite3) ![]u8 { |
| 390 | + if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; |
| 391 | + |
| 392 | + const msg = sqlite3_errmsg(db) orelse return allocator.dupe(u8, "unknown SQLite error"); |
| 393 | + return allocator.dupe(u8, std.mem.span(msg)); |
| 394 | +} |
| 395 | + |
| 396 | +/// Bind one JSON param at 1-based `idx`, returning SQLite's own verdict. |
| 397 | +/// |
| 398 | +/// The mapping Swift's downcast chain implies, minus its traps: strings carry |
| 399 | +/// their byte length with SQLITE_TRANSIENT (Swift's `-1, nil` truncated at NUL |
| 400 | +/// and borrowed a temporary), integers and bools go through `bind_int64` |
| 401 | +/// (Swift's `Int32(int)` traps at 2^31; its NSNumber `true` binds as INTEGER |
| 402 | +/// 1, matched here), floats through `bind_double`, null through `bind_null`. |
| 403 | +fn bindValue(stmt: ?*sqlite3_stmt, idx: c_int, value: std.json.Value) c_int { |
| 404 | + return switch (value) { |
| 405 | + .string => |s| if (s.len == 0) |
| 406 | + // A zero-length slice's pointer may be dangling; SQLite still |
| 407 | + // wants a readable address for the empty string. |
| 408 | + sqlite3_bind_text(stmt, idx, "", 0, SQLITE_TRANSIENT) |
| 409 | + else |
| 410 | + sqlite3_bind_text(stmt, idx, s.ptr, @intCast(s.len), SQLITE_TRANSIENT), |
| 411 | + .integer => |v| sqlite3_bind_int64(stmt, idx, v), |
| 412 | + .float => |v| sqlite3_bind_double(stmt, idx, v), |
| 413 | + .bool => |v| sqlite3_bind_int64(stmt, idx, @as(i64, @intFromBool(v))), |
| 414 | + .null => sqlite3_bind_null(stmt, idx), |
| 415 | + // parseRequest refused everything else before a statement existed. |
| 416 | + else => unreachable, |
| 417 | + }; |
| 418 | +} |
| 419 | + |
| 420 | +/// Bind all params; a failure returns the message for the page's rejection. |
| 421 | +/// Swift never checks these return codes — excess params vanish into an |
| 422 | +/// ignored SQLITE_RANGE. Divergence, recorded: a param that did not bind is a |
| 423 | +/// payload that did not do what the page said. |
| 424 | +fn bindAll( |
| 425 | + allocator: std.mem.Allocator, |
| 426 | + db: *sqlite3, |
| 427 | + stmt: ?*sqlite3_stmt, |
| 428 | + params: []const std.json.Value, |
| 429 | +) !?[]u8 { |
| 430 | + for (params, 0..) |param, i| { |
| 431 | + const rc = bindValue(stmt, @intCast(i + 1), param); |
| 432 | + if (rc != SQLITE_OK) return try copyErrMsg(allocator, db); |
| 433 | + } |
| 434 | + return null; |
| 435 | +} |
| 436 | + |
| 437 | +/// Prepare `sql` (exactly one statement of it), or say why not. |
| 438 | +/// |
| 439 | +/// Shared by both runners: same prepare, same multi-statement refusal, same |
| 440 | +/// "nothing to execute" refusal for whitespace/comment-only SQL (where Swift |
| 441 | +/// stepped a nil statement into SQLITE_MISUSE for `dbExecute` and resolved |
| 442 | +/// `[]` for `dbQuery` — neither is an answer about SQL that contains no |
| 443 | +/// statement). |
| 444 | +const Prepared = union(enum) { |
| 445 | + stmt: *sqlite3_stmt, |
| 446 | + failed: []u8, |
| 447 | +}; |
| 448 | + |
| 449 | +fn prepareOne(allocator: std.mem.Allocator, db: *sqlite3, sql: []const u8) !Prepared { |
| 450 | + if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; |
| 451 | + |
| 452 | + if (sql.len == 0) return bridge_error.BridgeError.InvalidParameter; |
| 453 | + |
| 454 | + var stmt: ?*sqlite3_stmt = null; |
| 455 | + var tail: [*]const u8 = sql.ptr; |
| 456 | + const rc = sqlite3_prepare_v2(db, sql.ptr, @intCast(sql.len), &stmt, &tail); |
| 457 | + if (rc != SQLITE_OK) { |
| 458 | + _ = sqlite3_finalize(stmt); |
| 459 | + return .{ .failed = try copyErrMsg(allocator, db) }; |
| 460 | + } |
| 461 | + const prepared = stmt orelse return bridge_error.BridgeError.InvalidParameter; |
| 462 | + if (hasTrailingStatement(sql, tail)) { |
| 463 | + _ = sqlite3_finalize(prepared); |
| 464 | + return bridge_error.BridgeError.InvalidParameter; |
| 465 | + } |
| 466 | + return .{ .stmt = prepared }; |
| 467 | +} |
| 468 | + |
| 469 | +/// `dbExecute`: prepare, bind, single step to DONE, report changes + rowid. |
| 470 | +/// |
| 471 | +/// Takes the handle explicitly so host tests run against their own `:memory:` |
| 472 | +/// database — nothing in here knows about `Documents/craft.db`. |
| 473 | +fn runExecute(allocator: std.mem.Allocator, db: *sqlite3, request: Request) !RunOutcome { |
| 474 | + if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; |
| 475 | + |
| 476 | + const stmt = switch (try prepareOne(allocator, db, request.sql)) { |
| 477 | + .failed => |message| return .{ .failed = message }, |
| 478 | + .stmt => |s| s, |
| 479 | + }; |
| 480 | + defer _ = sqlite3_finalize(stmt); |
| 481 | + |
| 482 | + if (try bindAll(allocator, db, stmt, request.params)) |message| { |
| 483 | + return .{ .failed = message }; |
| 484 | + } |
| 485 | + |
| 486 | + // Anything but DONE rejects — including SQLITE_ROW from a SELECT sent |
| 487 | + // through `dbExecute`, which Swift also rejects (with the "not an error" |
| 488 | + // text `sqlite3_errmsg` reports at that point). Bug-compatible; see the |
| 489 | + // module comment. |
| 490 | + if (sqlite3_step(stmt) != SQLITE_DONE) { |
| 491 | + return .{ .failed = try copyErrMsg(allocator, db) }; |
| 492 | + } |
| 493 | + |
| 494 | + return .{ .ok = try executeFragment(allocator, sqlite3_changes(db), sqlite3_last_insert_rowid(db)) }; |
| 495 | +} |
| 496 | + |
| 497 | +/// `dbQuery`: prepare, bind, step to completion, serialize each row as an |
| 498 | +/// object keyed by column name — the exact JSON the page consumes. |
| 499 | +fn runQuery(allocator: std.mem.Allocator, db: *sqlite3, request: Request) !RunOutcome { |
| 500 | + if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; |
| 501 | + |
| 502 | + const stmt = switch (try prepareOne(allocator, db, request.sql)) { |
| 503 | + .failed => |message| return .{ .failed = message }, |
| 504 | + .stmt => |s| s, |
| 505 | + }; |
| 506 | + defer _ = sqlite3_finalize(stmt); |
| 507 | + |
| 508 | + if (try bindAll(allocator, db, stmt, request.params)) |message| { |
| 509 | + return .{ .failed = message }; |
| 510 | + } |
| 511 | + |
| 512 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 513 | + errdefer out.deinit(allocator); |
| 514 | + try out.append(allocator, '['); |
| 515 | + |
| 516 | + const column_count = sqlite3_column_count(stmt); |
| 517 | + var first_row = true; |
| 518 | + while (true) { |
| 519 | + const rc = sqlite3_step(stmt); |
| 520 | + if (rc == SQLITE_DONE) break; |
| 521 | + if (rc != SQLITE_ROW) { |
| 522 | + // Swift's loop condition swallows this code and resolves the rows |
| 523 | + // collected so far as if they were all of them. Divergence, |
| 524 | + // chosen: an interrupted result set is a failure, not a shorter |
| 525 | + // success. |
| 526 | + const message = try copyErrMsg(allocator, db); |
| 527 | + out.deinit(allocator); |
| 528 | + return .{ .failed = message }; |
| 529 | + } |
| 530 | + |
| 531 | + if (!first_row) try out.append(allocator, ','); |
| 532 | + first_row = false; |
| 533 | + try out.append(allocator, '{'); |
| 534 | + |
| 535 | + var first_col = true; |
| 536 | + var i: c_int = 0; |
| 537 | + while (i < column_count) : (i += 1) { |
| 538 | + const col_type = sqlite3_column_type(stmt, i); |
| 539 | + // Swift's `default: break`: the key is entirely absent, not null. |
| 540 | + if (col_type == SQLITE_BLOB) continue; |
| 541 | + |
| 542 | + // Null only on OOM inside SQLite — never "no name". |
| 543 | + const name = sqlite3_column_name(stmt, i) orelse return error.OutOfMemory; |
| 544 | + |
| 545 | + if (!first_col) try out.append(allocator, ','); |
| 546 | + first_col = false; |
| 547 | + try out.append(allocator, '"'); |
| 548 | + // Page-controlled via `AS "alia\"s"` — escaped like every other |
| 549 | + // page-controlled byte that rides a reply. |
| 550 | + try bridge_error.appendJsonEscaped(allocator, &out, std.mem.span(name)); |
| 551 | + try out.appendSlice(allocator, "\":"); |
| 552 | + |
| 553 | + switch (col_type) { |
| 554 | + SQLITE_INTEGER => try out.print(allocator, "{d}", .{sqlite3_column_int64(stmt, i)}), |
| 555 | + SQLITE_FLOAT => { |
| 556 | + const value = sqlite3_column_double(stmt, i); |
| 557 | + if (!std.math.isFinite(value)) { |
| 558 | + // SQLite can hold an infinity (`9e999`); JSON cannot. |
| 559 | + // Swift's JSONSerialization throws and its catch |
| 560 | + // rejects with exactly this message. |
| 561 | + const message = try allocator.dupe(u8, "Native result could not be serialized"); |
| 562 | + out.deinit(allocator); |
| 563 | + return .{ .failed = message }; |
| 564 | + } |
| 565 | + try out.print(allocator, "{d}", .{value}); |
| 566 | + }, |
| 567 | + SQLITE_TEXT => { |
| 568 | + // `column_text` is null only on OOM for a TEXT column. |
| 569 | + // Length via `column_bytes` (valid right after |
| 570 | + // `column_text`), not a NUL scan — Swift's |
| 571 | + // `String(cString:)` truncated stored text at an embedded |
| 572 | + // NUL; the desktop reader and this one carry it whole. |
| 573 | + const text = sqlite3_column_text(stmt, i) orelse return error.OutOfMemory; |
| 574 | + const len: usize = @intCast(sqlite3_column_bytes(stmt, i)); |
| 575 | + try out.append(allocator, '"'); |
| 576 | + try bridge_error.appendJsonEscaped(allocator, &out, text[0..len]); |
| 577 | + try out.append(allocator, '"'); |
| 578 | + }, |
| 579 | + SQLITE_NULL => try out.appendSlice(allocator, "null"), |
| 580 | + // The five storage classes are exhaustive and BLOB was handled |
| 581 | + // above; SQLite returning a sixth is a broken runtime. The |
| 582 | + // `errdefer` above frees `out` on this error return — a manual |
| 583 | + // deinit here would run it twice. |
| 584 | + else => return error.NativeCallFailed, |
| 585 | + } |
| 586 | + } |
| 587 | + try out.append(allocator, '}'); |
| 588 | + } |
| 589 | + try out.append(allocator, ']'); |
| 590 | + |
| 591 | + return .{ .ok = try out.toOwnedSlice(allocator) }; |
| 592 | +} |
| 593 | + |
| 594 | +/// Reject the current call with a free-text message — the route that carries |
| 595 | +/// `sqlite3_errmsg`'s words to the page, where `sendErrorToJS` could only send |
| 596 | +/// a fixed per-code string. Same wire shape (`__craftBridgeError`), same |
| 597 | +/// escaping (`ErrorContext.toJSON`), stamped with the current request id so |
| 598 | +/// exactly the calling promise rejects. |
| 599 | +fn rejectWithMessage(allocator: std.mem.Allocator, action: []const u8, message: []const u8) void { |
| 600 | + var ctx = bridge_error.ErrorContext.init(bridge_error.BridgeError.NativeCallFailed, action, message); |
| 601 | + ctx.request_id = request_context.current(); |
| 602 | + |
| 603 | + const json = ctx.toJSON(allocator) catch { |
| 604 | + // Out of memory mid-reply: the fixed-message route still beats |
| 605 | + // silence — the page's promise must settle. |
| 606 | + bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.NativeCallFailed); |
| 607 | + return; |
| 608 | + }; |
| 609 | + defer allocator.free(json); |
| 610 | + |
| 611 | + const js = std.fmt.allocPrint( |
| 612 | + allocator, |
| 613 | + "if(window.__craftBridgeError)window.__craftBridgeError({s});", |
| 614 | + .{json}, |
| 615 | + ) catch { |
| 616 | + bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.NativeCallFailed); |
| 617 | + return; |
| 618 | + }; |
| 619 | + defer allocator.free(js); |
| 620 | + |
| 621 | + const bridge = ; |
| 622 | + bridge.evalJS(js) catch |err| { |
| 623 | + std.log.warn("db bridge: could not deliver rejection for '{s}': {}", .{ action, err }); |
| 624 | + }; |
| 625 | +} |
| 626 | + |
| 627 | +/// The process-wide handle, opened on first use at `Documents/craft.db` — |
| 628 | +/// Swift's `setupDatabase` path exactly, so a partially migrated app reads the |
| 629 | +/// data its Swift half wrote. A failed open is closed and reported (Swift left |
| 630 | +/// the broken handle installed — see the module comment) and retried on the |
| 631 | +/// next action rather than latched. |
| 632 | +fn ensureDatabase(allocator: std.mem.Allocator) !*sqlite3 { |
| 633 | + if (global_db) |db| return db; |
| 634 | + if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; |
| 635 | + |
| 636 | + const docs = try documentsPath(allocator); |
| 637 | + defer allocator.free(docs); |
| 638 | + |
| 639 | + const joined = try std.fmt.allocPrint(allocator, "{s}/craft.db", .{docs}); |
| 640 | + defer allocator.free(joined); |
| 641 | + |
| 642 | + const db = try openDatabaseFile(allocator, joined); |
| 643 | + global_db = db; |
| 644 | + return db; |
| 645 | +} |
| 646 | + |
| 647 | +/// `NSFileManager` → `URLsForDirectory:inDomains:` → `firstObject` → `path`, |
| 648 | +/// the chain Swift's `setupDatabase` runs, with every step guarded — a nil |
| 649 | +/// anywhere is a named error, never a message to nil read as a path. |
| 650 | +/// NSDocumentDirectory = 9, NSUserDomainMask = 1. (`mobile.zig`'s |
| 651 | +/// `getDocumentsDirectory` is a stub returning `""` — not used on purpose.) |
| 652 | +fn documentsPath(allocator: std.mem.Allocator) ![]u8 { |
| 653 | + if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; |
| 654 | + |
| 655 | + const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound; |
| 656 | + const sel_default = objc.sel_registerName("defaultManager") orelse return error.SelectorNotFound; |
| 657 | + const manager = objc.msgSendId(NSFileManager, sel_default); |
| 658 | + if (manager == null) return error.NativeCallFailed; |
| 659 | + |
| 660 | + const sel_urls = objc.sel_registerName("URLsForDirectory:inDomains:") orelse return error.SelectorNotFound; |
| 661 | + // NSUInteger arguments: explicit c_ulong, because the variadic msgSend |
| 662 | + // cast takes argument types from what is passed. |
| 663 | + const urls = objc.msgSendId2(manager, sel_urls, @as(c_ulong, 9), @as(c_ulong, 1)); |
| 664 | + if (urls == null) return error.NativeCallFailed; |
| 665 | + |
| 666 | + const sel_first = objc.sel_registerName("firstObject") orelse return error.SelectorNotFound; |
| 667 | + const url = objc.msgSendId(urls, sel_first); |
| 668 | + if (url == null) return error.NotFound; |
| 669 | + |
| 670 | + const sel_path = objc.sel_registerName("path") orelse return error.SelectorNotFound; |
| 671 | + const path_obj = objc.msgSendId(url, sel_path); |
| 672 | + if (path_obj == null) return error.NativeCallFailed; |
| 673 | + |
| 674 | + const cstr = objc.getNSStringUTF8(path_obj) orelse return error.NativeCallFailed; |
| 675 | + return allocator.dupe(u8, std.mem.span(cstr)); |
| 676 | +} |
| 677 | + |
| 678 | +/// Plain `sqlite3_open` — RW|CREATE, no pragmas — matching Swift; see the |
| 679 | +/// module comment for why `database.zig`'s pragma-applying `Database` would be |
| 680 | +/// wrong here. |
| 681 | +fn openDatabaseFile(allocator: std.mem.Allocator, path: []const u8) !*sqlite3 { |
| 682 | + if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; |
| 683 | + |
| 684 | + const path_z = try memory.dupeZ(allocator, u8, path); |
| 685 | + defer allocator.free(path_z); |
| 686 | + |
| 687 | + var handle: ?*sqlite3 = null; |
| 688 | + const rc = sqlite3_open(path_z.ptr, &handle); |
| 689 | + if (rc != SQLITE_OK) { |
| 690 | + // sqlite3_open hands back a handle even on failure, carrying the |
| 691 | + // error detail; read it for the log, then close it. |
| 692 | + if (handle) |h| { |
| 693 | + if (sqlite3_errmsg(h)) |msg| { |
| 694 | + std.log.warn("craft.db open failed: {s}", .{std.mem.span(msg)}); |
| 695 | + } |
| 696 | + _ = sqlite3_close(h); |
| 697 | + } |
| 698 | + return error.NativeCallFailed; |
| 699 | + } |
| 700 | + return handle orelse error.NativeCallFailed; |
| 701 | +} |
| 702 | + |
| 703 | +// ============================================================================= |
| 704 | +// Tests — host-only, two tiers. The routing/parsing/shaping tier is pure and |
| 705 | +// runs everywhere. The SQLite tier drives `runExecute`/`runQuery` against its |
| 706 | +// own `:memory:` handle — real statements, real bindings, exact reply bytes — |
| 707 | +// and is gated on Darwin because that is where `build.zig` compiles the |
| 708 | +// vendored amalgamation into the ios test artifacts (the code is portable C; |
| 709 | +// the gate mirrors the build wiring, not the code). No test reaches |
| 710 | +// `ensureDatabase`: on a macOS host that would create the developer's own |
| 711 | +// `~/Documents/craft.db`. |
| 712 | +// ============================================================================= |
| 713 | + |
| 714 | +const testing = std.testing; |
| 715 | + |
| 716 | +test "the declared actions are the ones the handler serves" { |
| 717 | + try testing.expectEqual(@as(usize, 2), capability_actions.len); |
| 718 | + try testing.expectEqualStrings(A.db_execute, capability_actions[0].name); |
| 719 | + try testing.expectEqualStrings(A.db_query, capability_actions[1].name); |
| 720 | + |
| 721 | + for (capability_actions) |decl| { |
| 722 | + try testing.expectEqual(capabilities.Reply.result, decl.reply); |
| 723 | + try testing.expectEqual(capabilities.ActionStatus.live, decl.status); |
| 724 | + } |
| 725 | +} |
| 726 | + |
| 727 | +test "the action names match the Swift case labels exactly" { |
| 728 | + // The conformance ratchet compares these against the `case "…":` labels in |
| 729 | + // `CraftApp.swift` in both directions; a prettier spelling would read as |
| 730 | + // Zig serving an action the spec does not have. |
| 731 | + try testing.expectEqualStrings("dbExecute", A.db_execute); |
| 732 | + try testing.expectEqualStrings("dbQuery", A.db_query); |
| 733 | +} |
| 734 | + |
| 735 | +test "every declared action routes, every route is declared, none shared" { |
| 736 | + var execute_declared = false; |
| 737 | + var query_declared = false; |
| 738 | + for (capability_actions) |decl| { |
| 739 | + const route = routeFor(decl.name) orelse { |
| 740 | + std.debug.print("declared action '{s}' does not route\n", .{decl.name}); |
| 741 | + return error.DeclaredActionDoesNotRoute; |
| 742 | + }; |
| 743 | + switch (route) { |
| 744 | + .execute => { |
| 745 | + if (execute_declared) return error.TwoDeclarationsShareARoute; |
| 746 | + execute_declared = true; |
| 747 | + }, |
| 748 | + .query => { |
| 749 | + if (query_declared) return error.TwoDeclarationsShareARoute; |
| 750 | + query_declared = true; |
| 751 | + }, |
| 752 | + } |
| 753 | + } |
| 754 | + // The other direction: a route with a handler and no declaration is an |
| 755 | + // action the page can call and the manifest denies exists. |
| 756 | + try testing.expect(execute_declared); |
| 757 | + try testing.expect(query_declared); |
| 758 | +} |
| 759 | + |
| 760 | +test "an action the namespace does not serve is reported, not ignored" { |
| 761 | + var bridge = DbBridge.init(testing.allocator); |
| 762 | + defer bridge.deinit(); |
| 763 | + |
| 764 | + const strangers = [_][]const u8{ |
| 765 | + "noSuchAction", |
| 766 | + // Near misses — casing is how a real typo arrives. |
| 767 | + "dbexecute", |
| 768 | + "DbQuery", |
| 769 | + // The JS surface names, which are not action names. |
| 770 | + "db", |
| 771 | + "execute", |
| 772 | + "query", |
| 773 | + // A Swift neighbor this module must not answer for. |
| 774 | + "startBluetoothScan", |
| 775 | + }; |
| 776 | + for (strangers) |name| { |
| 777 | + try testing.expectError( |
| 778 | + bridge_error.BridgeError.UnknownAction, |
| 779 | + bridge.handleMessage(name, "{}"), |
| 780 | + ); |
| 781 | + } |
| 782 | +} |
| 783 | + |
| 784 | +test "both actions fail validation before any database can be opened" { |
| 785 | + // `{}` has no `sql`, so both error out before `ensureDatabase` runs — |
| 786 | + // which is what makes this safe on a developer's machine (the lazy open |
| 787 | + // would land in ~/Documents/craft.db), and what rules out a routed name |
| 788 | + // whose handler ignores its payload contract. |
| 789 | + var bridge = DbBridge.init(testing.allocator); |
| 790 | + defer bridge.deinit(); |
| 791 | + |
| 792 | + try testing.expectError(bridge_error.BridgeError.MissingData, bridge.handleMessage(A.db_execute, "{}")); |
| 793 | + try testing.expectError(bridge_error.BridgeError.MissingData, bridge.handleMessage(A.db_query, "{}")); |
| 794 | +} |
| 795 | + |
| 796 | +test "a malformed payload is reported as bad JSON, not as a missing field" { |
| 797 | + var bridge = DbBridge.init(testing.allocator); |
| 798 | + defer bridge.deinit(); |
| 799 | + |
| 800 | + try testing.expectError( |
| 801 | + bridge_error.BridgeError.InvalidJSON, |
| 802 | + bridge.handleMessage(A.db_execute, "{not json"), |
| 803 | + ); |
| 804 | +} |
| 805 | + |
| 806 | +test "the gate off means PermissionDenied, never Swift's silence" { |
| 807 | + setEnabled(false); |
| 808 | + defer setEnabled(true); |
| 809 | + |
| 810 | + var bridge = DbBridge.init(testing.allocator); |
| 811 | + defer bridge.deinit(); |
| 812 | + |
| 813 | + try testing.expectError( |
| 814 | + bridge_error.BridgeError.PermissionDenied, |
| 815 | + bridge.handleMessage(A.db_query, "{}"), |
| 816 | + ); |
| 817 | +} |
| 818 | + |
| 819 | +fn parseRequestFrom(json: []const u8, out_parsed: *std.json.Parsed(std.json.Value)) !Request { |
| 820 | + out_parsed.* = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); |
| 821 | + return parseRequest(out_parsed.value); |
| 822 | +} |
| 823 | + |
| 824 | +fn expectRequestError(json: []const u8, expected: anyerror) !void { |
| 825 | + var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); |
| 826 | + defer parsed.deinit(); |
| 827 | + try testing.expectError(expected, parseRequest(parsed.value)); |
| 828 | +} |
| 829 | + |
| 830 | +test "the field names the page sends are the ones that are read" { |
| 831 | + // `body["sql"]` / `body["params"]` in the Swift dispatcher; the same pair |
| 832 | + // rides `d` through the shim hand-off. A rename on either side of the |
| 833 | + // migration would make the two handlers read different payloads. |
| 834 | + var parsed = try std.json.parseFromSlice( |
| 835 | + std.json.Value, |
| 836 | + testing.allocator, |
| 837 | + "{\"sql\":\"SELECT ?\",\"params\":[\"a\",1,2.5,null,true]}", |
| 838 | + .{}, |
| 839 | + ); |
| 840 | + defer parsed.deinit(); |
| 841 | + |
| 842 | + const request = try parseRequest(parsed.value); |
| 843 | + try testing.expectEqualStrings("SELECT ?", request.sql); |
| 844 | + try testing.expectEqual(@as(usize, 5), request.params.len); |
| 845 | + try testing.expectEqualStrings("a", request.params[0].string); |
| 846 | + try testing.expectEqual(@as(i64, 1), request.params[1].integer); |
| 847 | + try testing.expectEqual(@as(f64, 2.5), request.params[2].float); |
| 848 | + try testing.expect(request.params[3] == .null); |
| 849 | + try testing.expect(request.params[4].bool); |
| 850 | +} |
| 851 | + |
| 852 | +test "absent and null params are no params; anything else is refused" { |
| 853 | + var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"sql\":\"SELECT 1\"}", .{}); |
| 854 | + defer parsed.deinit(); |
| 855 | + try testing.expectEqual(@as(usize, 0), (try parseRequest(parsed.value)).params.len); |
| 856 | + |
| 857 | + var parsed_null = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"sql\":\"SELECT 1\",\"params\":null}", .{}); |
| 858 | + defer parsed_null.deinit(); |
| 859 | + try testing.expectEqual(@as(usize, 0), (try parseRequest(parsed_null.value)).params.len); |
| 860 | + |
| 861 | + // Swift's `as? [Any]` silently drops a non-array `params` and executes |
| 862 | + // without the values the page sent. |
| 863 | + try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":\"nope\"}", bridge_error.BridgeError.InvalidParameter); |
| 864 | + try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":7}", bridge_error.BridgeError.InvalidParameter); |
| 865 | +} |
| 866 | + |
| 867 | +test "an absent or non-string sql is refused rather than hung on" { |
| 868 | + try expectRequestError("{}", bridge_error.BridgeError.MissingData); |
| 869 | + try expectRequestError("{\"params\":[]}", bridge_error.BridgeError.MissingData); |
| 870 | + try expectRequestError("{\"sql\":7}", bridge_error.BridgeError.InvalidParameter); |
| 871 | + try expectRequestError("{\"sql\":null}", bridge_error.BridgeError.InvalidParameter); |
| 872 | +} |
| 873 | + |
| 874 | +test "sql with an embedded NUL is refused, not silently truncated" { |
| 875 | + // SQLite stops parsing at the NUL: "SELECT 1\x00; DROP ..." would execute |
| 876 | + // the SELECT and report success for the whole string. |
| 877 | + try expectRequestError("{\"sql\":\"SELECT 1\\u0000DROP TABLE t\"}", bridge_error.BridgeError.InvalidParameter); |
| 878 | +} |
| 879 | + |
| 880 | +test "a param SQLite has no honest binding for is refused, not bound as NULL" { |
| 881 | + // Swift binds NULL here — the value the page sent, silently replaced. |
| 882 | + try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":[{}]}", bridge_error.BridgeError.InvalidParameter); |
| 883 | + try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":[[1]]}", bridge_error.BridgeError.InvalidParameter); |
| 884 | +} |
| 885 | + |
| 886 | +test "a payload that is not an object is bad JSON, not a missing field" { |
| 887 | + try expectRequestError("[]", bridge_error.BridgeError.InvalidJSON); |
| 888 | + try expectRequestError("\"sql\"", bridge_error.BridgeError.InvalidJSON); |
| 889 | +} |
| 890 | + |
| 891 | +test "the execute fragment is the two-field object, keys and order pinned" { |
| 892 | + const fragment = try executeFragment(testing.allocator, 1, 42); |
| 893 | + defer testing.allocator.free(fragment); |
| 894 | + try testing.expectEqualStrings("{\"rowsAffected\":1,\"lastInsertId\":42}", fragment); |
| 895 | + |
| 896 | + const none = try executeFragment(testing.allocator, 0, 0); |
| 897 | + defer testing.allocator.free(none); |
| 898 | + try testing.expectEqualStrings("{\"rowsAffected\":0,\"lastInsertId\":0}", none); |
| 899 | +} |
| 900 | + |
| 901 | +test "the multi-statement detector sees a second statement and not a tail of semicolons" { |
| 902 | + const two: []const u8 = "SELECT 1; SELECT 2"; |
| 903 | + try testing.expect(hasTrailingStatement(two, two.ptr + 9)); |
| 904 | + |
| 905 | + const trailing: []const u8 = "SELECT 1; \n;"; |
| 906 | + try testing.expect(!hasTrailingStatement(trailing, trailing.ptr + 9)); |
| 907 | + |
| 908 | + const exact: []const u8 = "SELECT 1"; |
| 909 | + try testing.expect(!hasTrailingStatement(exact, exact.ptr + exact.len)); |
| 910 | +} |
| 911 | + |
| 912 | +// ----------------------------------------------------------------------------- |
| 913 | +// The SQLite tier: real statements against this test's own :memory: database. |
| 914 | +// ----------------------------------------------------------------------------- |
| 915 | + |
| 916 | +fn openMemoryDb() !*sqlite3 { |
| 917 | + var handle: ?*sqlite3 = null; |
| 918 | + const rc = sqlite3_open(":memory:", &handle); |
| 919 | + if (rc != SQLITE_OK) { |
| 920 | + if (handle) |h| _ = sqlite3_close(h); |
| 921 | + return error.OpenFailed; |
| 922 | + } |
| 923 | + return handle orelse error.OpenFailed; |
| 924 | +} |
| 925 | + |
| 926 | +const Runner = enum { execute, query }; |
| 927 | + |
| 928 | +/// Drive a runner through the same parse the handler uses, from the same |
| 929 | +/// payload shape the page posts. |
| 930 | +fn runFromJson(db: *sqlite3, runner: Runner, payload: []const u8) !RunOutcome { |
| 931 | + var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{}); |
| 932 | + defer parsed.deinit(); |
| 933 | + const request = try parseRequest(parsed.value); |
| 934 | + return switch (runner) { |
| 935 | + .execute => runExecute(testing.allocator, db, request), |
| 936 | + .query => runQuery(testing.allocator, db, request), |
| 937 | + }; |
| 938 | +} |
| 939 | + |
| 940 | +fn freeOutcome(outcome: RunOutcome) void { |
| 941 | + switch (outcome) { |
| 942 | + .ok => |s| testing.allocator.free(s), |
| 943 | + .failed => |s| testing.allocator.free(s), |
| 944 | + } |
| 945 | +} |
| 946 | + |
| 947 | +fn expectOk(outcome: RunOutcome, expected: []const u8) !void { |
| 948 | + switch (outcome) { |
| 949 | + .ok => |fragment| try testing.expectEqualStrings(expected, fragment), |
| 950 | + .failed => |message| { |
| 951 | + std.debug.print("expected ok, got rejection: {s}\n", .{message}); |
| 952 | + return error.UnexpectedRejection; |
| 953 | + }, |
| 954 | + } |
| 955 | +} |
| 956 | + |
| 957 | +test "create, insert with every JSON param type, and read the exact reply back" { |
| 958 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 959 | + |
| 960 | + const db = try openMemoryDb(); |
| 961 | + defer _ = sqlite3_close(db); |
| 962 | + |
| 963 | + const created = try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (a, b, c, d, e)\"}"); |
| 964 | + defer freeOutcome(created); |
| 965 | + try expectOk(created, "{\"rowsAffected\":0,\"lastInsertId\":0}"); |
| 966 | + |
| 967 | + // One param of each bindable JSON type. `true` binds as INTEGER 1 — the |
| 968 | + // NSNumber funnel Swift's `as? Int` implies — and reads back as 1. |
| 969 | + const inserted = try runFromJson( |
| 970 | + db, |
| 971 | + .execute, |
| 972 | + "{\"sql\":\"INSERT INTO t VALUES (?, ?, ?, ?, ?)\",\"params\":[\"text\",42,3.5,null,true]}", |
| 973 | + ); |
| 974 | + defer freeOutcome(inserted); |
| 975 | + try expectOk(inserted, "{\"rowsAffected\":1,\"lastInsertId\":1}"); |
| 976 | + |
| 977 | + // The whole observable reply, byte for byte: column keys, a TEXT string, |
| 978 | + // an INTEGER number, a REAL number, a JSON null, the bool as 1. |
| 979 | + const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT a, b, c, d, e FROM t\"}"); |
| 980 | + defer freeOutcome(rows); |
| 981 | + try expectOk(rows, "[{\"a\":\"text\",\"b\":42,\"c\":3.5,\"d\":null,\"e\":1}]"); |
| 982 | +} |
| 983 | + |
| 984 | +test "an empty result set is the bare fragment [], and rows accumulate with commas" { |
| 985 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 986 | + |
| 987 | + const db = try openMemoryDb(); |
| 988 | + defer _ = sqlite3_close(db); |
| 989 | + |
| 990 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (n)\"}")); |
| 991 | + |
| 992 | + const empty = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM t\"}"); |
| 993 | + defer freeOutcome(empty); |
| 994 | + try expectOk(empty, "[]"); |
| 995 | + |
| 996 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (1)\"}")); |
| 997 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (2)\"}")); |
| 998 | + |
| 999 | + const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM t ORDER BY n\"}"); |
| 1000 | + defer freeOutcome(rows); |
| 1001 | + try expectOk(rows, "[{\"n\":1},{\"n\":2}]"); |
| 1002 | +} |
| 1003 | + |
| 1004 | +test "a BLOB column's key is absent from the row, exactly as Swift's default: break" { |
| 1005 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1006 | + |
| 1007 | + const db = try openMemoryDb(); |
| 1008 | + defer _ = sqlite3_close(db); |
| 1009 | + |
| 1010 | + const mixed = try runFromJson(db, .query, "{\"sql\":\"SELECT 1 AS n, x'00ff' AS b\"}"); |
| 1011 | + defer freeOutcome(mixed); |
| 1012 | + try expectOk(mixed, "[{\"n\":1}]"); |
| 1013 | + |
| 1014 | + // A row that is all BLOBs is `{}` — present, and empty. |
| 1015 | + const all_blob = try runFromJson(db, .query, "{\"sql\":\"SELECT x'00' AS b\"}"); |
| 1016 | + defer freeOutcome(all_blob); |
| 1017 | + try expectOk(all_blob, "[{}]"); |
| 1018 | +} |
| 1019 | + |
| 1020 | +test "an INTEGER beyond 32 bits survives — Swift's column_int wrapped it" { |
| 1021 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1022 | + |
| 1023 | + const db = try openMemoryDb(); |
| 1024 | + defer _ = sqlite3_close(db); |
| 1025 | + |
| 1026 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (n)\"}")); |
| 1027 | + freeOutcome(try runFromJson( |
| 1028 | + db, |
| 1029 | + .execute, |
| 1030 | + "{\"sql\":\"INSERT INTO t VALUES (?)\",\"params\":[3000000000]}", |
| 1031 | + )); |
| 1032 | + |
| 1033 | + const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM t\"}"); |
| 1034 | + defer freeOutcome(rows); |
| 1035 | + try expectOk(rows, "[{\"n\":3000000000}]"); |
| 1036 | +} |
| 1037 | + |
| 1038 | +test "a TEXT value with an embedded NUL rides the round trip whole" { |
| 1039 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1040 | + |
| 1041 | + const db = try openMemoryDb(); |
| 1042 | + defer _ = sqlite3_close(db); |
| 1043 | + |
| 1044 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (a)\"}")); |
| 1045 | + // The NUL is the point: length+TRANSIENT on the way in, column_bytes on |
| 1046 | + // the way out. Swift stored a truncated "a" and read back a truncated "a". |
| 1047 | + freeOutcome(try runFromJson( |
| 1048 | + db, |
| 1049 | + .execute, |
| 1050 | + "{\"sql\":\"INSERT INTO t VALUES (?)\",\"params\":[\"a\\u0000b\"]}", |
| 1051 | + )); |
| 1052 | + |
| 1053 | + const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT a FROM t\"}"); |
| 1054 | + defer freeOutcome(rows); |
| 1055 | + try expectOk(rows, "[{\"a\":\"a\\u0000b\"}]"); |
| 1056 | +} |
| 1057 | + |
| 1058 | +test "a column name that needs escaping cannot break the reply" { |
| 1059 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1060 | + |
| 1061 | + const db = try openMemoryDb(); |
| 1062 | + defer _ = sqlite3_close(db); |
| 1063 | + |
| 1064 | + // The alias is page-controlled and lands as a JSON key. |
| 1065 | + const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT 1 AS \\\"we\\\"\\\"ird\\\"\"}"); |
| 1066 | + defer freeOutcome(rows); |
| 1067 | + try expectOk(rows, "[{\"we\\\"ird\":1}]"); |
| 1068 | +} |
| 1069 | + |
| 1070 | +test "bad SQL rejects with SQLite's own words, not a fixed code string" { |
| 1071 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1072 | + |
| 1073 | + const db = try openMemoryDb(); |
| 1074 | + defer _ = sqlite3_close(db); |
| 1075 | + |
| 1076 | + const outcome = try runFromJson(db, .query, "{\"sql\":\"SELECT * FROM no_such_table\"}"); |
| 1077 | + defer freeOutcome(outcome); |
| 1078 | + switch (outcome) { |
| 1079 | + .ok => return error.ExpectedRejection, |
| 1080 | + .failed => |message| { |
| 1081 | + // "no such table: no_such_table" is what the page displays; the |
| 1082 | + // table name in the message is the diagnostic contract. |
| 1083 | + try testing.expect(std.mem.indexOf(u8, message, "no such table") != null); |
| 1084 | + try testing.expect(std.mem.indexOf(u8, message, "no_such_table") != null); |
| 1085 | + }, |
| 1086 | + } |
| 1087 | +} |
| 1088 | + |
| 1089 | +test "dbExecute of a SELECT that returns rows rejects, as Swift's does" { |
| 1090 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1091 | + |
| 1092 | + const db = try openMemoryDb(); |
| 1093 | + defer _ = sqlite3_close(db); |
| 1094 | + |
| 1095 | + // step yields SQLITE_ROW, not SQLITE_DONE — bug-compatible rejection. |
| 1096 | + const outcome = try runFromJson(db, .execute, "{\"sql\":\"SELECT 1\"}"); |
| 1097 | + defer freeOutcome(outcome); |
| 1098 | + try testing.expect(outcome == .failed); |
| 1099 | +} |
| 1100 | + |
| 1101 | +test "a non-SELECT through dbQuery executes and resolves []" { |
| 1102 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1103 | + |
| 1104 | + const db = try openMemoryDb(); |
| 1105 | + defer _ = sqlite3_close(db); |
| 1106 | + |
| 1107 | + const created = try runFromJson(db, .query, "{\"sql\":\"CREATE TABLE made_by_query (n)\"}"); |
| 1108 | + defer freeOutcome(created); |
| 1109 | + try expectOk(created, "[]"); |
| 1110 | + |
| 1111 | + // And it really executed — the table exists now. |
| 1112 | + const proof = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM made_by_query\"}"); |
| 1113 | + defer freeOutcome(proof); |
| 1114 | + try expectOk(proof, "[]"); |
| 1115 | +} |
| 1116 | + |
| 1117 | +test "multi-statement SQL is refused, not silently truncated to its first statement" { |
| 1118 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1119 | + |
| 1120 | + const db = try openMemoryDb(); |
| 1121 | + defer _ = sqlite3_close(db); |
| 1122 | + |
| 1123 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (n)\"}")); |
| 1124 | + |
| 1125 | + // Swift executes the SELECT and silently discards the DROP. Neither |
| 1126 | + // running half the payload nor running all of it behind prepare_v2's back |
| 1127 | + // is honest; the page gets a refusal it can see. |
| 1128 | + try testing.expectError( |
| 1129 | + bridge_error.BridgeError.InvalidParameter, |
| 1130 | + runFromJson(db, .execute, "{\"sql\":\"SELECT 1; DROP TABLE t\"}"), |
| 1131 | + ); |
| 1132 | + try testing.expectError( |
| 1133 | + bridge_error.BridgeError.InvalidParameter, |
| 1134 | + runFromJson(db, .query, "{\"sql\":\"SELECT 1; SELECT 2\"}"), |
| 1135 | + ); |
| 1136 | + |
| 1137 | + // A trailing semicolon is one statement, not two. |
| 1138 | + const one = try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (1); \"}"); |
| 1139 | + defer freeOutcome(one); |
| 1140 | + try expectOk(one, "{\"rowsAffected\":1,\"lastInsertId\":1}"); |
| 1141 | +} |
| 1142 | + |
| 1143 | +test "SQL containing no statement is refused, not stepped into SQLITE_MISUSE" { |
| 1144 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1145 | + |
| 1146 | + const db = try openMemoryDb(); |
| 1147 | + defer _ = sqlite3_close(db); |
| 1148 | + |
| 1149 | + try testing.expectError( |
| 1150 | + bridge_error.BridgeError.InvalidParameter, |
| 1151 | + runFromJson(db, .execute, "{\"sql\":\"\"}"), |
| 1152 | + ); |
| 1153 | + try testing.expectError( |
| 1154 | + bridge_error.BridgeError.InvalidParameter, |
| 1155 | + runFromJson(db, .execute, "{\"sql\":\" \"}"), |
| 1156 | + ); |
| 1157 | + try testing.expectError( |
| 1158 | + bridge_error.BridgeError.InvalidParameter, |
| 1159 | + runFromJson(db, .query, "{\"sql\":\";\"}"), |
| 1160 | + ); |
| 1161 | +} |
| 1162 | + |
| 1163 | +test "a param that does not bind rejects — Swift ignored the SQLITE_RANGE" { |
| 1164 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1165 | + |
| 1166 | + const db = try openMemoryDb(); |
| 1167 | + defer _ = sqlite3_close(db); |
| 1168 | + |
| 1169 | + // Two params, one slot: the second bind fails and the failure is said, |
| 1170 | + // where Swift silently discarded the value the page sent. |
| 1171 | + const outcome = try runFromJson(db, .query, "{\"sql\":\"SELECT ?1 AS v\",\"params\":[1,2]}"); |
| 1172 | + defer freeOutcome(outcome); |
| 1173 | + try testing.expect(outcome == .failed); |
| 1174 | +} |
| 1175 | + |
| 1176 | +test "a stored infinity rejects with Swift's serialization message, not broken JSON" { |
| 1177 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1178 | + |
| 1179 | + const db = try openMemoryDb(); |
| 1180 | + defer _ = sqlite3_close(db); |
| 1181 | + |
| 1182 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (x)\"}")); |
| 1183 | + // 9e999 overflows to +Inf, which SQLite stores and JSON cannot spell. |
| 1184 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (9e999)\"}")); |
| 1185 | + |
| 1186 | + const outcome = try runFromJson(db, .query, "{\"sql\":\"SELECT x FROM t\"}"); |
| 1187 | + defer freeOutcome(outcome); |
| 1188 | + switch (outcome) { |
| 1189 | + .ok => return error.ExpectedRejection, |
| 1190 | + .failed => |message| try testing.expectEqualStrings("Native result could not be serialized", message), |
| 1191 | + } |
| 1192 | +} |
| 1193 | + |
| 1194 | +test "an empty-string param binds as the empty string, not as NULL" { |
| 1195 | + if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; |
| 1196 | + |
| 1197 | + const db = try openMemoryDb(); |
| 1198 | + defer _ = sqlite3_close(db); |
| 1199 | + |
| 1200 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (a)\"}")); |
| 1201 | + freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (?)\",\"params\":[\"\"]}")); |
| 1202 | + |
| 1203 | + // "" and NULL are different answers and must stay different. |
| 1204 | + const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT a FROM t\"}"); |
| 1205 | + defer freeOutcome(rows); |
| 1206 | + try expectOk(rows, "[{\"a\":\"\"}]"); |
| 1207 | +} |