Skip to main content

voa_config/file/
config_file.rs

1//! Handling of configuration files.
2
3use 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/// A thin wrapper around [`Os`] to allow for config specific serialization.
17#[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/// A thin wrapper around [`Purpose`] to allow for config specific serialization.
50#[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/// A thin wrapper around [`Context`] to allow for config specific serialization.
83#[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/// Settings for a specific `context` in a VOA `purpose`.
116///
117/// This is a specialization for a specific context, which overrides any default technology settings
118/// set on an OS level.
119#[derive(Debug, Deserialize, Serialize, Validate)]
120pub struct ConfigContextSettings {
121    /// The VOA purpose for this override.
122    #[garde(skip)]
123    pub(crate) purpose: ConfigPurpose,
124
125    /// The VOA context for this override.
126    #[garde(skip)]
127    pub(crate) context: ConfigContext,
128
129    /// The specific technology settings.
130    #[garde(dive)]
131    pub(crate) technology_settings: ConfigTechnologySettings,
132}
133
134impl ConfigContextSettings {
135    /// Creates a new [`ConfigContextSettings`].
136    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    /// Returns a reference to the [`Purpose`].
149    pub fn purpose(&self) -> &Purpose {
150        &self.purpose.0
151    }
152
153    /// Returns a reference to the [`Context`].
154    pub fn context(&self) -> &Context {
155        &self.context.0
156    }
157
158    /// Returns a reference to the [`ConfigTechnologySettings`].
159    pub fn config_technology_settings(&self) -> &ConfigTechnologySettings {
160        &self.technology_settings
161    }
162}
163
164/// Validates that the in a [`ConfigFile`] technology defaults are set, if no context override
165/// is provided.
166///
167/// # Errors
168///
169/// Returns an error, if the `technology_defaults` is [`None`] and `context_override` has no
170/// entries.
171fn 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
185/// Validates the `context_override` in a [`ConfigFile`].
186///
187/// # Errors
188///
189/// Returns an error, if the entries in `context_override` are not unique.
190fn 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    /// A default configuration file.
231    Config,
232
233    /// A drop-in configuration file.
234    DropIn,
235}
236
237/// Representation of a VOA configuration file.
238#[derive(Debug, Deserialize, Serialize, Validate)]
239pub struct ConfigFile {
240    /// Technology defaults for an OS.
241    #[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    /// Context overrides for an OS.
246    ///
247    /// # Note
248    ///
249    /// This list can be empty, if `technology_defaults` is set, else there has to be at least one
250    /// entry.
251    #[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    /// Creates a new [`ConfigFile`].
259    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    /// Creates a [`ConfigFile`] from a string slice containing YAML data.
276    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    /// Creates a [`ConfigFile`] from a file containing YAML data.
284    ///
285    /// The additional `file_type` sets the origin according to whether the file is a default
286    /// configuration file or a drop-in configuration file.
287    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        // Add the path from which the ConfigTechnologySettings were added to any OS-level and
310        // context-level.
311        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    /// Serializes `self` as a YAML string.
328    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    /// Returns the optional [`ConfigTechnologySettings`].
344    pub fn default_technology_settings(&self) -> Option<&ConfigTechnologySettings> {
345        self.default_technology_settings.as_ref()
346    }
347
348    /// Returns a reference to the list of [`ConfigContextSettings`].
349    pub fn contexts(&self) -> &[ConfigContextSettings] {
350        &self.contexts
351    }
352
353    /// Returns a reference to the list of config file origins.
354    ///
355    /// # Note
356    ///
357    /// This returns an empty list, if neither settings nor context settings contain origins.
358    /// However, this can not happen, as the validation of [`ConfigFile`] does not allow it.
359    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    /// Ensures that [`ConfigFile::new`] fails on duplicate entries in `context_override`.
428    #[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    /// Ensures that the string representation of [`ConfigFile`] remains
460    /// consistent when using a default OS-specific technology configuration.
461    #[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    /// Ensures that the string representation of [`ConfigFile`] remains
503    /// consistent when providing an OS specific technology configuration.
504    #[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    /// Ensures that the string representation of [`ConfigFile`] remains
569    /// consistent when providing context specific technology configurations.
570    #[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    /// Ensures that the string representation of [`ConfigFile`] remains
703    /// consistent when only providing context specific technology configurations.
704    #[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}