//! Whole-database snapshot mode. //! //! Produces a single, internally-consistent copy of KoboReader.sqlite and uploads it. //! //! Why a snapshot rather than copying the raw files: Nickel keeps the database in WAL mode, //! and recent commits live in `KoboReader.sqlite-wal` until a checkpoint. Copying the bare //! `.sqlite` silently loses hours of reading progress (measured: 19h of drift on a live //! device). Copying all three files instead is non-atomic — a checkpoint landing mid-copy //! yields a pre-checkpoint main file plus a post-reset WAL, losing data with no error. //! //! `VACUUM INTO` runs inside a read transaction, so it sees WAL-resident commits and emits //! one compacted `journal_mode=delete` file. One file, one consistent read, no reassembly. const std = @import("std"); const c = @cImport({ @cInclude("sqlite3.h"); }); pub const default_snapshot_path = "/tmp/kobo_snapshot.sqlite"; pub const default_timeout_secs: u64 = 120; /// Upper bound on a snapshot we're willing to buffer. The live database is ~2.4 MB; this /// is a guard against pathological growth, not a real expectation. const max_snapshot_bytes: usize = 256 * 1024 * 1024; /// Hard upper bound on the whole run. /// /// v1 hung for 16 days: the server stopped responding and `std.http.Client` has no timeout, /// so the process blocked forever *while holding the SQLite handle open*. This guarantees the /// process dies instead. Snapshotting can stall too (a locked database), so the watchdog /// covers the entire run, not just the upload. fn watchdog(secs: u64) void { std.Thread.sleep(secs * std.time.ns_per_s); std.debug.print("timeout: exceeded {d}s, aborting\n", .{secs}); std.process.exit(2); } /// Arm the watchdog for the current process. Every mode needs this, not just snapshot: /// legacy highlights mode POSTs to the same kind of endpoint and hangs the same way. pub fn startWatchdog(secs: u64) !void { const guard = try std.Thread.spawn(.{}, watchdog, .{secs}); guard.detach(); } fn sha256Hex(data: []const u8, out: *[64]u8) void { var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(data, &digest, .{}); const hex = "0123456789abcdef"; for (digest, 0..) |byte, i| { out[i * 2] = hex[byte >> 4]; out[i * 2 + 1] = hex[byte & 0x0F]; } } /// SQL string literals escape a single quote by doubling it. fn quoteSqlLiteral(allocator: std.mem.Allocator, s: []const u8) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); for (s) |ch| { if (ch == '\'') try out.append(allocator, '\''); try out.append(allocator, ch); } return out.toOwnedSlice(allocator); } fn deviceName(buf: *[std.posix.HOST_NAME_MAX]u8) []const u8 { return std.posix.gethostname(buf) catch "kobo"; } /// Write a consistent snapshot of `db_path` to `snap_path`, replacing any existing file. fn writeSnapshot(allocator: std.mem.Allocator, db_path: []const u8, snap_path: []const u8) !void { // VACUUM INTO refuses to overwrite. A leftover file from a killed run would otherwise // make every subsequent sync fail. std.fs.cwd().deleteFile(snap_path) catch |err| switch (err) { error.FileNotFound => {}, else => return err, }; const db_path_z = try allocator.dupeZ(u8, db_path); defer allocator.free(db_path_z); var db: ?*c.sqlite3 = null; if (c.sqlite3_open(db_path_z.ptr, &db) != c.SQLITE_OK) { std.debug.print("failed to open database: {s}\n", .{c.sqlite3_errmsg(db)}); _ = c.sqlite3_close(db); return error.DatabaseOpenFailed; } // Closed explicitly below rather than by defer: the handle must be released before any // network I/O so a stalled upload can never pin the database, its -wal and its -shm. errdefer _ = c.sqlite3_close(db); const escaped = try quoteSqlLiteral(allocator, snap_path); defer allocator.free(escaped); const sql = try std.fmt.allocPrintSentinel(allocator, "VACUUM INTO '{s}';", .{escaped}, 0); defer allocator.free(sql); var errmsg: [*c]u8 = null; if (c.sqlite3_exec(db, sql.ptr, null, null, &errmsg) != c.SQLITE_OK) { std.debug.print("VACUUM INTO failed: {s}\n", .{errmsg}); c.sqlite3_free(errmsg); return error.SnapshotFailed; } if (c.sqlite3_close(db) != c.SQLITE_OK) return error.DatabaseCloseFailed; } /// Caller is expected to have armed `startWatchdog` already. pub fn run( allocator: std.mem.Allocator, db_path: []const u8, endpoint: ?[]const u8, snap_path: []const u8, ) !void { try writeSnapshot(allocator, db_path, snap_path); // Database handle is released from here on. const payload = blk: { const file = try std.fs.cwd().openFile(snap_path, .{}); defer file.close(); // Deliberately NOT File.stat()/getEndPos(): both issue the `statx` syscall, which // does not exist before Linux 4.11. The Kobo Elipsa runs 4.9.77, where it returns // ENOSYS and surfaces as a bare `error.Unexpected`. readToEndAlloc with no size // hint grows the buffer using plain read() calls instead. break :blk try file.readToEndAlloc(allocator, max_snapshot_bytes); }; defer allocator.free(payload); var sha: [64]u8 = undefined; sha256Hex(payload, &sha); var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined; const device = deviceName(&host_buf); std.debug.print("snapshot: {d} bytes, sha256={s}\n", .{ payload.len, sha }); // Without an endpoint this is the inspect/debug path, so the file is deliberately // left in place. Cleanup is registered only once we know we're uploading. const url = endpoint orelse { std.debug.print("no endpoint given; snapshot left at {s}\n", .{snap_path}); return; }; defer std.fs.cwd().deleteFile(snap_path) catch {}; std.debug.print("uploading to {s} ...\n", .{url}); var client: std.http.Client = .{ .allocator = allocator }; defer client.deinit(); const result = try client.fetch(.{ .location = .{ .uri = try std.Uri.parse(url) }, .method = .POST, .payload = payload, .headers = .{ .content_type = .{ .override = "application/octet-stream" } }, .extra_headers = &.{ .{ .name = "x-kobo-sha256", .value = &sha }, .{ .name = "x-kobo-device", .value = device }, }, }); const status = @intFromEnum(result.status); if (status < 200 or status >= 300) { std.debug.print("server returned HTTP {d}\n", .{status}); return error.HttpRequestFailed; } std.debug.print("ok: uploaded snapshot (HTTP {d})\n", .{status}); } test "quoteSqlLiteral doubles single quotes" { const a = std.testing.allocator; const plain = try quoteSqlLiteral(a, "/tmp/snap.sqlite"); defer a.free(plain); try std.testing.expectEqualStrings("/tmp/snap.sqlite", plain); const tricky = try quoteSqlLiteral(a, "/tmp/o'brien.sqlite"); defer a.free(tricky); try std.testing.expectEqualStrings("/tmp/o''brien.sqlite", tricky); } test "sha256Hex matches known digest" { var out: [64]u8 = undefined; sha256Hex("abc", &out); try std.testing.expectEqualStrings( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", &out, ); }