1use 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#[derive(Debug, Clone, PartialEq)]
21pub enum SchemaWarning {
22 UnknownTypeName {
28 name: String,
30 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 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 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 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
271fn 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 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}