1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
//! Parser error types
use std::fmt::{self, Write};
use crate::lexer::{LexerError, LexerLimitHit, Position, Token};
/// Parse error
#[derive(Debug, Clone)]
pub struct ParseError {
/// Error message
pub message: String,
/// Position where error occurred
pub position: Position,
/// Expected tokens (for better error messages)
pub expected: Vec<String>,
/// Optional structured kind for hardening / DoS errors
pub kind: ParseErrorKind,
}
/// Categorical kind for a parse error.
///
/// Most parse errors are plain `Syntax` failures; the variants
/// below carry structured information for the parser-hardening
/// layer (issue #87) so callers can distinguish DoS-style refusals
/// from grammar errors without string matching.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseErrorKind {
/// Generic syntax / semantic error.
Syntax,
/// Recursion-depth limit exceeded during parsing.
DepthLimit {
limit_name: &'static str,
value: usize,
},
/// Input larger than the configured byte cap.
InputTooLarge {
limit_name: &'static str,
value: usize,
},
/// Identifier longer than the configured character cap.
IdentifierTooLong {
limit_name: &'static str,
value: usize,
},
/// Parser consumed more tokens than the configured cap.
TokenLimit {
limit_name: &'static str,
value: usize,
},
/// A literal value (integer / float) parsed cleanly but lies
/// outside the semantic range expected for its slot — e.g.
/// `MAX_SIZE 0`, `lat = 91.0`, `K = 0`, or a negative integer
/// where a positive one is required. The structured payload lets
/// the snapshot/property harness distinguish these from generic
/// syntax errors without string matching.
ValueOutOfRange {
/// Stable slot name, e.g. `"MAX_SIZE"`, `"lat"`, `"radius"`.
field: &'static str,
/// Free-text constraint, e.g. `"must be > 0"`,
/// `"must be in -90.0..=90.0"`.
constraint: &'static str,
},
/// The lexer recognized this token, but the parser does not support
/// it in the current grammar position.
UnsupportedToken { token: String },
}
impl ParseError {
/// Create a new parse error
pub fn new(message: impl Into<String>, position: Position) -> Self {
Self {
message: message.into(),
position,
expected: Vec::new(),
kind: ParseErrorKind::Syntax,
}
}
/// Create error with expected tokens
///
/// `found` is rendered through [`SafeTokenDisplay`] so caller-controlled
/// bytes inside `Token::Ident` / `Token::String` / `Token::JsonLiteral` /
/// `Token::Float` / `Token::Integer` payloads are escaped via Rust's
/// `escape_debug` rules (CR / LF / NUL / quote bytes become `\n`,
/// `\r`, `\0`, `\"`, …). Static keyword and punctuation arms keep their
/// existing UPPER-CASE rendering so error messages and snapshot tests
/// stay readable. This prevents F-05 smuggling through the downstream
/// JSON / audit / log / gRPC sinks that embed `ParseError::message`.
pub fn expected(expected: Vec<&str>, found: &Token, position: Position) -> Self {
Self {
message: format!("Unexpected token: {}", SafeTokenDisplay(found)),
position,
expected: expected.into_iter().map(|s| s.to_string()).collect(),
kind: ParseErrorKind::Syntax,
}
}
/// Create an error when a lexer-known keyword appears in a parser
/// position where that keyword has no supported production.
pub fn unsupported_recognized_token(found: &Token, position: Position) -> Option<Self> {
let token = recognized_keyword_name(found)?;
Some(Self {
message: format!("token {token} is recognized but not supported in this position"),
position,
expected: Vec::new(),
kind: ParseErrorKind::UnsupportedToken { token },
})
}
/// Recursion depth limit hit. The structured `kind` carries the
/// name + numeric value so the snapshot/property harness can
/// pattern-match without string slicing.
pub fn depth_limit(limit_name: &'static str, value: usize, position: Position) -> Self {
Self {
message: format!(
"recursion depth limit exceeded ({} = {})",
limit_name, value
),
position,
expected: Vec::new(),
kind: ParseErrorKind::DepthLimit { limit_name, value },
}
}
/// Input bytes exceeded the configured cap.
pub fn input_too_large(limit_name: &'static str, value: usize, position: Position) -> Self {
Self {
message: format!(
"input exceeds maximum size ({} = {} bytes)",
limit_name, value
),
position,
expected: Vec::new(),
kind: ParseErrorKind::InputTooLarge { limit_name, value },
}
}
/// Identifier exceeded the configured character cap.
pub fn identifier_too_long(limit_name: &'static str, value: usize, position: Position) -> Self {
Self {
message: format!(
"identifier exceeds maximum length ({} = {} chars)",
limit_name, value
),
position,
expected: Vec::new(),
kind: ParseErrorKind::IdentifierTooLong { limit_name, value },
}
}
/// Token budget exceeded during parsing.
pub fn token_limit(limit_name: &'static str, value: usize, position: Position) -> Self {
Self {
message: format!("parser token limit exceeded ({} = {})", limit_name, value),
position,
expected: Vec::new(),
kind: ParseErrorKind::TokenLimit { limit_name, value },
}
}
/// A literal value lies outside the allowed range for its slot.
/// The free-text `constraint` is included verbatim in the message
/// so callers can render a single line without re-formatting.
pub fn value_out_of_range(
field: &'static str,
constraint: &'static str,
position: Position,
) -> Self {
Self {
message: format!("{} {}", field, constraint),
position,
expected: Vec::new(),
kind: ParseErrorKind::ValueOutOfRange { field, constraint },
}
}
/// Didactic (#1704): a document-model newcomer wrote the Postgres
/// `CREATE TABLE <name> DOCUMENT (…)` form. Documents are schemaless
/// and have no column list — the native form is `CREATE DOCUMENT`.
pub fn document_create_table(position: Position) -> Self {
Self {
message: "documents are schemaless: write `CREATE DOCUMENT <name>` \
(no column list) instead of `CREATE TABLE <name> DOCUMENT (…)` — \
top-level body fields auto-flatten into queryable columns"
.to_string(),
position,
expected: Vec::new(),
kind: ParseErrorKind::Syntax,
}
}
/// Didactic (#1704): a document-model newcomer used the Postgres JSON
/// operators `->` / `->>` in a query. The native idiom is the dotted
/// path. (Graph-mode `->` / `<-` traversal is parsed elsewhere and is
/// unaffected.)
pub fn json_arrow_operator(position: Position) -> Self {
Self {
message: "the `->` / `->>` JSON operators are not supported: use the \
dotted-path idiom instead (e.g. `body.details.ip` rather than \
`body->'details'->>'ip'`)"
.to_string(),
position,
expected: Vec::new(),
kind: ParseErrorKind::Syntax,
}
}
/// Didactic (#1709 / ADR 0067): the document INSERT column list is
/// removed. Documents are written with a bare inline JSON literal, so
/// the old ceremonial `(body)` column list no longer exists.
pub fn document_insert_column_list(position: Position) -> Self {
Self {
message: "the document INSERT column list is removed: write \
`INSERT INTO <collection> DOCUMENT VALUES ({\"level\": \"info\"})` — \
a bare inline JSON literal with no `(body)` column list"
.to_string(),
position,
expected: Vec::new(),
kind: ParseErrorKind::Syntax,
}
}
/// Didactic (#1709 / ADR 0067): legacy `_ttl` / `_ttl_ms` /
/// `_expires_at` document INSERT columns are removed; expiry is set
/// with the `WITH TTL` clause instead.
pub fn document_insert_ttl_column(position: Position) -> Self {
Self {
message: "legacy `_ttl` metadata columns are removed from document INSERT: \
set expiry with the `WITH TTL <duration>` clause (e.g. \
`INSERT INTO events DOCUMENT VALUES ({\"level\": \"info\"}) WITH TTL 30 m`)"
.to_string(),
position,
expected: Vec::new(),
kind: ParseErrorKind::Syntax,
}
}
/// Didactic (#1709 / ADR 0067): the quoted-string body coercion
/// (`VALUES ('{…}')`) is removed. JSON is written as an inline literal;
/// `JSON_PARSE(<expr>)` converts a runtime string.
pub fn document_insert_quoted_body(position: Position) -> Self {
Self {
message: "document INSERT no longer coerces a quoted string to JSON: write the \
body as an inline JSON literal — \
`INSERT INTO events DOCUMENT VALUES ({\"level\": \"info\"})` — or wrap a \
runtime string with `JSON_PARSE(<expr>)`"
.to_string(),
position,
expected: Vec::new(),
kind: ParseErrorKind::Syntax,
}
}
}
fn recognized_keyword_name(token: &Token) -> Option<String> {
match token {
Token::String(_)
| Token::Integer(_)
| Token::Float(_)
| Token::JsonLiteral(_)
| Token::Ident(_)
| Token::QuotedIdent(_)
| Token::Eq
| Token::Ne
| Token::Lt
| Token::Le
| Token::Gt
| Token::Ge
| Token::Plus
| Token::Minus
| Token::Star
| Token::Slash
| Token::Percent
| Token::LParen
| Token::RParen
| Token::LBracket
| Token::RBracket
| Token::LBrace
| Token::RBrace
| Token::Comma
| Token::Dot
| Token::Colon
| Token::Semi
| Token::Dollar
| Token::Question
| Token::Arrow
| Token::ArrowLeft
| Token::Dash
| Token::DotDot
| Token::Pipe
| Token::DoublePipe
| Token::Eof => None,
other => Some(SafeTokenDisplay(other).to_string()),
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Parse error at {}: {}", self.position, self.message)?;
if !self.expected.is_empty() {
write!(f, " (expected: {})", self.expected.join(", "))?;
}
Ok(())
}
}
impl std::error::Error for ParseError {}
/// `Display` adapter that emits a `Token` while escaping the
/// caller-controlled byte payload of `Ident` / `String` / `JsonLiteral` /
/// `Integer` / `Float` arms.
///
/// F-05 (serialization-boundary audit, 2026-05-06): SQL parser error
/// messages flow into JSON HTTP bodies, JSONL audit rows, gRPC
/// `Status::message`, PG3 `ErrorResponse`, and `tracing::warn!` log
/// lines. The default `Token` Display arms emit raw user bytes for
/// `Token::Ident("foo\nbar")` etc., which lets a tenant smuggle CR /
/// LF / NUL / quote bytes through every downstream sink at once.
///
/// This adapter renders user-controlled arms via `escape_debug` (the
/// same rules `{:?}` applies to a `&str`) and leaves keyword /
/// punctuation arms untouched so existing snapshot tests and operator
/// log readability are preserved.
pub struct SafeTokenDisplay<'a>(pub &'a Token);
impl fmt::Display for SafeTokenDisplay<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
// User-controlled byte payloads. Render via `escape_debug`
// so embedded CR / LF / NUL / quote bytes do not reach
// downstream serialization sinks unescaped.
Token::Ident(s) | Token::QuotedIdent(s) => write_escaped(f, s),
Token::String(s) => {
f.write_str("'")?;
write_escaped(f, s)?;
f.write_str("'")
}
Token::JsonLiteral(s) => write_escaped(f, s),
// Numeric tokens come straight from the lexer; their
// canonical Display form is bounded ASCII, but the lexer
// builds them via `to_string` so they cannot carry control
// bytes. Pass through Display.
Token::Integer(_) | Token::Float(_) => fmt::Display::fmt(self.0, f),
// Static keyword / punctuation arms — fall back to the
// existing Display output verbatim.
other => fmt::Display::fmt(other, f),
}
}
}
fn write_escaped(f: &mut fmt::Formatter<'_>, s: &str) -> fmt::Result {
for ch in s.chars() {
// `escape_debug` matches Rust's Debug rules: ASCII control
// bytes become `\n`, `\r`, `\0`, `\t`, …; non-ASCII printable
// characters pass through; backslash and double-quote are
// escaped.
for esc in ch.escape_debug() {
f.write_char(esc)?;
}
}
Ok(())
}
impl From<LexerError> for ParseError {
fn from(e: LexerError) -> Self {
let kind = match &e.limit_hit {
Some(LexerLimitHit::IdentifierTooLong { limit_name, value }) => {
ParseErrorKind::IdentifierTooLong {
limit_name,
value: *value,
}
}
None => ParseErrorKind::Syntax,
};
ParseError {
message: e.message,
position: e.position,
expected: Vec::new(),
kind,
}
}
}