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(substitute) = self
60            .options
61            .as_ref()
62            .and_then(|options| options.substitute.as_ref())
63        {
64            validate_substitute_candidates(substitute, "options.substitute")?;
65        }
66
67        if let Some(templates) = &self.templates {
68            for (name, template) in templates {
69                budget.check_template(template, &format!("templates.{name}"), 0)?;
70            }
71        }
72        if let Some(citation) = &self.citation {
73            budget.check_citation_spec(citation, "citation", 0)?;
74        }
75        if let Some(bibliography) = &self.bibliography {
76            budget.check_bibliography_spec(bibliography, "bibliography", 0)?;
77        }
78
79        Ok(())
80    }
81
82    /// Validate the style and return any non-fatal warnings.
83    ///
84    /// This method checks for issues that are syntactically valid but
85    /// semantically suspect, such as unrecognized reference type names in
86    /// `TypeSelector` values.
87    ///
88    /// Warnings do not prevent rendering; they are informational only.
89    pub fn validate(&self) -> Vec<SchemaWarning> {
90        let mut warnings = Vec::new();
91        self.collect_type_selector_warnings(&mut warnings);
92        warnings
93    }
94
95    /// Collect warnings for all `TypeSelector` values in the style.
96    fn collect_type_selector_warnings(&self, warnings: &mut Vec<SchemaWarning>) {
97        if let Some(bib) = &self.bibliography
98            && let Some(type_variants) = &bib.type_variants
99        {
100            for selector in type_variants.keys() {
101                for name in selector.unknown_type_names() {
102                    warnings.push(SchemaWarning::UnknownTypeName {
103                        name: name.to_string(),
104                        location: "bibliography.type-variants".to_string(),
105                    });
106                }
107            }
108        }
109        if let Some(cit) = &self.citation {
110            collect_citation_spec_warnings(cit, "citation", warnings);
111        }
112    }
113
114    pub(crate) fn validate_profile_shape(&self) -> Result<(), ResolutionError> {
115        if self.templates.is_some() || yaml_path_present(self.raw_yaml.as_ref(), &["templates"]) {
116            return Err(ResolutionError::InvalidProfileOverride {
117                location: "templates".to_string(),
118            });
119        }
120
121        if let Some(location) = forbidden_profile_template_path(self.raw_yaml.as_ref()) {
122            return Err(ResolutionError::InvalidProfileOverride { location });
123        }
124
125        Ok(())
126    }
127}
128
129fn validate_substitute_candidates(
130    config: &crate::options::SubstituteConfig,
131    location: &str,
132) -> Result<(), String> {
133    let resolved = config.resolve();
134    validate_candidate_list(&resolved.template, &format!("{location}.template"))?;
135    for (reference_type, candidates) in &resolved.overrides {
136        validate_candidate_list(
137            candidates,
138            &format!("{location}.overrides.{reference_type}"),
139        )?;
140    }
141    Ok(())
142}
143
144fn validate_candidate_list(
145    candidates: &[crate::options::SubstituteKey],
146    location: &str,
147) -> Result<(), String> {
148    for (index, candidate) in candidates.iter().enumerate() {
149        let crate::options::SubstituteKey::Contributor(candidate) = candidate else {
150            continue;
151        };
152        let crate::template::ContributorRoles::Multiple(roles) = &candidate.contributor else {
153            continue;
154        };
155        if roles.len() < 2 {
156            return Err(format!(
157                "{location}[{index}].contributor must contain at least two roles in list form"
158            ));
159        }
160        let distinct = roles.iter().collect::<std::collections::HashSet<_>>();
161        if distinct.len() != roles.len() {
162            return Err(format!(
163                "{location}[{index}].contributor role list must not contain duplicates"
164            ));
165        }
166    }
167    Ok(())
168}
169
170fn forbidden_profile_template_path(raw_yaml: Option<&serde_yaml::Value>) -> Option<String> {
171    let raw_yaml = raw_yaml?;
172    for (section, recursive) in [("citation", true), ("bibliography", false)] {
173        if let Some(section_value) = mapping_child(raw_yaml, section) {
174            if recursive {
175                if let Some(location) = forbidden_citation_template_path(section_value, section) {
176                    return Some(location);
177                }
178            } else if let Some(location) = forbidden_section_template_path(section_value, section) {
179                return Some(location);
180            }
181        }
182    }
183    None
184}
185
186fn forbidden_section_template_path(section: &serde_yaml::Value, location: &str) -> Option<String> {
187    for key in ["template", "template-ref", "type-variants", "locales"] {
188        if mapping_child(section, key).is_some() {
189            return Some(format!("{location}.{key}"));
190        }
191    }
192    None
193}
194
195fn forbidden_citation_template_path(section: &serde_yaml::Value, location: &str) -> Option<String> {
196    if let Some(location) = forbidden_section_template_path(section, location) {
197        return Some(location);
198    }
199
200    for sub_section in ["integral", "non-integral", "subsequent", "ibid"] {
201        if let Some(child) = mapping_child(section, sub_section)
202            && let Some(location) =
203                forbidden_citation_template_path(child, &format!("{location}.{sub_section}"))
204        {
205            return Some(location);
206        }
207    }
208    None
209}
210
211fn mapping_child<'a>(value: &'a serde_yaml::Value, segment: &str) -> Option<&'a serde_yaml::Value> {
212    let serde_yaml::Value::Mapping(map) = value else {
213        return None;
214    };
215    let key = serde_yaml::Value::String(segment.to_string());
216    map.get(&key)
217}
218
219fn yaml_path_present(value: Option<&serde_yaml::Value>, path: &[&str]) -> bool {
220    let Some(mut current) = value else {
221        return false;
222    };
223    for segment in path {
224        let Some(next) = mapping_child(current, segment) else {
225            return false;
226        };
227        current = next;
228    }
229    true
230}
231
232/// Collect warnings from a `CitationSpec` and its sub-specs.
233fn collect_citation_spec_warnings(
234    spec: &CitationSpec,
235    location: &str,
236    warnings: &mut Vec<SchemaWarning>,
237) {
238    if let Some(type_variants) = &spec.type_variants {
239        for selector in type_variants.keys() {
240            for name in selector.unknown_type_names() {
241                warnings.push(SchemaWarning::UnknownTypeName {
242                    name: name.to_string(),
243                    location: format!("{location}.type-variants"),
244                });
245            }
246        }
247    }
248    // Recurse into sub-specs
249    for (sub_name, sub_spec) in [
250        ("integral", spec.integral.as_deref()),
251        ("non-integral", spec.non_integral.as_deref()),
252        ("subsequent", spec.subsequent.as_deref()),
253        ("ibid", spec.ibid.as_deref()),
254    ]
255    .into_iter()
256    .filter_map(|(n, s)| s.map(|s| (n, s)))
257    {
258        collect_citation_spec_warnings(sub_spec, &format!("{location}.{sub_name}"), warnings);
259    }
260}
261
262#[derive(Default)]
263struct TemplateResourceBudget {
264    component_count: usize,
265}
266
267impl TemplateResourceBudget {
268    fn check_template(
269        &mut self,
270        template: &[TemplateComponent],
271        location: &str,
272        depth: usize,
273    ) -> Result<(), String> {
274        if depth > MAX_TEMPLATE_NESTING_DEPTH {
275            return Err(format!(
276                "{location} exceeds maximum template nesting depth of {MAX_TEMPLATE_NESTING_DEPTH}"
277            ));
278        }
279        for component in template {
280            self.check_component(component, location, depth)?;
281        }
282        Ok(())
283    }
284
285    fn check_component(
286        &mut self,
287        component: &TemplateComponent,
288        location: &str,
289        depth: usize,
290    ) -> Result<(), String> {
291        self.component_count = self.component_count.saturating_add(1);
292        if self.component_count > MAX_TEMPLATE_COMPONENTS {
293            return Err(format!(
294                "style exceeds maximum template component count of {MAX_TEMPLATE_COMPONENTS}"
295            ));
296        }
297
298        match component {
299            TemplateComponent::Date(date) => {
300                if let Some(fallback) = &date.fallback {
301                    self.check_template(fallback, &format!("{location}.date.fallback"), depth + 1)?;
302                }
303            }
304            TemplateComponent::Group(group) => {
305                if let Some(cond) = &group.render_when {
306                    match (&cond.field_present, &cond.field_absent) {
307                        (None, None) => {
308                            return Err(format!(
309                                "{location}.group.render-when: must set field-present or field-absent"
310                            ));
311                        }
312                        (Some(present), Some(absent)) if present == absent => {
313                            return Err(format!(
314                                "{location}.group.render-when: field-present and field-absent must not be the same field ({present:?})"
315                            ));
316                        }
317                        _ => {}
318                    }
319                }
320                self.check_template(&group.group, &format!("{location}.group"), depth + 1)?;
321            }
322            TemplateComponent::Message(message) => {
323                for (name, source) in &message.args {
324                    if let Some(component) = source.as_template_component() {
325                        self.check_component(
326                            &component,
327                            &format!("{location}.message.args.{name}"),
328                            depth + 1,
329                        )?;
330                    }
331                }
332            }
333            TemplateComponent::Contributor(contributor) => match &contributor.contributor {
334                crate::template::ContributorRoles::Single(_) => {
335                    if contributor.merge.is_some() {
336                        return Err(format!(
337                            "{location}.merge is valid only for a contributor role list"
338                        ));
339                    }
340                }
341                crate::template::ContributorRoles::Multiple(roles) => {
342                    if roles.len() < 2 {
343                        return Err(format!(
344                            "{location}.contributor must contain at least two roles in list form"
345                        ));
346                    }
347                    let distinct = roles.iter().collect::<std::collections::HashSet<_>>();
348                    if distinct.len() != roles.len() {
349                        return Err(format!(
350                            "{location}.contributor role list must not contain duplicates"
351                        ));
352                    }
353                    if contributor.label.is_some() {
354                        return Err(format!("{location}.label is valid only for a single role"));
355                    }
356                    if let Some(merge) = &contributor.merge
357                        && let Some(role) = merge.roles.keys().find(|role| !roles.contains(role))
358                    {
359                        return Err(format!(
360                            "{location}.merge.roles contains undeclared role {}",
361                            role.as_str()
362                        ));
363                    }
364                }
365            },
366            TemplateComponent::Title(_)
367            | TemplateComponent::Number(_)
368            | TemplateComponent::Identifier(_)
369            | TemplateComponent::Variable(_)
370            | TemplateComponent::Term(_)
371            | TemplateComponent::TypeLabel(_) => {}
372        }
373
374        Ok(())
375    }
376
377    fn check_variant(
378        &mut self,
379        variant: &TemplateVariant,
380        location: &str,
381        depth: usize,
382    ) -> Result<(), String> {
383        match variant {
384            TemplateVariant::Full(template) => self.check_template(template, location, depth),
385            TemplateVariant::Diff(diff) => {
386                for (index, add) in diff.add.iter().enumerate() {
387                    self.check_component(
388                        &add.component,
389                        &format!("{location}.add[{index}].component"),
390                        depth,
391                    )?;
392                }
393                Ok(())
394            }
395        }
396    }
397
398    fn check_variants(
399        &mut self,
400        variants: &TemplateVariants,
401        location: &str,
402        depth: usize,
403    ) -> Result<(), String> {
404        for (selector, variant) in variants {
405            self.check_variant(variant, &format!("{location}.{selector:?}"), depth)?;
406        }
407        Ok(())
408    }
409
410    fn check_locales(
411        &mut self,
412        locales: &[LocalizedTemplateSpec],
413        location: &str,
414        depth: usize,
415    ) -> Result<(), String> {
416        for (index, locale) in locales.iter().enumerate() {
417            self.check_template(
418                &locale.template,
419                &format!("{location}[{index}].template"),
420                depth,
421            )?;
422            if let Some(variants) = &locale.type_variants {
423                for (selector, template) in variants {
424                    self.check_template(
425                        template,
426                        &format!("{location}[{index}].type-variants.{selector:?}"),
427                        depth,
428                    )?;
429                }
430            }
431        }
432        Ok(())
433    }
434
435    fn check_citation_spec(
436        &mut self,
437        spec: &CitationSpec,
438        location: &str,
439        depth: usize,
440    ) -> Result<(), String> {
441        if let Some(substitute) = spec
442            .options
443            .as_ref()
444            .and_then(|options| options.substitute.as_ref())
445        {
446            validate_substitute_candidates(substitute, &format!("{location}.options.substitute"))?;
447        }
448        if let Some(template) = &spec.template {
449            self.check_template(template, &format!("{location}.template"), depth)?;
450        }
451        if let Some(locales) = &spec.locales {
452            self.check_locales(locales, &format!("{location}.locales"), depth)?;
453        }
454        if let Some(variants) = &spec.type_variants {
455            self.check_variants(variants, &format!("{location}.type-variants"), depth)?;
456        }
457        for (sub_name, sub_spec) in [
458            ("integral", spec.integral.as_deref()),
459            ("non-integral", spec.non_integral.as_deref()),
460            ("subsequent", spec.subsequent.as_deref()),
461            ("ibid", spec.ibid.as_deref()),
462        ]
463        .into_iter()
464        .filter_map(|(n, s)| s.map(|s| (n, s)))
465        {
466            self.check_citation_spec(sub_spec, &format!("{location}.{sub_name}"), depth + 1)?;
467        }
468        Ok(())
469    }
470
471    fn check_bibliography_spec(
472        &mut self,
473        spec: &BibliographySpec,
474        location: &str,
475        depth: usize,
476    ) -> Result<(), String> {
477        if let Some(substitute) = spec
478            .options
479            .as_ref()
480            .and_then(|options| options.substitute.as_ref())
481        {
482            validate_substitute_candidates(substitute, &format!("{location}.options.substitute"))?;
483        }
484        if let Some(template) = &spec.template {
485            self.check_template(template, &format!("{location}.template"), depth)?;
486        }
487        if let Some(locales) = &spec.locales {
488            self.check_locales(locales, &format!("{location}.locales"), depth)?;
489        }
490        if let Some(variants) = &spec.type_variants {
491            self.check_variants(variants, &format!("{location}.type-variants"), depth)?;
492        }
493        Ok(())
494    }
495}
496
497#[cfg(test)]
498#[allow(
499    clippy::unwrap_used,
500    clippy::expect_used,
501    clippy::panic,
502    clippy::indexing_slicing,
503    clippy::todo,
504    clippy::unimplemented,
505    clippy::unreachable,
506    clippy::get_unwrap,
507    reason = "Panicking is acceptable and often desired in tests."
508)]
509mod security_resource_tests {
510    use super::*;
511
512    fn nested_group(depth: usize) -> TemplateComponent {
513        if depth == 0 {
514            TemplateComponent::default()
515        } else {
516            TemplateComponent::Group(TemplateGroup {
517                group: vec![nested_group(depth - 1)],
518                ..TemplateGroup::default()
519            })
520        }
521    }
522
523    #[test]
524    fn validate_resource_limits_rejects_deeply_nested_templates() {
525        let style = Style {
526            bibliography: Some(BibliographySpec {
527                template: Some(vec![nested_group(MAX_TEMPLATE_NESTING_DEPTH + 1)]),
528                ..BibliographySpec::default()
529            }),
530            ..Style::default()
531        };
532
533        let err = style
534            .validate_resource_limits()
535            .expect_err("deep template must be rejected");
536
537        assert!(err.contains("maximum template nesting depth"));
538    }
539
540    #[test]
541    fn validate_resource_limits_rejects_too_many_components() {
542        let style = Style {
543            bibliography: Some(BibliographySpec {
544                template: Some(vec![
545                    TemplateComponent::default();
546                    MAX_TEMPLATE_COMPONENTS + 1
547                ]),
548                ..BibliographySpec::default()
549            }),
550            ..Style::default()
551        };
552
553        let err = style
554            .validate_resource_limits()
555            .expect_err("oversized template must be rejected");
556
557        assert!(err.contains("maximum template component count"));
558    }
559}