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}