From 9d760b41c130eec9326731f6a9faa69b1f3cd4d1 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 02:06:37 +0200 Subject: [PATCH] Normalize terminal input traces --- src/input.zig | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 2 + 2 files changed, 276 insertions(+) create mode 100644 src/input.zig diff --git a/src/input.zig b/src/input.zig new file mode 100644 index 0000000..4c67fa0 --- /dev/null +++ b/src/input.zig @@ -0,0 +1,274 @@ +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, +}; + +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.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"); +} + +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, + } +} diff --git a/src/main.zig b/src/main.zig index fb0e18f..f9aeb81 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const input = @import("input.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session = @import("session.zig"); @@ -116,6 +117,7 @@ fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.It } test { + _ = input; _ = protocol; _ = replay; _ = session;