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 (will silently match nothing; 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
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 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 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
232fn 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 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}