1use std::{collections::HashSet, fs::read_to_string, path::Path, str::FromStr};
4
5use garde::Validate;
6use serde::{Deserialize, Serialize};
7use serde_saphyr::ser_options;
8
9use crate::{
10 ConfigOrigin,
11 Error,
12 core::{Context, Os, Purpose},
13 file::ConfigTechnologySettings,
14};
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(into = "String", try_from = "String")]
19pub struct ConfigOs(Os);
20
21impl From<Os> for ConfigOs {
22 fn from(value: Os) -> Self {
23 Self(value)
24 }
25}
26
27impl From<ConfigOs> for String {
28 fn from(value: ConfigOs) -> Self {
29 value.0.os_to_string()
30 }
31}
32
33impl FromStr for ConfigOs {
34 type Err = Error;
35
36 fn from_str(s: &str) -> Result<Self, Self::Err> {
37 Ok(ConfigOs(Os::from_str(s)?))
38 }
39}
40
41impl TryFrom<String> for ConfigOs {
42 type Error = Error;
43
44 fn try_from(value: String) -> Result<Self, Self::Error> {
45 Self::from_str(&value)
46 }
47}
48
49#[derive(Clone, Debug, Deserialize, Serialize)]
51#[serde(into = "String", try_from = "String")]
52pub struct ConfigPurpose(Purpose);
53
54impl From<Purpose> for ConfigPurpose {
55 fn from(value: Purpose) -> Self {
56 Self(value)
57 }
58}
59
60impl From<ConfigPurpose> for String {
61 fn from(value: ConfigPurpose) -> Self {
62 value.0.purpose_to_string()
63 }
64}
65
66impl FromStr for ConfigPurpose {
67 type Err = Error;
68
69 fn from_str(s: &str) -> Result<Self, Self::Err> {
70 Ok(ConfigPurpose(Purpose::from_str(s)?))
71 }
72}
73
74impl TryFrom<String> for ConfigPurpose {
75 type Error = Error;
76
77 fn try_from(value: String) -> Result<Self, Self::Error> {
78 Self::from_str(&value)
79 }
80}
81
82#[derive(Clone, Debug, Deserialize, Serialize)]
84#[serde(into = "String", try_from = "String")]
85pub struct ConfigContext(Context);
86
87impl From<Context> for ConfigContext {
88 fn from(value: Context) -> Self {
89 Self(value)
90 }
91}
92
93impl From<ConfigContext> for String {
94 fn from(value: ConfigContext) -> Self {
95 value.0.to_string()
96 }
97}
98
99impl FromStr for ConfigContext {
100 type Err = Error;
101
102 fn from_str(s: &str) -> Result<Self, Self::Err> {
103 Ok(ConfigContext(Context::from_str(s)?))
104 }
105}
106
107impl TryFrom<String> for ConfigContext {
108 type Error = Error;
109
110 fn try_from(value: String) -> Result<Self, Self::Error> {
111 Self::from_str(&value)
112 }
113}
114
115#[derive(Debug, Deserialize, Serialize, Validate)]
120pub struct ConfigContextSettings {
121 #[garde(skip)]
123 pub(crate) purpose: ConfigPurpose,
124
125 #[garde(skip)]
127 pub(crate) context: ConfigContext,
128
129 #[garde(dive)]
131 pub(crate) technology_settings: ConfigTechnologySettings,
132}
133
134impl ConfigContextSettings {
135 pub fn new(
137 purpose: impl Into<ConfigPurpose>,
138 context: impl Into<ConfigContext>,
139 technologies: ConfigTechnologySettings,
140 ) -> Self {
141 Self {
142 purpose: purpose.into(),
143 context: context.into(),
144 technology_settings: technologies,
145 }
146 }
147
148 pub fn purpose(&self) -> &Purpose {
150 &self.purpose.0
151 }
152
153 pub fn context(&self) -> &Context {
155 &self.context.0
156 }
157
158 pub fn config_technology_settings(&self) -> &ConfigTechnologySettings {
160 &self.technology_settings
161 }
162}
163
164fn validate_technology_defaults(
172 context_override: &[ConfigContextSettings],
173) -> impl FnOnce(&Option<ConfigTechnologySettings>, &()) -> garde::Result + '_ {
174 move |technology_defaults, _| {
175 if technology_defaults.is_none() && context_override.is_empty() {
176 return Err(garde::Error::new(
177 "must be set, if no context_override is defined".to_string(),
178 ));
179 }
180
181 Ok(())
182 }
183}
184
185fn validate_context_override(
191 context_override: &[ConfigContextSettings],
192 _context: &(),
193) -> garde::Result {
194 let duplicates = {
195 let mut duplicates = HashSet::new();
196 for context_settings in context_override.iter() {
197 let count = context_override
198 .iter()
199 .filter(|settings| {
200 context_settings.purpose() == settings.purpose()
201 && context_settings.context() == settings.context()
202 })
203 .count();
204
205 if count > 1 {
206 duplicates.insert((context_settings.purpose(), context_settings.context()));
207 }
208 }
209
210 duplicates
211 };
212
213 if !duplicates.is_empty() {
214 let mut dups_sorted = duplicates
215 .iter()
216 .map(|(purpose, context)| format!("{purpose}/{context}"))
217 .collect::<Vec<_>>();
218 dups_sorted.sort();
219 return Err(garde::Error::new(format!(
220 "cannot contain duplicates, but the following purpose/context combinations are listed more than once: {}",
221 dups_sorted.join(", ")
222 )));
223 }
224
225 Ok(())
226}
227
228#[derive(Clone, Copy, Debug)]
229pub enum ConfigFileType {
230 Config,
232
233 DropIn,
235}
236
237#[derive(Debug, Deserialize, Serialize, Validate)]
239pub struct ConfigFile {
240 #[serde(skip_serializing_if = "Option::is_none", default)]
242 #[garde(custom(validate_technology_defaults(&self.contexts)))]
243 pub(crate) default_technology_settings: Option<ConfigTechnologySettings>,
244
245 #[serde(skip_serializing_if = "Vec::is_empty", default)]
252 #[garde(custom(validate_context_override))]
253 #[garde(dive)]
254 pub(crate) contexts: Vec<ConfigContextSettings>,
255}
256
257impl ConfigFile {
258 pub fn new(
260 technology_settings: Option<ConfigTechnologySettings>,
261 context_override: Vec<ConfigContextSettings>,
262 ) -> Result<Self, Error> {
263 let settings = Self {
264 default_technology_settings: technology_settings,
265 contexts: context_override,
266 };
267 settings.validate().map_err(|source| Error::Validation {
268 context: "creating an override configuration file".to_string(),
269 source,
270 })?;
271
272 Ok(settings)
273 }
274
275 pub fn from_yaml_str(s: &str) -> Result<Self, Error> {
277 serde_saphyr::from_str(s).map_err(|source| Error::YamlDeserialize {
278 context: "creating an OS override configuration from a YAML string".to_string(),
279 source: Box::new(source),
280 })
281 }
282
283 pub fn from_yaml_file(
288 path: impl AsRef<Path>,
289 file_type: ConfigFileType,
290 ) -> Result<Self, Error> {
291 let path = path.as_ref();
292 let data = read_to_string(path).map_err(|source| Error::IoPath {
293 path: path.to_path_buf(),
294 context: "reading the file to string",
295 source,
296 })?;
297
298 let mut settings = Self::from_yaml_str(&data).map_err(|error| {
299 if let Error::Validation { source, .. } = error {
300 Error::Validation {
301 context: format!("creating an OS override configuration from file {path:?}"),
302 source,
303 }
304 } else {
305 error
306 }
307 })?;
308
309 if let Some(defaults) = settings.default_technology_settings.as_mut() {
312 defaults.origins.push(match file_type {
313 ConfigFileType::Config => ConfigOrigin::ConfigFile(path.to_path_buf()),
314 ConfigFileType::DropIn => ConfigOrigin::DropInFile(path.to_path_buf()),
315 });
316 }
317 settings.contexts.iter_mut().for_each(|settings| {
318 settings.technology_settings.origins.push(match file_type {
319 ConfigFileType::Config => ConfigOrigin::ConfigFile(path.to_path_buf()),
320 ConfigFileType::DropIn => ConfigOrigin::DropInFile(path.to_path_buf()),
321 })
322 });
323
324 Ok(settings)
325 }
326
327 pub fn to_yaml_string(&self) -> Result<String, Error> {
329 let options = ser_options! {
330 compact_list_indent: false,
331 empty_as_braces: true,
332 indent_step: 2,
333 };
334
335 serde_saphyr::to_string_with_options(&self, options).map_err(|source| {
336 Error::YamlSerialize {
337 context: "serializing OS settings",
338 source,
339 }
340 })
341 }
342
343 pub fn default_technology_settings(&self) -> Option<&ConfigTechnologySettings> {
345 self.default_technology_settings.as_ref()
346 }
347
348 pub fn contexts(&self) -> &[ConfigContextSettings] {
350 &self.contexts
351 }
352
353 pub fn origins(&self) -> &[ConfigOrigin] {
360 if let Some(settings) = self.default_technology_settings() {
361 &settings.origins
362 } else if let Some(context_settings) = self.contexts().first() {
363 &context_settings.technology_settings.origins
364 } else {
365 &[]
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use std::{
373 collections::HashSet,
374 fmt::Display,
375 num::{NonZeroU8, NonZeroUsize},
376 path::PathBuf,
377 str::FromStr,
378 thread::current,
379 };
380
381 use insta::{assert_snapshot, with_settings};
382 use rstest::rstest;
383 use testresult::TestResult;
384
385 use super::*;
386 use crate::{
387 core::{Context, Purpose},
388 file::{
389 ConfigOpenpgpSettings,
390 ConfigTrustAnchorMode,
391 ConfigVerificationMethod,
392 ConfigWebOfTrustMode,
393 ConfigWebOfTrustRoot,
394 },
395 openpgp::{
396 DomainName,
397 NumCertifications,
398 NumDataSignatures,
399 OpenpgpFingerprint,
400 TrustAmountFlow,
401 TrustAmountPartial,
402 TrustAmountRoot,
403 },
404 };
405
406 const SNAPSHOT_PATH: &str = "fixtures/config_file/";
407
408 #[derive(Debug)]
409 enum ConfigDataRepresentation {
410 Concise,
411 Full,
412 }
413
414 impl Display for ConfigDataRepresentation {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 write!(
417 f,
418 "{}",
419 match self {
420 Self::Concise => "concise",
421 Self::Full => "full",
422 }
423 )
424 }
425 }
426
427 #[test]
429 fn config_file_new_fails_on_duplicate_context_settings() -> TestResult {
430 match ConfigFile::new(
431 None,
432 vec![
433 ConfigContextSettings::new(
434 Purpose::from_str("purpose")?,
435 Context::from_str("context")?,
436 ConfigTechnologySettings::default(),
437 ),
438 ConfigContextSettings::new(
439 Purpose::from_str("purpose")?,
440 Context::from_str("context")?,
441 ConfigTechnologySettings::default(),
442 ),
443 ],
444 ) {
445 Ok(settings) => panic!(
446 "Should have failed with Error::Validation, but succeeded instead: {settings:?}"
447 ),
448 Err(Error::Validation { source, .. }) => {
449 assert!(source.to_string().contains("cannot contain duplicates"))
450 }
451 Err(error) => {
452 panic!("Should have failed with Error::Validation, but failed differently: {error}")
453 }
454 }
455
456 Ok(())
457 }
458
459 #[rstest]
462 #[case::yaml_concise(ConfigDataRepresentation::Concise)]
463 #[case::yaml_full(ConfigDataRepresentation::Full)]
464 fn config_file_default_string_representation(
465 #[case] data_representation: ConfigDataRepresentation,
466 ) -> TestResult {
467 let description = "Configuration with default technology settings and no context-level technology settings";
468 let config = match data_representation {
469 ConfigDataRepresentation::Concise => {
470 ConfigFile::new(Some(ConfigTechnologySettings::default()), Vec::new())?
471 }
472 ConfigDataRepresentation::Full => ConfigFile::new(
473 Some(ConfigTechnologySettings::new(
474 vec![ConfigOrigin::DropInFile(PathBuf::from(
475 "/usr/share/voa/example.yaml.d/10-example.yml",
476 ))],
477 Some(ConfigOpenpgpSettings::new(
478 Some(NumDataSignatures::default()),
479 ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
480 Some(NumCertifications::default()),
481 Some(HashSet::new()),
482 Some(HashSet::new()),
483 )?),
484 )?),
485 )),
486 Vec::new(),
487 )?,
488 };
489 let config_str = config.to_yaml_string()?;
490
491 with_settings!({
492 description => format!("{data_representation}: {description}"),
493 snapshot_path => SNAPSHOT_PATH,
494 prepend_module_to_snapshot => false,
495 }, {
496 assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
497 });
498
499 Ok(())
500 }
501
502 #[test]
505 fn config_file_as_yaml_defaults_with_wot_openpgp() -> TestResult {
506 let description = "Configuration with OS-level technology settings using the 'web of trust' verification method for OpenPGP and no context-level technology settings";
507 let config = ConfigFile::new(
508 Some(ConfigTechnologySettings::new(
509 vec![ConfigOrigin::DropInFile(PathBuf::from(
510 "/usr/share/voa/example.yaml.d/10-example.yml",
511 ))],
512 Some(ConfigOpenpgpSettings::new(
513 Some(NumDataSignatures::new(
514 NonZeroUsize::new(2).expect("2 is larger than 0"),
515 )),
516 ConfigVerificationMethod::WebOfTrust(ConfigWebOfTrustMode::new(
517 Some(TrustAmountFlow::new(
518 NonZeroUsize::new(100).expect("100 is larger than 0"),
519 )),
520 Some(TrustAmountPartial::new(
521 NonZeroU8::new(50).expect("50 is larger than 0"),
522 )?),
523 Some(HashSet::from_iter([
524 ConfigWebOfTrustRoot::new(
525 OpenpgpFingerprint::from_str(
526 "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
527 )?,
528 Some(TrustAmountRoot::new(
529 NonZeroU8::new(100).expect("100 is larger than 0"),
530 )?),
531 ),
532 ConfigWebOfTrustRoot::new(
533 OpenpgpFingerprint::from_str(
534 "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
535 )?,
536 Some(TrustAmountRoot::new(
537 NonZeroU8::new(120).expect("100 is larger than 0"),
538 )?),
539 ),
540 ConfigWebOfTrustRoot::new(
541 OpenpgpFingerprint::from_str(
542 "b787a81c32997fd39a5f4c0188363902d3586e7b",
543 )?,
544 Some(TrustAmountRoot::new(
545 NonZeroU8::new(110).expect("100 is larger than 0"),
546 )?),
547 ),
548 ])),
549 Some(HashSet::from_iter([DomainName::from_str("example.org")?])),
550 )),
551 )?),
552 )),
553 Vec::new(),
554 )?;
555 let config_str = config.to_yaml_string()?;
556
557 with_settings!({
558 description => description,
559 snapshot_path => SNAPSHOT_PATH,
560 prepend_module_to_snapshot => false,
561 }, {
562 assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
563 });
564
565 Ok(())
566 }
567
568 #[test]
571 fn config_file_as_yaml_default_trust_anchor_openpgp_context_trust_anchor_overrides()
572 -> TestResult {
573 let description = "Configuration with OS-level technology settings using the 'trust anchor' verification method for OpenPGP and two context-level technology settings overriding some settings.";
574 let config = ConfigFile::new(
575 Some(ConfigTechnologySettings::new(
576 vec![ConfigOrigin::DropInFile(PathBuf::from(
577 "/usr/share/voa/example.yaml.d/10-example.yml",
578 ))],
579 Some(ConfigOpenpgpSettings::new(
580 Some(NumDataSignatures::new(
581 NonZeroUsize::new(2).expect("2 is larger than 0"),
582 )),
583 ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
584 Some(NumCertifications::new(
585 NonZeroUsize::new(4).expect("4 is larger than 0"),
586 )),
587 Some(HashSet::from_iter([DomainName::from_str("example.org")?])),
588 Some(HashSet::from_iter([
589 OpenpgpFingerprint::from_str(
590 "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
591 )?,
592 OpenpgpFingerprint::from_str(
593 "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
594 )?,
595 OpenpgpFingerprint::from_str(
596 "b787a81c32997fd39a5f4c0188363902d3586e7b",
597 )?,
598 OpenpgpFingerprint::from_str(
599 "6132b58967cf1ebc05062492c17145e5ee9f82a8",
600 )?,
601 OpenpgpFingerprint::from_str(
602 "6eadeac2dade6347e87c0d24fd455feffa7069f0",
603 )?,
604 ])),
605 )?),
606 )?),
607 )),
608 vec![
609 ConfigContextSettings::new(
610 Purpose::from_str("package")?,
611 Context::from_str("default")?,
612 ConfigTechnologySettings::new(
613 vec![ConfigOrigin::DropInFile(PathBuf::from(
614 "/usr/share/voa/example.yaml.d/10-example.yml",
615 ))],
616 Some(ConfigOpenpgpSettings::new(
617 Some(NumDataSignatures::new(
618 NonZeroUsize::new(3).expect("3 is larger than 0"),
619 )),
620 ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
621 Some(NumCertifications::new(
622 NonZeroUsize::new(5).expect("5 is larger than 0"),
623 )),
624 Some(HashSet::from_iter([DomainName::from_str(
625 "packages.example.org",
626 )?])),
627 Some(HashSet::from_iter([
628 OpenpgpFingerprint::from_str(
629 "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
630 )?,
631 OpenpgpFingerprint::from_str(
632 "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
633 )?,
634 OpenpgpFingerprint::from_str(
635 "b787a81c32997fd39a5f4c0188363902d3586e7b",
636 )?,
637 OpenpgpFingerprint::from_str(
638 "6132b58967cf1ebc05062492c17145e5ee9f82a8",
639 )?,
640 OpenpgpFingerprint::from_str(
641 "6eadeac2dade6347e87c0d24fd455feffa7069f0",
642 )?,
643 ])),
644 )?),
645 )?),
646 ),
647 ),
648 ConfigContextSettings::new(
649 Purpose::from_str("image")?,
650 Context::from_str("installation-medium")?,
651 ConfigTechnologySettings::new(
652 vec![ConfigOrigin::DropInFile(PathBuf::from(
653 "/usr/share/voa/example.yaml.d/10-example.yml",
654 ))],
655 Some(ConfigOpenpgpSettings::new(
656 Some(NumDataSignatures::new(
657 NonZeroUsize::new(1).expect("1 is larger than 0"),
658 )),
659 ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
660 Some(NumCertifications::new(
661 NonZeroUsize::new(3).expect("3 is larger than 0"),
662 )),
663 Some(HashSet::from_iter([DomainName::from_str(
664 "packages.example.org",
665 )?])),
666 Some(HashSet::from_iter([
667 OpenpgpFingerprint::from_str(
668 "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
669 )?,
670 OpenpgpFingerprint::from_str(
671 "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
672 )?,
673 OpenpgpFingerprint::from_str(
674 "b787a81c32997fd39a5f4c0188363902d3586e7b",
675 )?,
676 OpenpgpFingerprint::from_str(
677 "6132b58967cf1ebc05062492c17145e5ee9f82a8",
678 )?,
679 OpenpgpFingerprint::from_str(
680 "6eadeac2dade6347e87c0d24fd455feffa7069f0",
681 )?,
682 ])),
683 )?),
684 )?),
685 ),
686 ),
687 ],
688 )?;
689 let config_str = config.to_yaml_string()?;
690
691 with_settings!({
692 description => description,
693 snapshot_path => SNAPSHOT_PATH,
694 prepend_module_to_snapshot => false,
695 }, {
696 assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
697 });
698
699 Ok(())
700 }
701
702 #[test]
705 fn config_file_as_yaml_context_trust_anchor_openpgp() -> TestResult {
706 let description = "Configuration with no OS-level technology settings and one context-level technology settings using the 'trust anchor' verification method for OpenPGP.";
707 let config = ConfigFile::new(
708 None,
709 vec![ConfigContextSettings::new(
710 Purpose::from_str("package")?,
711 Context::from_str("my-repo")?,
712 ConfigTechnologySettings::new(
713 vec![ConfigOrigin::DropInFile(PathBuf::from(
714 "/usr/share/voa/example.yaml.d/10-example.yml",
715 ))],
716 Some(ConfigOpenpgpSettings::new(
717 Some(NumDataSignatures::new(
718 NonZeroUsize::new(3).expect("3 is larger than 0"),
719 )),
720 ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
721 Some(NumCertifications::new(
722 NonZeroUsize::new(5).expect("5 is larger than 0"),
723 )),
724 Some(HashSet::from_iter([DomainName::from_str(
725 "packages.example.org",
726 )?])),
727 Some(HashSet::from_iter([
728 OpenpgpFingerprint::from_str(
729 "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
730 )?,
731 OpenpgpFingerprint::from_str(
732 "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
733 )?,
734 OpenpgpFingerprint::from_str(
735 "b787a81c32997fd39a5f4c0188363902d3586e7b",
736 )?,
737 OpenpgpFingerprint::from_str(
738 "6132b58967cf1ebc05062492c17145e5ee9f82a8",
739 )?,
740 OpenpgpFingerprint::from_str(
741 "6eadeac2dade6347e87c0d24fd455feffa7069f0",
742 )?,
743 ])),
744 )?),
745 )?),
746 ),
747 )],
748 )?;
749 let config_str = config.to_yaml_string()?;
750
751 with_settings!({
752 description => description,
753 snapshot_path => SNAPSHOT_PATH,
754 prepend_module_to_snapshot => false,
755 }, {
756 assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
757 });
758
759 Ok(())
760 }
761}