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