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