voa-core 0.4.1

File Hierarchy for the Verification of OS Artifacts (VOA)
Documentation
use std::{
    ffi::OsStr,
    fmt::{Display, Formatter},
    str::FromStr,
};

use winnow::{
    ModalResult,
    Parser,
    combinator::{alt, cut_err, eof, not, opt, peek, repeat_till},
    error::StrContext,
};

use crate::{
    Error,
    identifiers::{IdentifierString, base::SegmentPath},
};

/// Recognizes an [`IdentifierString`] in a string slice.
///
/// Consumes all characters in `input` up to the next colon (":") or EOF.
/// Allows providing a specific `label` to provide as context label in case of error (see
/// [`StrContext::Label`]).
///
/// # Errors
///
/// Returns an error if
///
/// - not all characters in `input` before ":"/EOF are in the allowed set of characters (see
///   [`IdentifierString::valid_chars`]),
/// - or there is not at least one character before a colon (":") or EOF.
fn identifier_string_parser(input: &mut &str) -> ModalResult<IdentifierString> {
    repeat_till::<_, _, (), _, _, _, _>(1.., IdentifierString::valid_chars, peek(alt((":", eof))))
        .take()
        .and_then(cut_err(IdentifierString::parser))
        .parse_next(input)
}

/// The Os identifier is used to uniquely identify an Operating System (OS), it relies on data
/// provided by [`os-release`].
///
/// [`os-release`]: https://man.archlinux.org/man/os-release.5.en
///
/// # Format
///
/// An Os identifier consists of up to five parts.
/// Each part of the identifier can consist of the characters "0–9", "a–z", ".", "_" and "-".
///
/// In the filesystem, the parts are concatenated into one path using `:` (colon) symbols
/// (e.g. `debian:12:server:company-x:25.01`).
///
/// Trailing colons must be omitted for all parts that are unset
/// (e.g. `arch` instead of `arch::::`).
///
/// However, colons for intermediate parts must be included.
/// (e.g. `debian:12:::25.01`).
///
/// See <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#os>
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Os {
    id: IdentifierString,
    version_id: Option<IdentifierString>,
    variant_id: Option<IdentifierString>,
    image_id: Option<IdentifierString>,
    image_version: Option<IdentifierString>,
}

impl Os {
    /// Creates a new operating system identifier.
    ///
    /// # Examples
    ///
    /// ```
    /// use voa_core::identifiers::Os;
    ///
    /// # fn main() -> Result<(), voa_core::Error> {
    /// // Arch Linux is a rolling release distribution.
    /// Os::new("arch".parse()?, None, None, None, None);
    ///
    /// // This Debian system is a special purpose image-based OS.
    /// Os::new(
    ///     "debian".parse()?,
    ///     Some("12".parse()?),
    ///     Some("workstation".parse()?),
    ///     Some("cashier-system".parse()?),
    ///     Some("1.0.0".parse()?),
    /// );
    /// # Ok(())
    /// # }
    /// ```
    pub fn new(
        id: IdentifierString,
        version_id: Option<IdentifierString>,
        variant_id: Option<IdentifierString>,
        image_id: Option<IdentifierString>,
        image_version: Option<IdentifierString>,
    ) -> Self {
        Self {
            id,
            version_id,
            variant_id,
            image_id,
            image_version,
        }
    }

    /// A [`String`] representation of this Os specifier.
    ///
    /// All parts are joined with `:`, trailing colons are omitted.
    /// Parts that are unset are represented as empty strings.
    ///
    /// This function produces the exact representation specified in
    /// <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#os>
    pub fn os_to_string(&self) -> String {
        let os = format!(
            "{}:{}:{}:{}:{}",
            &self.id,
            self.version_id.as_deref().unwrap_or(""),
            self.variant_id.as_deref().unwrap_or(""),
            self.image_id.as_deref().unwrap_or(""),
            self.image_version.as_deref().unwrap_or(""),
        );

        os.trim_end_matches(':').into()
    }

    /// A [`SegmentPath`] representation of this Os specifier.
    ///
    /// All parts are joined with `:`, trailing colons are omitted.
    /// Parts that are unset are represented as empty strings.
    pub(crate) fn path_segment(&self) -> Result<SegmentPath, Error> {
        self.os_to_string().try_into()
    }

    /// Recognizes an [`Os`] in a string slice.
    ///
    /// Relies on [`winnow`] to parse `input` and recognizes the `id`, and the optional
    /// `version_id`, `variant_id`, `image_id` and `image_version` components.
    ///
    /// # Errors
    ///
    /// Returns an error, if
    ///
    /// - detection of one of the [`Os`] components fails,
    /// - or there is a trailing colon (`:`) character.
    ///
    /// # Examples
    ///
    /// ```
    /// use voa_core::identifiers::Os;
    /// use winnow::Parser;
    ///
    /// # fn main() -> Result<(), voa_core::Error> {
    /// Os::parser.parse("arch")?;
    /// Os::parser.parse("debian:13:test-system:test-image:2025.01")?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn parser(input: &mut &str) -> ModalResult<Self> {
        // Advance the parser to beyond the `id` component (until either a colon character (":"), or
        // EOF is reached), e.g.: "id:version_id:variant_id:image_id:image_version" ->
        // ":version_id:variant_id:image_id:image_version"
        let id = identifier_string_parser
            .context(StrContext::Label("VOA OS ID"))
            .parse_next(input)?;

        // Consume leading colon, e.g. ":version_id:variant_id:image_id:image_version" ->
        // "version_id:variant_id:image_id:image_version".
        // If there is no colon character (":"), EOF is reached and there is only the `id`
        // component.
        if opt(":").parse_next(input)?.is_none() {
            return Ok(Self {
                id,
                version_id: None,
                variant_id: None,
                image_id: None,
                image_version: None,
            });
        }

        // Advance the parser to beyond the optional `version_id` component (until either a colon
        // character (":"), or EOF is reached), e.g.:
        // "version_id:variant_id:image_id:image_version" -> ":variant_id:image_id:image_version"
        let version_id = opt(identifier_string_parser)
            .context(StrContext::Label("optional VOA OS VERSION_ID"))
            .parse_next(input)?;

        // Consume leading colon, e.g. ":variant_id:image_id:image_version" ->
        // "variant_id:image_id:image_version".
        //
        // If there is no colon character (":"), EOF is reached and there are only the `id`
        // component and the optional `version_id` component.
        if opt(":").parse_next(input)?.is_none() {
            return Ok(Self {
                id,
                version_id,
                variant_id: None,
                image_id: None,
                image_version: None,
            });
        }

        // Advance the parser to beyond the optional `variant_id` component (until either a colon
        // character (":"), or EOF is reached), e.g.:
        // "variant_id:image_id:image_version" -> ":image_id:image_version"
        let variant_id = opt(identifier_string_parser)
            .context(StrContext::Label("optional VOA OS VARIANT_ID"))
            .parse_next(input)?;

        // Consume leading colon, e.g. ":image_id:image_version" -> "image_id:image_version".
        //
        // If there is no colon character (":"), EOF is reached and there are only the `id`
        // component and the optional `version_id` and `variant_id` components.
        if opt(":").parse_next(input)?.is_none() {
            return Ok(Self {
                id,
                version_id,
                variant_id,
                image_id: None,
                image_version: None,
            });
        }

        // Advance the parser to beyond the optional `image_id` component (until either a colon
        // character (":"), or EOF is reached), e.g.:
        // "image_id:image_version" -> ":image_version"
        let image_id = opt(identifier_string_parser)
            .context(StrContext::Label("optional VOA OS IMAGE_ID"))
            .parse_next(input)?;

        // Consume leading colon, e.g. ":image_version" -> "image_version".
        //
        // If there is no colon character (":"), EOF is reached and there are only the `id`
        // component and the optional `version_id`, `variant_id` and `image_id` components.
        if opt(":").parse_next(input)?.is_none() {
            return Ok(Self {
                id,
                version_id,
                variant_id,
                image_id,
                image_version: None,
            });
        }

        // Advance the parser to beyond the optional `image_version` component (until either a colon
        // character (":"), or EOF is reached), e.g.:
        // "image_version" -> ""
        let image_version = opt(identifier_string_parser)
            .context(StrContext::Label("optional VOA OS IMAGE_VERSION"))
            .parse_next(input)?;

        // If there is still a trailing colon character, return an error.
        not(":")
            .context(StrContext::Expected(
                winnow::error::StrContextValue::Description("no further colon"),
            ))
            .parse_next(input)?;

        Ok(Self {
            id,
            version_id,
            variant_id,
            image_id,
            image_version,
        })
    }
}

impl Display for Os {
    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
        write!(fmt, "{}", self.os_to_string())
    }
}

impl FromStr for Os {
    type Err = crate::Error;

    /// Creates an [`Os`] from a string slice.
    ///
    /// # Note
    ///
    /// Delegates to [`Os::parser`].
    ///
    /// # Errors
    ///
    /// Returns an error if [`Os::parser`] fails.
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Os::parser.parse(s)?)
    }
}

impl TryFrom<&OsStr> for Os {
    type Error = crate::Error;

    fn try_from(value: &OsStr) -> Result<Self, Self::Error> {
        Self::from_str(value.to_string_lossy().as_ref())
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;
    use testresult::TestResult;

    use super::*;

    #[rstest]
    #[case(Os::new("arch".parse()?, None, None, None, None), "arch")]
    #[case(
        Os::new(
            "debian".parse()?,
            Some("12".parse()?),
            Some("workstation".parse()?),
            Some("cashier-system".parse()?),
            Some("1.0.0".parse()?),
        ),
        "debian:12:workstation:cashier-system:1.0.0"
    )]
    #[case(
        Os::new(
            "debian".parse()?,
            Some("12".parse()?),
            Some("workstation".parse()?),
            None,
            None,
        ),
        "debian:12:workstation"
    )]
    #[case(
        Os::new(
            "debian".parse()?,
            None,
            None,
            None,
            Some("25.01".parse()?),
        ),
        "debian::::25.01"
    )]
    fn os_display(#[case] os: Os, #[case] display: &str) -> testresult::TestResult {
        assert_eq!(format!("{os}"), display);
        Ok(())
    }

    #[rstest]
    #[case::id_with_trailing_colons("id::::", Some("id"))]
    #[case::all_components("id:version_id:variant_id:image_id:image_version", None)]
    #[case::only_id("id", None)]
    #[case::only_id_and_version_id("id:version_id", None)]
    #[case::only_id_version_id_and_variant_id("id:version_id:variant_id", None)]
    #[case::all_but_image_version("id:version_id:variant_id:image_id", None)]
    #[case::all_but_image_id_and_image_version("id:version_id:variant_id", None)]
    #[case::all_but_image_id_and_image_version("id:version_id:variant_id", None)]
    #[case::only_id_and_variant_id("id::variant_id", None)]
    #[case::only_id_and_image_id("id:::image_id", None)]
    #[case::only_id_and_image_version("id::::image_version", None)]
    fn os_from_str_valid_chars(
        #[case] input: &str,
        #[case] string_repr: Option<&str>,
    ) -> TestResult {
        match Os::from_str(input) {
            Ok(id_string) => {
                assert_eq!(id_string.to_string(), string_repr.unwrap_or(input));
                Ok(())
            }
            Err(error) => {
                panic!("Should have succeeded to parse {input} but failed: {error}");
            }
        }
    }

    #[rstest]
    #[case::all_components_trailing_colon(
        "id:version_id:variant_id:image_id:image_version:other",
        "id:version_id:variant_id:image_id:image_version:other\n                                               ^\nexpected no further colon"
    )]
    #[case::all_caps_id(
        "ID",
        "ID\n^\ninvalid VOA OS ID\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
    )]
    #[case::all_caps_id(
        "üd",
        "üd\n^\ninvalid VOA OS ID\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
    )]
    fn os_from_str_invalid_chars(#[case] input: &str, #[case] error_msg: &str) -> TestResult {
        match Os::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(())
            }
        }
    }
}