From f0d81f15ee886e17e188422ec057caf4cfcdaa92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 13:10:51 -0700 Subject: [PATCH] terminal/apc: reject malformed glyph clear cp Glyph clear execution previously treated an unparsable cp option the same as an omitted cp option. That made inputs such as c;cp=zz behave like a bare clear request and remove every glossary registration. Track clear option presence separately from successful decoding. A present but malformed cp now returns a malformed_payload clear failure without mutating the glossary, while an omitted cp still clears all registrations. --- src/terminal/apc/glyph/execute.zig | 35 ++++++++++++++++++++++++++++++ src/terminal/apc/glyph/request.zig | 25 +++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/terminal/apc/glyph/execute.zig b/src/terminal/apc/glyph/execute.zig index 26dc6d2c9..059029a62 100644 --- a/src/terminal/apc/glyph/execute.zig +++ b/src/terminal/apc/glyph/execute.zig @@ -112,6 +112,11 @@ fn clear( error.OutOfNamespace => "out_of_namespace", }, } }; + } else if (clr.has(.cp)) { + return .{ .clear = .{ + .status = .err, + .reason = "malformed_payload", + } }; } else { glossary.clearAndFree(alloc); } @@ -278,6 +283,36 @@ test "execute clear rejects non-PUA" { }, testExecute(alloc, &glossary, &req).?); } +test "execute clear rejects malformed cp without clearing glossary" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var req = try testParse(alloc, data); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .clear = .{ + .status = .err, + .reason = "malformed_payload", + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(glossary.contains(0xE0A0)); + try testing.expect(glossary.contains(0xE0A1)); + } +} + test "execute query reports no coverage" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig index 6d63673df..6fecbc287 100644 --- a/src/terminal/apc/glyph/request.zig +++ b/src/terminal/apc/glyph/request.zig @@ -350,6 +350,12 @@ pub const Request = union(enum) { .cp => std.fmt.parseInt(u21, value, 16) catch null, }; } + + /// Return whether the option is present in the raw option string, + /// independent of whether its value can be decoded. + pub fn present(comptime self: Option, raw: []const u8) bool { + return optionValue(raw, self.key()) != null; + } }; /// Lazily decode a clear option on demand. @@ -357,6 +363,11 @@ pub const Request = union(enum) { return option.read(self.rawOptions()); } + /// Return whether a clear option was provided, even if malformed. + pub fn has(self: Clear, comptime option: Option) bool { + return option.present(self.rawOptions()); + } + /// Return the raw option portion of a valid clear command. fn rawOptions(self: Clear) []const u8 { assert(self.raw.len >= 2); @@ -799,9 +810,23 @@ test "clear command" { defer cmd.deinit(testing.allocator); try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?); } +test "clear command tracks malformed cp presence" { + const testing = std.testing; + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var cmd = try testParse(testing.allocator, data); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); + try testing.expect(cmd.clear.get(.cp) == null); + } +} + test "invalid command" { const testing = std.testing;