voa_core/identifiers/
purpose.rs

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