a73x

578a7a61

fix: an empty expect needle is a check that cannot fail — reject it

a73x   2026-08-10 07:39

Commit message
fix: an empty expect needle is a check that cannot fail — reject it

test/ptyclient.zig
Old New
@@ -27,9 +27,11 @@ fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
27 '\\' => try out.append(alloc, '\\'), 27 '\\' => try out.append(alloc, '\\'),
28 'x' => { 28 'x' => {
29 if (i + 2 >= s.len) return error.BadEscape; 29 if (i + 2 >= s.len) return error.BadEscape;
30 const b = std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16) catch 30 // Digit by digit rather than parseInt: parseInt accepts a
31 return error.BadEscape; 31 // sign, so `\x+1` would quietly decode as 0x01.
32 try out.append(alloc, b); 32 const hi = std.fmt.charToDigit(s[i + 1], 16) catch return error.BadEscape;
33 const lo = std.fmt.charToDigit(s[i + 2], 16) catch return error.BadEscape;
34 try out.append(alloc, hi * 16 + lo);
33 i += 2; 35 i += 2;
34 }, 36 },
35 else => return error.BadEscape, 37 else => return error.BadEscape,
@@ -69,11 +71,24 @@ const Verb = union(enum) {
69 expect: struct { needle: []u8, deadline_ms: u64 }, 71 expect: struct { needle: []u8, deadline_ms: u64 },
70 resize: struct { cols: u16, rows: u16 }, 72 resize: struct { cols: u16, rows: u16 },
71 waitexit: u64, 73 waitexit: u64,
74
75 /// Exhaustive on purpose: a future arm that owns memory will not
76 /// compile until it is freed here, so the loop's one `defer` stays
77 /// correct without anyone remembering to revisit it.
78 fn deinit(self: Verb, alloc: std.mem.Allocator) void {
79 switch (self) {
80 .send => |s| alloc.free(s),
81 .expect => |e| alloc.free(e.needle),
82 .resize, .waitexit => {},
83 }
84 }
72 }; 85 };
73 86
74 /// One script line -> one verb; blank lines and #-comments are null. 87 /// One script line -> one verb; blank lines and #-comments are null.
75 /// Payloads may contain spaces: `send` takes the whole rest of the line; 88 /// Payloads may contain spaces: `send` takes the whole rest of the line;
76 /// `expect` takes everything up to the LAST space, then the deadline. 89 /// `expect` takes everything up to the LAST space, then the deadline.
90 /// Payload ends are trimmed along with the line, so a needle that must
91 /// end in a space needs \x20.
77 fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb { 92 fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
78 const line = std.mem.trim(u8, raw, " \t\r"); 93 const line = std.mem.trim(u8, raw, " \t\r");
79 if (line.len == 0 or line[0] == '#') return null; 94 if (line.len == 0 or line[0] == '#') return null;
@@ -84,11 +99,13 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
84 return .{ .send = try decodeEscapes(alloc, rest) }; 99 return .{ .send = try decodeEscapes(alloc, rest) };
85 } else if (std.mem.eql(u8, verb, "expect")) { 100 } else if (std.mem.eql(u8, verb, "expect")) {
86 const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb; 101 const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb;
102 // An empty needle matches instantly at any cursor — a check that
103 // cannot fail is worse than no check, so refuse it here.
104 if (last == 0) return error.BadVerb;
105 // Deadline first: a bad one must not strand an allocated needle.
87 const ms = std.fmt.parseInt(u64, rest[last + 1 ..], 10) catch return error.BadVerb; 106 const ms = std.fmt.parseInt(u64, rest[last + 1 ..], 10) catch return error.BadVerb;
88 return .{ .expect = .{ 107 const needle = try decodeEscapes(alloc, rest[0..last]);
89 .needle = try decodeEscapes(alloc, rest[0..last]), 108 return .{ .expect = .{ .needle = needle, .deadline_ms = ms } };
90 .deadline_ms = ms,
91 } };
92 } else if (std.mem.eql(u8, verb, "resize")) { 109 } else if (std.mem.eql(u8, verb, "resize")) {
93 var it = std.mem.tokenizeScalar(u8, rest, ' '); 110 var it = std.mem.tokenizeScalar(u8, rest, ' ');
94 const cols = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; 111 const cols = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
@@ -115,6 +132,8 @@ test "decodeEscapes: named, hex, literal backslash" {
115 .{ .in = "\\x1b[5;2~", .want = "\x1b[5;2~" }, 132 .{ .in = "\\x1b[5;2~", .want = "\x1b[5;2~" },
116 .{ .in = "a\\\\b", .want = "a\\b" }, 133 .{ .in = "a\\\\b", .want = "a\\b" },
117 .{ .in = "\\x04", .want = "\x04" }, 134 .{ .in = "\\x04", .want = "\x04" },
135 .{ .in = "cr\\r", .want = "cr\r" },
136 .{ .in = "tab\\there", .want = "tab\there" },
118 }; 137 };
119 for (cases) |cs| { 138 for (cases) |cs| {
120 const got = try decodeEscapes(alloc, cs.in); 139 const got = try decodeEscapes(alloc, cs.in);
@@ -123,6 +142,9 @@ test "decodeEscapes: named, hex, literal backslash" {
123 } 142 }
124 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "bad\\q")); 143 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "bad\\q"));
125 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trunc\\x1")); 144 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trunc\\x1"));
145 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trailing\\"));
146 // parseInt would take the sign and decode this as 0x01.
147 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\x+1"));
126 } 148 }
127 149
128 test "Expecter: a needle split across two feeds still matches" { 150 test "Expecter: a needle split across two feeds still matches" {
@@ -155,11 +177,11 @@ test "parseLine: verbs, spaces in payloads, comments" {
155 try std.testing.expect(try parseLine(alloc, "# comment") == null); 177 try std.testing.expect(try parseLine(alloc, "# comment") == null);
156 178
157 const s = (try parseLine(alloc, "send echo tp2-claim\\n")).?; 179 const s = (try parseLine(alloc, "send echo tp2-claim\\n")).?;
158 defer alloc.free(s.send); 180 defer s.deinit(alloc);
159 try std.testing.expectEqualSlices(u8, "echo tp2-claim\n", s.send); 181 try std.testing.expectEqualSlices(u8, "echo tp2-claim\n", s.send);
160 182
161 const x = (try parseLine(alloc, "expect two words 15000")).?; 183 const x = (try parseLine(alloc, "expect two words 15000")).?;
162 defer alloc.free(x.expect.needle); 184 defer x.deinit(alloc);
163 try std.testing.expectEqualSlices(u8, "two words", x.expect.needle); 185 try std.testing.expectEqualSlices(u8, "two words", x.expect.needle);
164 try std.testing.expectEqual(@as(u64, 15000), x.expect.deadline_ms); 186 try std.testing.expectEqual(@as(u64, 15000), x.expect.deadline_ms);
165 187
@@ -172,4 +194,9 @@ test "parseLine: verbs, spaces in payloads, comments" {
172 194
173 try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x")); 195 try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x"));
174 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline")); 196 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline"));
197 // Doubled space: the needle would be empty, and an empty needle is a
198 // check that cannot fail.
199 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect 1000"));
200 // A bad deadline must not strand the needle: parse before allocating.
201 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect two words later"));
175 } 202 }