From aed5bbf1b6894b45fcdc7a70e461ffd07cba72cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 24 May 2026 11:35:07 +0200 Subject: [PATCH] schema: reject backslash escapes in quoted values Explicitly reject backslash-escaped quotes with a clear error message, suggesting alternative quote styles or triple quotes. Bare backslashes (e.g. in Windows paths) remain supported. --- src/agent/Agent.zig | 1 + src/script/Schema.zig | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index ea3365de..8326c8c7 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -600,6 +600,7 @@ fn printSlashParseError(self: *Agent, err: Schema.ParseError, name: []const u8) error.UnknownField => "unknown field (typo?)", error.PositionalNotAllowed => "positional only works for tools with one required field. Use key=value", error.UnterminatedQuote => "unterminated quote", + error.UnsupportedEscape => "backslash escapes aren't supported in quoted values; use the other quote style or `'''…'''`", error.OutOfMemory => return self.terminal.printError("out of memory", .{}), }; self.terminal.printError("{s}: {s}. Try /help {s}.", .{ name, reason, name }); diff --git a/src/script/Schema.zig b/src/script/Schema.zig index 9aec8789..413153bd 100644 --- a/src/script/Schema.zig +++ b/src/script/Schema.zig @@ -70,6 +70,7 @@ pub const ParseError = error{ MalformedKv, PositionalNotAllowed, UnterminatedQuote, + UnsupportedEscape, OutOfMemory, }; @@ -423,8 +424,15 @@ fn tokenize(arena: std.mem.Allocator, input: []const u8) ParseError![][]const u8 const close = std.mem.indexOfPos(u8, input, i + 3, triple_delim) orelse return error.UnterminatedQuote; i = close + 2; } else { - const close = std.mem.indexOfScalarPos(u8, input, i + 1, ch) orelse return error.UnterminatedQuote; - i = close; + // Scan for the closer. `\` is rejected rather + // than decoded — choose the other quote style or a + // triple-quoted block instead. + var j = i + 1; + while (j < input.len) : (j += 1) { + if (input[j] == '\\' and j + 1 < input.len and input[j + 1] == ch) return error.UnsupportedEscape; + if (input[j] == ch) break; + } else return error.UnterminatedQuote; + i = j; } } } @@ -767,6 +775,21 @@ test "tokenize: inline triple quotes with spaces" { try testing.expectString("value=\"\"\"foo bar\"\"\"", tokens[1]); } +test "tokenize: rejects backslash-escaped quote inside same-style quote" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expectError(error.UnsupportedEscape, tokenize(arena.allocator(), "value=\"hello \\\"world\\\"\"")); + try testing.expectError(error.UnsupportedEscape, tokenize(arena.allocator(), "value='it\\'s'")); +} + +test "tokenize: bare backslash inside quotes is allowed (e.g. Windows paths)" { + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const tokens = try tokenize(arena.allocator(), "value='C:\\Users\\bob'"); + try testing.expectEqual(@as(usize, 1), tokens.len); + try testing.expectString("value='C:\\Users\\bob'", tokens[0]); +} + test "hasUnclosedTripleQuote" { try testing.expect(!hasUnclosedTripleQuote("")); try testing.expect(!hasUnclosedTripleQuote("/goto https://x"));