Skip to main content

ctl_core/input/
limits.rs

1//! How much a text may hold before [`Input::parse`](super::Input::parse)
2//! refuses it, and who decides. The order is on [`Limits`]. Three fields hold
3//! a text of every format: `max_reader_input_bytes`, `max_depth`, and
4//! `max_nodes`. The others name YAML syntax and exist only with `input-yaml`.
5
6use std::fmt;
7#[cfg(feature = "input-yaml")]
8use std::path::Path;
9use std::sync::{PoisonError, RwLock};
10
11use serde::Deserialize;
12#[cfg(feature = "input-yaml")]
13use serde_saphyr::Budget;
14#[cfg(feature = "input-yaml")]
15use serde_saphyr::budget::BudgetBreach;
16
17#[cfg(feature = "input-yaml")]
18use super::{Input, InputError};
19
20/// Where a repository overrides limits, relative to its root.
21#[cfg(feature = "input-yaml")]
22pub const REPO_FILE: &str = ".ctl/input.yaml";
23
24macro_rules! limits {
25    (
26        shared { $( $(#[$sdoc:meta])* $sfield:ident = $sdefault:ident, )* }
27        yaml { $( $(#[$ydoc:meta])* $yfield:ident: $yty:ty, )* }
28    ) => {
29        /// Overrides for the limits a text is held to, one per field. `None`
30        /// leaves the field to the next tier.
31        ///
32        /// Each field is decided on its own, highest first:
33        ///
34        /// 1. the call: [`Input::limits`](super::Input::limits);
35        /// 2. with `input-yaml`, the repository: `limits:` in
36        ///    `.ctl/input.yaml`, installed by `use_repo_limits`;
37        /// 3. the process: [`set_default_limits`], or `App::input_limits`;
38        /// 4. the default: ctl-core's for the three fields every format
39        ///    reads, and serde-saphyr's for the YAML-only fields.
40        ///
41        /// A repository beats the CLI's default because the user owns the
42        /// repository, as a user's environment beats the CLI's built-in width.
43        ///
44        /// The field set follows the enabled format features.
45        /// `max_reader_input_bytes`, `max_depth`, and `max_nodes` exist in
46        /// every build, and the YAML-only fields exist with `input-yaml`.
47        /// Cargo enables a feature for every crate in a build, so another
48        /// crate can add fields to a build of yours. Build a value with
49        /// `..Limits::NONE`, as in `Limits { max_depth: Some(8),
50        /// ..Limits::NONE }`, and it compiles whatever the build enables.
51        ///
52        /// `enforce_alias_anchor_ratio` is not here: the ratio check is one
53        /// comparison after scanning, so turning it off saves nothing. It
54        /// stays on; its multiplier and minimum alias count are overridable.
55        #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
56        #[serde(deny_unknown_fields, default)]
57        pub struct Limits {
58            $( $(#[$sdoc])* pub $sfield: Option<usize>, )*
59            $( $(#[$ydoc])* #[cfg(feature = "input-yaml")] pub $yfield: Option<$yty>, )*
60        }
61
62        impl Limits {
63            /// No overrides: every field falls through.
64            pub const NONE: Self = Self {
65                $( $sfield: None, )*
66                $( #[cfg(feature = "input-yaml")] $yfield: None, )*
67            };
68
69            /// `self`, with each unset field taken from `lower`.
70            #[must_use]
71            pub fn or(self, lower: &Self) -> Self {
72                Self {
73                    $( $sfield: self.$sfield.or(lower.$sfield), )*
74                    $( #[cfg(feature = "input-yaml")] $yfield: self.$yfield.or(lower.$yfield), )*
75                }
76            }
77
78            /// serde-saphyr's settings for these limits. An unset shared
79            /// field takes ctl-core's default, and an unset YAML-only field
80            /// takes serde-saphyr's.
81            #[cfg(feature = "input-yaml")]
82            pub(super) fn budget(&self) -> Budget {
83                let bounds = self.bounds();
84                let mut budget = Budget::default();
85                budget.max_reader_input_bytes = Some(bounds.input_bytes);
86                budget.max_depth = bounds.depth;
87                budget.max_nodes = bounds.nodes;
88                $( if let Some(value) = self.$yfield {
89                    budget.$yfield = value;
90                } )*
91                budget
92            }
93
94            /// Each set field that is looser than its default.
95            #[cfg(feature = "input-yaml")]
96            fn loosened(&self) -> Vec<Loosened> {
97                let mut loosened = Vec::new();
98                $( if let Some(value) = self.$sfield
99                    && value > Self::$sdefault
100                {
101                    loosened.push(Loosened {
102                        field: stringify!($sfield),
103                        value: value.to_string(),
104                        default: Self::$sdefault.to_string(),
105                    });
106                } )*
107                let default = Budget::default();
108                $( if let Some(value) = self.$yfield
109                    && value > default.$yfield
110                {
111                    loosened.push(Loosened {
112                        field: stringify!($yfield),
113                        value: value.to_string(),
114                        default: default.$yfield.to_string(),
115                    });
116                } )*
117                loosened
118            }
119        }
120    };
121}
122
123limits! {
124    shared {
125        /// Bytes of input text. Also checked for text already in memory.
126        max_reader_input_bytes = DEFAULT_MAX_READER_INPUT_BYTES,
127        /// Nesting of YAML sequences and mappings, or of TOML arrays and
128        /// tables, with the root table as 1.
129        max_depth = DEFAULT_MAX_DEPTH,
130        /// YAML sequences, mappings, and scalars in total; in TOML, the root
131        /// table and every table, array, and value.
132        max_nodes = DEFAULT_MAX_NODES,
133    }
134    yaml {
135        /// Comment events buffered while resolving an ambiguous entry.
136        max_buffered_comment_events: usize,
137        /// Characters scanned to resolve a simple key.
138        simple_key_max_lookahead: usize,
139        /// Simultaneously nested flow collections.
140        flow_nesting_limit: usize,
141        /// Parser events in total.
142        max_events: usize,
143        /// Alias (`*ref`) events.
144        max_aliases: usize,
145        /// Distinct anchor (`&anchor`) definitions.
146        max_anchors: usize,
147        /// Event copies retained for anchors.
148        max_recorded_anchor_events: usize,
149        /// Bytes of string payload retained for anchors.
150        max_recorded_anchor_bytes: usize,
151        /// Nested expansions in one property interpolation.
152        max_property_expansion_depth: usize,
153        /// Bytes inspected across all property interpolation.
154        max_total_property_interpolation_work: usize,
155        /// Nesting of `!include`.
156        max_inclusion_depth: u32,
157        /// YAML documents in one text.
158        max_documents: usize,
159        /// Bytes of scalar text in total.
160        max_total_scalar_bytes: usize,
161        /// Bytes of comment text in total.
162        max_total_comment_bytes: usize,
163        /// Merge keys (`<<`). ctl-core refuses merge keys whatever this says.
164        max_merge_keys: usize,
165        /// Aliases allowed before the alias-to-anchor ratio applies.
166        alias_anchor_min_aliases: usize,
167        /// How many aliases each anchor may have once the ratio applies.
168        alias_anchor_ratio_multiplier: usize,
169    }
170}
171
172impl Limits {
173    /// `max_reader_input_bytes` when no tier sets it: 256 MiB.
174    pub const DEFAULT_MAX_READER_INPUT_BYTES: usize = 256 * 1024 * 1024;
175    /// `max_depth` when no tier sets it.
176    pub const DEFAULT_MAX_DEPTH: usize = 64;
177    /// `max_nodes` when no tier sets it.
178    pub const DEFAULT_MAX_NODES: usize = 250_000;
179
180    /// The three limits every format reads, with ctl-core's default for each
181    /// unset field.
182    pub(super) fn bounds(&self) -> Bounds {
183        Bounds {
184            input_bytes: self
185                .max_reader_input_bytes
186                .unwrap_or(Self::DEFAULT_MAX_READER_INPUT_BYTES),
187            depth: self.max_depth.unwrap_or(Self::DEFAULT_MAX_DEPTH),
188            nodes: self.max_nodes.unwrap_or(Self::DEFAULT_MAX_NODES),
189        }
190    }
191}
192
193/// The three limits every format reads, as they are in force.
194#[derive(Clone, Copy)]
195pub(super) struct Bounds {
196    pub(super) input_bytes: usize,
197    pub(super) depth: usize,
198    pub(super) nodes: usize,
199}
200
201static PROCESS: RwLock<Limits> = RwLock::new(Limits::NONE);
202#[cfg(feature = "input-yaml")]
203static REPOSITORY: RwLock<Limits> = RwLock::new(Limits::NONE);
204
205/// Set the process-wide default, which a call's
206/// [`Input::limits`](super::Input::limits) beats, and with `input-yaml` a
207/// repository's `.ctl/input.yaml` too.
208pub fn set_default_limits(limits: Limits) {
209    *PROCESS.write().unwrap_or_else(PoisonError::into_inner) = limits;
210}
211
212/// Read `limits:` from `root`'s [`REPO_FILE`] and install it for every later
213/// parse in this process. A repository without the file overrides nothing.
214/// The file itself is read under the process default, not under whatever
215/// repository was installed before.
216///
217/// Returns each limit the repository raises above its default. Print each as
218/// `{bin}: warning: {loosened}`. A raised limit is one above ctl-core's or
219/// serde-saphyr's default, whatever the CLI set as its process default.
220///
221/// # Errors
222///
223/// An [`InputError`] when the file cannot be read, is not YAML, or names a
224/// field [`Limits`] does not have.
225#[cfg(feature = "input-yaml")]
226pub fn use_repo_limits(root: &Path) -> Result<Vec<Loosened>, InputError> {
227    let path = root.join(REPO_FILE);
228    let limits = match std::fs::read_to_string(&path) {
229        Ok(text) => {
230            let process = PROCESS
231                .read()
232                .unwrap_or_else(PoisonError::into_inner)
233                .clone();
234            Input::new(path.display().to_string(), text)
235                .parse_within::<RepoFile>(&process, Raise::InProcess)?
236                .limits
237        }
238        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Limits::NONE,
239        Err(error) => {
240            return Err(InputError::unread(path.display().to_string(), &error));
241        }
242    };
243    let loosened = limits.loosened();
244    *REPOSITORY.write().unwrap_or_else(PoisonError::into_inner) = limits;
245    Ok(loosened)
246}
247
248/// A limit a repository's [`REPO_FILE`] raises above its default.
249#[cfg(feature = "input-yaml")]
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub struct Loosened {
252    /// The [`Limits`] field.
253    pub field: &'static str,
254    /// The repository's value.
255    pub value: String,
256    /// The default: ctl-core's for a field every format reads, and
257    /// serde-saphyr's for a YAML-only field.
258    pub default: String,
259}
260
261#[cfg(feature = "input-yaml")]
262impl fmt::Display for Loosened {
263    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264        write!(
265            formatter,
266            "{REPO_FILE} raises {} to {}, above the default {}",
267            self.field, self.value, self.default
268        )
269    }
270}
271
272/// The shape of [`REPO_FILE`].
273#[cfg(feature = "input-yaml")]
274#[derive(Deserialize)]
275#[serde(deny_unknown_fields)]
276struct RepoFile {
277    #[serde(default)]
278    limits: Limits,
279}
280
281/// What a parse with `call` uses, field by field.
282pub(super) fn effective(call: &Limits) -> Limits {
283    let process = PROCESS.read().unwrap_or_else(PoisonError::into_inner);
284    #[cfg(feature = "input-yaml")]
285    let call = call
286        .clone()
287        .or(&REPOSITORY.read().unwrap_or_else(PoisonError::into_inner));
288    #[cfg(not(feature = "input-yaml"))]
289    let call = call.clone();
290    call.or(&process)
291}
292
293/// Where a user raises a limit a text went past.
294#[derive(Clone, Copy)]
295pub(super) enum Raise {
296    /// In the repository's [`REPO_FILE`].
297    #[cfg(feature = "input-yaml")]
298    InRepository,
299    /// In the tool's process default, for [`REPO_FILE`] itself, which is
300    /// read before any repository override applies.
301    #[cfg(feature = "input-yaml")]
302    InProcess,
303    /// Nowhere the user can reach: a build without `input-yaml` reads no
304    /// repository file, so only the tool sets limits.
305    #[cfg(not(feature = "input-yaml"))]
306    InTool,
307}
308
309impl Raise {
310    /// Where a limit hit by a CLI's own text is raised.
311    #[cfg(feature = "input-yaml")]
312    pub(super) const FOR_TEXT: Self = Self::InRepository;
313    /// Where a limit hit by a CLI's own text is raised.
314    #[cfg(not(feature = "input-yaml"))]
315    pub(super) const FOR_TEXT: Self = Self::InTool;
316}
317
318/// A limit a text went past: the field and the value in force.
319pub(super) struct Exceeded {
320    field: &'static str,
321    limit: String,
322    raise: Raise,
323}
324
325impl Exceeded {
326    pub(super) fn input_bytes(limit: usize, raise: Raise) -> Self {
327        Self::new("max_reader_input_bytes", limit, raise)
328    }
329
330    /// The [`Limits`] `field` the text went past, with the value in force.
331    pub(super) fn new(field: &'static str, limit: usize, raise: Raise) -> Self {
332        Self {
333            field,
334            limit: limit.to_string(),
335            raise,
336        }
337    }
338
339    /// The field serde-saphyr reports as breached, with its value in `budget`.
340    /// `None` for a breach this version does not know, which keeps
341    /// serde-saphyr's own wording.
342    #[cfg(feature = "input-yaml")]
343    #[expect(
344        clippy::wildcard_enum_match_arm,
345        reason = "BudgetBreach is non_exhaustive: a variant added later keeps serde-saphyr's wording"
346    )]
347    pub(super) fn breach(breach: &BudgetBreach, budget: &Budget, raise: Raise) -> Option<Self> {
348        let (field, limit) = match breach {
349            BudgetBreach::Events { .. } => ("max_events", budget.max_events.to_string()),
350            BudgetBreach::Aliases { .. } => ("max_aliases", budget.max_aliases.to_string()),
351            BudgetBreach::Anchors { .. } => ("max_anchors", budget.max_anchors.to_string()),
352            BudgetBreach::Depth { .. } => ("max_depth", budget.max_depth.to_string()),
353            BudgetBreach::InclusionDepth { .. } => (
354                "max_inclusion_depth",
355                budget.max_inclusion_depth.to_string(),
356            ),
357            BudgetBreach::Documents { .. } => ("max_documents", budget.max_documents.to_string()),
358            BudgetBreach::Nodes { .. } => ("max_nodes", budget.max_nodes.to_string()),
359            BudgetBreach::ScalarBytes { .. } => (
360                "max_total_scalar_bytes",
361                budget.max_total_scalar_bytes.to_string(),
362            ),
363            BudgetBreach::CommentBytes { .. } => (
364                "max_total_comment_bytes",
365                budget.max_total_comment_bytes.to_string(),
366            ),
367            BudgetBreach::MergeKeys { .. } => ("max_merge_keys", budget.max_merge_keys.to_string()),
368            BudgetBreach::AliasAnchorRatio { .. } => (
369                "alias_anchor_ratio_multiplier",
370                budget.alias_anchor_ratio_multiplier.to_string(),
371            ),
372            BudgetBreach::InputBytes { .. } => (
373                "max_reader_input_bytes",
374                budget
375                    .max_reader_input_bytes
376                    .map_or_else(|| "none".to_owned(), |limit| limit.to_string()),
377            ),
378            BudgetBreach::RecordedAnchorEvents { .. } => (
379                "max_recorded_anchor_events",
380                budget.max_recorded_anchor_events.to_string(),
381            ),
382            BudgetBreach::RecordedAnchorBytes { .. } => (
383                "max_recorded_anchor_bytes",
384                budget.max_recorded_anchor_bytes.to_string(),
385            ),
386            BudgetBreach::PropertyExpansionDepth { .. } => (
387                "max_property_expansion_depth",
388                budget.max_property_expansion_depth.to_string(),
389            ),
390            BudgetBreach::PropertyInterpolationWork { .. } => (
391                "max_total_property_interpolation_work",
392                budget.max_total_property_interpolation_work.to_string(),
393            ),
394            _ => return None,
395        };
396        Some(Self {
397            field,
398            limit,
399            raise,
400        })
401    }
402}
403
404impl fmt::Display for Exceeded {
405    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
406        write!(formatter, "exceeds {} ({}); ", self.field, self.limit)?;
407        match self.raise {
408            #[cfg(feature = "input-yaml")]
409            Raise::InRepository => {
410                write!(formatter, "raise limits.{} in {REPO_FILE}", self.field)
411            }
412            #[cfg(feature = "input-yaml")]
413            Raise::InProcess => write!(
414                formatter,
415                "raise {} in the tool's process default, which reads {REPO_FILE}",
416                self.field
417            ),
418            #[cfg(not(feature = "input-yaml"))]
419            Raise::InTool => write!(formatter, "the tool sets this limit"),
420        }
421    }
422}
423
424#[cfg(all(test, feature = "input-yaml"))]
425mod tests {
426    use serde_saphyr::Budget;
427
428    use super::Limits;
429
430    /// ctl-core states the three shared defaults, and a YAML text meets the
431    /// same ones it met under serde-saphyr's. A serde-saphyr release that
432    /// moves its defaults fails here before it ships.
433    #[test]
434    fn the_shared_defaults_match_serde_saphyr() {
435        let budget = Budget::default();
436        assert_eq!(
437            budget.max_reader_input_bytes,
438            Some(Limits::DEFAULT_MAX_READER_INPUT_BYTES)
439        );
440        assert_eq!(budget.max_depth, Limits::DEFAULT_MAX_DEPTH);
441        assert_eq!(budget.max_nodes, Limits::DEFAULT_MAX_NODES);
442    }
443}