const std = @import("std"); // Terminal input normalization and trace capture. // req: input/004, input/005, testing/001, testing/002, testing/003, testing/004 test { _ = normalize; _ = TraceRecorder; } pub const Key = enum { space, enter, tab, backspace, escape, arrow_left, arrow_right, arrow_up, arrow_down, home, end, page_up, page_down, }; pub const Event = union(enum) { key: Key, text: []const u8, unknown: []const u8, }; pub const TraceResult = union(enum) { ok, diverged: Divergence, pub fn deinit(self: TraceResult, allocator: std.mem.Allocator) void { switch (self) { .ok => {}, .diverged => |diverged| allocator.free(diverged.message), } } }; pub const Divergence = struct { step: usize, message: []const u8, }; pub const TraceRecorder = struct { allocator: std.mem.Allocator, lines: std.ArrayList(u8) = .empty, pub fn init(allocator: std.mem.Allocator) TraceRecorder { return .{ .allocator = allocator }; } pub fn deinit(self: *TraceRecorder) void { self.lines.deinit(self.allocator); self.* = undefined; } pub fn capture(self: *TraceRecorder, raw: []const u8) !void { const label = try eventLabelAlloc(self.allocator, normalize(raw)); defer self.allocator.free(label); try appendHex(self.allocator, &self.lines, raw); try self.lines.appendSlice(self.allocator, " => "); try self.lines.appendSlice(self.allocator, label); try self.lines.append(self.allocator, '\n'); } pub fn text(self: *const TraceRecorder) []const u8 { return self.lines.items; } }; pub fn normalize(raw: []const u8) Event { if (raw.len == 0) return .{ .unknown = raw }; if (std.mem.eql(u8, raw, " ")) return .{ .key = .space }; if (std.mem.eql(u8, raw, "\r") or std.mem.eql(u8, raw, "\n")) return .{ .key = .enter }; if (std.mem.eql(u8, raw, "\t")) return .{ .key = .tab }; if (std.mem.eql(u8, raw, "\x7f") or std.mem.eql(u8, raw, "\x08")) return .{ .key = .backspace }; if (std.mem.eql(u8, raw, "\x1b")) return .{ .key = .escape }; if (std.mem.eql(u8, raw, "\x1b[D")) return .{ .key = .arrow_left }; if (std.mem.eql(u8, raw, "\x1b[C")) return .{ .key = .arrow_right }; if (std.mem.eql(u8, raw, "\x1b[A")) return .{ .key = .arrow_up }; if (std.mem.eql(u8, raw, "\x1b[B")) return .{ .key = .arrow_down }; if (std.mem.eql(u8, raw, "\x1b[H") or std.mem.eql(u8, raw, "\x1b[1~") or std.mem.eql(u8, raw, "\x1b[7~")) return .{ .key = .home }; if (std.mem.eql(u8, raw, "\x1b[F") or std.mem.eql(u8, raw, "\x1b[4~") or std.mem.eql(u8, raw, "\x1b[8~")) return .{ .key = .end }; if (std.mem.eql(u8, raw, "\x1b[5~")) return .{ .key = .page_up }; if (std.mem.eql(u8, raw, "\x1b[6~")) return .{ .key = .page_down }; if (std.unicode.utf8ValidateSlice(raw) and isPrintableText(raw)) return .{ .text = raw }; return .{ .unknown = raw }; } pub fn replayTrace(allocator: std.mem.Allocator, trace: []const u8) !TraceResult { var step: usize = 0; var lines = std.mem.splitScalar(u8, trace, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; step += 1; const separator = std.mem.indexOf(u8, line, " => ") orelse return divergence(allocator, step, "trace line is missing separator"); const raw_hex = line[0..separator]; const expected = line[separator + 4 ..]; var raw = std.ArrayList(u8).empty; defer raw.deinit(allocator); parseHexInto(allocator, &raw, raw_hex) catch |err| switch (err) { error.InvalidHex => return divergence(allocator, step, "trace line has invalid hex bytes"), else => return err, }; const actual = try eventLabelAlloc(allocator, normalize(raw.items)); defer allocator.free(actual); if (!std.mem.eql(u8, expected, actual)) { return divergenceFmt(allocator, step, "expected event '{s}' but got '{s}'", .{ expected, actual }); } } return .ok; } pub fn eventLabelAlloc(allocator: std.mem.Allocator, event: Event) ![]u8 { return switch (event) { .key => |key| std.fmt.allocPrint(allocator, "key:{s}", .{@tagName(key)}), .text => |text| std.fmt.allocPrint(allocator, "text:{s}", .{text}), .unknown => |raw| unknownLabelAlloc(allocator, raw), }; } fn unknownLabelAlloc(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); try out.appendSlice(allocator, "unknown:"); try appendHex(allocator, &out, raw); return out.toOwnedSlice(allocator); } fn isPrintableText(raw: []const u8) bool { var i: usize = 0; while (i < raw.len) { const len = std.unicode.utf8ByteSequenceLength(raw[i]) catch return false; if (i + len > raw.len) return false; const cp = std.unicode.utf8Decode(raw[i .. i + len]) catch return false; if (cp < 0x20 or cp == 0x7f) return false; if (cp == 0x1b) return false; i += len; } return true; } fn appendHex(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8) !void { const alphabet = "0123456789abcdef"; for (bytes) |byte| { try out.append(allocator, alphabet[byte >> 4]); try out.append(allocator, alphabet[byte & 0x0f]); } } fn parseHexInto(allocator: std.mem.Allocator, out: *std.ArrayList(u8), hex: []const u8) !void { if (hex.len % 2 != 0) return error.InvalidHex; var i: usize = 0; while (i < hex.len) : (i += 2) { const high = hexNibble(hex[i]) orelse return error.InvalidHex; const low = hexNibble(hex[i + 1]) orelse return error.InvalidHex; try out.append(allocator, (high << 4) | low); } } fn hexNibble(byte: u8) ?u8 { return switch (byte) { '0'...'9' => byte - '0', 'a'...'f' => byte - 'a' + 10, 'A'...'F' => byte - 'A' + 10, else => null, }; } fn divergence(allocator: std.mem.Allocator, step: usize, message: []const u8) !TraceResult { return .{ .diverged = .{ .step = step, .message = try allocator.dupe(u8, message) } }; } fn divergenceFmt(allocator: std.mem.Allocator, step: usize, comptime fmt: []const u8, args: anytype) !TraceResult { return .{ .diverged = .{ .step = step, .message = try std.fmt.allocPrint(allocator, fmt, args) } }; } fn expectEventLabel(raw: []const u8, expected: []const u8) !void { const label = try eventLabelAlloc(std.testing.allocator, normalize(raw)); defer std.testing.allocator.free(label); try std.testing.expectEqualStrings(expected, label); } fn expectTraceOk(result: TraceResult) !void { switch (result) { .ok => {}, .diverged => return error.ExpectedTraceOk, } } test "regular: raw terminal bytes normalize to stable editor input events" { try expectEventLabel("a", "text:a"); try expectEventLabel("ä", "text:ä"); try expectEventLabel("🔥", "text:🔥"); try expectEventLabel(" ", "key:space"); try expectEventLabel("\r", "key:enter"); try expectEventLabel("\x7f", "key:backspace"); try expectEventLabel("\x1b[D", "key:arrow_left"); try expectEventLabel("\x1b[C", "key:arrow_right"); try expectEventLabel("\x1b[A", "key:arrow_up"); try expectEventLabel("\x1b[B", "key:arrow_down"); try expectEventLabel("\x1b[H", "key:home"); try expectEventLabel("\x1b[1~", "key:home"); try expectEventLabel("\x1b[F", "key:end"); try expectEventLabel("\x1b[4~", "key:end"); try expectEventLabel("\x1b[5~", "key:page_up"); try expectEventLabel("\x1b[6~", "key:page_down"); } test "regular: captured trace can be replayed" { var recorder = TraceRecorder.init(std.testing.allocator); defer recorder.deinit(); try recorder.capture("a"); try recorder.capture("ä"); try recorder.capture(" "); try recorder.capture("\x1b[D"); try recorder.capture("\x7f"); const result = try replayTrace(std.testing.allocator, recorder.text()); defer result.deinit(std.testing.allocator); try expectTraceOk(result); } test "regular: trace text preserves raw bytes rather than keyboard-layout guesses" { var recorder = TraceRecorder.init(std.testing.allocator); defer recorder.deinit(); try recorder.capture("z"); try recorder.capture("y"); try std.testing.expectEqualStrings( \\7a => text:z \\79 => text:y \\ , recorder.text()); } test "adversarial: invalid UTF-8 and control bytes normalize as unknown" { const bad_utf8 = [_]u8{ 0xc3, 0x28 }; try expectEventLabel(&bad_utf8, "unknown:c328"); try expectEventLabel("\x00", "unknown:00"); try expectEventLabel("\x1b[999~", "unknown:1b5b3939397e"); } test "adversarial: replay names malformed trace lines" { const result = try replayTrace(std.testing.allocator, "61 text:a\n"); defer result.deinit(std.testing.allocator); switch (result) { .diverged => |diverged| { try std.testing.expectEqual(@as(usize, 1), diverged.step); try std.testing.expectEqualStrings("trace line is missing separator", diverged.message); }, .ok => return error.ExpectedTraceDivergence, } } test "adversarial: replay names invalid hex in traces" { const result = try replayTrace(std.testing.allocator, "zz => text:a\n"); defer result.deinit(std.testing.allocator); switch (result) { .diverged => |diverged| { try std.testing.expectEqual(@as(usize, 1), diverged.step); try std.testing.expectEqualStrings("trace line has invalid hex bytes", diverged.message); }, .ok => return error.ExpectedTraceDivergence, } } test "adversarial: replay names first event mismatch" { const result = try replayTrace(std.testing.allocator, "61 => key:space\n62 => text:b\n"); defer result.deinit(std.testing.allocator); switch (result) { .diverged => |diverged| { try std.testing.expectEqual(@as(usize, 1), diverged.step); try std.testing.expect(std.mem.indexOf(u8, diverged.message, "expected event") != null); }, .ok => return error.ExpectedTraceDivergence, } }