Skip to content

Commit eca2cec

Browse files
authored
fix(native): report deeply nested header values instead of crashing (#1015)
* fix(native): report deeply nested header values instead of crashing The native header value parser recursed once per nesting level, so a header value nested about 20,000 levels deep overflowed the stack and crashed discovery under every compiler engine. The parser now refuses to open more than 256 nested containers, the same limit the YAML loader uses. Discovery reports it under each statement's existing header syntax code, naming the file line of the container that is too deep, with help on how to fix it. The in-memory parse entry points run on the discovery worker pool. * fix(native): project nested header values without Python recursion Python projected native header values recursively, spending about four frames per call level, so call syntax nested 231 to 256 levels deep raised RecursionError in every engine. The projection now walks values with an explicit stack, and a plain call counts as the two levels it builds (a hook's arguments count as one more), so the 256-level limit measures the values discovery actually produces. Lint suggests the same flatten help for a header nested too deeply.
1 parent 8bc5325 commit eca2cec

43 files changed

Lines changed: 1341 additions & 112 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//! The invalid-header-syntax failure every statement header reports, as Python's discovery does.
2+
3+
use crate::models::{DiscoveryFailure, FailureKind};
4+
use sqlbuild_sqltext::compiler::main::model_header_errors::header_nesting_failure;
5+
6+
/// One header that failed to parse, with the one-based file line its text starts on.
7+
pub(crate) struct FailedHeader<'a> {
8+
pub(crate) kind: FailureKind,
9+
/// The name in syntax errors, such as `MODEL`.
10+
pub(crate) statement_name: &'a str,
11+
pub(crate) file_path: &'a str,
12+
pub(crate) text: &'a str,
13+
pub(crate) line: usize,
14+
}
15+
16+
/// The invalid-syntax failure for `error`; a nesting error names its file line and a fix.
17+
pub(crate) fn header_syntax_failure(header: &FailedHeader<'_>, error: &str) -> DiscoveryFailure {
18+
let (statement_name, file_path) = (header.statement_name, header.file_path);
19+
let Some(nesting) = header_nesting_failure(error, header.text, header.line) else {
20+
return DiscoveryFailure::new(
21+
header.kind,
22+
format!(
23+
"{statement_name}(...) in '{file_path}' contains invalid SQLBuild header syntax: \
24+
{error}"
25+
),
26+
);
27+
};
28+
DiscoveryFailure {
29+
kind: header.kind,
30+
message: format!(
31+
"{statement_name}(...) in '{file_path}:{}' contains invalid SQLBuild header syntax: {}",
32+
nesting.line, nesting.message
33+
),
34+
help: Some(nesting.help),
35+
}
36+
}

‎crates/sqlbuild-discovery/src/_helpers/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub(crate) mod header_keys;
2+
pub(crate) mod header_syntax;
23
pub(crate) mod locations;
34
pub(crate) mod pool;
45
pub(crate) mod reading;

‎crates/sqlbuild-discovery/src/_helpers/pool.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! The worker pool native discovery runs on; header parsing recurses, so stacks are large.
1+
//! The worker pool native discovery runs on; parsing recurses, so stacks are large.
22
33
use rayon::{ThreadPool, ThreadPoolBuilder};
44
use std::sync::OnceLock;
@@ -18,3 +18,11 @@ pub(crate) fn discovery_pool() -> Result<&'static ThreadPool, String> {
1818
.as_ref()
1919
.map_err(Clone::clone)
2020
}
21+
22+
/// Run `work` on the discovery pool, or on the calling thread (safe: nesting is bounded).
23+
pub(crate) fn on_discovery_pool<T: Send>(work: impl FnOnce() -> T + Send) -> T {
24+
match discovery_pool() {
25+
Ok(pool) => pool.install(work),
26+
Err(_) => work(),
27+
}
28+
}

‎crates/sqlbuild-discovery/src/_helpers/statement_headers.rs‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Parse a statement header and reject its unsupported keys, as Python's discovery does.
22
33
use crate::_helpers::header_keys::{UnsupportedKeys, unsupported_keys_failure};
4+
use crate::_helpers::header_syntax::{FailedHeader, header_syntax_failure};
45
use crate::models::{DiscoveryFailure, FailureKind};
56
use sqlbuild_core::text::models::PythonText;
67
use sqlbuild_sqltext::compiler::main::model_header_single_parsing::parse_one;
@@ -25,11 +26,13 @@ pub(crate) fn parse_statement_header(
2526
header_line: usize,
2627
) -> Result<Vec<(String, AuthoredValue)>, DiscoveryFailure> {
2728
let values: Vec<(String, AuthoredValue)> = match parse_one(header) {
28-
(_, _, Some(error)) => return Err(syntax_failure(contract, &error)),
29+
(_, _, Some(error)) => return Err(syntax_failure(contract, header, header_line, &error)),
2930
(Some(AuthoredValue::Map(values)), _, None) => values,
3031
_ => {
3132
return Err(syntax_failure(
3233
contract,
34+
header,
35+
header_line,
3336
"Native MODEL header parser returned neither values nor an error",
3437
));
3538
}
@@ -54,13 +57,21 @@ pub(crate) fn parse_statement_header(
5457
}))
5558
}
5659

57-
fn syntax_failure(contract: &StatementHeader<'_>, error: &str) -> DiscoveryFailure {
58-
DiscoveryFailure::new(
59-
contract.kind,
60-
format!(
61-
"{}(...) in '{}' contains invalid SQLBuild header syntax: {error}",
62-
contract.statement_name, contract.file_path
63-
),
60+
fn syntax_failure(
61+
contract: &StatementHeader<'_>,
62+
header: &str,
63+
header_line: usize,
64+
error: &str,
65+
) -> DiscoveryFailure {
66+
header_syntax_failure(
67+
&FailedHeader {
68+
kind: contract.kind,
69+
statement_name: contract.statement_name,
70+
file_path: contract.file_path,
71+
text: header,
72+
line: header_line,
73+
},
74+
error,
6475
)
6576
}
6677

‎crates/sqlbuild-discovery/src/declaration_files/_helpers/parsing/declaration_headers.rs‎

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Python's `_parse_declaration_headers`: split a file into `KIND(...);` headers and parse each.
22
3+
use crate::_helpers::header_syntax::{FailedHeader, header_syntax_failure};
34
use crate::declaration_files::_helpers::checks::python_values::failure;
45
use crate::declaration_files::_helpers::checks::stops::ParseStop;
56
use crate::models::FailureKind;
@@ -55,21 +56,21 @@ pub(crate) fn declaration_headers(
5556
)));
5657
}
5758
let header_start: usize = open_index + 1;
58-
let (values, column_offsets) = match parse_one(&contents[header_start..close_index]) {
59-
(_, _, Some(error)) => {
60-
return Err(declaration(syntax_message(
61-
expected_kind,
62-
file_path,
63-
&error,
64-
)));
65-
}
59+
let header = FailedHeader {
60+
kind: FailureKind::Declaration,
61+
statement_name: expected_kind,
62+
file_path,
63+
text: &contents[header_start..close_index],
64+
line: contents[..header_start].matches('\n').count() + 1,
65+
};
66+
let syntax_failure = |error: &str| ParseStop::Failed(header_syntax_failure(&header, error));
67+
let (values, column_offsets) = match parse_one(header.text) {
68+
(_, _, Some(error)) => return Err(syntax_failure(&error)),
6669
(Some(AuthoredValue::Map(values)), Some(offsets), None) => (values, offsets),
6770
_ => {
68-
return Err(declaration(syntax_message(
69-
expected_kind,
70-
file_path,
71+
return Err(syntax_failure(
7172
"Native MODEL header parser returned neither values nor an error",
72-
)));
73+
));
7374
}
7475
};
7576
headers.push(DeclarationHeader {
@@ -88,10 +89,6 @@ pub(crate) fn declaration_headers(
8889
Ok(headers)
8990
}
9091

91-
fn syntax_message(kind: &str, file_path: &str, error: &str) -> String {
92-
format!("{kind}(...) in '{file_path}' contains invalid SQLBuild header syntax: {error}")
93-
}
94-
9592
/// `(ENUM|CONSTANT|SCHEMA)\s*\(` at `start`: the kind and the byte offset of the parenthesis.
9693
fn declaration_start(contents: &str, start: usize) -> Option<(&'static str, usize)> {
9794
let rest: &str = &contents[start..];

‎crates/sqlbuild-discovery/src/declaration_files/main/parse_declaration_text.rs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Parse in-memory declaration file contents as discovery parses a file of one kind.
22
3+
use crate::_helpers::pool::on_discovery_pool;
34
use crate::declaration_files::_helpers::checks::stops::ParseStop;
45
use crate::declaration_files::_helpers::parsing::audit_files::parse_audit_file;
56
use crate::declaration_files::_helpers::parsing::constant_files::parse_constant_file;
@@ -12,12 +13,21 @@ use crate::declaration_files::models::{
1213
};
1314
use crate::models::FileOutcome;
1415

15-
/// `contents` parsed as a `kind` file named `file_path`; `None` for seeds and macros, which have none.
16+
/// `contents` parsed on the discovery pool as a `kind` file; `None` for seeds and macros.
1617
pub fn parse_declaration_text(
1718
kind: CollectionKind,
1819
(file_path, hook_name): (&str, &str),
1920
contents: String,
2021
options: &DeclarationFileOptions,
22+
) -> Option<FileOutcome<ParsedDeclarationText>> {
23+
on_discovery_pool(|| parse_text(kind, (file_path, hook_name), contents, options))
24+
}
25+
26+
fn parse_text(
27+
kind: CollectionKind,
28+
(file_path, hook_name): (&str, &str),
29+
contents: String,
30+
options: &DeclarationFileOptions,
2131
) -> Option<FileOutcome<ParsedDeclarationText>> {
2232
let parsed: Result<ParsedDeclarationText, ParseStop> = match kind {
2333
CollectionKind::Enums => {

‎crates/sqlbuild-discovery/src/declaration_files/tests/helpers.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,18 @@ pub(super) fn session_reads(
8282
fn collection_len(collection: &DeclarationCollection) -> usize {
8383
format!("{collection:?}").matches("ScopedFile {").count()
8484
}
85+
86+
/// The debug text of the failure a header nested too deeply reports at `(statement, line)`.
87+
pub(super) fn nesting_failure_debug((statement, line): (&str, usize)) -> String {
88+
format!(
89+
"Some(Failed(DiscoveryFailure {{ kind: {}, message: \"{statement}(...) in '{FILE_PATH}:{line}' \
90+
contains invalid SQLBuild header syntax: values nest deeper than 256 levels\", help: \
91+
Some(\"flatten the value so it nests at most 256 levels deep\") }}))",
92+
match statement {
93+
"CONSTANT" => "Declaration",
94+
"HOOK" => "SqlHook",
95+
"AUDIT" => "SqlAudit",
96+
_ => "ModelSql",
97+
}
98+
)
99+
}

‎crates/sqlbuild-discovery/src/declaration_files/tests/test_parse_declaration_text.rs‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use crate::declaration_files::models::CollectionKind;
2-
use crate::declaration_files::tests::helpers::parsed_debug;
3-
use crate::declaration_files::tests::test_types::DeclarationTextTestCase;
2+
use crate::declaration_files::tests::helpers::{nesting_failure_debug, parsed_debug};
3+
use crate::declaration_files::tests::test_types::{
4+
DeclarationTextTestCase, DeepDeclarationTestCase,
5+
};
46

57
#[test]
68
fn given_declaration_files_when_parsing_then_values_and_failures_match_python() {
@@ -213,3 +215,56 @@ fn given_declaration_files_when_parsing_then_values_and_failures_match_python()
213215
);
214216
}
215217
}
218+
219+
#[test]
220+
fn given_deeply_nested_declaration_headers_when_parsing_then_each_reports_its_file_line() {
221+
let test_cases = [
222+
DeepDeclarationTestCase {
223+
description: "a constant value",
224+
kind: CollectionKind::Constants,
225+
prefix: "CONSTANT (\n name limits,\n value ",
226+
suffix: ",\n);\n",
227+
expected_location: ("CONSTANT", 3),
228+
},
229+
DeepDeclarationTestCase {
230+
description: "a hook description",
231+
kind: CollectionKind::SqlHooks,
232+
prefix: "HOOK (\n description ",
233+
suffix: ",\n);\n\nSELECT 1\n",
234+
expected_location: ("HOOK", 2),
235+
},
236+
DeepDeclarationTestCase {
237+
description: "a function return type",
238+
kind: CollectionKind::SqlFunctions,
239+
prefix: "FUNCTION (\n description \"Whether placed\",\n returns ",
240+
suffix: ",\n);\n\norder_status = 'placed'\n",
241+
expected_location: ("FUNCTION", 3),
242+
},
243+
DeepDeclarationTestCase {
244+
description: "an audit value on a later block",
245+
kind: CollectionKind::Audits,
246+
prefix: "AUDIT (name a);\nSELECT 1\nAUDIT (\n name b,\n value ",
247+
suffix: ",\n);\nSELECT 2\n",
248+
expected_location: ("AUDIT", 5),
249+
},
250+
];
251+
252+
for test_case in test_cases {
253+
let contents: String = format!(
254+
"{}{}1{}{}",
255+
test_case.prefix,
256+
"[".repeat(20_000),
257+
"]".repeat(20_000),
258+
test_case.suffix
259+
);
260+
261+
let debug: String = parsed_debug(test_case.kind, &contents);
262+
263+
assert_eq!(
264+
debug,
265+
nesting_failure_debug(test_case.expected_location),
266+
"{}",
267+
test_case.description
268+
);
269+
}
270+
}

‎crates/sqlbuild-discovery/src/declaration_files/tests/test_types.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,15 @@ pub(super) struct SessionReadTestCase {
1818
/// The layouts walked: one shared by every kind, plus one per isolated kind.
1919
pub(super) expected_layouts: usize,
2020
}
21+
22+
/// A declaration file whose header nests one value far too deeply, on header line `line`.
23+
pub(super) struct DeepDeclarationTestCase {
24+
pub(super) description: &'static str,
25+
pub(super) kind: CollectionKind,
26+
/// The file text before the nested value.
27+
pub(super) prefix: &'static str,
28+
/// The file text after the nested value.
29+
pub(super) suffix: &'static str,
30+
/// The statement name and file line the failure reports.
31+
pub(super) expected_location: (&'static str, usize),
32+
}

‎crates/sqlbuild-discovery/src/model_files/_helpers/parsing.rs‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Parse one model file: header checks, model-local declaration checks and their messages.
22
33
use crate::_helpers::header_keys::{UnsupportedKeys, unsupported_keys_failure};
4+
use crate::_helpers::header_syntax::{FailedHeader, header_syntax_failure};
45
use crate::_helpers::locations::header_column_locations;
56
use crate::model_files::_helpers::output_columns::output_column_locations;
67
use crate::model_files::models::{DiscoveredModelFile, ModelFileOptions};
@@ -28,12 +29,20 @@ pub(crate) fn parse_model_file(
2829
)));
2930
};
3031
let header = &contents[header_start..header_end];
32+
let header_line: usize = contents[..header_start].matches('\n').count() + 1;
33+
let failed_header = FailedHeader {
34+
kind: FailureKind::ModelSql,
35+
statement_name: "MODEL",
36+
file_path,
37+
text: header,
38+
line: header_line,
39+
};
3140
let (header_values, column_offsets) = match parse_one(header) {
32-
(_, _, Some(error)) => return Err(syntax_failure(file_path, &error)),
41+
(_, _, Some(error)) => return Err(header_syntax_failure(&failed_header, &error)),
3342
(Some(AuthoredValue::Map(values)), Some(offsets), None) => (values, offsets),
3443
_ => {
35-
return Err(syntax_failure(
36-
file_path,
44+
return Err(header_syntax_failure(
45+
&failed_header,
3746
"Native MODEL header parser returned neither values nor an error",
3847
));
3948
}
@@ -58,7 +67,7 @@ pub(crate) fn parse_model_file(
5867
statement: MODEL_STATEMENT,
5968
file_path,
6069
header,
61-
header_line: contents[..header_start].matches('\n').count() + 1,
70+
header_line,
6271
keys: &unsupported,
6372
supported_keys: &options.supported_keys,
6473
python: options.python,
@@ -91,12 +100,6 @@ pub(crate) fn parse_model_file(
91100
})
92101
}
93102

94-
fn syntax_failure(file_path: &str, error: &str) -> DiscoveryFailure {
95-
failure(format!(
96-
"MODEL(...) in '{file_path}' contains invalid SQLBuild header syntax: {error}"
97-
))
98-
}
99-
100103
fn header_keys(values: &[(String, AuthoredValue)]) -> impl Iterator<Item = &str> {
101104
values.iter().map(|(key, _)| key.as_str())
102105
}

0 commit comments

Comments
 (0)