use std::fmt;
#[cfg(feature = "input-yaml")]
use std::path::Path;
use std::sync::{PoisonError, RwLock};
use serde::Deserialize;
#[cfg(feature = "input-yaml")]
use serde_saphyr::Budget;
#[cfg(feature = "input-yaml")]
use serde_saphyr::budget::BudgetBreach;
#[cfg(feature = "input-yaml")]
use super::{Input, InputError};
#[cfg(feature = "input-yaml")]
pub const REPO_FILE: &str = ".ctl/input.yaml";
macro_rules! limits {
(
shared { $( $(#[$sdoc:meta])* $sfield:ident = $sdefault:ident, )* }
yaml { $( $(#[$ydoc:meta])* $yfield:ident: $yty:ty, )* }
) => {
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Limits {
$( $(#[$sdoc])* pub $sfield: Option<usize>, )*
$( $(#[$ydoc])* #[cfg(feature = "input-yaml")] pub $yfield: Option<$yty>, )*
}
impl Limits {
pub const NONE: Self = Self {
$( $sfield: None, )*
$( #[cfg(feature = "input-yaml")] $yfield: None, )*
};
#[must_use]
pub fn or(self, lower: &Self) -> Self {
Self {
$( $sfield: self.$sfield.or(lower.$sfield), )*
$( #[cfg(feature = "input-yaml")] $yfield: self.$yfield.or(lower.$yfield), )*
}
}
#[cfg(feature = "input-yaml")]
pub(super) fn budget(&self) -> Budget {
let bounds = self.bounds();
let mut budget = Budget::default();
budget.max_reader_input_bytes = Some(bounds.input_bytes);
budget.max_depth = bounds.depth;
budget.max_nodes = bounds.nodes;
$( if let Some(value) = self.$yfield {
budget.$yfield = value;
} )*
budget
}
#[cfg(feature = "input-yaml")]
fn loosened(&self) -> Vec<Loosened> {
let mut loosened = Vec::new();
$( if let Some(value) = self.$sfield
&& value > Self::$sdefault
{
loosened.push(Loosened {
field: stringify!($sfield),
value: value.to_string(),
default: Self::$sdefault.to_string(),
});
} )*
let default = Budget::default();
$( if let Some(value) = self.$yfield
&& value > default.$yfield
{
loosened.push(Loosened {
field: stringify!($yfield),
value: value.to_string(),
default: default.$yfield.to_string(),
});
} )*
loosened
}
}
};
}
limits! {
shared {
max_reader_input_bytes = DEFAULT_MAX_READER_INPUT_BYTES,
max_depth = DEFAULT_MAX_DEPTH,
max_nodes = DEFAULT_MAX_NODES,
}
yaml {
max_buffered_comment_events: usize,
simple_key_max_lookahead: usize,
flow_nesting_limit: usize,
max_events: usize,
max_aliases: usize,
max_anchors: usize,
max_recorded_anchor_events: usize,
max_recorded_anchor_bytes: usize,
max_property_expansion_depth: usize,
max_total_property_interpolation_work: usize,
max_inclusion_depth: u32,
max_documents: usize,
max_total_scalar_bytes: usize,
max_total_comment_bytes: usize,
max_merge_keys: usize,
alias_anchor_min_aliases: usize,
alias_anchor_ratio_multiplier: usize,
}
}
impl Limits {
pub const DEFAULT_MAX_READER_INPUT_BYTES: usize = 256 * 1024 * 1024;
pub const DEFAULT_MAX_DEPTH: usize = 64;
pub const DEFAULT_MAX_NODES: usize = 250_000;
pub(super) fn bounds(&self) -> Bounds {
Bounds {
input_bytes: self
.max_reader_input_bytes
.unwrap_or(Self::DEFAULT_MAX_READER_INPUT_BYTES),
depth: self.max_depth.unwrap_or(Self::DEFAULT_MAX_DEPTH),
nodes: self.max_nodes.unwrap_or(Self::DEFAULT_MAX_NODES),
}
}
}
#[derive(Clone, Copy)]
pub(super) struct Bounds {
pub(super) input_bytes: usize,
pub(super) depth: usize,
pub(super) nodes: usize,
}
static PROCESS: RwLock<Limits> = RwLock::new(Limits::NONE);
#[cfg(feature = "input-yaml")]
static REPOSITORY: RwLock<Limits> = RwLock::new(Limits::NONE);
pub fn set_default_limits(limits: Limits) {
*PROCESS.write().unwrap_or_else(PoisonError::into_inner) = limits;
}
#[cfg(feature = "input-yaml")]
pub fn use_repo_limits(root: &Path) -> Result<Vec<Loosened>, InputError> {
let path = root.join(REPO_FILE);
let limits = match std::fs::read_to_string(&path) {
Ok(text) => {
let process = PROCESS
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone();
Input::new(path.display().to_string(), text)
.parse_within::<RepoFile>(&process, Raise::InProcess)?
.limits
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Limits::NONE,
Err(error) => {
return Err(InputError::unread(path.display().to_string(), &error));
}
};
let loosened = limits.loosened();
*REPOSITORY.write().unwrap_or_else(PoisonError::into_inner) = limits;
Ok(loosened)
}
#[cfg(feature = "input-yaml")]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Loosened {
pub field: &'static str,
pub value: String,
pub default: String,
}
#[cfg(feature = "input-yaml")]
impl fmt::Display for Loosened {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{REPO_FILE} raises {} to {}, above the default {}",
self.field, self.value, self.default
)
}
}
#[cfg(feature = "input-yaml")]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoFile {
#[serde(default)]
limits: Limits,
}
pub(super) fn effective(call: &Limits) -> Limits {
let process = PROCESS.read().unwrap_or_else(PoisonError::into_inner);
#[cfg(feature = "input-yaml")]
let call = call
.clone()
.or(&REPOSITORY.read().unwrap_or_else(PoisonError::into_inner));
#[cfg(not(feature = "input-yaml"))]
let call = call.clone();
call.or(&process)
}
#[derive(Clone, Copy)]
pub(super) enum Raise {
#[cfg(feature = "input-yaml")]
InRepository,
#[cfg(feature = "input-yaml")]
InProcess,
#[cfg(not(feature = "input-yaml"))]
InTool,
}
impl Raise {
#[cfg(feature = "input-yaml")]
pub(super) const FOR_TEXT: Self = Self::InRepository;
#[cfg(not(feature = "input-yaml"))]
pub(super) const FOR_TEXT: Self = Self::InTool;
}
pub(super) struct Exceeded {
field: &'static str,
limit: String,
raise: Raise,
}
impl Exceeded {
pub(super) fn input_bytes(limit: usize, raise: Raise) -> Self {
Self::new("max_reader_input_bytes", limit, raise)
}
pub(super) fn new(field: &'static str, limit: usize, raise: Raise) -> Self {
Self {
field,
limit: limit.to_string(),
raise,
}
}
#[cfg(feature = "input-yaml")]
#[expect(
clippy::wildcard_enum_match_arm,
reason = "BudgetBreach is non_exhaustive: a variant added later keeps serde-saphyr's wording"
)]
pub(super) fn breach(breach: &BudgetBreach, budget: &Budget, raise: Raise) -> Option<Self> {
let (field, limit) = match breach {
BudgetBreach::Events { .. } => ("max_events", budget.max_events.to_string()),
BudgetBreach::Aliases { .. } => ("max_aliases", budget.max_aliases.to_string()),
BudgetBreach::Anchors { .. } => ("max_anchors", budget.max_anchors.to_string()),
BudgetBreach::Depth { .. } => ("max_depth", budget.max_depth.to_string()),
BudgetBreach::InclusionDepth { .. } => (
"max_inclusion_depth",
budget.max_inclusion_depth.to_string(),
),
BudgetBreach::Documents { .. } => ("max_documents", budget.max_documents.to_string()),
BudgetBreach::Nodes { .. } => ("max_nodes", budget.max_nodes.to_string()),
BudgetBreach::ScalarBytes { .. } => (
"max_total_scalar_bytes",
budget.max_total_scalar_bytes.to_string(),
),
BudgetBreach::CommentBytes { .. } => (
"max_total_comment_bytes",
budget.max_total_comment_bytes.to_string(),
),
BudgetBreach::MergeKeys { .. } => ("max_merge_keys", budget.max_merge_keys.to_string()),
BudgetBreach::AliasAnchorRatio { .. } => (
"alias_anchor_ratio_multiplier",
budget.alias_anchor_ratio_multiplier.to_string(),
),
BudgetBreach::InputBytes { .. } => (
"max_reader_input_bytes",
budget
.max_reader_input_bytes
.map_or_else(|| "none".to_owned(), |limit| limit.to_string()),
),
BudgetBreach::RecordedAnchorEvents { .. } => (
"max_recorded_anchor_events",
budget.max_recorded_anchor_events.to_string(),
),
BudgetBreach::RecordedAnchorBytes { .. } => (
"max_recorded_anchor_bytes",
budget.max_recorded_anchor_bytes.to_string(),
),
BudgetBreach::PropertyExpansionDepth { .. } => (
"max_property_expansion_depth",
budget.max_property_expansion_depth.to_string(),
),
BudgetBreach::PropertyInterpolationWork { .. } => (
"max_total_property_interpolation_work",
budget.max_total_property_interpolation_work.to_string(),
),
_ => return None,
};
Some(Self {
field,
limit,
raise,
})
}
}
impl fmt::Display for Exceeded {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "exceeds {} ({}); ", self.field, self.limit)?;
match self.raise {
#[cfg(feature = "input-yaml")]
Raise::InRepository => {
write!(formatter, "raise limits.{} in {REPO_FILE}", self.field)
}
#[cfg(feature = "input-yaml")]
Raise::InProcess => write!(
formatter,
"raise {} in the tool's process default, which reads {REPO_FILE}",
self.field
),
#[cfg(not(feature = "input-yaml"))]
Raise::InTool => write!(formatter, "the tool sets this limit"),
}
}
}
#[cfg(all(test, feature = "input-yaml"))]
mod tests {
use serde_saphyr::Budget;
use super::Limits;
#[test]
fn the_shared_defaults_match_serde_saphyr() {
let budget = Budget::default();
assert_eq!(
budget.max_reader_input_bytes,
Some(Limits::DEFAULT_MAX_READER_INPUT_BYTES)
);
assert_eq!(budget.max_depth, Limits::DEFAULT_MAX_DEPTH);
assert_eq!(budget.max_nodes, Limits::DEFAULT_MAX_NODES);
}
}