Skip to main content

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;