//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this //! file calls SQLite directly. //! //! **SQLite's own file I/O takes no `std.Io`.** It is the one deliberate //! exception to Decision E. SQLite performs its own file I/O through its VFS; //! routing it through `std.Io` would mean writing a custom SQLite VFS — a large, //! security-sensitive component bought for nothing at household scale. Every //! other storage file that touches the filesystem takes `io: std.Io`. //! //! The exception covers SQLite, not nxdns. The one place this file does its own //! filesystem calls — the probes guarding `OpenMode.immutable`, at the open and //! again at `Db.verifyImmutable` — follows the ordinary rule and takes an //! `io: std.Io`, which is why that mode carries one. //! //! The C API is declared by hand below. No `@cImport` — the handles stay //! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach. const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; const log = std.log.scoped(.db); pub const c = struct { pub const Sqlite3 = opaque {}; pub const Stmt = opaque {}; /// The C prototype is a function pointer, but the only value nxdns passes /// is the `SQLITE_TRANSIENT` sentinel (-1), which is not a valid function /// address — a Zig fn-pointer type would reject it on targets with aligned /// function pointers (aarch64). `?*anyopaque` is ABI-identical. pub const Destructor = ?*anyopaque; /// `SQLITE_TRANSIENT`: tells SQLite to copy the bound bytes immediately. pub const transient: Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1)))); pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int; pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int; pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int; pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int; pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int; pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8; pub extern fn sqlite3_errcode(db: *Sqlite3) c_int; pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int; pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8; pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*c.Stmt, pzTail: ?*?[*]const u8) c_int; pub extern fn sqlite3_step(stmt: *c.Stmt) c_int; pub extern fn sqlite3_reset(stmt: *c.Stmt) c_int; pub extern fn sqlite3_clear_bindings(stmt: *c.Stmt) c_int; pub extern fn sqlite3_finalize(stmt: ?*c.Stmt) c_int; pub extern fn sqlite3_bind_int64(stmt: *c.Stmt, idx: c_int, value: i64) c_int; pub extern fn sqlite3_bind_text(stmt: *c.Stmt, idx: c_int, text: [*]const u8, n: c_int, d: Destructor) c_int; pub extern fn sqlite3_bind_null(stmt: *c.Stmt, idx: c_int) c_int; pub extern fn sqlite3_bind_parameter_count(stmt: *c.Stmt) c_int; pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int; pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int; pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64; pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8; pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int; pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64; pub extern fn sqlite3_changes(db: *Sqlite3) c_int; pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int; }; /// Result codes, from the vendored `sqlite3.h` (3.53.4). pub const result = struct { pub const ok: c_int = 0; pub const err: c_int = 1; pub const internal: c_int = 2; pub const perm: c_int = 3; pub const abort: c_int = 4; pub const busy: c_int = 5; pub const locked: c_int = 6; pub const nomem: c_int = 7; pub const readonly: c_int = 8; pub const interrupt: c_int = 9; pub const ioerr: c_int = 10; pub const corrupt: c_int = 11; pub const notfound: c_int = 12; pub const full: c_int = 13; pub const cantopen: c_int = 14; pub const protocol: c_int = 15; pub const empty: c_int = 16; pub const schema: c_int = 17; pub const toobig: c_int = 18; pub const constraint: c_int = 19; pub const mismatch: c_int = 20; pub const misuse: c_int = 21; pub const nolfs: c_int = 22; pub const auth: c_int = 23; pub const format: c_int = 24; pub const range: c_int = 25; pub const notadb: c_int = 26; pub const row: c_int = 100; pub const done: c_int = 101; }; /// Open flags, from the vendored `sqlite3.h` (3.53.4). pub const open_flag = struct { pub const readonly: c_int = 0x1; pub const readwrite: c_int = 0x2; pub const create: c_int = 0x4; pub const uri: c_int = 0x40; pub const nomutex: c_int = 0x8000; pub const fullmutex: c_int = 0x10000; pub const exrescode: c_int = 0x2000000; }; /// Column type codes returned by `sqlite3_column_type`. pub const column_type = struct { pub const integer: c_int = 1; pub const float: c_int = 2; pub const text: c_int = 3; pub const blob: c_int = 4; pub const null_value: c_int = 5; }; pub const Error = error{ Abort, Auth, Busy, CantOpen, Constraint, Corrupt, Empty, Format, Full, Internal, Interrupt, IoErr, Locked, Mismatch, Misuse, NoLfs, NotADb, NotFound, Perm, Protocol, Range, ReadOnly, Schema, TooBig, SqliteError, OutOfMemory, Unexpected, /// Not a SQLite result code. An `OpenMode.immutable` read would have /// answered from a stale main file, because `-wal` holds bytes or the /// files moved while the read ran. Raised by the open and again by /// `Db.verifyImmutable`. See `OpenMode.immutable`. WalPending, }; /// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes /// `error.OutOfMemory` so it joins `transport.LocalResource` semantics: out of /// memory is never the data's fault. /// /// The switch runs on the primary code (`code & 0xff`), so every extended code /// (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) lands on /// its family. The extended code stays visible to humans through `Db.lastError`. /// /// `SQLITE_ERROR` — the generic "SQL error" — maps to `error.Unexpected`, not to /// `error.SqliteError`. `SqliteError` is reserved for a primary code this /// function does not know, so an unmapped future code stays distinguishable /// from an ordinary SQL error. pub fn mapCode(code: c_int) Error { const primary = code & 0xff; assert(primary != result.ok); assert(primary != result.row); assert(primary != result.done); return switch (primary) { result.err => error.Unexpected, result.internal => error.Internal, result.perm => error.Perm, result.abort => error.Abort, result.busy => error.Busy, result.locked => error.Locked, result.nomem => error.OutOfMemory, result.readonly => error.ReadOnly, result.interrupt => error.Interrupt, result.ioerr => error.IoErr, result.corrupt => error.Corrupt, result.notfound => error.NotFound, result.full => error.Full, result.cantopen => error.CantOpen, result.protocol => error.Protocol, result.empty => error.Empty, result.schema => error.Schema, result.toobig => error.TooBig, result.constraint => error.Constraint, result.mismatch => error.Mismatch, result.misuse => error.Misuse, result.nolfs => error.NoLfs, result.auth => error.Auth, result.format => error.Format, result.range => error.Range, result.notadb => error.NotADb, else => error.SqliteError, }; } fn check(code: c_int) Error!void { if (code == result.ok) return; return mapCode(code); } pub const OpenMode = union(enum) { read_write_create, read_write_existing, read_only, memory, /// Read a database that this process promises not to change, and that no /// writer may be touching: `SQLITE_OPEN_READONLY` plus the `immutable=1` URI /// parameter, which makes the pager treat the file like a temp file — no /// locking, no rollback journal, no wal-index — so **no `-wal` and no `-shm` /// appear beside it**. /// /// This mode exists for `nxdns check` (milestone-13 ruling F-c), which must /// validate without writing. `.read_only` alone is not enough, and this is /// measured, not assumed: reading a database whose header says WAL makes /// SQLite build the wal-index, so `config.db-wal` (0 bytes) and /// `config.db-shm` (32 KiB) are created, and a read-only connection cannot /// remove them on close. A command that claims to write nothing must not /// leave two files behind. Do not "simplify" this back to `.read_only`. /// /// What `immutable=1` costs: SQLite then **ignores any `-wal` file**. The /// newest committed rows live there, so an immutable read of a database with /// an un-checkpointed WAL would answer from stale data and say nothing — a /// worse failure than the two sidecar files it removes. `Db.open` therefore /// refuses this mode with `error.WalPending` whenever `-wal` exists and /// is not empty; the caller reports that as a failure and names `nxdns run`, /// which opens read-write and checkpoints, as the fix. /// /// The guard lives inside `open` rather than in a helper callers are trusted /// to call, because the failure it prevents is silent: a caller that forgets /// a helper gets a plausible wrong answer, and nothing anywhere reports it. /// /// **The open is half the guard.** `immutable=1` takes no lock, so nothing /// keeps a writer out for the duration of the read, and a check made only /// before the read can say only that the log was empty *then*. /// `Db.verifyImmutable` makes the other half, and a caller that grades what /// it read without calling it is back to the stale answer this mode exists /// to refuse. /// /// A zero-length `-wal` does not block the open: it holds no frames, so the /// main file is complete. That is the exact leftover the pre-F-c `check` /// used to create. /// /// The `std.Io` is for that probe — the one filesystem call nxdns itself /// makes in this file. Passing it is what makes the guard unskippable. immutable: std.Io, }; pub const OpenOptions = struct { mode: OpenMode = .read_write_create, busy_timeout_ms: c_int = 5000, }; /// SQLite's name for the write-ahead log beside ``. Exported so a caller /// reporting `error.WalPending` can name the file without hard-coding SQLite's /// naming convention, which this file owns. pub const wal_suffix = "-wal"; /// One SQLite connection. /// /// A `Db` must not move once a `Stmt` prepared from it is alive: every `Stmt` /// holds a `*Db`. pub const Db = struct { handle: *c.Sqlite3, /// Set by `OpenMode.immutable` and null in every other mode: what the /// database file and its `-wal` looked like when the read began, for /// `verifyImmutable` to compare against when it ends. immutable_guard: ?ImmutableGuard = null, /// Every mode carries `FULLMUTEX` (serialized mode). The query logger and /// the web API handlers share one handle across `std.Io` tasks, and a /// per-handle mutex inside SQLite is cheaper to be correct about than a /// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE` /// makes `sqlite3_extended_errcode` meaningful from the first call. /// /// `open` deliberately applies no pragmas — see `applyPragmas`, which the /// migration runner must call before it opens a transaction. /// /// For `.memory`, `path` is ignored and `":memory:"` is used. pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db { const base = open_flag.exrescode | open_flag.fullmutex; const flags: c_int = switch (options.mode) { .read_write_create, .memory => base | open_flag.readwrite | open_flag.create, .read_write_existing => base | open_flag.readwrite, .read_only => base | open_flag.readonly, // Its own function: the URI buffer is 12 KiB, and every other open // in the process would carry it in this frame. .immutable => |io| return openImmutable(io, path, options.busy_timeout_ms), }; const filename: [:0]const u8 = switch (options.mode) { .memory => ":memory:", else => path, }; const h = try openHandle(filename, flags); return applyBusyTimeout(h, options.busy_timeout_ms); } /// Proves that the files an `OpenMode.immutable` read answered from stood /// still while it ran, and fails the read with `error.WalPending` when they /// did not. Call it after the last read and before anything read is /// reported. /// /// `immutable=1` takes no lock at all — that is what stops the pager /// building a wal-index, and the price is that nothing keeps a writer out. /// The probe at open time can only say the log was empty at that instant: a /// writer that appends one frame the instant after leaves the read answering /// from the older pages of the main file, silently, which is the whole /// failure `OpenMode.immutable`'s guard exists to prevent. /// /// Two things are compared, because a writer can hide in either: /// /// - `-wal` holding bytes now. A log that appeared, one that grew, and /// one written into the empty file the open accepted all land here. /// - `` itself moving — size, inode, mtime or ctime. This is the /// checkpoint the log cannot show: a writer that checkpointed into the main /// file and truncated its log back to nothing leaves both stats saying "no /// frames" while the pages the read saw have been replaced. /// /// Best effort, and honestly so: a filesystem with coarse timestamps can /// hide a rewrite that lands on the same byte count in the same tick. That /// cannot be fixed from outside SQLite's locking, and taking a lock is the /// one thing this mode may not do. What it closes is the window a single /// stat before the read leaves open for the whole of the read. /// /// Two stats and nothing else: no `-wal` or `-shm` is created, and neither /// sidecar is removed or truncated. Calling this on a handle opened in any /// other mode is a caller bug. pub fn verifyImmutable(self: *Db) Error!void { const guard = self.immutable_guard orelse unreachable; const now = try markImmutable(guard.io, guard.path); if (!std.meta.eql(now, guard.mark)) { log.warn( "'{s}' changed while it was being read without a lock; the read is not trustworthy", .{guard.path}, ); return error.WalPending; } } pub fn close(self: *Db) void { const rc = c.sqlite3_close_v2(self.handle); if (rc != result.ok) { log.err("sqlite3_close_v2 returned {s} (code {d})", .{ std.mem.span(c.sqlite3_errstr(rc)), rc, }); } } /// Borrowed; valid until the next SQLite call on this handle. Formats as /// " (code /)". pub fn lastError(self: *Db, buf: []u8) []const u8 { const extended = c.sqlite3_extended_errcode(self.handle); const message = std.mem.span(c.sqlite3_errmsg(self.handle)); return std.fmt.bufPrint(buf, "{s} (code {d}/{d})", .{ message, extended & 0xff, extended, }) catch "sqlite error (message did not fit the buffer)"; } /// For DDL and multi-statement scripts. `errmsg` is passed as null and the /// message is read back through `sqlite3_errmsg`, so there is no /// `sqlite3_free` obligation. pub fn exec(self: *Db, sql: [:0]const u8) Error!void { return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null)); } /// `sql` must hold exactly one statement; text with a second statement in it /// is `error.Misuse` and belongs in `exec`. pub fn prepare(self: *Db, sql: []const u8) Error!Stmt { if (sql.len > std.math.maxInt(c_int)) return error.TooBig; var handle: ?*c.Stmt = null; var tail: ?[*]const u8 = null; try check(c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &handle, &tail)); const h = handle orelse return error.Misuse; const tail_ptr = tail orelse sql.ptr + sql.len; const consumed = @intFromPtr(tail_ptr) - @intFromPtr(sql.ptr); const remaining = std.mem.trim(u8, sql[consumed..], " \t\r\n"); if (remaining.len != 0) { _ = c.sqlite3_finalize(h); return error.Misuse; } return .{ .handle = h, .db = self }; } /// Runs `sql` (which must yield exactly one row with one integer column) and /// returns it. A statement that produces no row is `error.SqliteError`. /// /// The row shape is verified, not assumed: a result with a column count /// other than 1, a first column that is not `SQLITE_INTEGER` (NULL, text, /// float and blob all count), or a second row is `error.Misuse`. That is the /// same member `prepare` returns for a caller that hands it the wrong SQL, /// because these are the same class of fault — a caller bug or schema drift, /// never a runtime condition. Without the checks a `SELECT` of the wrong /// column silently returns 0. pub fn queryInt(self: *Db, sql: []const u8) Error!i64 { var stmt = try self.prepare(sql); defer stmt.deinit(); if (!try stmt.step()) { log.warn("queryInt produced no row for '{s}'", .{sql}); return error.SqliteError; } const columns = c.sqlite3_column_count(stmt.handle); if (columns != 1) { log.warn("queryInt expects 1 column, got {d}, for '{s}'", .{ columns, sql }); return error.Misuse; } const kind = c.sqlite3_column_type(stmt.handle, 0); if (kind != column_type.integer) { log.warn("queryInt expects an integer column, got type {d}, for '{s}'", .{ kind, sql }); return error.Misuse; } const value = stmt.columnInt(0); if (try stmt.step()) { log.warn("queryInt expects 1 row, got more, for '{s}'", .{sql}); return error.Misuse; } return value; } pub fn lastInsertRowid(self: *Db) i64 { return c.sqlite3_last_insert_rowid(self.handle); } pub fn changes(self: *Db) i64 { return c.sqlite3_changes(self.handle); } /// Every row this connection has inserted, updated or deleted since it was /// opened. Monotonic, so a caller proves "this call wrote nothing" by /// reading it either side and comparing — which is stronger than comparing /// content, because an UPDATE that rewrites identical values still moves /// this counter. pub fn totalChanges(self: *Db) i64 { return c.sqlite3_total_changes(self.handle); } }; fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 { var handle: ?*c.Sqlite3 = null; const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null); if (rc != result.ok) { // sqlite3_open_v2 allocates a handle even on failure. Read the // message from it, then close it; dropping it leaks on every // failed open. // Logged at `warn`, not `err`: the failure itself reaches the // caller as a typed error, and this line only carries the message // that would otherwise die with the handle. if (handle) |h| { log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d}/{d})", .{ filename, std.mem.span(c.sqlite3_errmsg(h)), rc & 0xff, c.sqlite3_extended_errcode(h), }); _ = c.sqlite3_close_v2(h); } else { log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d})", .{ filename, std.mem.span(c.sqlite3_errstr(rc)), rc, }); } return mapCode(rc); } return handle orelse error.SqliteError; } /// A silently ignored busy timeout is how a contended WAL database turns into /// random SQLITE_BUSY failures under load. fn applyBusyTimeout(h: *c.Sqlite3, busy_timeout_ms: c_int) Error!Db { check(c.sqlite3_busy_timeout(h, busy_timeout_ms)) catch |e| { _ = c.sqlite3_close_v2(h); return e; }; return .{ .handle = h }; } // --------------------------------------------------------------------------- // OpenMode.immutable // --------------------------------------------------------------------------- const uri_scheme = "file:"; const uri_immutable_query = "?immutable=1"; /// Worst case: every byte of the longest path the platform accepts becomes /// `%HH`. const immutable_uri_buf_len = uri_scheme.len + 3 * std.Io.Dir.max_path_bytes + uri_immutable_query.len + 1; fn openImmutable(io: std.Io, path: [:0]const u8, busy_timeout_ms: c_int) Error!Db { const mark = try markImmutable(io, path); var buf: [immutable_uri_buf_len]u8 = undefined; const uri = try immutableUri(&buf, path); // `uri` without `open_flag.uri` would be opened as a filename spelled // "file:...", creating nothing and finding nothing. const flags = open_flag.exrescode | open_flag.fullmutex | open_flag.readonly | open_flag.uri; const h = try openHandle(uri, flags); var database = try applyBusyTimeout(h, busy_timeout_ms); database.immutable_guard = .{ .io = io, .path = path, .mark = mark }; return database; } /// What `OpenMode.immutable` recorded at the start of a read so that /// `Db.verifyImmutable` can prove nothing moved by the end of it. pub const ImmutableGuard = struct { io: std.Io, /// Borrowed. Must outlive the `Db`, which every caller satisfies by owning /// the path for at least as long as the connection it opened with it. path: []const u8, mark: FileMark, }; /// The main database file at one instant, in the fields an outside observer can /// compare cheaply. `atime` is deliberately absent: reading the file changes it, /// so comparing it would report every read as a change. /// /// All zero when the file does not exist, which is itself a state worth /// comparing — a database replaced by an unlink is a database that moved. const FileMark = struct { present: bool, size: u64, inode: std.Io.File.INode, mtime_ns: i96, ctime_ns: i96, }; /// The state an immutable read must find unchanged, or `error.WalPending` when /// `-wal` already holds bytes. /// /// The `-wal` rule is deliberately conservative: any non-empty log fails. /// Deciding whether it really holds committed frames means running WAL recovery /// — checksums, salt, the wal-index — which is the writing that /// `OpenMode.immutable` exists to avoid. A live writer, a crash, and a /// checkpointed-but-retained log all land here, and refusing to answer is the /// right side to err on: the alternative is a stale answer nobody can see is /// stale. A zero-length log holds no frames, so the main file is complete and it /// passes. /// /// A failed stat is not "no WAL": it means this cannot be known, so it stays a /// failure. fn markImmutable(io: std.Io, path: []const u8) Error!FileMark { var buf: [std.Io.Dir.max_path_bytes + wal_suffix.len]u8 = undefined; const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, wal_suffix }) catch return error.TooBig; if (try statOrAbsent(io, sidecar)) |wal| { if (wal.size > 0) return error.WalPending; } const main = try statOrAbsent(io, path) orelse return .{ .present = false, .size = 0, .inode = 0, .mtime_ns = 0, .ctime_ns = 0, }; return .{ .present = true, .size = main.size, .inode = main.inode, .mtime_ns = main.mtime.nanoseconds, .ctime_ns = main.ctime.nanoseconds, }; } fn statOrAbsent(io: std.Io, path: []const u8) Error!?std.Io.Dir.Stat { return std.Io.Dir.cwd().statFile(io, path, .{}) catch |e| switch (e) { error.FileNotFound => return null, else => { log.warn("cannot stat '{s}': {t}", .{ path, e }); return error.IoErr; }, }; } /// `file:` + percent-encoded `path` + `?immutable=1`. /// /// The encoding is load-bearing, not cosmetic. `?` opens SQLite's query section /// and `#` its fragment, so an unencoded data directory named `dns?db` would /// silently open a *different* file; `%` must be encoded because SQLite decodes /// `%HH` on its way back to a filename. `--data-dir` is operator input, so all /// three are reachable. /// /// Everything outside the unreserved set (`A-Z a-z 0-9 - . _ ~ /`) is encoded, /// which is always safe: SQLite decodes every escape in the path before handing /// the name to its VFS, so the bytes it opens are the bytes passed in. /// /// `/` stays literal to keep diagnostics readable, with one exception. SQLite /// reads `file://…` as a URI authority and rejects any authority but the empty /// one or `localhost` (`sqlite3ParseUri`), so a path beginning with `//` — legal /// POSIX — has its second slash encoded. fn immutableUri(buf: []u8, path: []const u8) error{TooBig}![:0]const u8 { var out: usize = 0; try appendSlice(buf, &out, uri_scheme); for (path, 0..) |ch, i| { const opens_authority = i == 1 and ch == '/' and path[0] == '/'; if (isUriUnreserved(ch) and !opens_authority) { try appendByte(buf, &out, ch); } else { const hex = "0123456789ABCDEF"; try appendByte(buf, &out, '%'); try appendByte(buf, &out, hex[ch >> 4]); try appendByte(buf, &out, hex[ch & 0xf]); } } try appendSlice(buf, &out, uri_immutable_query); try appendByte(buf, &out, 0); return buf[0 .. out - 1 :0]; } fn isUriUnreserved(ch: u8) bool { return switch (ch) { 'a'...'z', 'A'...'Z', '0'...'9', '-', '.', '_', '~', '/' => true, else => false, }; } fn appendByte(buf: []u8, out: *usize, ch: u8) error{TooBig}!void { if (out.* == buf.len) return error.TooBig; buf[out.*] = ch; out.* += 1; } fn appendSlice(buf: []u8, out: *usize, bytes: []const u8) error{TooBig}!void { for (bytes) |ch| try appendByte(buf, out, ch); } /// One prepared statement. /// /// There is deliberately **no prepared-statement cache in this milestone**. /// `config.db` is written a handful of times per process lifetime, so a cache is /// unmeasured complexity here. Phase 6's query-log flush loop is the only hot /// path and it owns its own long-lived statements. This is a decision, not an /// oversight against PLAN §3.4. pub const Stmt = struct { handle: *c.Stmt, db: *Db, /// The code of the last failed `step`, or `SQLITE_OK`. `sqlite3_reset` and /// `sqlite3_finalize` both re-report that code; without this the caller /// would see one failure logged as a second, unrelated one. pending_error: c_int = result.ok, pub fn deinit(self: *Stmt) void { const rc = c.sqlite3_finalize(self.handle); if (rc != result.ok and rc != self.pending_error) { log.err("sqlite3_finalize returned {s} (code {d})", .{ std.mem.span(c.sqlite3_errstr(rc)), rc, }); } } pub fn reset(self: *Stmt) Error!void { const rc = c.sqlite3_reset(self.handle); self.pending_error = result.ok; try check(rc); try check(c.sqlite3_clear_bindings(self.handle)); } /// 1-based, matching SQLite. pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void { return check(c.sqlite3_bind_int64(self.handle, idx, value)); } pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void { return self.bindInt(idx, if (value) 1 else 0); } /// Binds with `SQLITE_TRANSIENT`, so SQLite copies the bytes and the caller /// never has to keep `value` alive. The copy costs an allocation per bind; /// at config.db volumes that is invisible, and it removes a whole class of /// use-after-free from every caller. pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void { if (value.len > std.math.maxInt(c_int)) return error.TooBig; return check(c.sqlite3_bind_text(self.handle, idx, value.ptr, @intCast(value.len), c.transient)); } pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void { if (value) |v| return self.bindText(idx, v); return self.bindNull(idx); } pub fn bindNull(self: *Stmt, idx: c_int) Error!void { return check(c.sqlite3_bind_null(self.handle, idx)); } /// true = a row is available, false = the statement finished. pub fn step(self: *Stmt) Error!bool { const rc = c.sqlite3_step(self.handle); if (rc == result.row) return true; if (rc == result.done) return false; self.pending_error = rc; return mapCode(rc); } /// Runs to completion; asserts no rows were produced. pub fn exec(self: *Stmt) Error!void { const has_row = try self.step(); assert(!has_row); } pub fn columnInt(self: *Stmt, col: c_int) i64 { return c.sqlite3_column_int64(self.handle, col); } pub fn columnBool(self: *Stmt, col: c_int) bool { return self.columnInt(col) != 0; } pub fn isNull(self: *Stmt, col: c_int) bool { return c.sqlite3_column_type(self.handle, col) == column_type.null_value; } /// Borrowed: valid only until the next `step`, `reset` or `deinit` on this /// statement. Every caller that keeps the value must copy it. /// /// A NULL column reads as `""`. A `NOT NULL` column makes that unreachable /// in practice, but it must not be undefined behaviour. pub fn columnText(self: *Stmt, col: c_int) []const u8 { const ptr = c.sqlite3_column_text(self.handle, col) orelse return ""; const len = c.sqlite3_column_bytes(self.handle, col); if (len <= 0) return ""; return ptr[0..@intCast(len)]; } /// Borrowed under the same rules as `columnText`; NULL reads as `null`. pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8 { if (self.isNull(col)) return null; return self.columnText(col); } /// Copies into `gpa`. Caller owns the result. pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8 { return gpa.dupe(u8, self.columnText(col)); } /// Copies into `gpa`. Caller owns the result. NULL reads as `null`. pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8 { const value = self.columnTextOrNull(col) orelse return null; return try gpa.dupe(u8, value); } }; pub const Pragmas = struct { journal_wal: bool = true, synchronous_normal: bool = true, foreign_keys: bool = true, /// Null leaves SQLite's 1000-page default. A caller that sets it owns the /// durability consequences, which depend on what the database holds. wal_autocheckpoint_pages: ?i32 = null, }; /// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a /// no-op inside a transaction, so applying it later silently leaves referential /// integrity off. pub fn applyPragmas(self: *Db, p: Pragmas) Error!void { if (p.journal_wal) { // The pragma returns a row holding the mode it actually reached. `exec` // would discard that answer, and an in-memory database — which cannot do // WAL — would look fine. var stmt = try self.prepare("PRAGMA journal_mode = WAL"); defer stmt.deinit(); if (!try stmt.step()) return error.SqliteError; const mode = stmt.columnText(0); const wal = std.ascii.eqlIgnoreCase(mode, "wal"); const memory = std.ascii.eqlIgnoreCase(mode, "memory"); if (!wal and !memory) { log.warn("PRAGMA journal_mode = WAL reported '{s}'", .{mode}); return error.SqliteError; } } if (p.synchronous_normal) { try self.exec("PRAGMA synchronous = NORMAL;"); } if (p.foreign_keys) { try self.exec("PRAGMA foreign_keys = ON;"); if (try self.queryInt("PRAGMA foreign_keys") != 1) { log.warn("PRAGMA foreign_keys did not take", .{}); return error.SqliteError; } } if (p.wal_autocheckpoint_pages) |pages| { var buf: [64]u8 = undefined; const sql = std.fmt.bufPrintZ(&buf, "PRAGMA wal_autocheckpoint = {d};", .{pages}) catch unreachable; try self.exec(sql); const applied = try self.queryInt("PRAGMA wal_autocheckpoint"); if (applied != pages) { log.warn("PRAGMA wal_autocheckpoint = {d} reported {d}", .{ pages, applied }); return error.SqliteError; } } } /// A write transaction. /// /// Usage contract, followed everywhere in this milestone: /// /// ```zig /// var tx = try Tx.begin(db); /// errdefer tx.rollback(); /// ... // all writes /// try tx.commit(); /// ``` /// /// `commit` and `rollback` both clear `active`, so the `errdefer` after a /// successful commit is a no-op. pub const Tx = struct { db: *Db, active: bool, /// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction /// that upgrades mid-way can fail with SQLITE_BUSY after arbitrary work; /// immediate cannot. pub fn begin(db: *Db) Error!Tx { try db.exec("BEGIN IMMEDIATE;"); return .{ .db = db, .active = true }; } pub fn commit(self: *Tx) Error!void { assert(self.active); try self.db.exec("COMMIT;"); self.active = false; } /// Safe in `errdefer` and after `commit`. Never returns an error; a failed /// ROLLBACK is logged at `err` level with the SQLite message, because a /// database that will not roll back is an operational event, not a detail. pub fn rollback(self: *Tx) void { if (!self.active) return; self.active = false; self.db.exec("ROLLBACK;") catch { var buf: [256]u8 = undefined; log.err("ROLLBACK failed: {s}", .{self.db.lastError(&buf)}); }; } }; /// A read transaction: one consistent view of the file across several /// statements. /// /// `BEGIN DEFERRED`, not `Tx`'s `BEGIN IMMEDIATE`. A reader that took the write /// lock would stall the logger and retention for the length of an HTTP /// response; a deferred transaction that only ever reads never upgrades, so it /// cannot hit the mid-way `SQLITE_BUSY` the `Tx` doc warns about. In WAL mode it /// pins the snapshot at the first read, which is the point: an aggregate and the /// coverage watermark beside it describe the same database state even when /// retention prunes between them. /// /// Ending it is fallible, and the caller must treat it that way. A connection /// left inside a transaction refuses the next `BEGIN`, so a swallowed failure /// here does not cost one response — it costs every later one on the same /// connection, and they would each be a correct-looking answer over a foreign /// snapshot or an outright error the client was never told about. /// /// ```zig /// var tx = try ReadTx.begin(db); /// errdefer tx.rollback(); /// ... // reads only /// try tx.commit(); /// ``` pub const ReadTx = struct { db: *Db, active: bool, pub fn begin(db: *Db) Error!ReadTx { try db.exec("BEGIN DEFERRED;"); return .{ .db = db, .active = true }; } /// Ends the transaction, and says so. A read transaction has nothing to /// conflict over, so a failed COMMIT means the connection is in a state /// this code did not put it in: the ROLLBACK below is the attempt to hand /// the next caller a usable connection anyway, and the error is returned so /// the response it was serving fails rather than reporting success over a /// database whose state nobody can name. pub fn commit(self: *ReadTx) Error!void { assert(self.active); self.active = false; self.execCommit() catch |err| { self.reportFault("COMMIT"); self.forceRollback(); return err; }; } fn execCommit(self: *ReadTx) Error!void { if (commitFaultTripped()) return error.Internal; return self.db.exec("COMMIT;"); } /// Safe in `errdefer` and after `commit`. Never returns an error: it runs /// on the path where something has already gone wrong, and that error is /// the one worth reporting. pub fn rollback(self: *ReadTx) void { if (!self.active) return; self.active = false; self.forceRollback(); } /// A ROLLBACK that fails leaves the connection inside a transaction with no /// way left to get it out. Every later `begin` on it fails, which is the /// visible symptom this reports the cause of. fn forceRollback(self: *ReadTx) void { self.db.exec("ROLLBACK;") catch { self.reportFault("ROLLBACK"); }; } /// The only record that this connection may be unusable, so in a real build /// it is `err`. A test that deliberately causes the fault captures it /// instead — see `read_tx_faults`. fn reportFault(self: *ReadTx, comptime what: []const u8) void { var buf: [256]u8 = undefined; const message = self.db.lastError(&buf); if (faultCaptured()) return; log.err("read-transaction " ++ what ++ " failed: {s}", .{message}); } }; /// Drives and observes the read-transaction teardown faults, which a unit test /// cannot arrange against a healthy SQLite connection. Test builds only; it /// reduces to nothing everywhere else — the rotation seam's shape /// (logging.zig). const read_tx_seam = if (builtin.is_test) struct { var fail_next_commit: bool = false; var capturing: bool = false; var faults: usize = 0; } else struct {}; fn commitFaultTripped() bool { if (!builtin.is_test) return false; if (!read_tx_seam.fail_next_commit) return false; read_tx_seam.fail_next_commit = false; return true; } /// True when a test has said it is expecting this fault and will assert on it. /// Capture is opt-in for exactly one reason: the test runner fails a test that /// logs at `err`, so a blanket silence would turn an unexpected teardown fault /// in some unrelated test into a silent pass. fn faultCaptured() bool { if (!builtin.is_test) return false; if (!read_tx_seam.capturing) return false; read_tx_seam.faults += 1; return true; } /// The seam's controls, for tests in this file and in the web layer. pub const read_tx_faults = if (builtin.is_test) struct { /// Fails the next `ReadTx.commit` before it issues COMMIT, so the /// transaction is still open when the recovery path runs — which is the /// shape of a real COMMIT failure. pub fn failNextCommit() void { read_tx_seam.fail_next_commit = true; } pub fn beginCapture() void { read_tx_seam.capturing = true; read_tx_seam.faults = 0; } /// Stops capturing and answers how many faults were reported meanwhile. pub fn endCapture() usize { read_tx_seam.capturing = false; return read_tx_seam.faults; } } else struct {}; const testing = std.testing; fn openMemory() Error!Db { return Db.open(":memory:", .{ .mode = .memory }); } test "mapCode maps every primary result code to a distinct error" { var seen: [26]Error = undefined; var code: c_int = 1; while (code <= 26) : (code += 1) { seen[@intCast(code - 1)] = mapCode(code); } for (seen, 0..) |a, i| { for (seen[i + 1 ..]) |b| { try testing.expect(a != b); } } } test "mapCode maps SQLITE_NOMEM to error.OutOfMemory and keeps extended codes in the family" { try testing.expectEqual(Error.OutOfMemory, mapCode(result.nomem)); // SQLITE_IOERR_READ = 266, SQLITE_CONSTRAINT_UNIQUE = 2067. try testing.expectEqual(Error.IoErr, mapCode(266)); try testing.expectEqual(Error.Constraint, mapCode(2067)); // A primary code this build does not know stays visible as SqliteError. try testing.expectEqual(Error.SqliteError, mapCode(99)); } test "open and close an in-memory database" { var db = try openMemory(); defer db.close(); try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT 1")); } test "queryInt rejects a result that is not exactly one row of one integer" { var db = try openMemory(); defer db.close(); // No row keeps the documented error.SqliteError. try testing.expectError(error.SqliteError, db.queryInt("SELECT 1 WHERE 0")); // Wrong column count. try testing.expectError(error.Misuse, db.queryInt("SELECT 1, 2")); // Wrong column type: NULL, text, float and blob are all rejected. try testing.expectError(error.Misuse, db.queryInt("SELECT NULL")); try testing.expectError(error.Misuse, db.queryInt("SELECT 'one'")); try testing.expectError(error.Misuse, db.queryInt("SELECT 1.5")); try testing.expectError(error.Misuse, db.queryInt("SELECT x'00'")); // A second row. try testing.expectError(error.Misuse, db.queryInt("SELECT 1 UNION ALL SELECT 2")); } test "queryInt accepts a single integer row after the shape checks" { var db = try openMemory(); defer db.close(); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);"); try db.exec("INSERT INTO t (id, name) VALUES (7, 'only');"); try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT id FROM t")); try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); try testing.expectEqual(@as(i64, -3), try db.queryInt("SELECT -3")); // sum() over an empty table is NULL, not an integer: a caller that wants a // total from a possibly-empty table must write total(), or COALESCE. try testing.expectError(error.Misuse, db.queryInt("SELECT sum(id) FROM t WHERE 0")); try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT sum(id) FROM t")); } test "applyPragmas succeeds and foreign_keys reads back as 1" { var db = try openMemory(); defer db.close(); try applyPragmas(&db, .{}); try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys")); } test "applyPragmas leaves wal_autocheckpoint at the default unless a page count is given" { var db = try openMemory(); defer db.close(); try applyPragmas(&db, .{}); try testing.expectEqual(@as(i64, 1000), try db.queryInt("PRAGMA wal_autocheckpoint")); var configured = try openMemory(); defer configured.close(); try applyPragmas(&configured, .{ .wal_autocheckpoint_pages = 8192 }); try testing.expectEqual(@as(i64, 8192), try configured.queryInt("PRAGMA wal_autocheckpoint")); } test "open on a directory path returns error.CantOpen and leaks no handle" { var i: usize = 0; while (i < 1000) : (i += 1) { try testing.expectError(error.CantOpen, Db.open(".", .{})); } } test "prepare rejects text holding more than one statement" { var db = try openMemory(); defer db.close(); try testing.expectError(error.Misuse, db.prepare("SELECT 1; SELECT 2")); var stmt = try db.prepare("SELECT 1;"); stmt.deinit(); } test "bind, step and column round-trip including a NULL text column" { var db = try openMemory(); defer db.close(); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, note TEXT, flag INTEGER NOT NULL);"); var insert = try db.prepare("INSERT INTO t (name, note, flag) VALUES (?1, ?2, ?3)"); defer insert.deinit(); try insert.bindText(1, "kitchen"); try insert.bindTextOrNull(2, null); try insert.bindBool(3, true); try insert.exec(); try testing.expectEqual(@as(i64, 1), db.changes()); try testing.expectEqual(@as(i64, 1), db.lastInsertRowid()); var select = try db.prepare("SELECT id, name, note, flag FROM t"); defer select.deinit(); try testing.expect(try select.step()); try testing.expectEqual(@as(i64, 1), select.columnInt(0)); try testing.expectEqualStrings("kitchen", select.columnText(1)); try testing.expect(select.isNull(2)); try testing.expectEqual(@as(?[]const u8, null), select.columnTextOrNull(2)); try testing.expectEqualStrings("", select.columnText(2)); try testing.expect(select.columnBool(3)); try testing.expect(!try select.step()); } test "columnTextAlloc returns an owned copy that survives a subsequent step" { var db = try openMemory(); defer db.close(); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);"); try db.exec("INSERT INTO t (id, name) VALUES (1, 'first'), (2, 'second');"); var stmt = try db.prepare("SELECT name FROM t ORDER BY id"); defer stmt.deinit(); try testing.expect(try stmt.step()); const owned = try stmt.columnTextAlloc(testing.allocator, 0); defer testing.allocator.free(owned); const owned_or_null = try stmt.columnTextAllocOrNull(testing.allocator, 0); defer if (owned_or_null) |v| testing.allocator.free(v); try testing.expect(try stmt.step()); try testing.expectEqualStrings("second", stmt.columnText(0)); try testing.expectEqualStrings("first", owned); try testing.expectEqualStrings("first", owned_or_null.?); } test "transaction commit persists and rollback discards" { var db = try openMemory(); defer db.close(); try applyPragmas(&db, .{}); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);"); { var tx = try Tx.begin(&db); errdefer tx.rollback(); try db.exec("INSERT INTO t (id) VALUES (1);"); try tx.commit(); } try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); { var tx = try Tx.begin(&db); try db.exec("INSERT INTO t (id) VALUES (2);"); tx.rollback(); } try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); } test "rollback after commit is a no-op" { var db = try openMemory(); defer db.close(); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);"); var tx = try Tx.begin(&db); try db.exec("INSERT INTO t (id) VALUES (1);"); try tx.commit(); try testing.expect(!tx.active); tx.rollback(); tx.rollback(); try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); } test "a read transaction sees one state and frees the connection when it commits" { var db = try openMemory(); defer db.close(); try applyPragmas(&db, .{}); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);"); try db.exec("INSERT INTO t (id) VALUES (1);"); var tx = try ReadTx.begin(&db); try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); try tx.commit(); try testing.expect(!tx.active); // Committed, so the connection takes a fresh transaction again. var second = try ReadTx.begin(&db); second.rollback(); try testing.expect(!second.active); // Idempotent, so an `errdefer` that survives a successful rollback is a // no-op rather than a stray ROLLBACK against the next transaction. second.rollback(); var third = try ReadTx.begin(&db); try third.commit(); } test "a connection left inside a transaction refuses the next begin" { var db = try openMemory(); defer db.close(); try applyPragmas(&db, .{}); // The poisoned connection, reached the only way it can be: a transaction // that was opened and never ended. This is what a swallowed COMMIT failure // would leave behind, and the point is that it is *loud* — the next reader // gets an error it must report, never a silent read outside a snapshot. try db.exec("BEGIN DEFERRED;"); try testing.expectError(error.Unexpected, ReadTx.begin(&db)); // And it is recoverable: ending the stray transaction restores the // connection, which is what `commit`'s rollback attempt is reaching for. try db.exec("ROLLBACK;"); var tx = try ReadTx.begin(&db); try tx.commit(); } test "a failed commit is reported, rolled back, and leaves the connection usable" { var db = try openMemory(); defer db.close(); try applyPragmas(&db, .{}); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);"); try db.exec("INSERT INTO t (id) VALUES (1);"); read_tx_faults.beginCapture(); defer _ = read_tx_faults.endCapture(); var tx = try ReadTx.begin(&db); try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); // The failure lands where a real one does: the transaction is still open // when the recovery path runs. read_tx_faults.failNextCommit(); try testing.expectError(error.Internal, tx.commit()); try testing.expect(!tx.active); // Exactly one fault: the COMMIT. The ROLLBACK behind it succeeded, which is // the whole point of attempting it. try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture()); // The connection is not poisoned — the next reader gets a transaction // rather than inheriting the fault. read_tx_faults.beginCapture(); var next = try ReadTx.begin(&db); try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t")); try next.commit(); try testing.expectEqual(@as(usize, 0), read_tx_faults.endCapture()); } test "an armed commit fault fires once and no further" { var db = try openMemory(); defer db.close(); try applyPragmas(&db, .{}); read_tx_faults.beginCapture(); defer _ = read_tx_faults.endCapture(); read_tx_faults.failNextCommit(); var first = try ReadTx.begin(&db); try testing.expectError(error.Internal, first.commit()); // The seam disarms itself, so it cannot leak into a later test in the same // binary and fail a commit nobody asked to fail. var second = try ReadTx.begin(&db); try second.commit(); try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture()); } test "a row-producing statement reports its row through step" { var db = try openMemory(); defer db.close(); // Stmt.exec asserts on this shape; the test observes it through `step` // instead, so the assertion path stays out of the test binary. var stmt = try db.prepare("SELECT 1"); defer stmt.deinit(); try testing.expect(try stmt.step()); } // --------------------------------------------------------------------------- // OpenMode.immutable // --------------------------------------------------------------------------- /// `std.testing.tmpDir` creates its directory under `.zig-cache/tmp/` relative to /// the process working directory, which is also how SQLite's VFS resolves the /// filename it is handed (`queries_repo.zig:721`). const tmp_prefix = ".zig-cache/tmp/"; const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len; const test_io = testing.io; fn tmpPath(buf: []u8, tmp: *const testing.TmpDir, name: []const u8) ![:0]const u8 { return std.fmt.bufPrintZ(buf, "{s}{s}/{s}", .{ tmp_prefix, &tmp.sub_path, name }); } /// A file database in WAL mode holding one row, `id = marker`. Closing the last /// connection checkpoints and unlinks both sidecars, but the header keeps saying /// WAL — which is what makes a later `.read_only` open recreate them. fn writeWalDatabase(path: [:0]const u8, marker: i64) !void { var database = try Db.open(path, .{ .mode = .read_write_create }); defer database.close(); try applyPragmas(&database, .{}); try database.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);"); var stmt = try database.prepare("INSERT INTO t (id) VALUES (?1)"); defer stmt.deinit(); try stmt.bindInt(1, marker); try stmt.exec(); } /// A second writer doing what a running nxdns does: it appends to the /// write-ahead log and, being the last connection, checkpoints into the main /// file and unlinks both sidecars on close. The blob is what makes the main /// file grow by whole pages, so a test that watches for the change does not rest /// on the filesystem's timestamp resolution. fn checkpointOver(path: [:0]const u8) !void { var database = try Db.open(path, .{ .mode = .read_write_existing }); defer database.close(); try applyPragmas(&database, .{}); try database.exec("INSERT INTO t (id) VALUES (8);"); try database.exec("CREATE TABLE bulk (v TEXT);"); try database.exec("INSERT INTO bulk (v) VALUES (hex(randomblob(30000)));"); } fn expectAbsent(dir: std.Io.Dir, name: []const u8) !void { dir.access(test_io, name, .{}) catch |e| switch (e) { error.FileNotFound => return, else => |other| return other, }; std.debug.print("sidecar '{s}' exists and must not\n", .{name}); return error.SidecarPresent; } test "immutableUri encodes what would otherwise change which file is opened" { var buf: [256]u8 = undefined; try testing.expectEqualStrings( "file:/var/lib/nxdns/config.db?immutable=1", try immutableUri(&buf, "/var/lib/nxdns/config.db"), ); // '?' would start SQLite's query section, '#' its fragment, '%' an escape. try testing.expectEqualStrings( "file:/data%3Fdir/config.db?immutable=1", try immutableUri(&buf, "/data?dir/config.db"), ); try testing.expectEqualStrings( "file:/data%23dir/config.db?immutable=1", try immutableUri(&buf, "/data#dir/config.db"), ); try testing.expectEqualStrings( "file:/data%25dir/config.db?immutable=1", try immutableUri(&buf, "/data%dir/config.db"), ); try testing.expectEqualStrings( "file:/a%20b/c%3Fd%23e%25f.db?immutable=1", try immutableUri(&buf, "/a b/c?d#e%f.db"), ); // A relative path stays relative: SQLite's VFS resolves it against the // working directory, exactly as a bare filename would be. try testing.expectEqualStrings( "file:config.db?immutable=1", try immutableUri(&buf, "config.db"), ); // A leading "//" would be read as a URI authority and rejected. try testing.expectEqualStrings( "file:/%2Fnet/share/config.db?immutable=1", try immutableUri(&buf, "//net/share/config.db"), ); // Only the authority position is special: "//" further in stays literal. try testing.expectEqualStrings( "file:/net//share/config.db?immutable=1", try immutableUri(&buf, "/net//share/config.db"), ); // Non-ASCII bytes survive the round trip because SQLite decodes them back. try testing.expectEqualStrings( "file:/caf%C3%A9/config.db?immutable=1", try immutableUri(&buf, "/café/config.db"), ); var small: [16]u8 = undefined; try testing.expectError(error.TooBig, immutableUri(&small, "/var/lib/nxdns/config.db")); } test "an immutable open of a WAL database creates no -wal and no -shm" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; const path = try tmpPath(&path_buf, &tmp, "config.db"); try writeWalDatabase(path, 7); try expectAbsent(tmp.dir, "config.db-wal"); try expectAbsent(tmp.dir, "config.db-shm"); { var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } }); defer database.close(); try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t")); // The point of the mode: a `.read_only` open creates both of these here, // and cannot delete them on close. try expectAbsent(tmp.dir, "config.db-wal"); try expectAbsent(tmp.dir, "config.db-shm"); } try expectAbsent(tmp.dir, "config.db-wal"); try expectAbsent(tmp.dir, "config.db-shm"); } test "an immutable open of a path holding URI metacharacters opens the intended file" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var decoy_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; var path_buf: [tmp_prefix.len + sub_path_len + 64]u8 = undefined; // Unencoded, SQLite cuts the filename at the '?' and opens "/d". That // file exists here and holds a different database, so the failure without // percent-encoding is a wrong answer, not an error. try tmp.dir.createDirPath(test_io, "d?x#y%z"); try writeWalDatabase(try tmpPath(&decoy_buf, &tmp, "d"), 99); const path = try tmpPath(&path_buf, &tmp, "d?x#y%z/config.db"); try writeWalDatabase(path, 7); var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } }); defer database.close(); try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t")); } test "an immutable open refuses a database whose -wal holds bytes" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; const path = try tmpPath(&path_buf, &tmp, "config.db"); try writeWalDatabase(path, 7); // What an unclean shutdown leaves behind, written directly so the case does // not depend on when SQLite decides to checkpoint. try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = &([_]u8{0x37} ** 32) }); try testing.expectError(error.WalPending, Db.open(path, .{ .mode = .{ .immutable = test_io } })); // A zero-length `-wal` holds no frames, so the main file is complete: the // exact leftover the pre-F-c `check` created must not block a check. try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" }); var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } }); defer database.close(); try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t")); } test "an immutable read refuses a -wal that arrives while it is in flight" { // The probe at open time can only say the log was empty *then*. // `immutable=1` takes no lock, so a writer is free to arrive one instant // later, and the read goes on answering from the older pages of the main // file with nothing anywhere reporting it. var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; const path = try tmpPath(&path_buf, &tmp, "config.db"); try writeWalDatabase(path, 7); var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } }); defer database.close(); try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t")); // Nothing has moved yet, so the read stands. try database.verifyImmutable(); // A zero-length log still holds no frames: the rule at the end of the read // is the rule at the start of it. try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" }); try database.verifyImmutable(); // Frames, now, in the log the open accepted as empty. try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = &([_]u8{0x37} ** 32), }); try testing.expectError(error.WalPending, database.verifyImmutable()); // Repeatable: reporting the race is all it does. try testing.expectError(error.WalPending, database.verifyImmutable()); // And it repairs nothing. The operator's log is byte for byte what was // written, and no wal-index appeared beside it. const wal = try tmp.dir.statFile(test_io, "config.db" ++ wal_suffix, .{}); try testing.expectEqual(@as(u64, 32), wal.size); try expectAbsent(tmp.dir, "config.db-shm"); } test "an immutable read refuses a main file checkpointed under it" { // The case a `-wal` probe cannot see at either end: a writer checkpointed // into the main file and, closing, unlinked its log again. Both stats say // "no frames" while the pages the read answered from have been replaced. var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; const path = try tmpPath(&path_buf, &tmp, "config.db"); try writeWalDatabase(path, 7); var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } }); defer database.close(); try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t")); try database.verifyImmutable(); const before = try tmp.dir.statFile(test_io, "config.db", .{}); try checkpointOver(path); const after = try tmp.dir.statFile(test_io, "config.db", .{}); // The premise of the test, proved rather than assumed: the main file really // did move, and the log really is gone again. try testing.expect(after.size != before.size); try expectAbsent(tmp.dir, "config.db" ++ wal_suffix); try testing.expectError(error.WalPending, database.verifyImmutable()); } test "a duplicate insert into a UNIQUE column returns error.Constraint" { var db = try openMemory(); defer db.close(); try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);"); try db.exec("INSERT INTO t (name) VALUES ('only');"); var stmt = try db.prepare("INSERT INTO t (name) VALUES (?1)"); defer stmt.deinit(); try stmt.bindText(1, "only"); try testing.expectError(error.Constraint, stmt.step()); }