fraiseql_core/graphql/complexity.rs
1// GraphQL query complexity analysis — AST-based to prevent DoS attacks.
2//
3// Uses `graphql-parser` to walk the document tree, correctly handling
4// operation names, arguments, fragment spreads, and aliases (which a
5// character-scan approach cannot distinguish from field names).
6
7use std::collections::{HashMap, HashSet};
8
9use graphql_parser::query::{
10 Definition, Document, Field, FragmentDefinition, OperationDefinition, Selection, SelectionSet,
11};
12
13/// Parse a GraphQL query source into a [`graphql_parser`] document.
14///
15/// This is the **only** place in the workspace that calls
16/// [`graphql_parser::parse_query`]. Every other parse — the validator, the
17/// analyzer, the subscription name extractor, the REST `validate` endpoint —
18/// comes through here, because the parser can panic on input a client controls
19/// (see below) and a guard is only worth having if nothing can walk around it.
20/// `tools/check-graphql-parse-sites.sh` fails the build if a second call site
21/// appears.
22///
23/// Exposed so the server's HTTP handler can parse the request exactly once
24/// and reuse the AST for both complexity validation (via
25/// [`RequestValidator::validate_query_doc`]) and the downstream execution path
26/// — replacing the previous "validate then re-parse" pattern. See F001 in
27/// `docs/history/IMPROVEMENTS.md`.
28///
29/// # Errors
30///
31/// Returns [`ComplexityValidationError::MalformedQuery`] when the source fails
32/// to parse as a GraphQL query document, when it carries block-string
33/// indentation `graphql-parser` cannot handle (#976), or when the parser panics.
34pub fn parse_graphql_document(
35 query: &str,
36) -> Result<Document<'_, String>, ComplexityValidationError> {
37 if query.trim().is_empty() {
38 return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
39 }
40 if !block_string_indent_is_supported(query) {
41 return Err(ComplexityValidationError::MalformedQuery(
42 "unsupported block string indentation: a line inside this document is indented \
43 with non-ASCII whitespace, which the GraphQL parser cannot process (#976). \
44 Indent block strings with spaces or tabs."
45 .to_string(),
46 ));
47 }
48 // Defence in depth for the rest of the parser. `graphql-parser` 0.4.1 is
49 // unmaintained, and #976 showed a 27-byte document is enough to panic it. The
50 // guard above closes that specific hole soundly; this closes the class, so an
51 // unknown parser panic costs the client a rejected query rather than costing
52 // everyone on that connection their response. libFuzzer's panic hook aborts
53 // before unwinding, so this does not hide anything from the fuzz campaign.
54 match std::panic::catch_unwind(|| {
55 graphql_parser::parse_query::<String>(query)
56 .map_err(|e| ComplexityValidationError::MalformedQuery(format!("{e}")))
57 }) {
58 Ok(result) => result,
59 Err(_) => Err(ComplexityValidationError::MalformedQuery(
60 "the GraphQL parser failed on this document".to_string(),
61 )),
62 }
63}
64
65/// Whether every line's indentation is ASCII, which is what makes
66/// `graphql-parser`'s block-string handling safe on this document (#976).
67///
68/// The parser measures a block string's common indent in **bytes**
69/// (`line.len() - line.trim_start().len()`) but strips it with `str::trim_start`,
70/// which is Unicode-aware, then slices the line at that byte offset. Indent a
71/// line with U+00A0 and the offset lands mid-codepoint, and `&line[indent..]`
72/// panics — on a document that is well-formed by the GraphQL spec.
73///
74/// When every line's leading whitespace run is ASCII, each such char is one byte,
75/// so the computed indent equals the character count and every offset up to it is
76/// a char boundary. The common indent is a minimum over those lines and the
77/// parser only slices lines at or past it, so no slice can split a codepoint.
78///
79/// Only *indentation* is checked. Non-ASCII whitespace is not GraphQL whitespace,
80/// so elsewhere it is string content — which the parser copies without slicing,
81/// and which real queries legitimately carry.
82fn block_string_indent_is_supported(query: &str) -> bool {
83 if !query.contains("\"\"\"") {
84 return true;
85 }
86 !query
87 .lines()
88 .any(|line| line.chars().take_while(|c| c.is_whitespace()).any(|c| !c.is_ascii()))
89}
90
91/// Default maximum number of aliases per query (alias amplification protection).
92///
93/// This constant is the single source of truth used by [`ComplexityConfig`],
94/// [`RequestValidator`], the server HTTP handler, and the CLI `explain` command.
95pub const DEFAULT_MAX_ALIASES: usize = 30;
96
97/// Maximum number of variables per request (`DoS` protection).
98///
99/// A single GraphQL request with thousands of variables can cause excessive memory
100/// allocation during deserialization and variable injection. This constant caps
101/// the number of top-level keys in the `variables` JSON object.
102pub const MAX_VARIABLES_COUNT: usize = 1_000;
103
104/// Configuration for query complexity limits.
105#[derive(Debug, Clone)]
106pub struct ComplexityConfig {
107 /// Maximum query depth (nesting level) — default: 10
108 pub max_depth: usize,
109 /// Maximum complexity score — default: 100
110 pub max_complexity: usize,
111 /// Maximum number of field aliases per query — default: 30
112 pub max_aliases: usize,
113}
114
115impl Default for ComplexityConfig {
116 fn default() -> Self {
117 Self {
118 max_depth: 10,
119 max_complexity: 100,
120 max_aliases: DEFAULT_MAX_ALIASES,
121 }
122 }
123}
124
125/// Metrics returned by the AST-based analyzer.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct QueryMetrics {
128 /// Maximum selection-set nesting depth.
129 pub depth: usize,
130 /// Total complexity score (accounts for pagination multipliers).
131 pub complexity: usize,
132 /// Number of aliased fields in the document.
133 pub alias_count: usize,
134}
135
136/// GraphQL query validation error types (depth, complexity, aliases).
137#[derive(Debug, thiserror::Error, Clone)]
138#[non_exhaustive]
139pub enum ComplexityValidationError {
140 /// Query exceeds maximum allowed depth.
141 #[error("Query exceeds maximum depth of {max_depth}: depth = {actual_depth}")]
142 QueryTooDeep {
143 /// Maximum allowed depth
144 max_depth: usize,
145 /// Actual query depth
146 actual_depth: usize,
147 },
148
149 /// Query exceeds maximum complexity score.
150 #[error("Query exceeds maximum complexity of {max_complexity}: score = {actual_complexity}")]
151 QueryTooComplex {
152 /// Maximum allowed complexity
153 max_complexity: usize,
154 /// Actual query complexity
155 actual_complexity: usize,
156 },
157
158 /// Query contains too many aliases (alias amplification attack).
159 #[error("Query exceeds maximum alias count of {max_aliases}: count = {actual_aliases}")]
160 TooManyAliases {
161 /// Maximum allowed alias count
162 max_aliases: usize,
163 /// Actual alias count
164 actual_aliases: usize,
165 },
166
167 /// Invalid query variables.
168 #[error("Invalid variables: {0}")]
169 InvalidVariables(String),
170
171 /// Malformed GraphQL query.
172 #[error("Malformed GraphQL query: {0}")]
173 MalformedQuery(String),
174}
175
176/// AST-based GraphQL request validator.
177///
178/// Uses `graphql-parser` to walk the full document tree. Correctly handles
179/// operation names, arguments, fragment spreads, inline fragments, and aliases —
180/// none of which a character-scan can distinguish from field names.
181#[derive(Debug, Clone)]
182pub struct RequestValidator {
183 /// Maximum query depth allowed.
184 max_depth: usize,
185 /// Maximum query complexity score allowed.
186 max_complexity: usize,
187 /// Maximum number of field aliases per query (alias amplification protection).
188 max_aliases_per_query: usize,
189 /// Enable query depth validation.
190 validate_depth: bool,
191 /// Enable query complexity validation.
192 validate_complexity: bool,
193}
194
195impl RequestValidator {
196 /// Create a new validator with default settings.
197 #[must_use]
198 pub fn new() -> Self {
199 Self::default()
200 }
201
202 /// Create from a `ComplexityConfig`.
203 #[must_use]
204 pub const fn from_config(config: &ComplexityConfig) -> Self {
205 Self {
206 max_depth: config.max_depth,
207 max_complexity: config.max_complexity,
208 max_aliases_per_query: config.max_aliases,
209 validate_depth: true,
210 validate_complexity: true,
211 }
212 }
213
214 /// Set maximum query depth.
215 #[must_use]
216 pub const fn with_max_depth(mut self, max_depth: usize) -> Self {
217 self.max_depth = max_depth;
218 self
219 }
220
221 /// Set maximum query complexity.
222 #[must_use]
223 pub const fn with_max_complexity(mut self, max_complexity: usize) -> Self {
224 self.max_complexity = max_complexity;
225 self
226 }
227
228 /// Enable/disable depth validation.
229 #[must_use]
230 pub const fn with_depth_validation(mut self, enabled: bool) -> Self {
231 self.validate_depth = enabled;
232 self
233 }
234
235 /// Enable/disable complexity validation.
236 #[must_use]
237 pub const fn with_complexity_validation(mut self, enabled: bool) -> Self {
238 self.validate_complexity = enabled;
239 self
240 }
241
242 /// Set maximum number of aliases per query.
243 #[must_use]
244 pub const fn with_max_aliases(mut self, max_aliases: usize) -> Self {
245 self.max_aliases_per_query = max_aliases;
246 self
247 }
248
249 /// Compute query metrics without enforcing any limits.
250 ///
251 /// Returns [`QueryMetrics`] for the query.
252 /// Used by CLI tooling (`explain`, `cost` commands) where raw metrics
253 /// are needed without a hard rejection.
254 ///
255 /// # Errors
256 ///
257 /// Returns [`ComplexityValidationError::MalformedQuery`] if the query cannot be parsed.
258 pub fn analyze(&self, query: &str) -> Result<QueryMetrics, ComplexityValidationError> {
259 self.analyze_with_variables(query, None)
260 }
261
262 /// Compute query metrics without enforcing any limits, resolving
263 /// variable-valued pagination arguments (`first: $n`) against `variables`.
264 ///
265 /// With `variables = None` an unresolvable pagination variable scores at
266 /// the fail-closed ceiling (#869); callers that have the request variables
267 /// must pass them or legitimate variable-paginated queries are over-scored.
268 ///
269 /// # Errors
270 ///
271 /// Returns [`ComplexityValidationError::MalformedQuery`] if the query cannot be parsed.
272 pub fn analyze_with_variables(
273 &self,
274 query: &str,
275 variables: Option<&serde_json::Value>,
276 ) -> Result<QueryMetrics, ComplexityValidationError> {
277 if query.trim().is_empty() {
278 return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
279 }
280 let document = parse_graphql_document(query)?;
281 Ok(self.document_metrics(&document, variables))
282 }
283
284 /// Compute depth, complexity, and alias count in a single memoizing pass.
285 ///
286 /// Resolving each fragment's contribution exactly once (and rejecting
287 /// fragment cycles) keeps this linear in document size regardless of
288 /// fragment-spread topology — without it, B-way branching across D chained
289 /// spreads costs B^D walks and the validation step itself becomes the `DoS`
290 /// (audit H4).
291 fn document_metrics<'a>(
292 &self,
293 document: &'a Document<'a, String>,
294 variables: Option<&'a serde_json::Value>,
295 ) -> QueryMetrics {
296 let fragments = collect_fragments(document);
297 let mut analyzer = DocumentAnalyzer::new(
298 document,
299 &fragments,
300 self.max_depth,
301 self.max_complexity,
302 variables,
303 );
304 analyzer.analyze_document(document)
305 }
306
307 /// Returns true when every configured validation knob is disabled and the
308 /// expensive AST parse can be skipped entirely.
309 ///
310 /// A validator is "fully disabled" only when depth, complexity, AND alias
311 /// checks are all off. The alias-amplification check is a distinct `DoS`
312 /// vector — `max_aliases_per_query == 0` disables it, any other value
313 /// keeps it active even when depth/complexity validation are turned off.
314 #[must_use]
315 pub const fn is_no_op(&self) -> bool {
316 !self.validate_depth && !self.validate_complexity && self.max_aliases_per_query == 0
317 }
318
319 /// Validate a GraphQL query string, enforcing configured limits.
320 ///
321 /// # Errors
322 ///
323 /// Returns [`ComplexityValidationError`] if the query violates any validation rules.
324 pub fn validate_query(&self, query: &str) -> Result<(), ComplexityValidationError> {
325 if query.trim().is_empty() {
326 return Err(ComplexityValidationError::MalformedQuery("Empty query".to_string()));
327 }
328
329 if self.is_no_op() {
330 return Ok(());
331 }
332
333 let document = parse_graphql_document(query)?;
334 self.validate_query_doc(&document, None)
335 }
336
337 /// Validate an already-parsed GraphQL document, enforcing configured limits.
338 ///
339 /// This is the AST-only entry point used by the server's HTTP handler so the
340 /// document is parsed exactly once per request (rather than once by the
341 /// validator and once by the executor's matcher). See F001 in
342 /// `docs/history/IMPROVEMENTS.md`.
343 ///
344 /// # Errors
345 ///
346 /// Returns [`ComplexityValidationError`] if the document violates depth,
347 /// complexity, or alias-amplification limits.
348 pub fn validate_query_doc<'a>(
349 &self,
350 document: &'a Document<'a, String>,
351 variables: Option<&'a serde_json::Value>,
352 ) -> Result<(), ComplexityValidationError> {
353 if self.is_no_op() {
354 return Ok(());
355 }
356
357 let metrics = self.document_metrics(document, variables);
358
359 if self.validate_depth && metrics.depth > self.max_depth {
360 return Err(ComplexityValidationError::QueryTooDeep {
361 max_depth: self.max_depth,
362 actual_depth: metrics.depth,
363 });
364 }
365
366 if self.validate_complexity && metrics.complexity > self.max_complexity {
367 return Err(ComplexityValidationError::QueryTooComplex {
368 max_complexity: self.max_complexity,
369 actual_complexity: metrics.complexity,
370 });
371 }
372
373 if metrics.alias_count > self.max_aliases_per_query {
374 return Err(ComplexityValidationError::TooManyAliases {
375 max_aliases: self.max_aliases_per_query,
376 actual_aliases: metrics.alias_count,
377 });
378 }
379
380 Ok(())
381 }
382
383 /// Validate variables JSON.
384 ///
385 /// # Errors
386 ///
387 /// Returns [`ComplexityValidationError`] if variables are not a JSON object or exceed
388 /// [`MAX_VARIABLES_COUNT`].
389 ///
390 /// # Panics
391 ///
392 /// Cannot panic in practice — the `expect` on `as_object()` is guarded
393 /// by a preceding `is_object()` check that returns `Err` first.
394 pub fn validate_variables(
395 &self,
396 variables: Option<&serde_json::Value>,
397 ) -> Result<(), ComplexityValidationError> {
398 if let Some(vars) = variables {
399 if !vars.is_object() {
400 return Err(ComplexityValidationError::InvalidVariables(
401 "Variables must be an object".to_string(),
402 ));
403 }
404 // Reason: `is_object()` check on the line above guarantees this is an object;
405 // `as_object()` cannot return None here.
406 let obj = vars.as_object().expect("invariant: vars.is_object() checked above");
407 if obj.len() > MAX_VARIABLES_COUNT {
408 return Err(ComplexityValidationError::InvalidVariables(format!(
409 "Too many variables: {} exceeds maximum of {}",
410 obj.len(),
411 MAX_VARIABLES_COUNT
412 )));
413 }
414 }
415 Ok(())
416 }
417}
418
419/// Hard ceiling on fragment-spread chain length the analyzer resolves before
420/// declaring the document over-limit. Mirrors the pre-memoization 32-hop
421/// recursion guard: a document nesting fragment spreads beyond this is rejected
422/// (real APIs never approach it), which also bounds the analyzer's own
423/// recursion depth so a long fragment chain cannot overflow the stack. Fragment
424/// cycles are caught earlier by the [`DocumentAnalyzer`] `visiting` set.
425const FRAGMENT_SPREAD_DEPTH_LIMIT: usize = 32;
426
427/// Depth, complexity, and alias contribution of one selection set (or one
428/// fragment). Computed once per fragment and memoized by name so a fragment
429/// spread costs a map lookup instead of a re-walk.
430#[derive(Debug, Clone, Copy)]
431struct SelectionMetrics {
432 depth: usize,
433 complexity: usize,
434 aliases: usize,
435}
436
437/// Single-pass, memoizing analyzer shared by depth, complexity, and alias
438/// counting.
439///
440/// Each fragment's metrics are resolved exactly once (on demand) and cached by
441/// name; fragment cycles are detected via `visiting` and treated as over-limit
442/// rather than recursed into. This is what bounds the validator's own cost —
443/// the previous per-spread re-walk made the validation step itself a `DoS`:
444/// B-way branching across D chained fragment spreads cost B^D walks (audit H4),
445/// and aliases hidden inside spread fragments were never counted, bypassing the
446/// alias-amplification limit (audit H4, alias counter).
447struct DocumentAnalyzer<'a> {
448 by_name: HashMap<&'a str, &'a FragmentDefinition<'a, String>>,
449 memo: HashMap<&'a str, SelectionMetrics>,
450 visiting: HashSet<&'a str>,
451 max_depth: usize,
452 max_complexity: usize,
453 /// Request variables for resolving variable-valued pagination arguments
454 /// (#869). `None` means unavailable: a variable-valued `first`/`limit`/
455 /// `take`/`last` then scores the clamp ceiling (fail closed), never 1.
456 variables: Option<&'a serde_json::Map<String, serde_json::Value>>,
457 /// Integer defaults the document's operations declare for their variables: an
458 /// omitted pagination variable takes its default (§ 6.4.1, #1504), so it scores
459 /// that value. Two operations declaring the same name keep the larger default.
460 defaults: HashMap<&'a str, i64>,
461}
462
463impl<'a> DocumentAnalyzer<'a> {
464 fn new(
465 document: &'a Document<'a, String>,
466 fragments: &[&'a FragmentDefinition<'a, String>],
467 max_depth: usize,
468 max_complexity: usize,
469 variables: Option<&'a serde_json::Value>,
470 ) -> Self {
471 let mut defaults: HashMap<&'a str, i64> = HashMap::new();
472 for def in &document.definitions {
473 let Definition::Operation(op) = def else {
474 continue;
475 };
476 for var in operation_variable_definitions(op) {
477 if let Some(graphql_parser::query::Value::Int(n)) = &var.default_value {
478 if let Some(n) = n.as_i64() {
479 let slot = defaults.entry(var.name.as_str()).or_insert(n);
480 *slot = (*slot).max(n);
481 }
482 }
483 }
484 }
485 let mut by_name = HashMap::with_capacity(fragments.len());
486 for frag in fragments {
487 // First definition wins, matching the previous `.find()` lookup.
488 by_name.entry(frag.name.as_str()).or_insert(*frag);
489 }
490 Self {
491 by_name,
492 memo: HashMap::new(),
493 visiting: HashSet::new(),
494 max_depth,
495 max_complexity,
496 variables: variables.and_then(serde_json::Value::as_object),
497 defaults,
498 }
499 }
500
501 /// Metrics that force rejection on both the depth and complexity gates,
502 /// used for fragment cycles and over-long spread chains (never executable).
503 const fn over_limit(&self) -> SelectionMetrics {
504 SelectionMetrics {
505 depth: self.max_depth.saturating_add(1),
506 complexity: self.max_complexity.saturating_add(1),
507 aliases: 0,
508 }
509 }
510
511 /// Reduce the whole document to a single [`QueryMetrics`].
512 ///
513 /// Depth is the max across every definition (operations *and* fragment
514 /// definitions, preserving legacy behaviour); complexity sums operations
515 /// only; the alias total sums operations only, where each fragment spread
516 /// contributes the fragment's own alias count *per occurrence* — that is
517 /// the alias-amplification fix (the old counter scored spreads as 0).
518 fn analyze_document(&mut self, document: &'a Document<'a, String>) -> QueryMetrics {
519 let mut depth = 0usize;
520 let mut complexity = 0usize;
521 let mut aliases = 0usize;
522 for def in &document.definitions {
523 match def {
524 Definition::Operation(op) => {
525 let m = self.selection_metrics(
526 operation_selection_set(op),
527 FRAGMENT_SPREAD_DEPTH_LIMIT,
528 );
529 depth = depth.max(m.depth);
530 complexity = complexity.saturating_add(m.complexity);
531 aliases = aliases.saturating_add(m.aliases);
532 },
533 Definition::Fragment(f) => {
534 let m = self.resolve_fragment(f.name.as_str(), FRAGMENT_SPREAD_DEPTH_LIMIT);
535 depth = depth.max(m.depth);
536 },
537 }
538 }
539 QueryMetrics {
540 depth,
541 complexity,
542 alias_count: aliases,
543 }
544 }
545
546 /// Memoized metrics for a fragment by name.
547 ///
548 /// `budget` is the remaining fragment-spread hops; it decrements on each
549 /// spread resolution and a cycle (`visiting`) or exhausted budget yields the
550 /// over-limit sentinel without recursing. Cut/cycle results are deliberately
551 /// not memoized — they are context-dependent, not the fragment's intrinsic
552 /// metric.
553 fn resolve_fragment(&mut self, name: &'a str, budget: usize) -> SelectionMetrics {
554 if let Some(m) = self.memo.get(name) {
555 return *m;
556 }
557 if self.visiting.contains(name) || budget == 0 {
558 return self.over_limit();
559 }
560 let Some(frag) = self.by_name.get(name).copied() else {
561 // Unknown fragment: preserve the legacy contribution — depth =
562 // max_depth (so the enclosing selection set trips the depth gate),
563 // complexity = 10, no aliases.
564 let m = SelectionMetrics {
565 depth: self.max_depth,
566 complexity: 10,
567 aliases: 0,
568 };
569 self.memo.insert(name, m);
570 return m;
571 };
572 self.visiting.insert(name);
573 let m = self.selection_metrics(&frag.selection_set, budget - 1);
574 self.visiting.remove(name);
575 self.memo.insert(name, m);
576 m
577 }
578
579 fn selection_metrics(
580 &mut self,
581 selection_set: &'a SelectionSet<'a, String>,
582 budget: usize,
583 ) -> SelectionMetrics {
584 let mut max_child_depth = 0usize;
585 let mut complexity = 0usize;
586 let mut aliases = 0usize;
587 for sel in &selection_set.items {
588 let child = match sel {
589 Selection::Field(field) => {
590 let self_alias = usize::from(field.alias.is_some());
591 if field.selection_set.items.is_empty() {
592 SelectionMetrics {
593 depth: 0,
594 complexity: 1,
595 aliases: self_alias,
596 }
597 } else {
598 let nested = self.selection_metrics(&field.selection_set, budget);
599 let multiplier = extract_limit_multiplier(
600 &field.arguments,
601 self.variables,
602 &self.defaults,
603 );
604 SelectionMetrics {
605 depth: nested.depth,
606 // Saturating: the multiplier compounds per nesting
607 // level, so a crafted deep query overflows `usize`.
608 // Saturating to `usize::MAX` keeps the score
609 // monotonic and fail-closed (the limit always
610 // rejects it) rather than wrapping under the limit.
611 complexity: 1usize
612 .saturating_add(nested.complexity.saturating_mul(multiplier)),
613 aliases: self_alias.saturating_add(nested.aliases),
614 }
615 }
616 },
617 Selection::InlineFragment(inline) => {
618 self.selection_metrics(&inline.selection_set, budget)
619 },
620 Selection::FragmentSpread(spread) => {
621 self.resolve_fragment(spread.fragment_name.as_str(), budget)
622 },
623 };
624 max_child_depth = max_child_depth.max(child.depth);
625 complexity = complexity.saturating_add(child.complexity);
626 aliases = aliases.saturating_add(child.aliases);
627 }
628 let depth = if selection_set.items.is_empty() {
629 0
630 } else {
631 max_child_depth.saturating_add(1)
632 };
633 SelectionMetrics {
634 depth,
635 complexity,
636 aliases,
637 }
638 }
639
640 /// Complexity contribution of a single field — `1` for a leaf, else
641 /// `1 + nested_complexity * pagination_multiplier`. This is exactly the
642 /// per-field score [`selection_metrics`](Self::selection_metrics) assigns, so
643 /// a cost walk with no `@cost` overrides equals the complexity score.
644 fn field_complexity(&mut self, field: &'a Field<'a, String>, budget: usize) -> usize {
645 if field.selection_set.items.is_empty() {
646 1
647 } else {
648 let nested = self.selection_metrics(&field.selection_set, budget);
649 let multiplier =
650 extract_limit_multiplier(&field.arguments, self.variables, &self.defaults);
651 1usize.saturating_add(nested.complexity.saturating_mul(multiplier))
652 }
653 }
654
655 /// Document cost for per-tenant budget enforcement.
656 ///
657 /// Equals the complexity score, except a top-level (root) operation field
658 /// whose name carries an `@cost` weight contributes exactly that weight (its
659 /// subtree is not walked). Root inline fragments/spreads fall back to the
660 /// normal complexity contribution.
661 fn document_cost<S: std::hash::BuildHasher>(
662 &mut self,
663 document: &'a Document<'a, String>,
664 root_weights: &HashMap<String, usize, S>,
665 ) -> usize {
666 let mut cost = 0usize;
667 for def in &document.definitions {
668 if let Definition::Operation(op) = def {
669 cost =
670 cost.saturating_add(self.root_cost(operation_selection_set(op), root_weights));
671 }
672 }
673 cost
674 }
675
676 fn root_cost<S: std::hash::BuildHasher>(
677 &mut self,
678 selection_set: &'a SelectionSet<'a, String>,
679 root_weights: &HashMap<String, usize, S>,
680 ) -> usize {
681 let mut cost = 0usize;
682 for sel in &selection_set.items {
683 let c = match sel {
684 Selection::Field(field) => root_weights
685 .get(field.name.as_str())
686 .copied()
687 .unwrap_or_else(|| self.field_complexity(field, FRAGMENT_SPREAD_DEPTH_LIMIT)),
688 Selection::InlineFragment(inline) => {
689 self.root_cost(&inline.selection_set, root_weights)
690 },
691 Selection::FragmentSpread(spread) => {
692 self.resolve_fragment(
693 spread.fragment_name.as_str(),
694 FRAGMENT_SPREAD_DEPTH_LIMIT,
695 )
696 .complexity
697 },
698 };
699 cost = cost.saturating_add(c);
700 }
701 cost
702 }
703}
704
705/// Estimate a parsed document's cost for per-tenant budget enforcement.
706///
707/// The cost equals the AST complexity score (so it is consistent with the
708/// `max_complexity` gate), except a top-level operation field whose name appears
709/// in `root_cost_weights` contributes exactly that `@cost` weight instead of its
710/// walked subtree. With an empty map the result equals
711/// [`RequestValidator::analyze`]'s `complexity`.
712#[must_use]
713pub fn estimate_query_cost<'a, S: std::hash::BuildHasher>(
714 document: &'a Document<'a, String>,
715 root_cost_weights: &HashMap<String, usize, S>,
716 variables: Option<&'a serde_json::Value>,
717) -> usize {
718 let fragments = collect_fragments(document);
719 // usize::MAX sentinels so a fragment cycle yields a saturating (rejected) cost
720 // rather than an artificial cap.
721 let mut analyzer =
722 DocumentAnalyzer::new(document, &fragments, usize::MAX, usize::MAX, variables);
723 analyzer.document_cost(document, root_cost_weights)
724}
725
726/// A direct read's projection, in the shape the cost estimator scores it.
727///
728/// The leaf fields this level materialises, the page it materialises them over, and
729/// every level composed **into the same statement** beneath it.
730///
731/// Every read this codebase issues today is [`flat`](Self::flat) — leaves, a page, no
732/// nesting — and scores exactly what a bare field count scored before this type
733/// existed. The nesting is here because the REST `?select=` fan-out is to be composed
734/// into the parent statement with `LATERAL`, and a composed statement materialises its
735/// embedded levels itself. Handed to a flat estimator it would be charged for the
736/// parent's own columns alone, and `per_request_max` would loosen by whatever the embed
737/// adds — silently, because the number the operator wrote would not have changed.
738#[derive(Debug, Clone, PartialEq, Eq, Default)]
739pub struct DirectReadProjection {
740 /// Scalar fields projected at this level.
741 pub leaf_fields: usize,
742 /// The page this level is read over. `None` is an unpaginated read and scores a
743 /// multiplier of 1, for the reason given on [`estimate_direct_read_cost`].
744 pub limit: Option<u32>,
745 /// The levels composed into the same statement beneath this one, each with its
746 /// own page — which is the point of `LATERAL`: the embedded `ORDER BY`/`LIMIT`
747 /// live inside the subquery, so a nested level's page is genuinely its own and
748 /// not a share of the parent's.
749 pub nested: Vec<DirectReadProjection>,
750}
751
752impl DirectReadProjection {
753 /// A projection with nothing composed beneath it.
754 ///
755 /// `leaf_fields` scalars over one page — the shape of every direct read today.
756 #[must_use]
757 pub const fn flat(leaf_fields: usize, limit: Option<u32>) -> Self {
758 Self {
759 leaf_fields,
760 limit,
761 nested: Vec::new(),
762 }
763 }
764
765 /// This level's complexity, and its subtree's.
766 ///
767 /// `DocumentAnalyzer::field_complexity`'s arithmetic, reached through a projection
768 /// rather than an AST: a level with nothing under it scores 1, and otherwise
769 /// `1 + subtree × page`, where the subtree is this level's own scalars plus the
770 /// score of each level composed beneath it.
771 ///
772 /// Sharing the arithmetic is the whole point — see [`estimate_direct_read_cost`].
773 fn complexity(&self) -> usize {
774 let inner = self
775 .nested
776 .iter()
777 .fold(self.leaf_fields, |acc, level| acc.saturating_add(level.complexity()));
778 if inner == 0 {
779 return 1;
780 }
781 let multiplier =
782 self.limit.map_or(1, |l| (l as usize).clamp(1, PAGINATION_MULTIPLIER_CEILING));
783 1usize.saturating_add(inner.saturating_mul(multiplier))
784 }
785}
786
787/// Estimate the cost of a **resolved direct read** — one that never had a GraphQL
788/// document to parse (#1351).
789///
790/// `[security.cost_budget] per_request_max` was enforced only in `run_gate1`, which
791/// takes a query string and scores its AST. Every read that does not carry a document
792/// — the whole REST direct-read surface and both gRPC read arms — therefore reached
793/// the database without the ceiling the operator compiled in. It was never a gRPC gap:
794/// routing gRPC through the engine could not fix it, because the engine's own
795/// direct-read path did not have it either.
796///
797/// # Why this mirrors the document scoring rather than inventing a formula
798///
799/// `per_request_max` is one number, written once by an operator who does not know
800/// which transport a client will pick. Scoring a direct read on a different scale
801/// would make the same cap refuse different requests depending on how they arrived —
802/// which is the defect class this is fixing, one layer up. So the arithmetic is the
803/// document path's, for the document a direct read *is*: a single root field over the
804/// projection it materialises.
805///
806/// - A query whose name carries an `@cost` weight contributes exactly that weight, subtree unwalked
807/// — as a root field does in `DocumentAnalyzer::root_cost`.
808/// - Otherwise `1 + subtree × multiplier`, the case of `DocumentAnalyzer::field_complexity`, where
809/// each projected scalar contributes 1 and each composed level contributes its own score.
810/// - `multiplier` is the page size clamped to `[1, 100]`, as `extract_limit_multiplier` clamps
811/// `first`/`limit`/`take`/`last`.
812///
813/// `limit: None` scores 1, which is what the document path scores for a list field
814/// carrying no pagination argument. That is inherited permissiveness, not a decision
815/// taken here: an unbounded list read is the most expensive one either path can issue,
816/// and it scores lowest on both. `max_page_size` (#421) is what bounds it today.
817///
818/// # Why the projection is a tree
819///
820/// Because the `?select=` fan-out is to become one composed statement, and the score
821/// has to survive that change without the ceiling moving. It does, exactly: for a
822/// parent of `p` scalars over a page of `M` with one embed of `c` scalars over a page
823/// of `m`, today's fan-out charges `1 + p·M` for the parent read and up to `M`
824/// sub-reads of `1 + c·m`, which is `1 + M·(p + 1 + c·m)` — and that is the number
825/// this function returns for the composed projection. The identity is not a
826/// coincidence and it is not arranged: it is what `field_complexity` already computed
827/// for the same shape, which is why the document path is the right formula to borrow
828/// rather than a second one to invent.
829///
830/// `a_composed_embed_scores_what_its_fan_out_charges` is that identity as a test, and
831/// `the_composed_score_is_the_document_paths_score` pins it against
832/// [`estimate_query_cost`] itself rather than against a restatement of it.
833#[must_use]
834pub fn estimate_direct_read_cost<S: std::hash::BuildHasher>(
835 query_name: &str,
836 root_cost_weights: &HashMap<String, usize, S>,
837 projection: &DirectReadProjection,
838) -> usize {
839 if let Some(weight) = root_cost_weights.get(query_name) {
840 return *weight;
841 }
842 projection.complexity()
843}
844
845/// The top-level selection set of any operation kind.
846fn operation_variable_definitions<'a>(
847 op: &'a graphql_parser::query::OperationDefinition<'a, String>,
848) -> &'a [graphql_parser::query::VariableDefinition<'a, String>] {
849 use graphql_parser::query::OperationDefinition as Op;
850 match op {
851 Op::SelectionSet(_) => &[],
852 Op::Query(q) => &q.variable_definitions,
853 Op::Mutation(m) => &m.variable_definitions,
854 Op::Subscription(s) => &s.variable_definitions,
855 }
856}
857
858const fn operation_selection_set<'a>(
859 op: &'a OperationDefinition<'a, String>,
860) -> &'a SelectionSet<'a, String> {
861 match op {
862 OperationDefinition::Query(q) => &q.selection_set,
863 OperationDefinition::Mutation(m) => &m.selection_set,
864 OperationDefinition::Subscription(s) => &s.selection_set,
865 OperationDefinition::SelectionSet(ss) => ss,
866 }
867}
868
869impl Default for RequestValidator {
870 fn default() -> Self {
871 Self {
872 max_depth: 10,
873 max_complexity: 100,
874 max_aliases_per_query: DEFAULT_MAX_ALIASES,
875 validate_depth: true,
876 validate_complexity: true,
877 }
878 }
879}
880
881/// Collect all fragment definitions from a parsed document.
882fn collect_fragments<'a>(
883 document: &'a Document<'a, String>,
884) -> Vec<&'a FragmentDefinition<'a, String>> {
885 document
886 .definitions
887 .iter()
888 .filter_map(|def| {
889 if let Definition::Fragment(f) = def {
890 Some(f)
891 } else {
892 None
893 }
894 })
895 .collect()
896}
897
898/// Extract pagination limit from field arguments to use as a cost multiplier.
899/// Upper clamp for the pagination multiplier — also the fail-closed score
900/// for a variable-valued pagination argument that cannot be resolved
901/// (#869): scoring it 1 is what let `first: $n` bypass `max_complexity`.
902///
903/// Module-level since #1351, because the direct-read estimator has to clamp the same
904/// way the document estimator does: a cap the operator wrote once must mean the same
905/// number whether or not the request arrived as a GraphQL document.
906const PAGINATION_MULTIPLIER_CEILING: usize = 100;
907
908fn extract_limit_multiplier(
909 arguments: &[(String, graphql_parser::query::Value<String>)],
910 variables: Option<&serde_json::Map<String, serde_json::Value>>,
911 defaults: &HashMap<&str, i64>,
912) -> usize {
913 for (name, value) in arguments {
914 if matches!(name.as_str(), "first" | "limit" | "take" | "last") {
915 match value {
916 graphql_parser::query::Value::Int(n) => {
917 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
918 // Reason: value is clamped to [1, 100] immediately after; truncation and
919 // sign loss are safe
920 let limit = n.as_i64().unwrap_or(10) as usize;
921 return limit.clamp(1, PAGINATION_MULTIPLIER_CEILING);
922 },
923 graphql_parser::query::Value::Variable(var) => {
924 // A supplied value wins, `null` included (it scores the ceiling);
925 // an omitted variable takes its declared default.
926 let supplied = variables.and_then(|vars| vars.get(var));
927 return supplied
928 .map_or_else(
929 || defaults.get(var.as_str()).copied(),
930 serde_json::Value::as_i64,
931 )
932 .map_or(PAGINATION_MULTIPLIER_CEILING, |n| {
933 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
934 // Reason: value is clamped to [1, 100] immediately after; truncation
935 // and sign loss are safe
936 let limit = n as usize;
937 limit.clamp(1, PAGINATION_MULTIPLIER_CEILING)
938 });
939 },
940 _ => {},
941 }
942 }
943 }
944 1
945}
946
947#[cfg(test)]
948#[path = "complexity_tests.rs"]
949mod complexity_tests;