voa_core/identifiers/
purpose.rs

1use std::{
2    fmt::{Display, Formatter},
3    path::PathBuf,
4    str::FromStr,
5};
6
7use strum::IntoStaticStr;
8use winnow::{
9    ModalResult,
10    Parser,
11    combinator::{alt, cut_err, eof, not, opt},
12    error::{StrContext, StrContextValue},
13    token::{rest, take},
14};
15
16use crate::{error::Error, identifiers::IdentifierString};
17
18/// Combines a [`Role`] and a [`Mode`] to describe the context in which signature verifiers
19/// in a directory structure are used.
20///
21/// The combination of [`Role`] and [`Mode`] reflects one directory layer in the VOA directory
22/// hierarchy. Purpose paths have values such as: `packages`, `trust-anchor-packages`,
23/// `repository-metadata`.
24///
25/// See <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#purpose>
26#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
27#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
28pub struct Purpose {
29    role: Role,
30    mode: Mode,
31}
32
33impl Purpose {
34    /// Creates a new [`Purpose`].
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use voa_core::identifiers::{Mode, Purpose, Role};
40    ///
41    /// # fn main() -> Result<(), voa_core::Error> {
42    /// Purpose::new(Role::Packages, Mode::ArtifactVerifier);
43    /// # Ok(())
44    /// # }
45    /// ```
46    pub fn new(role: Role, mode: Mode) -> Self {
47        Self { role, mode }
48    }
49
50    /// Recognizes a [`Purpose`] in a string slice.
51    ///
52    /// # Errors
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use voa_core::identifiers::{Mode, Purpose, Role};
58    /// use winnow::Parser;
59    ///
60    /// # fn main() -> Result<(), voa_core::Error> {
61    /// assert_eq!(
62    ///     Purpose::parser.parse("trust-anchor-test")?,
63    ///     Purpose::new(Role::Custom("test".parse()?), Mode::TrustAnchor),
64    /// );
65    /// assert_eq!(
66    ///     Purpose::parser.parse("test")?,
67    ///     Purpose::new(Role::Custom("test".parse()?), Mode::ArtifactVerifier),
68    /// );
69    /// # Ok(())
70    /// # }
71    /// ```
72    pub fn parser(input: &mut &str) -> ModalResult<Self> {
73        // Check whether we have a `trust-anchor-` prefix.
74        // The case of a pure `trust-anchor` string, is handled in the `Role::Custom` parser.
75        let trust_anchor = opt("trust-anchor-").parse_next(input)?;
76
77        let mode = if trust_anchor.is_some() {
78            Mode::TrustAnchor
79        } else {
80            Mode::ArtifactVerifier
81        };
82
83        // Take the rest of the input and create a `Role` from it.
84        let role = Role::parser.parse_next(input)?;
85
86        Ok(Self { role, mode })
87    }
88
89    /// A [`String`] representation of this Purpose specifier.
90    ///     
91    /// This function produces the exact representation specified in
92    /// <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#purpose>
93    pub fn purpose_to_string(&self) -> String {
94        match self.mode {
95            Mode::TrustAnchor => format!("{}-{}", self.mode, self.role),
96            Mode::ArtifactVerifier => format!("{}", self.role),
97        }
98    }
99
100    /// Returns the path segment for this [`Purpose`]
101    pub(crate) fn path_segment(&self) -> PathBuf {
102        self.purpose_to_string().into()
103    }
104
105    /// Checks whether `self` uses [`Mode::TrustAnchor`].
106    ///
107    /// Returns `true` if `self` uses [`Mode::TrustAnchor`], `false` otherwise.
108    pub fn is_trust_anchor(&self) -> bool {
109        self.mode == Mode::TrustAnchor
110    }
111
112    /// Consumes `self` and returns a [`Purpose`] which uses [`Mode::TrustAnchor`].
113    pub fn to_trust_anchor(mut self) -> Self {
114        self.mode = Mode::TrustAnchor;
115        self
116    }
117}
118
119impl Display for Purpose {
120    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
121        write!(fmt, "{}", self.purpose_to_string())
122    }
123}
124
125impl FromStr for Purpose {
126    type Err = crate::Error;
127
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        Ok(Self::parser.parse(s)?)
130    }
131}
132
133/// Acts as a trust domain that is associated with a set of verifiers.
134///
135/// A [`Role`] is always combined with a [`Mode`] and in combination forms a [`Purpose`].
136/// E.g. [`Role::Packages`] combined with [`Mode::TrustAnchor`] specify the purpose path
137/// `trust-anchor-packages`.
138///
139/// See <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#purpose>
140#[derive(Clone, Debug, strum::Display, Eq, Hash, IntoStaticStr, Ord, PartialEq, PartialOrd)]
141#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
142pub enum Role {
143    /// Identifies verifiers used for verifying package signatures.
144    #[strum(to_string = "packages")]
145    #[cfg_attr(feature = "serde", serde(rename = "packages"))]
146    Packages,
147
148    /// Identifies verifiers used for verifying package repository metadata signatures.
149    #[strum(to_string = "repository-metadata")]
150    #[cfg_attr(feature = "serde", serde(rename = "repository-metadata"))]
151    RepositoryMetadata,
152
153    /// Identifies verifiers used for verifying OS image signatures.
154    #[strum(to_string = "image")]
155    #[cfg_attr(feature = "serde", serde(rename = "image"))]
156    Image,
157
158    /// Identifies verifiers used for verifying OS image signatures.
159    #[strum(to_string = "{0}")]
160    #[cfg_attr(feature = "serde", serde(rename = "custom"))]
161    Custom(CustomRole),
162}
163
164impl Role {
165    /// Recognizes a [`Role`] in a string slice.
166    ///
167    /// Consumes all of its `input`.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if none of the variants of [`Role`] can be created from `input`.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use voa_core::identifiers::Role;
177    /// use winnow::Parser;
178    ///
179    /// # fn main() -> Result<(), voa_core::Error> {
180    /// assert_eq!(Role::parser.parse("packages")?, Role::Packages);
181    /// assert_eq!(
182    ///     Role::parser.parse("repository-metadata")?,
183    ///     Role::RepositoryMetadata
184    /// );
185    /// assert_eq!(Role::parser.parse("image")?, Role::Image);
186    /// assert_eq!(
187    ///     Role::parser.parse("custom")?,
188    ///     Role::Custom("custom".parse()?)
189    /// );
190    /// # Ok(())
191    /// # }
192    /// ```
193    pub fn parser(input: &mut &str) -> ModalResult<Self> {
194        // Perform a direct mapping of known static variants.
195        //
196        // Usually, such logic would be handled by strum's `EnumString` impl.
197        // However, since this enum contains the special `Custom` struct variant, we have to
198        // perform manual mapping.
199        cut_err(alt((
200            ("packages", eof).value(Role::Packages),
201            ("repository-metadata", eof).value(Role::RepositoryMetadata),
202            ("image", eof).value(Role::Image),
203            // At this point, we know that we have a custom string and delegate to the
204            // `CustomRole` parser, which will thrown an contextualized error if
205            // any invalid characters are provided.
206            rest.and_then(CustomRole::parser).map(Self::Custom),
207        )))
208        .context(StrContext::Label("a valid VOA role"))
209        .context(StrContext::Expected(StrContextValue::Description(
210            "'packages', 'repository-metadata', 'image' or a custom value",
211        )))
212        .parse_next(input)
213    }
214}
215
216impl FromStr for Role {
217    type Err = Error;
218
219    /// Creates a new [`Role`] from a string slice.
220    ///
221    /// # Note
222    ///
223    /// Delegates to [`Role::parser`].
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if [`Role::parser`] fails.
228    fn from_str(s: &str) -> Result<Self, Self::Err> {
229        Ok(Self::parser.parse(s)?)
230    }
231}
232
233/// A custom value for a [`Role`]
234#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
235#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
236#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
237pub struct CustomRole(IdentifierString);
238
239impl CustomRole {
240    /// Creates a new [`CustomRole`] instance.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if the `role` starts with the string "trust-anchor-".
245    /// This prefix is disallowed to avoid overlaps with [`Mode::TrustAnchor`].
246    pub fn new(role: IdentifierString) -> Result<Self, Error> {
247        if role.as_str().starts_with("trust-anchor-") {
248            return Err(Error::IllegalIdentifier {
249                context: "Custom role may not start with 'trust-anchor-'",
250            });
251        }
252
253        Ok(Self(role))
254    }
255
256    /// Recognizes a [`CustomRole`] in a string slice.
257    ///
258    /// Consumes all of its `input`.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if
263    ///
264    /// - `input` starts with the string representation of [`Mode::TrustAnchor`],
265    /// - or one of the characters in `input` is not covered by [`IdentifierString::valid_chars`].
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// use voa_core::identifiers::CustomRole;
271    /// use winnow::Parser;
272    ///
273    /// # fn main() -> Result<(), voa_core::Error> {
274    /// assert_eq!(CustomRole::parser.parse("test")?.to_string(), "test");
275    /// assert_eq!(
276    ///     CustomRole::parser
277    ///         .parse("something-very-special")?
278    ///         .to_string(),
279    ///     "something-very-special"
280    /// );
281    /// assert_eq!(
282    ///     CustomRole::parser
283    ///         .parse("something-very-trust-anchor")?
284    ///         .to_string(),
285    ///     "something-very-trust-anchor"
286    /// );
287    /// assert!(CustomRole::parser.parse("trust-anchor").is_err());
288    /// assert!(CustomRole::parser.parse("trust-anchor-test").is_err());
289    /// # Ok(())
290    /// # }
291    /// ```
292    pub fn parser(input: &mut &str) -> ModalResult<Self> {
293        // Make sure that we **don't** start with a trust-anchor prefix.
294        // That prefix is strictly forbidden.
295        // This handles both cases of `trust-anchor` and `trust-anchor-`.
296        cut_err(not("trust-anchor"))
297            .context(StrContext::Label(
298                "custom VOA role. Custom roles may not start with 'trust-anchor'.",
299            ))
300            .parse_next(input)?;
301
302        // Take the rest of the input and create a IdentifierString from it.
303        let id_string = cut_err(rest.try_map(IdentifierString::from_str))
304            .context(StrContext::Label("role in a VOA purpose"))
305            .parse_next(input)?;
306
307        Ok(Self(id_string))
308    }
309}
310
311impl Display for CustomRole {
312    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
313        write!(f, "{}", self.0)
314    }
315}
316
317impl FromStr for CustomRole {
318    type Err = crate::Error;
319
320    /// Creates a new [`CustomRole`] from a string slice.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if either [`IdentifierString::from_str`] or [`CustomRole::new`] fail.
325    fn from_str(s: &str) -> Result<Self, Self::Err> {
326        Ok(Self::parser.parse(s)?)
327    }
328}
329
330impl From<CustomRole> for Role {
331    fn from(val: CustomRole) -> Self {
332        Role::Custom(val)
333    }
334}
335
336/// Component of a [`Purpose`] to distinguish between direct artifact verifiers and trust anchors.
337///
338/// A [`Mode`] is always combined with a [`Role`] and in combination forms a [`Purpose`].
339/// E.g. [`Role::Packages`] combined with [`Mode::TrustAnchor`] specify the purpose path
340/// `trust-anchor-packages`.
341///
342/// See <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#purpose>
343#[derive(
344    Clone, Copy, Debug, strum::Display, Eq, Hash, IntoStaticStr, Ord, PartialEq, PartialOrd,
345)]
346#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
347#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
348pub enum Mode {
349    /// Identifies verifiers that are used to directly validate signatures on artifacts.
350    ///
351    /// For a [`Role`] `foo`, in artifact verifier mode, the purpose is represented as `foo`.
352    /// That is, [`Mode::ArtifactVerifier`] is represented in the purpose by an empty string
353    /// (and no additional dash).
354    #[strum(serialize = "")]
355    ArtifactVerifier,
356
357    /// Identifies verifiers that are used to ascertain the authenticity of verifiers used to
358    /// directly validate signatures on artifacts.
359    ///
360    /// For a [`Role`] `foo`, in trust anchor mode, the purpose is represented as
361    /// `trust-anchor-foo`.
362    /// That is, [`Mode::TrustAnchor`] is represented in the purpose by the string "trust-anchor"
363    /// (and an additional dash).
364    #[strum(serialize = "trust-anchor")]
365    TrustAnchor,
366}
367
368impl Mode {
369    /// Recognizes a [`Mode`] in a string slice.
370    ///
371    /// # Errors
372    ///
373    /// # Examples
374    ///
375    /// ```
376    /// use voa_core::identifiers::Mode;
377    /// use winnow::Parser;
378    ///
379    /// # fn main() -> Result<(), voa_core::Error> {
380    /// assert_eq!(Mode::parser.parse("")?, Mode::ArtifactVerifier);
381    /// assert_eq!(Mode::parser.parse("trust-anchor")?, Mode::TrustAnchor);
382    ///
383    /// assert!(Mode::parser.parse("test").is_err());
384    /// # Ok(())
385    /// # }
386    /// ```
387    pub fn parser(input: &mut &str) -> ModalResult<Self> {
388        if input.is_empty() {
389            return Ok(Self::ArtifactVerifier);
390        }
391
392        take(input.len())
393            .and_then(Into::<&str>::into(Self::TrustAnchor))
394            .context(StrContext::Label("trust-anchor mode for VOA purpose"))
395            .context(StrContext::Expected(StrContextValue::StringLiteral(
396                Mode::TrustAnchor.into(),
397            )))
398            .parse_next(input)?;
399
400        Ok(Mode::TrustAnchor)
401    }
402}
403
404impl FromStr for Mode {
405    type Err = crate::Error;
406
407    /// Creates a new [`Mode`] from a string slice.
408    ///
409    /// # Note
410    ///
411    /// Delegates to [`Mode::parser`].
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if [`Mode::parser`] fails.
416    fn from_str(s: &str) -> Result<Self, Self::Err> {
417        Ok(Self::parser.parse(s)?)
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use rstest::rstest;
424    use testresult::TestResult;
425
426    use super::*;
427
428    #[rstest]
429    #[case(Mode::ArtifactVerifier, "")]
430    #[case(Mode::TrustAnchor, "trust-anchor")]
431    fn mode_display(#[case] mode: Mode, #[case] display: &str) {
432        assert_eq!(format!("{mode}"), display);
433    }
434
435    #[rstest]
436    #[case(Role::Packages, "packages")]
437    #[case(Role::Image, "image")]
438    #[case(Role::RepositoryMetadata, "repository-metadata")]
439    #[case(Role::Custom(CustomRole::new("foo".parse()?)?), "foo")]
440    fn role_display(#[case] role: Role, #[case] display: &str) -> TestResult {
441        assert_eq!(format!("{role}"), display);
442        Ok(())
443    }
444
445    #[rstest]
446    #[case(Purpose::new(Role::Packages, Mode::ArtifactVerifier), "packages")]
447    #[case(
448        Purpose::new(Role::Packages, Mode::TrustAnchor),
449        "trust-anchor-packages"
450    )]
451    #[case(Purpose::new(Role::Packages, Mode::ArtifactVerifier), "packages")]
452    #[case(
453        Purpose::new(Role::RepositoryMetadata, Mode::TrustAnchor),
454        "trust-anchor-repository-metadata"
455    )]
456    #[case(Purpose::new(
457                Role::Custom(CustomRole::new("foo".parse()?)?),
458                Mode::ArtifactVerifier
459        ), "foo")]
460    #[case(Purpose::new(
461                Role::Custom(CustomRole::new("foo".parse()?)?),
462                Mode::TrustAnchor
463        ), "trust-anchor-foo")]
464    fn purpose_display(#[case] purpose: Purpose, #[case] display: &str) -> TestResult {
465        assert_eq!(format!("{purpose}"), display);
466        Ok(())
467    }
468
469    #[test]
470    fn illegal_custom_role() -> TestResult {
471        let res = CustomRole::new("trust-anchor-foo".parse()?);
472        assert!(matches!(res, Err(Error::IllegalIdentifier { .. })));
473
474        Ok(())
475    }
476
477    #[rstest]
478    #[case::no_mode("test")]
479    #[case::no_mode("trust-anchor-test")]
480    #[case::no_mode("trust-anchor-test-foo-bar")]
481    #[case::no_mode("test-foo-bar")]
482    fn purpose_from_str_valid(#[case] input: &str) -> TestResult {
483        assert_eq!(Purpose::from_str(input)?.to_string(), input);
484        Ok(())
485    }
486
487    #[test]
488    fn purpose_is_trust_anchor() -> TestResult {
489        let purpose: Purpose = "trust-anchor-foo".parse()?;
490        assert!(purpose.is_trust_anchor());
491        Ok(())
492    }
493
494    #[test]
495    fn purpose_is_not_trust_anchor() -> TestResult {
496        let purpose: Purpose = "foo".parse()?;
497        assert!(!purpose.is_trust_anchor());
498        Ok(())
499    }
500
501    #[rstest]
502    #[case::artifact_verifier("foo".parse()?, "trust-anchor-foo".parse()?)]
503    #[case::trust_anchor("trust-anchor-foo".parse()?, "trust-anchor-foo".parse()?)]
504    fn purpose_to_trust_anchor(#[case] purpose: Purpose, #[case] output: Purpose) -> TestResult {
505        assert_eq!(purpose.to_trust_anchor(), output);
506        Ok(())
507    }
508
509    #[rstest]
510    #[case::custom("test", Role::Custom(CustomRole::new("test".parse()?)?))]
511    #[case::packages("packages", Role::Packages)]
512    #[case::repository_metadata("repository-metadata", Role::RepositoryMetadata)]
513    #[case::image("image", Role::Image)]
514    fn role_from_str_succeeds(#[case] input: &str, #[case] expected: Role) -> TestResult {
515        assert_eq!(Role::from_str(input)?, expected);
516        Ok(())
517    }
518
519    #[rstest]
520    #[case::invalid_character(
521        "test$",
522        "test$\n^\ninvalid role in a VOA purpose\nexpected 'packages', 'repository-metadata', 'image' or a custom value\nParser error:\ntest$\n    ^\ninvalid VOA identifier string\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
523    )]
524    #[case::all_caps(
525        "TEST",
526        "TEST\n^\ninvalid role in a VOA purpose\nexpected 'packages', 'repository-metadata', 'image' or a custom value\nParser error:\nTEST\n^\ninvalid VOA identifier string\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
527    )]
528    #[case::empty_string(
529        "",
530        "\n^\ninvalid role in a VOA purpose\nexpected 'packages', 'repository-metadata', 'image' or a custom value\nParser error:\n\n^\ninvalid VOA identifier string\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
531    )]
532    fn role_from_str_invalid_chars(#[case] input: &str, #[case] error_msg: &str) -> TestResult {
533        match Role::from_str(input) {
534            Ok(id_string) => {
535                panic!("Should have failed to parse {input} but succeeded: {id_string}");
536            }
537            Err(error) => {
538                assert_eq!(error.to_string(), format!("Parser error:\n{error_msg}"));
539                Ok(())
540            }
541        }
542    }
543
544    #[rstest]
545    #[case::artifact_verifier("", Mode::ArtifactVerifier)]
546    #[case::trust_anchor("trust-anchor", Mode::TrustAnchor)]
547    fn mode_from_str_succeeds(#[case] input: &str, #[case] expected: Mode) -> TestResult {
548        assert_eq!(Mode::from_str(input)?, expected);
549        Ok(())
550    }
551
552    #[rstest]
553    #[case::invalid_character(
554        "test$",
555        "test$\n^\ninvalid trust-anchor mode for VOA purpose\nexpected `trust-anchor`"
556    )]
557    #[case::all_caps(
558        "TEST",
559        "TEST\n^\ninvalid trust-anchor mode for VOA purpose\nexpected `trust-anchor`"
560    )]
561    fn mode_from_str_invalid_chars(#[case] input: &str, #[case] error_msg: &str) -> TestResult {
562        match Mode::from_str(input) {
563            Ok(id_string) => {
564                panic!("Should have failed to parse {input} but succeeded: {id_string}");
565            }
566            Err(error) => {
567                assert_eq!(error.to_string(), format!("Parser error:\n{error_msg}"));
568                Ok(())
569            }
570        }
571    }
572}