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#[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 pub fn new(role: Role, mode: Mode) -> Self {
49 Self { role, mode }
50 }
51
52 pub fn parser(input: &mut &str) -> ModalResult<Self> {
75 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 let role = Role::parser.parse_next(input)?;
87
88 Ok(Self { role, mode })
89 }
90
91 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 pub(crate) fn path_segment(&self) -> Result<SegmentPath, Error> {
104 self.purpose_to_string().try_into()
105 }
106
107 pub fn is_trust_anchor(&self) -> bool {
111 self.mode == Mode::TrustAnchor
112 }
113
114 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#[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 #[strum(to_string = "packages")]
147 #[cfg_attr(feature = "serde", serde(rename = "packages"))]
148 Packages,
149
150 #[strum(to_string = "repository-metadata")]
152 #[cfg_attr(feature = "serde", serde(rename = "repository-metadata"))]
153 RepositoryMetadata,
154
155 #[strum(to_string = "image")]
157 #[cfg_attr(feature = "serde", serde(rename = "image"))]
158 Image,
159
160 #[strum(to_string = "{0}")]
162 #[cfg_attr(feature = "serde", serde(rename = "custom"))]
163 Custom(CustomRole),
164}
165
166impl Role {
167 pub fn parser(input: &mut &str) -> ModalResult<Self> {
196 cut_err(alt((
202 ("packages", eof).value(Role::Packages),
203 ("repository-metadata", eof).value(Role::RepositoryMetadata),
204 ("image", eof).value(Role::Image),
205 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 fn from_str(s: &str) -> Result<Self, Self::Err> {
231 Ok(Self::parser.parse(s)?)
232 }
233}
234
235#[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 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 pub fn parser(input: &mut &str) -> ModalResult<Self> {
295 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 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 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#[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 #[strum(serialize = "")]
357 ArtifactVerifier,
358
359 #[strum(serialize = "trust-anchor")]
367 TrustAnchor,
368}
369
370impl Mode {
371 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 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}