use std::{
fmt::{Display, Formatter},
str::FromStr,
};
use strum::IntoStaticStr;
use winnow::{
ModalResult,
Parser,
combinator::{alt, cut_err, eof, not, opt},
error::{StrContext, StrContextValue},
token::{rest, take},
};
use crate::{
error::Error,
identifiers::{IdentifierString, SegmentPath},
};
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Purpose {
role: Role,
mode: Mode,
}
impl Purpose {
pub fn new(role: Role, mode: Mode) -> Self {
Self { role, mode }
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
let trust_anchor = opt("trust-anchor-").parse_next(input)?;
let mode = if trust_anchor.is_some() {
Mode::TrustAnchor
} else {
Mode::ArtifactVerifier
};
let role = Role::parser.parse_next(input)?;
Ok(Self { role, mode })
}
pub fn purpose_to_string(&self) -> String {
match self.mode {
Mode::TrustAnchor => format!("{}-{}", self.mode, self.role),
Mode::ArtifactVerifier => format!("{}", self.role),
}
}
pub(crate) fn path_segment(&self) -> Result<SegmentPath, Error> {
self.purpose_to_string().try_into()
}
pub fn is_trust_anchor(&self) -> bool {
self.mode == Mode::TrustAnchor
}
pub fn to_trust_anchor(mut self) -> Self {
self.mode = Mode::TrustAnchor;
self
}
}
impl Display for Purpose {
fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.purpose_to_string())
}
}
impl FromStr for Purpose {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
#[derive(Clone, Debug, strum::Display, Eq, Hash, IntoStaticStr, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum Role {
#[strum(to_string = "packages")]
#[cfg_attr(feature = "serde", serde(rename = "packages"))]
Packages,
#[strum(to_string = "repository-metadata")]
#[cfg_attr(feature = "serde", serde(rename = "repository-metadata"))]
RepositoryMetadata,
#[strum(to_string = "image")]
#[cfg_attr(feature = "serde", serde(rename = "image"))]
Image,
#[strum(to_string = "{0}")]
#[cfg_attr(feature = "serde", serde(rename = "custom"))]
Custom(CustomRole),
}
impl Role {
pub fn parser(input: &mut &str) -> ModalResult<Self> {
cut_err(alt((
("packages", eof).value(Role::Packages),
("repository-metadata", eof).value(Role::RepositoryMetadata),
("image", eof).value(Role::Image),
rest.and_then(CustomRole::parser).map(Self::Custom),
)))
.context(StrContext::Label("a valid VOA role"))
.context(StrContext::Expected(StrContextValue::Description(
"'packages', 'repository-metadata', 'image' or a custom value",
)))
.parse_next(input)
}
}
impl FromStr for Role {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
pub struct CustomRole(IdentifierString);
impl CustomRole {
pub fn new(role: IdentifierString) -> Result<Self, Error> {
if role.as_str().starts_with("trust-anchor-") {
return Err(Error::IllegalIdentifier {
context: "Custom role may not start with 'trust-anchor-'",
});
}
Ok(Self(role))
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
cut_err(not("trust-anchor"))
.context(StrContext::Label(
"custom VOA role. Custom roles may not start with 'trust-anchor'.",
))
.parse_next(input)?;
let id_string = cut_err(rest.try_map(IdentifierString::from_str))
.context(StrContext::Label("role in a VOA purpose"))
.parse_next(input)?;
Ok(Self(id_string))
}
}
impl Display for CustomRole {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for CustomRole {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
impl From<CustomRole> for Role {
fn from(val: CustomRole) -> Self {
Role::Custom(val)
}
}
#[derive(
Clone, Copy, Debug, strum::Display, Eq, Hash, IntoStaticStr, Ord, PartialEq, PartialOrd,
)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
pub enum Mode {
#[strum(serialize = "")]
ArtifactVerifier,
#[strum(serialize = "trust-anchor")]
TrustAnchor,
}
impl Mode {
pub fn parser(input: &mut &str) -> ModalResult<Self> {
if input.is_empty() {
return Ok(Self::ArtifactVerifier);
}
take(input.len())
.and_then(Into::<&str>::into(Self::TrustAnchor))
.context(StrContext::Label("trust-anchor mode for VOA purpose"))
.context(StrContext::Expected(StrContextValue::StringLiteral(
Mode::TrustAnchor.into(),
)))
.parse_next(input)?;
Ok(Mode::TrustAnchor)
}
}
impl FromStr for Mode {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use testresult::TestResult;
use super::*;
#[rstest]
#[case(Mode::ArtifactVerifier, "")]
#[case(Mode::TrustAnchor, "trust-anchor")]
fn mode_display(#[case] mode: Mode, #[case] display: &str) {
assert_eq!(format!("{mode}"), display);
}
#[rstest]
#[case(Role::Packages, "packages")]
#[case(Role::Image, "image")]
#[case(Role::RepositoryMetadata, "repository-metadata")]
#[case(Role::Custom(CustomRole::new("foo".parse()?)?), "foo")]
fn role_display(#[case] role: Role, #[case] display: &str) -> TestResult {
assert_eq!(format!("{role}"), display);
Ok(())
}
#[rstest]
#[case(Purpose::new(Role::Packages, Mode::ArtifactVerifier), "packages")]
#[case(
Purpose::new(Role::Packages, Mode::TrustAnchor),
"trust-anchor-packages"
)]
#[case(Purpose::new(Role::Packages, Mode::ArtifactVerifier), "packages")]
#[case(
Purpose::new(Role::RepositoryMetadata, Mode::TrustAnchor),
"trust-anchor-repository-metadata"
)]
#[case(Purpose::new(
Role::Custom(CustomRole::new("foo".parse()?)?),
Mode::ArtifactVerifier
), "foo")]
#[case(Purpose::new(
Role::Custom(CustomRole::new("foo".parse()?)?),
Mode::TrustAnchor
), "trust-anchor-foo")]
fn purpose_display(#[case] purpose: Purpose, #[case] display: &str) -> TestResult {
assert_eq!(format!("{purpose}"), display);
Ok(())
}
#[test]
fn illegal_custom_role() -> TestResult {
let res = CustomRole::new("trust-anchor-foo".parse()?);
assert!(matches!(res, Err(Error::IllegalIdentifier { .. })));
Ok(())
}
#[rstest]
#[case::no_mode("test")]
#[case::no_mode("trust-anchor-test")]
#[case::no_mode("trust-anchor-test-foo-bar")]
#[case::no_mode("test-foo-bar")]
fn purpose_from_str_valid(#[case] input: &str) -> TestResult {
assert_eq!(Purpose::from_str(input)?.to_string(), input);
Ok(())
}
#[test]
fn purpose_is_trust_anchor() -> TestResult {
let purpose: Purpose = "trust-anchor-foo".parse()?;
assert!(purpose.is_trust_anchor());
Ok(())
}
#[test]
fn purpose_is_not_trust_anchor() -> TestResult {
let purpose: Purpose = "foo".parse()?;
assert!(!purpose.is_trust_anchor());
Ok(())
}
#[rstest]
#[case::artifact_verifier("foo".parse()?, "trust-anchor-foo".parse()?)]
#[case::trust_anchor("trust-anchor-foo".parse()?, "trust-anchor-foo".parse()?)]
fn purpose_to_trust_anchor(#[case] purpose: Purpose, #[case] output: Purpose) -> TestResult {
assert_eq!(purpose.to_trust_anchor(), output);
Ok(())
}
#[rstest]
#[case::custom("test", Role::Custom(CustomRole::new("test".parse()?)?))]
#[case::packages("packages", Role::Packages)]
#[case::repository_metadata("repository-metadata", Role::RepositoryMetadata)]
#[case::image("image", Role::Image)]
fn role_from_str_succeeds(#[case] input: &str, #[case] expected: Role) -> TestResult {
assert_eq!(Role::from_str(input)?, expected);
Ok(())
}
#[rstest]
#[case::invalid_character(
"test$",
"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, `_`, `-`, `.`"
)]
#[case::all_caps(
"TEST",
"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, `_`, `-`, `.`"
)]
#[case::empty_string(
"",
"\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, `_`, `-`, `.`"
)]
fn role_from_str_invalid_chars(#[case] input: &str, #[case] error_msg: &str) -> TestResult {
match Role::from_str(input) {
Ok(id_string) => {
panic!("Should have failed to parse {input} but succeeded: {id_string}");
}
Err(error) => {
assert_eq!(error.to_string(), format!("Parser error:\n{error_msg}"));
Ok(())
}
}
}
#[rstest]
#[case::artifact_verifier("", Mode::ArtifactVerifier)]
#[case::trust_anchor("trust-anchor", Mode::TrustAnchor)]
fn mode_from_str_succeeds(#[case] input: &str, #[case] expected: Mode) -> TestResult {
assert_eq!(Mode::from_str(input)?, expected);
Ok(())
}
#[rstest]
#[case::invalid_character(
"test$",
"test$\n^\ninvalid trust-anchor mode for VOA purpose\nexpected `trust-anchor`"
)]
#[case::all_caps(
"TEST",
"TEST\n^\ninvalid trust-anchor mode for VOA purpose\nexpected `trust-anchor`"
)]
fn mode_from_str_invalid_chars(#[case] input: &str, #[case] error_msg: &str) -> TestResult {
match Mode::from_str(input) {
Ok(id_string) => {
panic!("Should have failed to parse {input} but succeeded: {id_string}");
}
Err(error) => {
assert_eq!(error.to_string(), format!("Parser error:\n{error_msg}"));
Ok(())
}
}
}
}