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