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.
This commit is contained in:
Mitchell Hashimoto
2026-06-05 13:10:51 -07:00
parent 05eeb43942
commit f0d81f15ee
2 changed files with 60 additions and 0 deletions

View File

@@ -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, &reg1);
var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
defer reg2.deinit(alloc);
_ = testExecute(alloc, &glossary, &reg2);
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;

View File

@@ -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;