fix(automation): escape menu snapshot catalogs
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user