Files
kb_exfiltrator/src/snapshot.zig
T
wesandClaude Opus 5 32d7db1668 Arm the watchdog for every mode, not just snapshot
The watchdog was spawned inside snapshot.run, so legacy highlights mode ran
without it. That is the mode NickelMenu invokes, against the endpoint that
stopped responding and hung a run for 16 days — so the exact failure being
fixed was still reachable from the device's menu.

Moves the spawn into main, ahead of mode dispatch, so both paths are covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:22:23 -04:00

188 lines
7.2 KiB
Zig

//! 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,
);
}