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#[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 pub fn new(role: Role, mode: Mode) -> Self {
47 Self { role, mode }
48 }
49
50 pub fn parser(input: &mut &str) -> ModalResult<Self> {
73 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 let role = Role::parser.parse_next(input)?;
85
86 Ok(Self { role, mode })
87 }
88
89 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 pub(crate) fn path_segment(&self) -> PathBuf {
102 self.purpose_to_string().into()
103 }
104
105 pub fn is_trust_anchor(&self) -> bool {
109 self.mode == Mode::TrustAnchor
110 }
111
112 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#[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 #[strum(to_string = "packages")]
145 #[cfg_attr(feature = "serde", serde(rename = "packages"))]
146 Packages,
147
148 #[strum(to_string = "repository-metadata")]
150 #[cfg_attr(feature = "serde", serde(rename = "repository-metadata"))]
151 RepositoryMetadata,
152
153 #[strum(to_string = "image")]
155 #[cfg_attr(feature = "serde", serde(rename = "image"))]
156 Image,
157
158 #[strum(to_string = "{0}")]
160 #[cfg_attr(feature = "serde", serde(rename = "custom"))]
161 Custom(CustomRole),
162}
163
164impl Role {
165 pub fn parser(input: &mut &str) -> ModalResult<Self> {
194 cut_err(alt((
200 ("packages", eof).value(Role::Packages),
201 ("repository-metadata", eof).value(Role::RepositoryMetadata),
202 ("image", eof).value(Role::Image),
203 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 fn from_str(s: &str) -> Result<Self, Self::Err> {
229 Ok(Self::parser.parse(s)?)
230 }
231}
232
233#[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 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 pub fn parser(input: &mut &str) -> ModalResult<Self> {
293 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 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 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#[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 #[strum(serialize = "")]
355 ArtifactVerifier,
356
357 #[strum(serialize = "trust-anchor")]
365 TrustAnchor,
366}
367
368impl Mode {
369 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 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}