fix(automation): escape menu snapshot catalogs

This commit is contained in:
Chris Tate
2026-08-16 22:46:59 -05:00
parent 6787f7adf4
commit edaa882337
3 changed files with 72 additions and 13 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import fs from "node:fs";
// EffectFileOp appended `delete`; the reflected journal layout fingerprint
// moves so older recordings refuse cleanly instead of decoding op 8 wrongly.
export const journalFormatFingerprint = 0xb3bd2e83971de44dn;
export const automationProtocolFingerprint = 0xa3e0bbfecdcbac86n;
export const automationProtocolFingerprint = 0x51f7889bbe3305e7n;
const requestKeyBase = 0x5453525100000000n;
const textEncoder = new TextEncoder();
+3 -1
View File
@@ -57,7 +57,9 @@ pub const fingerprint: u64 = layout_fingerprint.hash(layoutDescription(semantic_
/// shorthand remains valid).
/// Epoch 3: automation snapshots expose the app menus configured on the
/// runtime, including command ids and enabled/checked/key state.
pub const semantic_epoch: u32 = 3;
/// Epoch 4: command and app-menu catalog strings in snapshots use
/// JSON-style escapes so every catalog entry remains exactly one line.
pub const semantic_epoch: u32 = 4;
/// The canonical description the protocol fingerprint hashes: the
/// command vocabulary (the `Action` enum, reflected — names and values,
+68 -11
View File
@@ -603,26 +603,31 @@ pub fn writeText(input: Input, writer: anytype) !void {
try writer.writeByte('\n');
}
for (input.commands) |command| {
try writer.print("command id=\"{s}\" title=\"{s}\" enabled={any} checked={any}\n", .{
command.id,
command.title,
command.enabled,
command.checked,
});
try writer.writeAll("command id=");
try writeQuotedSnapshotText(command.id, writer);
try writer.writeAll(" title=");
try writeQuotedSnapshotText(command.title, writer);
try writer.print(" enabled={any} checked={any}\n", .{ command.enabled, command.checked });
}
for (input.menus) |menu| {
try writer.print("app-menu title=\"{s}\" items={d}\n", .{ menu.title, menu.items.len });
try writer.writeAll("app-menu title=");
try writeQuotedSnapshotText(menu.title, writer);
try writer.print(" items={d}\n", .{menu.items.len});
for (menu.items) |item| {
if (item.separator) {
try writer.writeAll(" app-menu-item separator\n");
continue;
}
try writer.print(" app-menu-item label=\"{s}\" command=\"{s}\" enabled={any} checked={any} key=\"{s}\" modifiers=(primary={any},command={any},control={any},option={any},shift={any})\n", .{
item.label,
item.command,
try writer.writeAll(" app-menu-item label=");
try writeQuotedSnapshotText(item.label, writer);
try writer.writeAll(" command=");
try writeQuotedSnapshotText(item.command, writer);
try writer.print(" enabled={any} checked={any} key=", .{
item.enabled,
item.checked,
item.key,
});
try writeQuotedSnapshotText(item.key, writer);
try writer.print(" modifiers=(primary={any},command={any},control={any},option={any},shift={any})\n", .{
item.modifiers.primary,
item.modifiers.command,
item.modifiers.control,
@@ -762,6 +767,31 @@ pub fn writeA11yText(input: Input, writer: anytype) !void {
}
}
/// Catalog values are user-authored but snapshots are line-oriented. Keep
/// each configured command/menu record on exactly one line and preserve its
/// byte identity with JSON-style escapes for delimiters and control bytes.
fn writeQuotedSnapshotText(value: []const u8, writer: anytype) !void {
const hex = "0123456789abcdef";
try writer.writeByte('"');
for (value) |byte| {
switch (byte) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
else => if (byte < 0x20 or byte == 0x7f) {
try writer.writeAll("\\u00");
try writer.writeByte(hex[byte >> 4]);
try writer.writeByte(hex[byte & 0x0f]);
} else {
try writer.writeByte(byte);
},
}
}
try writer.writeByte('"');
}
fn writeWidgetParent(widget: Widget, writer: anytype) !void {
if (widget.parent_id) |parent_id| try writer.print(" parent=#{d}", .{parent_id});
}
@@ -1004,6 +1034,33 @@ test "snapshot emits configured command and app-menu catalogs" {
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item separator\n") != null);
}
test "snapshot escapes hostile command and app-menu catalog text" {
var buffer: [2048]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
const windows = [_]Window{.{ .title = "Test", .bounds = geometry.RectF.init(0, 0, 100, 100) }};
const commands = [_]app_manifest.Command{
.{ .id = "app.\"quoted", .title = "Line 1\nLine 2\\tail\t\x01\x7f Café" },
};
const items = [_]platform.MenuItem{
.{ .label = "Open \"now\"\rnext\\", .command = "app.\"run", .key = "r\t" },
};
const menus = [_]platform.Menu{
.{ .title = "Tools\"\nInjected", .items = &items },
};
try writeText(.{
.windows = &windows,
.commands = &commands,
.menus = &menus,
}, &writer);
const text = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, text, "\ncommand id=\"app.\\\"quoted\" title=\"Line 1\\nLine 2\\\\tail\\t\\u0001\\u007f Café\" enabled=true checked=false\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "app-menu title=\"Tools\\\"\\nInjected\" items=1\n") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " app-menu-item label=\"Open \\\"now\\\"\\rnext\\\\\" command=\"app.\\\"run\" enabled=true checked=false key=\"r\\t\" modifiers=(primary=false,command=false,control=false,option=false,shift=false)\n") != null);
// Header, window, command, menu, and item: hostile values inject no
// additional records into the line-oriented snapshot.
try std.testing.expectEqual(@as(usize, 5), std.mem.count(u8, text, "\n"));
}
test "accessibility snapshot uses visible view text as name" {
var buffer: [512]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);