1use 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#[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 #[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 pub const NONE: Self = Self {
65 $( $sfield: None, )*
66 $( #[cfg(feature = "input-yaml")] $yfield: None, )*
67 };
68
69 #[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 #[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 #[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 max_reader_input_bytes = DEFAULT_MAX_READER_INPUT_BYTES,
127 max_depth = DEFAULT_MAX_DEPTH,
130 max_nodes = DEFAULT_MAX_NODES,
133 }
134 yaml {
135 max_buffered_comment_events: usize,
137 simple_key_max_lookahead: usize,
139 flow_nesting_limit: usize,
141 max_events: usize,
143 max_aliases: usize,
145 max_anchors: usize,
147 max_recorded_anchor_events: usize,
149 max_recorded_anchor_bytes: usize,
151 max_property_expansion_depth: usize,
153 max_total_property_interpolation_work: usize,
155 max_inclusion_depth: u32,
157 max_documents: usize,
159 max_total_scalar_bytes: usize,
161 max_total_comment_bytes: usize,
163 max_merge_keys: usize,
165 alias_anchor_min_aliases: usize,
167 alias_anchor_ratio_multiplier: usize,
169 }
170}
171
172impl Limits {
173 pub const DEFAULT_MAX_READER_INPUT_BYTES: usize = 256 * 1024 * 1024;
175 pub const DEFAULT_MAX_DEPTH: usize = 64;
177 pub const DEFAULT_MAX_NODES: usize = 250_000;
179
180 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#[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
205pub fn set_default_limits(limits: Limits) {
209 *PROCESS.write().unwrap_or_else(PoisonError::into_inner) = limits;
210}
211
212#[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#[cfg(feature = "input-yaml")]
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub struct Loosened {
252 pub field: &'static str,
254 pub value: String,
256 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#[cfg(feature = "input-yaml")]
274#[derive(Deserialize)]
275#[serde(deny_unknown_fields)]
276struct RepoFile {
277 #[serde(default)]
278 limits: Limits,
279}
280
281pub(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#[derive(Clone, Copy)]
295pub(super) enum Raise {
296 #[cfg(feature = "input-yaml")]
298 InRepository,
299 #[cfg(feature = "input-yaml")]
302 InProcess,
303 #[cfg(not(feature = "input-yaml"))]
306 InTool,
307}
308
309impl Raise {
310 #[cfg(feature = "input-yaml")]
312 pub(super) const FOR_TEXT: Self = Self::InRepository;
313 #[cfg(not(feature = "input-yaml"))]
315 pub(super) const FOR_TEXT: Self = Self::InTool;
316}
317
318pub(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 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 #[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 #[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}