578a7a61
fix: an empty expect needle is a check that cannot fail — reject it
a73x 2026-08-10 07:39
Commit message
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 | } |