Skip to main content

ctl_core/input/
limits.rs

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