a801f719897c

logAction JSON-escapes ctx.machine.name before embedding it in a JSONL log entry, so a config machine name containing a quote or backslash no longer produces an invalid line that renderJsonLogLine can only dump raw (NINA-020)

time
2026-07-02 13:49 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
fable audit fix — commands.zig
parent
e923ab8ccb1a
1 file src/commands.zig+48 −1
src/commands.zig+48 −1
@@ -2281,14 +2281,61 @@22812281 22822282 // --- Log helper ---22832283 2284+// Escapes a string for embedding inside a JSON string literal. Truncates2285+// (rather than overflows `buf`) if the escaped form doesn't fit — used for a2286+// single log line where losing the tail of an implausibly long machine name is2287+// preferable to corrupting the line.2288+fn jsonEscape(buf: []u8, s: []const u8) []const u8 {2289+    var i: usize = 0;2290+    for (s) |ch| {2291+        const esc: ?u8 = switch (ch) {2292+            '"' => '"',2293+            '\\' => '\\',2294+            '\n' => 'n',2295+            '\r' => 'r',2296+            '\t' => 't',2297+            else => null,2298+        };2299+        if (esc) |e| {2300+            if (i + 2 > buf.len) break;2301+            buf[i] = '\\';2302+            buf[i + 1] = e;2303+            i += 2;2304+        } else {2305+            if (i + 1 > buf.len) break;2306+            buf[i] = ch;2307+            i += 1;2308+        }2309+    }2310+    return buf[0..i];2311+}2312+22842313 fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {22852314     var ts_buf: [32]u8 = undefined;22862315     const ts = nowIso(ctx.io, &ts_buf);2316+    // machine.name is config-parsed and may legally contain `"` or `\`2317+    // (NINA-020) — embedding it unescaped produced an invalid JSON line that2318+    // renderJsonLogLine could only fall back to showing raw.2319+    var name_buf: [128]u8 = undefined;2320+    const machine_name = jsonEscape(&name_buf, ctx.machine.name);22872321     var buf: [256]u8 = undefined;22882322     const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{2289-        ts, ctx.machine.name, action, gen,2323+        ts, machine_name, action, gen,22902324     }) catch return;22912325     exec.appendNinaLog(ctx.io, ctx.environ, entry);2326+}2327+2328+test "jsonEscape lets a machine name containing quotes and backslashes round-trip through JSON" {2329+    var esc_buf: [128]u8 = undefined;2330+    const escaped = jsonEscape(&esc_buf, "kyoshi\" evil\\host");2331+2332+    var line_buf: [256]u8 = undefined;2333+    const entry = std.fmt.bufPrint(&line_buf, "{{\"ts\":\"2026-01-01T00:00:00Z\",\"machine\":\"{s}\",\"command\":\"apply\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":5,\"duration_ms\":0}}\n", .{escaped}) catch unreachable;2334+2335+    const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, entry, .{});2336+    defer parsed.deinit();2337+    try std.testing.expect(parsed.value == .object);2338+    try std.testing.expectEqualStrings("kyoshi\" evil\\host", parsed.value.object.get("machine").?.string);22922339 }22932340 22942341 // Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);

view file