Skip to main content

citum_schema_style/style/
validation.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Style validation and resource-limit checks.
7
8use crate::template::{
9    LocalizedTemplateSpec, TemplateComponent, TemplateVariant, TemplateVariants,
10};
11use crate::version::{MAX_TEMPLATE_COMPONENTS, MAX_TEMPLATE_NESTING_DEPTH};
12use crate::{BibliographySpec, CitationSpec, ResolutionError};
13
14use super::Style;
15
16#[cfg(test)]
17use crate::template::TemplateGroup;
18
19/// A non-fatal validation warning emitted by [`Style::validate`].
20#[derive(Debug, Clone, PartialEq)]
21pub enum SchemaWarning {
22    /// A `TypeSelector` references an unrecognized reference type name.
23    ///
24    /// This usually indicates a typo (e.g., `article_journal` instead of
25    /// `article-journal`). The selector will silently match nothing at
26    /// render time.
27    UnknownTypeName {
28        /// The unrecognized type name string.
29        name: String,
30        /// Human-readable location hint (e.g., `"bibliography.type-variants"`).
31        location: String,
32    },
33}
34
35impl std::fmt::Display for SchemaWarning {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            SchemaWarning::UnknownTypeName { name, location } => {
39                write!(
40                    f,
41                    "unknown reference type \"{name}\" in {location} \
42                     (will silently match nothing; check for typos)"
43                )
44            }
45        }
46    }
47}
48
49impl Style {
50    /// Validate hard resource limits for style templates.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error when authored template structure exceeds the maximum
55    /// depth or component count accepted by the engine.
56    pub fn validate_resource_limits(&self) -> Result<(), String> {
57        let mut budget = TemplateResourceBudget::default();
58
59        if let Some(templates) = &self.templates {
60            for (name, template) in templates {
61                budget.check_template(template, &format!("templates.{name}"), 0)?;
62            }
63        }
64        if let Some(citation) = &self.citation {
65            budget.check_citation_spec(citation, "citation", 0)?;
66        }
67        if let Some(bibliography) = &self.bibliography {
68            budget.check_bibliography_spec(bibliography, "bibliography", 0)?;
69        }
70
71        Ok(())
72    }
73
74    /// Validate the style and return any non-fatal warnings.
75    ///
76    /// This method checks for issues that are syntactically valid but
77    /// semantically suspect, such as unrecognized reference type names in
78    /// `TypeSelector` values.
79    ///
80    /// Warnings do not prevent rendering; they are informational only.
81    pub fn validate(&self) -> Vec<SchemaWarning> {
82        let mut warnings = Vec::new();
83        self.collect_type_selector_warnings(&mut warnings);
84        warnings
85    }
86
87    /// Collect warnings for all `TypeSelector` values in the style.
88    fn collect_type_selector_warnings(&self, warnings: &mut Vec<SchemaWarning>) {
89        if let Some(bib) = &self.bibliography
90            && let Some(type_variants) = &bib.type_variants
91        {
92            for selector in type_variants.keys() {
93                for name in selector.unknown_type_names() {
94                    warnings.push(SchemaWarning::UnknownTypeName {
95                        name: name.to_string(),
96                        location: "bibliography.type-variants".to_string(),
97                    });
98                }
99            }
100        }
101        if let Some(cit) = &self.citation {
102            collect_citation_spec_warnings(cit, "citation", warnings);
103        }
104    }
105
106    pub(crate) fn validate_profile_shape(&self) -> Result<(), ResolutionError> {
107        if self.templates.is_some() || yaml_path_present(self.raw_yaml.as_ref(), &["templates"]) {
108            return Err(ResolutionError::InvalidProfileOverride {
109                location: "templates".to_string(),
110            });
111        }
112
113        if let Some(location) = forbidden_profile_template_path(self.raw_yaml.as_ref()) {
114            return Err(ResolutionError::InvalidProfileOverride { location });
115        }
116
117        Ok(())
118    }
119}
120
121fn forbidden_profile_template_path(raw_yaml: Option<&serde_yaml::Value>) -> Option<String> {
122    let raw_yaml = raw_yaml?;
123    for (section, recursive) in [("citation", true), ("bibliography", false)] {
124        if let Some(section_value) = mapping_child(raw_yaml, section) {
125            if recursive {
126                if let Some(location) = forbidden_citation_template_path(section_value, section) {
127                    return Some(location);
128                }
129            } else if let Some(location) = forbidden_section_template_path(section_value, section) {
130                return Some(location);
131            }
132        }
133    }
134    None
135}
136
137fn forbidden_section_template_path(section: &serde_yaml::Value, location: &str) -> Option<String> {
138    for key in ["template", "template-ref", "type-variants", "locales"] {
139        if mapping_child(section, key).is_some() {
140            return Some(format!("{location}.{key}"));
141        }
142    }
143    None
144}
145
146fn forbidden_citation_template_path(section: &serde_yaml::Value, location: &str) -> Option<String> {
147    if let Some(location) = forbidden_section_template_path(section, location) {
148        return Some(location);
149    }
150
151    for sub_section in ["integral", "non-integral", "subsequent", "ibid"] {
152        if let Some(child) = mapping_child(section, sub_section)
153            && let Some(location) =
154                forbidden_citation_template_path(child, &format!("{location}.{sub_section}"))
155        {
156            return Some(location);
157        }
158    }
159    None
160}
161
162fn mapping_child<'a>(value: &'a serde_yaml::Value, segment: &str) -> Option<&'a serde_yaml::Value> {
163    let serde_yaml::Value::Mapping(map) = value else {
164        return None;
165    };
166    let key = serde_yaml::Value::String(segment.to_string());
167    map.get(&key)
168}
169
170fn yaml_path_present(value: Option<&serde_yaml::Value>, path: &[&str]) -> bool {
171    let Some(mut current) = value else {
172        return false;
173    };
174    for segment in path {
175        let Some(next) = mapping_child(current, segment) else {
176            return false;
177        };
178        current = next;
179    }
180    true
181}
182
183/// Collect warnings from a `CitationSpec` and its sub-specs.
184fn collect_citation_spec_warnings(
185    spec: &CitationSpec,
186    location: &str,
187    warnings: &mut Vec<SchemaWarning>,
188) {
189    if let Some(type_variants) = &spec.type_variants {
190        for selector in type_variants.keys() {
191            for name in selector.unknown_type_names() {
192                warnings.push(SchemaWarning::UnknownTypeName {
193                    name: name.to_string(),
194                    location: format!("{location}.type-variants"),
195                });
196            }
197        }
198    }
199    // Recurse into sub-specs
200    for (sub_name, sub_spec) in [
201        ("integral", spec.integral.as_deref()),
202        ("non-integral", spec.non_integral.as_deref()),
203        ("subsequent", spec.subsequent.as_deref()),
204        ("ibid", spec.ibid.as_deref()),
205    ]
206    .into_iter()
207    .filter_map(|(n, s)| s.map(|s| (n, s)))
208    {
209        collect_citation_spec_warnings(sub_spec, &format!("{location}.{sub_name}"), warnings);
210    }
211}
212
213#[derive(Default)]
214struct TemplateResourceBudget {
215    component_count: usize,
216}
217
218impl TemplateResourceBudget {
219    fn check_template(
220        &mut self,
221        template: &[TemplateComponent],
222        location: &str,
223        depth: usize,
224    ) -> Result<(), String> {
225        if depth > MAX_TEMPLATE_NESTING_DEPTH {
226            return Err(format!(
227                "{location} exceeds maximum template nesting depth of {MAX_TEMPLATE_NESTING_DEPTH}"
228            ));
229        }
230        for component in template {
231            self.check_component(component, location, depth)?;
232        }
233        Ok(())
234    }
235
236    fn check_component(
237        &mut self,
238        component: &TemplateComponent,
239        location: &str,
240        depth: usize,
241    ) -> Result<(), String> {
242        self.component_count = self.component_count.saturating_add(1);
243        if self.component_count > MAX_TEMPLATE_COMPONENTS {
244            return Err(format!(
245                "style exceeds maximum template component count of {MAX_TEMPLATE_COMPONENTS}"
246            ));
247        }
248
249        match component {
250            TemplateComponent::Date(date) => {
251                if let Some(fallback) = &date.fallback {
252                    self.check_template(fallback, &format!("{location}.date.fallback"), depth + 1)?;
253                }
254            }
255            TemplateComponent::Group(group) => {
256                self.check_template(&group.group, &format!("{location}.group"), depth + 1)?;
257            }
258            TemplateComponent::Message(message) => {
259                for (name, source) in &message.args {
260                    if let Some(component) = source.as_template_component() {
261                        self.check_component(
262                            &component,
263                            &format!("{location}.message.args.{name}"),
264                            depth + 1,
265                        )?;
266                    }
267                }
268            }
269            TemplateComponent::Contributor(_)
270            | TemplateComponent::Title(_)
271            | TemplateComponent::Number(_)
272            | TemplateComponent::Variable(_)
273            | TemplateComponent::Term(_) => {}
274        }
275
276        Ok(())
277    }
278
279    fn check_variant(
280        &mut self,
281        variant: &TemplateVariant,
282        location: &str,
283        depth: usize,
284    ) -> Result<(), String> {
285        match variant {
286            TemplateVariant::Full(template) => self.check_template(template, location, depth),
287            TemplateVariant::Diff(diff) => {
288                for (index, add) in diff.add.iter().enumerate() {
289                    self.check_component(
290                        &add.component,
291                        &format!("{location}.add[{index}].component"),
292                        depth,
293                    )?;
294                }
295                Ok(())
296            }
297        }
298    }
299
300    fn check_variants(
301        &mut self,
302        variants: &TemplateVariants,
303        location: &str,
304        depth: usize,
305    ) -> Result<(), String> {
306        for (selector, variant) in variants {
307            self.check_variant(variant, &format!("{location}.{selector:?}"), depth)?;
308        }
309        Ok(())
310    }
311
312    fn check_locales(
313        &mut self,
314        locales: &[LocalizedTemplateSpec],
315        location: &str,
316        depth: usize,
317    ) -> Result<(), String> {
318        for (index, locale) in locales.iter().enumerate() {
319            self.check_template(
320                &locale.template,
321                &format!("{location}[{index}].template"),
322                depth,
323            )?;
324        }
325        Ok(())
326    }
327
328    fn check_citation_spec(
329        &mut self,
330        spec: &CitationSpec,
331        location: &str,
332        depth: usize,
333    ) -> Result<(), String> {
334        if let Some(template) = &spec.template {
335            self.check_template(template, &format!("{location}.template"), depth)?;
336        }
337        if let Some(locales) = &spec.locales {
338            self.check_locales(locales, &format!("{location}.locales"), depth)?;
339        }
340        if let Some(variants) = &spec.type_variants {
341            self.check_variants(variants, &format!("{location}.type-variants"), depth)?;
342        }
343        for (sub_name, sub_spec) in [
344            ("integral", spec.integral.as_deref()),
345            ("non-integral", spec.non_integral.as_deref()),
346            ("subsequent", spec.subsequent.as_deref()),
347            ("ibid", spec.ibid.as_deref()),
348        ]
349        .into_iter()
350        .filter_map(|(n, s)| s.map(|s| (n, s)))
351        {
352            self.check_citation_spec(sub_spec, &format!("{location}.{sub_name}"), depth + 1)?;
353        }
354        Ok(())
355    }
356
357    fn check_bibliography_spec(
358        &mut self,
359        spec: &BibliographySpec,
360        location: &str,
361        depth: usize,
362    ) -> Result<(), String> {
363        if let Some(template) = &spec.template {
364            self.check_template(template, &format!("{location}.template"), depth)?;
365        }
366        if let Some(locales) = &spec.locales {
367            self.check_locales(locales, &format!("{location}.locales"), depth)?;
368        }
369        if let Some(variants) = &spec.type_variants {
370            self.check_variants(variants, &format!("{location}.type-variants"), depth)?;
371        }
372        Ok(())
373    }
374}
375
376#[cfg(test)]
377#[allow(
378    clippy::unwrap_used,
379    clippy::expect_used,
380    clippy::panic,
381    clippy::indexing_slicing,
382    clippy::todo,
383    clippy::unimplemented,
384    clippy::unreachable,
385    clippy::get_unwrap,
386    reason = "Panicking is acceptable and often desired in tests."
387)]
388mod security_resource_tests {
389    use super::*;
390
391    fn nested_group(depth: usize) -> TemplateComponent {
392        if depth == 0 {
393            TemplateComponent::default()
394        } else {
395            TemplateComponent::Group(TemplateGroup {
396                group: vec![nested_group(depth - 1)],
397                ..TemplateGroup::default()
398            })
399        }
400    }
401
402    #[test]
403    fn validate_resource_limits_rejects_deeply_nested_templates() {
404        let style = Style {
405            bibliography: Some(BibliographySpec {
406                template: Some(vec![nested_group(MAX_TEMPLATE_NESTING_DEPTH + 1)]),
407                ..BibliographySpec::default()
408            }),
409            ..Style::default()
410        };
411
412        let err = style
413            .validate_resource_limits()
414            .expect_err("deep template must be rejected");
415
416        assert!(err.contains("maximum template nesting depth"));
417    }
418
419    #[test]
420    fn validate_resource_limits_rejects_too_many_components() {
421        let style = Style {
422            bibliography: Some(BibliographySpec {
423                template: Some(vec![
424                    TemplateComponent::default();
425                    MAX_TEMPLATE_COMPONENTS + 1
426                ]),
427                ..BibliographySpec::default()
428            }),
429            ..Style::default()
430        };
431
432        let err = style
433            .validate_resource_limits()
434            .expect_err("oversized template must be rejected");
435
436        assert!(err.contains("maximum template component count"));
437    }
438}