Skip to main content

ctl_core/input/
limits.rs

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