From 014095222f9005096a67e4171e655b7fb5f51814 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 22:02:04 +0200 Subject: [PATCH 1/2] Grow the stack in the translator's recursion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A left-deep chain such as `a + a + … + a` aborted the process with a stack overflow, which no caller can catch, at 74 operands in a release build and 13 in a debug build on a 2 MiB thread. sqlparser reads such chains iteratively, so it accepts them at any length and hands over a tree the translator then walks recursively. Every recursion cycle of the forward and reverse translators now carries `#[recursive]` under the `std` feature, the same guard sqlparser's `recursive-protection` uses. Statement translation also raises `recursive`'s minimum headroom to 256 KiB, because one step of the expression translator takes about 140 KiB in a debug build. The same chains now translate past 1,800 operands in release and about 350 in debug, and the remaining aborts happen in sqlparser's derived `Clone` and in sql-traits. --- Cargo.toml | 6 +- src/history/effects.rs | 1 + src/history/placement.rs | 1 + src/history/rebind/query.rs | 3 + src/history/rebind/scope.rs | 1 + src/impls.rs | 1 + src/impls/datetime_helpers.rs | 1 + src/impls/function_helpers.rs | 1 + src/impls/returning_scope.rs | 1 + src/impls/reverse_translator_impls/expr.rs | 5 ++ src/impls/reverse_translator_impls/query.rs | 1 + .../reverse_translator_impls/statement.rs | 1 + src/impls/session_variable.rs | 1 + src/impls/shared_helpers.rs | 13 +++ src/impls/stack_headroom.rs | 15 ++++ src/impls/temporal_arithmetic.rs | 2 + src/impls/timezone.rs | 1 + src/impls/translator_impls/create_index.rs | 2 + src/impls/translator_impls/create_trigger.rs | 2 + src/impls/translator_impls/expr.rs | 4 + src/impls/translator_impls/function.rs | 2 + src/impls/translator_impls/insert.rs | 1 + .../translator_impls/plpgsql/translator.rs | 5 ++ src/impls/translator_impls/postgis.rs | 2 + src/impls/translator_impls/query.rs | 3 + src/impls/translator_impls/rls.rs | 9 ++ src/impls/translator_impls/statement.rs | 1 + src/impls/translator_impls/uuid.rs | 1 + tests/test_translator_stack_depth.rs | 84 +++++++++++++++++++ 29 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/impls/stack_headroom.rs create mode 100644 tests/test_translator_stack_depth.rs diff --git a/Cargo.toml b/Cargo.toml index a1ae0354bd..0abccd09e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ sql-traits = { git = "https://github.com/connetto-rs/sql-traits", branch = "main git2 = { version = "0.21", optional = true } tempfile = { version = "3.27", optional = true } thiserror = { version = "2.0", default-features = false } +# Grows the stack on demand in the translator's recursion, as `sqlparser`'s +# `recursive-protection` does in its parser. +recursive = { version = "0.1", optional = true } # Optional Arbitrary impls on the public option types so fuzz harnesses # can drive `Pg2SqliteOptions` (and its dependent enums) from libfuzzer # input bytes. Gated behind the `arbitrary` feature so production @@ -56,7 +59,7 @@ optional = true [features] # Default feature: pulls in everything that depends on the host environment # (filesystem-backed loaders, git-backed loaders, std-bound error variants, -# and the `recursive-protection` stack guard from sqlparser). Drop this with +# and the stack growth in sqlparser's parser and this crate's translator). Drop this with # `--no-default-features` to build for `wasm32-unknown-unknown` or other # no_std targets. The `[patch.crates-io]` block below tracks apache main, # which makes `sqlparser/visitor` no_std-compatible. @@ -65,6 +68,7 @@ std = [ "sqlparser/std", "sqlparser/recursive-protection", "sql-traits/std", + "dep:recursive", "thiserror/std", "git", ] diff --git a/src/history/effects.rs b/src/history/effects.rs index 714b08f5df..f344275b02 100644 --- a/src/history/effects.rs +++ b/src/history/effects.rs @@ -402,6 +402,7 @@ enum RowWrite<'a> { } /// What `statement` writes, or `None` when it writes no rows. +#[cfg_attr(feature = "std", recursive::recursive)] fn row_write(statement: &Statement) -> Option> { match statement { Statement::Insert(insert) => { diff --git a/src/history/placement.rs b/src/history/placement.rs index bb51178f7e..89fe4ce4a3 100644 --- a/src/history/placement.rs +++ b/src/history/placement.rs @@ -563,6 +563,7 @@ fn place_statement( }) } +#[cfg_attr(feature = "std", recursive::recursive)] pub(super) fn primary_write_target(statement: &Statement) -> Option<&ObjectName> { fn factor(factor: &TableFactor) -> Option<&ObjectName> { match factor { diff --git a/src/history/rebind/query.rs b/src/history/rebind/query.rs index 88f8104f3c..16b36deafa 100644 --- a/src/history/rebind/query.rs +++ b/src/history/rebind/query.rs @@ -117,6 +117,7 @@ impl Planner<'_> { }) } + #[cfg_attr(feature = "std", recursive::recursive)] pub(super) fn set_expr( &mut self, scope: &ColumnScope<'_, '_, ParserDB>, @@ -229,6 +230,7 @@ impl Planner<'_> { Ok(()) } + #[cfg_attr(feature = "std", recursive::recursive)] pub(super) fn factor<'s, 'q, 'd>( &mut self, scope: &ColumnScope<'_, '_, ParserDB>, @@ -266,6 +268,7 @@ impl Planner<'_> { /// Plans one expression, returning the previous name of the column it is /// when it is a bare reference to a renamed file column. + #[cfg_attr(feature = "std", recursive::recursive)] pub(super) fn expr<'s, 'q, 'd>( &mut self, scope: &ColumnScope<'_, '_, ParserDB>, diff --git a/src/history/rebind/scope.rs b/src/history/rebind/scope.rs index f21b4d6c88..bb803753a9 100644 --- a/src/history/rebind/scope.rs +++ b/src/history/rebind/scope.rs @@ -71,6 +71,7 @@ impl Planner<'_> { self.list_as(factor, listed, true) } + #[cfg_attr(feature = "std", recursive::recursive)] fn list_as( &self, factor: &TableFactor, diff --git a/src/impls.rs b/src/impls.rs index f5ccc5e632..faa12b319e 100644 --- a/src/impls.rs +++ b/src/impls.rs @@ -32,6 +32,7 @@ pub(crate) mod session_variable; pub(crate) mod shared_helpers; pub(crate) mod sqlite_functions; pub(crate) mod sqlite_keywords; +pub(crate) mod stack_headroom; pub(crate) mod temporal_arithmetic; pub(crate) mod temporal_literals; pub(crate) mod timezone; diff --git a/src/impls/datetime_helpers.rs b/src/impls/datetime_helpers.rs index d17b2a017a..13bcc12304 100644 --- a/src/impls/datetime_helpers.rs +++ b/src/impls/datetime_helpers.rs @@ -421,6 +421,7 @@ fn take_last_argument(expr: &mut Expr) -> Option { /// Whether `expr` is a timestamp one of SQLite's date functions computed, /// which is text without an offset. #[must_use] +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn is_offset_less_timestamp_call(expr: &Expr) -> bool { let Expr::Function(function) = peel_nested(expr) else { return false }; let named = |name| super::function_helpers::is_function_named(function, name); diff --git a/src/impls/function_helpers.rs b/src/impls/function_helpers.rs index 6e8e797a2d..d477921665 100644 --- a/src/impls/function_helpers.rs +++ b/src/impls/function_helpers.rs @@ -104,6 +104,7 @@ pub(crate) fn integer_literal(value: i64) -> Expr { /// a narrower type convert at the call site, so the range a caller accepts is /// stated where that caller decides it rather than here. #[must_use] +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn integer_literal_value(expr: &Expr) -> Option { match expr { Expr::Value(ValueWithSpan { value: Value::Number(digits, _), .. }) => digits.parse().ok(), diff --git a/src/impls/returning_scope.rs b/src/impls/returning_scope.rs index eeca25ff4d..ec91de4c9c 100644 --- a/src/impls/returning_scope.rs +++ b/src/impls/returning_scope.rs @@ -182,6 +182,7 @@ impl<'a> Scope<'a> { } } + #[cfg_attr(feature = "std", recursive::recursive)] fn rewrite_expr(&self, expr: &Expr) -> Result { match expr { Expr::CompoundIdentifier(parts) if parts.len() >= 2 => { diff --git a/src/impls/reverse_translator_impls/expr.rs b/src/impls/reverse_translator_impls/expr.rs index 9af3ca5983..060ff4fe8d 100644 --- a/src/impls/reverse_translator_impls/expr.rs +++ b/src/impls/reverse_translator_impls/expr.rs @@ -165,6 +165,7 @@ fn reverse_numeric_rules_may_apply(op: &BinaryOperator, left: &Expr, right: &Exp ) { return false; } + #[cfg_attr(feature = "std", recursive::recursive)] fn addressable(expr: &Expr) -> bool { match expr { Expr::Identifier(_) @@ -178,6 +179,7 @@ fn reverse_numeric_rules_may_apply(op: &BinaryOperator, left: &Expr, right: &Exp _ => false, } } + #[cfg_attr(feature = "std", recursive::recursive)] fn numeric_candidate(expr: &Expr) -> bool { match expr { Expr::Value(ValueWithSpan { value: Value::Number(_, _), .. }) => true, @@ -210,6 +212,7 @@ fn reverse_unscale_or_translate( /// Rescales integer literals inside an already-translated scale-preserving /// call. +#[cfg_attr(feature = "std", recursive::recursive)] fn reverse_scale_call_arguments(call: Expr, scale: u32) -> Result { let Expr::Function(mut function) = call else { return Ok(call) }; if !is_scale_preserving_call(&function) { @@ -235,6 +238,7 @@ fn reverse_scale_call_arguments(call: Expr, scale: u32) -> Result { } /// True when `expr` is definitely non-text, so `||` would fail in PostgreSQL. +#[cfg_attr(feature = "std", recursive::recursive)] fn is_definitely_non_text( expr: &Expr, schema: &ParserDB, @@ -382,6 +386,7 @@ impl ReverseTranslator for Expr { type Schema = ParserDB; type PostgresEntry = Self; + #[cfg_attr(feature = "std", recursive::recursive)] fn reverse_translate( &self, schema: &Self::Schema, diff --git a/src/impls/reverse_translator_impls/query.rs b/src/impls/reverse_translator_impls/query.rs index 5cddd2bc5c..012b66a887 100644 --- a/src/impls/reverse_translator_impls/query.rs +++ b/src/impls/reverse_translator_impls/query.rs @@ -162,6 +162,7 @@ impl ReverseTranslator for Query { type Schema = ParserDB; type PostgresEntry = Query; + #[cfg_attr(feature = "std", recursive::recursive)] fn reverse_translate( &self, schema: &Self::Schema, diff --git a/src/impls/reverse_translator_impls/statement.rs b/src/impls/reverse_translator_impls/statement.rs index fd1b520d0d..5f6757a19e 100644 --- a/src/impls/reverse_translator_impls/statement.rs +++ b/src/impls/reverse_translator_impls/statement.rs @@ -412,6 +412,7 @@ impl ReverseTranslator for Statement { schema: &Self::Schema, options: &crate::options::TranslationContext<'_>, ) -> Result { + crate::impls::stack_headroom::ensure(); ensure_usable_rls_table_suffix(options)?; let mut translated = match self { Statement::Insert(insert) => { diff --git a/src/impls/session_variable.rs b/src/impls/session_variable.rs index 4f6dbc67b0..8aee9463d2 100644 --- a/src/impls/session_variable.rs +++ b/src/impls/session_variable.rs @@ -315,6 +315,7 @@ pub(crate) fn same_postgres_type(left: &DataType, right: &DataType) -> bool { /// change nothing a membership test reads, since `current_setting` already /// answers text. #[must_use] +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn setting_operand(expr: &Expr) -> &Expr { match expr { Expr::Nested(inner) diff --git a/src/impls/shared_helpers.rs b/src/impls/shared_helpers.rs index 15d9d6f725..8b5b2471ce 100644 --- a/src/impls/shared_helpers.rs +++ b/src/impls/shared_helpers.rs @@ -207,6 +207,7 @@ pub(crate) fn match_partial_not_supported_error() -> Error { /// /// The qualifier of a compound name is dropped, since it may be an alias rather /// than a table. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn referenced_column_name(expr: &Expr) -> Option<&str> { match expr { Expr::Identifier(ident) => Some(ident.value.as_str()), @@ -264,6 +265,7 @@ impl Visitor for FunctionColumnCollector { /// Returns every column name `expr` mentions. #[must_use] +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn extract_columns_from_expr(expr: &Expr) -> ColumnReferences { match expr { Expr::Identifier(ident) => ColumnReferences::Complete(vec![ident.value.clone()]), @@ -452,6 +454,7 @@ enum DefinitionValue { Opaque, } +#[cfg_attr(feature = "std", recursive::recursive)] fn evaluate_definition( definition: &ColumnDefinition<'_, '_, '_, ParserDB>, schema: &ParserDB, @@ -538,6 +541,7 @@ pub(crate) fn query_output_matches( Ok(false) } +#[cfg_attr(feature = "std", recursive::recursive)] fn definition_matches( definition: &ColumnDefinition<'_, '_, '_, ParserDB>, schema: &ParserDB, @@ -661,6 +665,7 @@ fn has_nested_join(entry: &sqlparser::ast::TableWithJoins) -> bool { } /// Appends `entry` and everything a nested join inside it hides. +#[cfg_attr(feature = "std", recursive::recursive)] fn flatten_relations( entry: &sqlparser::ast::TableWithJoins, out: &mut Vec, @@ -704,6 +709,7 @@ pub(crate) fn relations_scope_query( } /// Unwraps parentheses, which carry no meaning for resolution. +#[cfg_attr(feature = "std", recursive::recursive)] fn strip_nesting(expr: &Expr) -> &Expr { match expr { Expr::Nested(inner) => strip_nesting(inner), @@ -720,6 +726,7 @@ fn unresolved_reference(reference: &Expr, reason: &str) -> crate::errors::Error /// True when the column `expr` names is declared with a type `predicate` /// accepts. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn declared_type_matches( expr: &Expr, schema: &ParserDB, @@ -739,6 +746,7 @@ pub(crate) fn declared_type_matches( } /// True when `expr` is a whole number by construction, so its scale is 0. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn is_integral_expression( expr: &Expr, schema: &ParserDB, @@ -833,6 +841,7 @@ pub(crate) fn is_scale_preserving_call(function: &Function) -> bool { }) } +#[cfg_attr(feature = "std", recursive::recursive)] fn numeric_precision_and_scale_of( expr: &Expr, schema: &ParserDB, @@ -1912,6 +1921,7 @@ pub(crate) fn scale_literal_for_column( } /// True when `expr` is a number literal with a decimal point. +#[cfg_attr(feature = "std", recursive::recursive)] fn is_decimal_number_literal(expr: &Expr) -> bool { match expr { Expr::Value(ValueWithSpan { value: Value::Number(digits, _), .. }) => digits.contains('.'), @@ -3624,6 +3634,7 @@ fn translate_select_with_from( /// becomes a select over it as a derived table, so `(SELECT a FROM t ORDER BY /// a LIMIT 1) UNION (SELECT b FROM t ORDER BY b DESC LIMIT 1)` keeps /// answering 1, 30. +#[cfg_attr(feature = "std", recursive::recursive)] fn unparenthesize_compound_operand(operand: sqlparser::ast::SetExpr) -> sqlparser::ast::SetExpr { use sqlparser::ast::SetExpr; @@ -3655,6 +3666,7 @@ fn unparenthesize_compound_operand(operand: sqlparser::ast::SetExpr) -> sqlparse } /// Shared `SetExpr` translation. Forward errors on `Table` and `Merge`. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn translate_set_expr_shared( set_expr: &sqlparser::ast::SetExpr, schema: &ParserDB, @@ -4103,6 +4115,7 @@ fn subquery_is_trivially_uncorrelated(query: &Query) -> bool { } #[allow(clippy::too_many_lines)] +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn translate_table_factor( table_factor: &TableFactor, schema: &ParserDB, diff --git a/src/impls/stack_headroom.rs b/src/impls/stack_headroom.rs new file mode 100644 index 0000000000..4108f28f01 --- /dev/null +++ b/src/impls/stack_headroom.rs @@ -0,0 +1,15 @@ +//! The stack headroom the translator's `#[recursive]` functions need. + +/// The most one step of a translator recursion cycle uses, which a debug build +/// takes up to about 140 KiB of. +#[cfg(feature = "std")] +const HEADROOM: usize = 256 * 1024; + +/// Raises `recursive`'s process-wide minimum to [`HEADROOM`], never lowering +/// it. +pub(crate) fn ensure() { + #[cfg(feature = "std")] + if recursive::get_minimum_stack_size() < HEADROOM { + recursive::set_minimum_stack_size(HEADROOM); + } +} diff --git a/src/impls/temporal_arithmetic.rs b/src/impls/temporal_arithmetic.rs index 37cb20cb1d..e0166a9b95 100644 --- a/src/impls/temporal_arithmetic.rs +++ b/src/impls/temporal_arithmetic.rs @@ -40,6 +40,7 @@ pub(crate) enum TemporalKind { /// Resolve the temporal type of `expr` from its own spelling or from the /// declared type of the column it names. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn temporal_kind( expr: &Expr, schema: &ParserDB, @@ -159,6 +160,7 @@ fn binary_result_kind( }) } +#[cfg_attr(feature = "std", recursive::recursive)] fn is_interval(expr: &Expr) -> bool { match expr { Expr::Interval(_) => true, diff --git a/src/impls/timezone.rs b/src/impls/timezone.rs index 9875b9bc17..8d776782eb 100644 --- a/src/impls/timezone.rs +++ b/src/impls/timezone.rs @@ -99,6 +99,7 @@ pub(crate) enum TimestampAwareness { /// /// Both directions need this and must agree: the forward direction negates an /// aware operand's offset, so the reverse direction has to negate it back. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn timestamp_awareness( expr: &Expr, schema: &ParserDB, diff --git a/src/impls/translator_impls/create_index.rs b/src/impls/translator_impls/create_index.rs index 26cc286b61..bf0c237118 100644 --- a/src/impls/translator_impls/create_index.rs +++ b/src/impls/translator_impls/create_index.rs @@ -121,6 +121,7 @@ fn lower_argument(function: &Function) -> Option<&Expr> { plain_argument_expr(argument) } +#[cfg_attr(feature = "std", recursive::recursive)] fn is_reproducible_fts_document(expr: &Expr) -> bool { match expr { Expr::Identifier(_) | Expr::CompoundIdentifier(_) => true, @@ -142,6 +143,7 @@ fn is_reproducible_fts_document(expr: &Expr) -> bool { } /// Returns the columns in a `to_tsvector` document that FTS5 can reproduce. +#[cfg_attr(feature = "std", recursive::recursive)] fn analyze_fts_expression(expr: &Expr) -> Option> { match expr { Expr::Function(function) => { diff --git a/src/impls/translator_impls/create_trigger.rs b/src/impls/translator_impls/create_trigger.rs index 2b413956b4..09261e13d8 100644 --- a/src/impls/translator_impls/create_trigger.rs +++ b/src/impls/translator_impls/create_trigger.rs @@ -48,6 +48,7 @@ use crate::{ /// `recursive_triggers = ON` (measured: `too many levels of trigger /// recursion`). References to columns assigned LATER stay raw, because reading /// the pre-update row is exactly what PostgreSQL does for them. +#[cfg_attr(feature = "std", recursive::recursive)] fn compose_assignment_refs(expr: &Expr, assigned: &[(String, Expr)]) -> Expr { let earlier = |name: &str| { assigned @@ -221,6 +222,7 @@ fn route_trigger_table_factor( Ok(()) } +#[cfg_attr(feature = "std", recursive::recursive)] fn route_trigger_query_writes( query: &mut Query, schema: &ParserDB, diff --git a/src/impls/translator_impls/expr.rs b/src/impls/translator_impls/expr.rs index 191df14a3a..d2316df953 100644 --- a/src/impls/translator_impls/expr.rs +++ b/src/impls/translator_impls/expr.rs @@ -280,6 +280,7 @@ fn translate_extract( /// Under the JSON array representation an array column is TEXT holding a JSON /// array, so nothing about the value distinguishes it from a string and the /// declared type is the only evidence there is. +#[cfg_attr(feature = "std", recursive::recursive)] fn is_array_expression( expr: &Expr, schema: &ParserDB, @@ -396,6 +397,7 @@ fn translate_boolean_cast( } /// The word PostgreSQL renders a boolean literal as, when `expr` is one. +#[cfg_attr(feature = "std", recursive::recursive)] fn boolean_literal_word(expr: &Expr) -> Option<&'static str> { match expr { Expr::Nested(inner) => boolean_literal_word(inner), @@ -413,6 +415,7 @@ fn boolean_literal_word(expr: &Expr) -> Option<&'static str> { /// and nothing downstream can tell it from a count. Anything this does not /// recognise is left alone, which keeps a value that merely happens to be 1 /// from being rendered as a word. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn is_boolean_expression( expr: &Expr, schema: &ParserDB, @@ -3362,6 +3365,7 @@ impl crate::traits::translator::TranslatorWithContext for Expr { type SQLiteEntry = Expr; #[allow(clippy::too_many_lines)] + #[cfg_attr(feature = "std", recursive::recursive)] fn translate_with_warnings( &self, schema: &sql_traits::structs::ParserDB, diff --git a/src/impls/translator_impls/function.rs b/src/impls/translator_impls/function.rs index 8efe8e74b5..0d69e2776f 100644 --- a/src/impls/translator_impls/function.rs +++ b/src/impls/translator_impls/function.rs @@ -1245,6 +1245,7 @@ fn declares_function_by_option(name: &str, options: &Pg2SqliteOptions) -> bool { /// It cannot see through a bare column reference, so an array or `json` COLUMN /// takes the `json_quote` path and is quoted into a string, which is wrong and /// needs the column's declared type to fix. Tracked as R89. +#[cfg_attr(feature = "std", recursive::recursive)] fn is_already_json(expr: &Expr) -> bool { const JSON_VALUED: [&str; 10] = [ "json", @@ -1347,6 +1348,7 @@ fn resolves_to_non_textual_column( /// thing that separates a document from its own text. An unqualified name is /// accepted only when every column with that name in the schema agrees, since /// guessing between the two is wrong half the time in either direction. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn carries_json( expr: &Expr, schema: &ParserDB, diff --git a/src/impls/translator_impls/insert.rs b/src/impls/translator_impls/insert.rs index d9d5250626..b60665e0df 100644 --- a/src/impls/translator_impls/insert.rs +++ b/src/impls/translator_impls/insert.rs @@ -799,6 +799,7 @@ fn apply_column_type_conversions( /// Wildcards and multi-column aliases are skipped (unmappable). All three /// column-typed rewrites share this walk so they cover exactly the same source /// shapes. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn for_each_insert_position( body: &mut SetExpr, column_names: &[String], diff --git a/src/impls/translator_impls/plpgsql/translator.rs b/src/impls/translator_impls/plpgsql/translator.rs index d1cf029577..a83e78d442 100644 --- a/src/impls/translator_impls/plpgsql/translator.rs +++ b/src/impls/translator_impls/plpgsql/translator.rs @@ -534,6 +534,7 @@ impl PlPgSqlTranslator { /// or an error for a multi-event trigger. /// - `TG_TABLE_NAME` becomes the table name literal. /// - Any other `TG_*` name produces an error naming the variable. + #[cfg_attr(feature = "std", recursive::recursive)] fn fold_trigger_specials(expr: &mut Expr, context: &PlPgSqlContext) -> Result<(), Error> { use crate::impls::expr_helpers::mutate_expr_children; @@ -899,6 +900,7 @@ impl PlPgSqlTranslator { vec![Statement::Delete(new_delete)] } + #[cfg_attr(feature = "std", recursive::recursive)] fn inject_with_into_in_subqueries( expr: &mut Expr, with: &sqlparser::ast::With, @@ -959,6 +961,7 @@ impl PlPgSqlTranslator { Self::set_expr_references_ctes(&query.body, &normalized) } + #[cfg_attr(feature = "std", recursive::recursive)] fn set_expr_references_ctes(body: &SetExpr, cte_names: &[String]) -> bool { match body { SetExpr::Select(select) => Self::select_references_ctes(select, cte_names), @@ -1110,6 +1113,7 @@ impl PlPgSqlTranslator { new_delete } + #[cfg_attr(feature = "std", recursive::recursive)] fn transform_set_expr( set_expr: &mut SetExpr, context: &mut PlPgSqlContext, @@ -1199,6 +1203,7 @@ impl PlPgSqlTranslator { } #[allow(clippy::too_many_lines)] + #[cfg_attr(feature = "std", recursive::recursive)] fn transform_expr( expr: &mut Expr, context: &mut PlPgSqlContext, diff --git a/src/impls/translator_impls/postgis.rs b/src/impls/translator_impls/postgis.rs index ec38391e02..ba8c0b291a 100644 --- a/src/impls/translator_impls/postgis.rs +++ b/src/impls/translator_impls/postgis.rs @@ -547,6 +547,7 @@ pub(crate) fn single_base_table(twj: &TableWithJoins) -> Option<(String, Option< /// (no `OR`, no `NOT`). Conjuncts may themselves contain `OR` or `NOT` /// internally - what we forbid is `OR` or `NOT` at the boolean root, because /// that would mean the spatial predicate isn't a required filter. +#[cfg_attr(feature = "std", recursive::recursive)] fn is_safe_top_level_and(expr: &sqlparser::ast::Expr) -> bool { match expr { sqlparser::ast::Expr::BinaryOp { op: BinaryOperator::And, left, right } => { @@ -566,6 +567,7 @@ fn flatten_top_level_and(expr: &sqlparser::ast::Expr) -> Vec<&sqlparser::ast::Ex out } +#[cfg_attr(feature = "std", recursive::recursive)] fn walk_and<'a>(expr: &'a sqlparser::ast::Expr, out: &mut Vec<&'a sqlparser::ast::Expr>) { if let sqlparser::ast::Expr::BinaryOp { op: BinaryOperator::And, left, right } = expr { walk_and(left, out); diff --git a/src/impls/translator_impls/query.rs b/src/impls/translator_impls/query.rs index 1d45adf1b9..12c763ed3e 100644 --- a/src/impls/translator_impls/query.rs +++ b/src/impls/translator_impls/query.rs @@ -43,6 +43,7 @@ pub(crate) const DISTINCT_ON_ROWNUM_ALIAS: &str = "__pg2sqlite_rn"; impl crate::traits::translator::TranslatorWithContext for Query { type SQLiteEntry = Query; + #[cfg_attr(feature = "std", recursive::recursive)] fn translate_with_warnings( &self, schema: &sql_traits::structs::ParserDB, @@ -395,6 +396,7 @@ fn reject_compound_ordered_by_an_input_expression( /// The output column names of a set operation, taken from its leftmost /// branch, which is where PostgreSQL takes them from. +#[cfg_attr(feature = "std", recursive::recursive)] fn compound_output_names(body: &SetExpr) -> Vec { match body { SetExpr::SetOperation { left, .. } => compound_output_names(left), @@ -1003,6 +1005,7 @@ pub(crate) fn is_aggregate_call(func: &Function) -> bool { aggregate_function_name(func).is_some_and(|name| AGGREGATE_NAMES.contains(&name.as_str())) } +#[cfg_attr(feature = "std", recursive::recursive)] fn is_aggregate_expression(expr: &Expr) -> bool { match expr { Expr::Function(func) => func.over.is_none() && is_aggregate_call(func), diff --git a/src/impls/translator_impls/rls.rs b/src/impls/translator_impls/rls.rs index c201e754a0..b7cc9cdb17 100644 --- a/src/impls/translator_impls/rls.rs +++ b/src/impls/translator_impls/rls.rs @@ -308,6 +308,7 @@ const RLS_VIOLATION_ERROR: &str = "new row violates row-level security policy"; /// True when `expr` is the boolean literal `true`, possibly wrapped in /// redundant parentheses. Such a predicate constrains nothing. +#[cfg_attr(feature = "std", recursive::recursive)] fn is_true_literal(expr: &Expr) -> bool { match expr { Expr::Value(value) => matches!(value.value, Value::Boolean(true)), @@ -903,6 +904,7 @@ fn collect_patterns_from_function(func: &Function, patterns: &mut Vec, @@ -963,6 +965,7 @@ fn collect_patterns_from_select( } } +#[cfg_attr(feature = "std", recursive::recursive)] fn collect_patterns_from_set_expr( set_expr: &sqlparser::ast::SetExpr, patterns: &mut Vec, @@ -985,6 +988,7 @@ fn collect_patterns_from_set_expr( } } +#[cfg_attr(feature = "std", recursive::recursive)] fn collect_patterns_from_query( query: &sqlparser::ast::Query, patterns: &mut Vec, @@ -1032,6 +1036,7 @@ fn collect_patterns_from_query( } /// Collect all session variable patterns used by an expression tree. +#[cfg_attr(feature = "std", recursive::recursive)] fn collect_session_variable_patterns(expr: &Expr, patterns: &mut Vec) { match expr { Expr::Identifier(ident) if ident.value.eq_ignore_ascii_case("current_user") => { @@ -1233,6 +1238,7 @@ fn transform_window_frame_bound_rls( } #[allow(clippy::too_many_lines)] +#[cfg_attr(feature = "std", recursive::recursive)] fn transform_expr_generic( expr: &Expr, options: &crate::options::TranslationContext<'_>, @@ -1550,6 +1556,7 @@ fn transform_query( /// before; the `SetOperation` case recurses into both arms so that an outer /// table reference inside any arm (e.g. a `UNION ALL`) is reached; the `Query` /// case calls `transform_query` on the nested query. +#[cfg_attr(feature = "std", recursive::recursive)] fn transform_set_expr( set_expr: &sqlparser::ast::SetExpr, context: &SubqueryTransformContext<'_>, @@ -1815,6 +1822,7 @@ fn transform_function_arg_with_rls( } } +#[cfg_attr(feature = "std", recursive::recursive)] fn transform_outer_table_refs( expr: &Expr, outer_table_name: &str, @@ -1879,6 +1887,7 @@ fn rls_read_predicate( /// Collects the unqualified name of every table that appears in a direct /// subquery FROM clause within `expr`, aliased or not. Both spellings are read /// through the table's view, so both can close a cycle. +#[cfg_attr(feature = "std", recursive::recursive)] fn collect_subquery_tables(expr: &Expr, out: &mut Vec) { match expr { Expr::Exists { subquery, .. } | Expr::Subquery(subquery) => { diff --git a/src/impls/translator_impls/statement.rs b/src/impls/translator_impls/statement.rs index 12e55f40eb..131b60078d 100644 --- a/src/impls/translator_impls/statement.rs +++ b/src/impls/translator_impls/statement.rs @@ -1706,6 +1706,7 @@ impl crate::traits::translator::TranslatorWithContext for Statement { options: &crate::options::TranslationContext<'_>, emit: &mut dyn FnMut(crate::warnings::TranslationWarning), ) -> Result { + crate::impls::stack_headroom::ensure(); let mut translated: Vec = match self { Self::CreateTable(create_table) => { translate_create_table(create_table, schema, options, emit)? diff --git a/src/impls/translator_impls/uuid.rs b/src/impls/translator_impls/uuid.rs index 2503280056..dfbcd10415 100644 --- a/src/impls/translator_impls/uuid.rs +++ b/src/impls/translator_impls/uuid.rs @@ -131,6 +131,7 @@ pub(crate) fn make_uuid_conversion_call(arg: Expr, options: &Pg2SqliteOptions) - /// `(SELECT '')` is the literal written another way, so the conversion /// goes inside the subquery. A subquery that reads a relation is left alone: /// its value may already be a BLOB, and wrapping one would convert twice. +#[cfg_attr(feature = "std", recursive::recursive)] pub(crate) fn maybe_wrap_text_uuid_literal( expr: Expr, options: &Pg2SqliteOptions, diff --git a/tests/test_translator_stack_depth.rs b/tests/test_translator_stack_depth.rs new file mode 100644 index 0000000000..c8ac313af5 --- /dev/null +++ b/tests/test_translator_stack_depth.rs @@ -0,0 +1,84 @@ +//! A long left-deep chain translates on an ordinary thread instead of +//! overflowing its stack, which aborts the process. + +#[path = "helpers/run_translated.rs"] +mod run_translated_helper; + +use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +use run_translated_helper::run_translated_with; +use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; + +/// Past the 13 levels a debug build and the 74 a release build survived, and +/// short of where `sqlparser`'s derived `Clone` aborts in a debug build. +const OPERANDS: usize = 150; + +/// Past the 15 arms a debug build survived, and short of where `sqlparser` +/// clones a `SetExpr` chain into an abort in a debug build. +const ARMS: usize = 80; + +const ROWS: &str = "CREATE TABLE t (a int, b text); INSERT INTO t VALUES (1, 'x');"; + +/// Runs `f` on a thread with the 2 MiB stack a spawned Rust thread gets by +/// default. +fn on_a_default_thread(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(f) + .expect("spawn") + .join() + .expect("the translation thread") +} + +fn chain(term: &str, separator: &str, length: usize) -> String { + vec![term; length].join(separator) +} + +/// The single value the last statement of `pg` answers in SQLite. +fn answer(pg: String) -> Option { + on_a_default_thread(move || { + let mut rows = run_translated_with(&pg, &Pg2SqliteOptions::default()); + assert_eq!(rows.len(), 1, "one row"); + rows.remove(0) + }) +} + +#[test] +fn a_long_arithmetic_chain_translates() { + let pg = format!("{ROWS} SELECT {} FROM t;", chain("a", " + ", OPERANDS)); + assert_eq!(answer(pg), Some(OPERANDS.to_string())); +} + +#[test] +fn a_long_boolean_chain_translates() { + let pg = format!("{ROWS} SELECT count(*) FROM t WHERE {};", chain("a > 0", " AND ", OPERANDS)); + assert_eq!(answer(pg), Some("1".to_string())); +} + +#[test] +fn a_long_concatenation_translates() { + let pg = format!("{ROWS} SELECT length({}) FROM t;", chain("b", " || ", OPERANDS)); + assert_eq!(answer(pg), Some(OPERANDS.to_string())); +} + +#[test] +fn a_long_set_operation_chain_translates() { + let pg = format!( + "{ROWS} SELECT count(*) FROM ({}) AS u;", + chain("SELECT a FROM t", " UNION ALL ", ARMS) + ); + assert_eq!(answer(pg), Some(ARMS.to_string())); +} + +#[test] +fn a_long_arithmetic_chain_reverses() { + let sqlite = format!("SELECT {} FROM t", chain("a", " + ", OPERANDS)); + let postgres = on_a_default_thread(move || { + let translator = Pg2Sqlite::default().sql("CREATE TABLE t (a int, b text);").unwrap(); + let schema = translator.build_schema().unwrap(); + let statements = + translator.reverse_sql(&sqlite, &schema, &Pg2SqliteOptions::default()).unwrap(); + statements.iter().map(ToString::to_string).collect::>().join(";") + }); + assert_eq!(postgres.matches(" + ").count(), OPERANDS - 1, "{postgres}"); + Parser::parse_sql(&PostgreSqlDialect {}, &postgres).expect("reverse output reparses"); +} From 481077e8da521cf698cf96dd9ec78eeeace7450c Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 25 Oct 2026 22:46:25 +0100 Subject: [PATCH 2/2] Cover deep set operations in insert sources --- tests/test_translator_stack_depth.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_translator_stack_depth.rs b/tests/test_translator_stack_depth.rs index c8ac313af5..ed008339a1 100644 --- a/tests/test_translator_stack_depth.rs +++ b/tests/test_translator_stack_depth.rs @@ -69,6 +69,15 @@ fn a_long_set_operation_chain_translates() { assert_eq!(answer(pg), Some(ARMS.to_string())); } +#[test] +fn a_long_set_operation_chain_as_insert_source_translates() { + let pg = format!( + "{ROWS} INSERT INTO t (a) {}; SELECT count(*) FROM t;", + chain("SELECT a FROM t", " UNION ALL ", ARMS) + ); + assert_eq!(answer(pg), Some((1 + ARMS).to_string())); +} + #[test] fn a_long_arithmetic_chain_reverses() { let sqlite = format!("SELECT {} FROM t", chain("a", " + ", OPERANDS));