voa_core/identifiers/
os.rs

1use std::{
2    ffi::OsStr,
3    fmt::{Display, Formatter},
4    str::FromStr,
5};
6
7use winnow::{
8    ModalResult,
9    Parser,
10    combinator::{alt, cut_err, eof, not, opt, peek, repeat_till},
11    error::StrContext,
12};
13
14use crate::{
15    Error,
16    identifiers::{IdentifierString, base::SegmentPath},
17};
18
19/// Recognizes an [`IdentifierString`] in a string slice.
20///
21/// Consumes all characters in `input` up to the next colon (":") or EOF.
22/// Allows providing a specific `label` to provide as context label in case of error (see
23/// [`StrContext::Label`]).
24///
25/// # Errors
26///
27/// Returns an error if
28///
29/// - not all characters in `input` before ":"/EOF are in the allowed set of characters (see
30///   [`IdentifierString::valid_chars`]),
31/// - or there is not at least one character before a colon (":") or EOF.
32fn identifier_string_parser(input: &mut &str) -> ModalResult<IdentifierString> {
33    repeat_till::<_, _, (), _, _, _, _>(1.., IdentifierString::valid_chars, peek(alt((":", eof))))
34        .take()
35        .and_then(cut_err(IdentifierString::parser))
36        .parse_next(input)
37}
38
39/// The Os identifier is used to uniquely identify an Operating System (OS), it relies on data
40/// provided by [`os-release`].
41///
42/// [`os-release`]: https://man.archlinux.org/man/os-release.5.en
43///
44/// # Format
45///
46/// An Os identifier consists of up to five parts.
47/// Each part of the identifier can consist of the characters "0–9", "a–z", ".", "_" and "-".
48///
49/// In the filesystem, the parts are concatenated into one path using `:` (colon) symbols
50/// (e.g. `debian:12:server:company-x:25.01`).
51///
52/// Trailing colons must be omitted for all parts that are unset
53/// (e.g. `arch` instead of `arch::::`).
54///
55/// However, colons for intermediate parts must be included.
56/// (e.g. `debian:12:::25.01`).
57///
58/// See <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#os>
59#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
60#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
61pub struct Os {
62    id: IdentifierString,
63    version_id: Option<IdentifierString>,
64    variant_id: Option<IdentifierString>,
65    image_id: Option<IdentifierString>,
66    image_version: Option<IdentifierString>,
67}
68
69impl Os {
70    /// Creates a new operating system identifier.
71    ///
72    /// # Examples
73    ///
74    /// ```
75    /// use voa_core::identifiers::Os;
76    ///
77    /// # fn main() -> Result<(), voa_core::Error> {
78    /// // Arch Linux is a rolling release distribution.
79    /// Os::new("arch".parse()?, None, None, None, None);
80    ///
81    /// // This Debian system is a special purpose image-based OS.
82    /// Os::new(
83    ///     "debian".parse()?,
84    ///     Some("12".parse()?),
85    ///     Some("workstation".parse()?),
86    ///     Some("cashier-system".parse()?),
87    ///     Some("1.0.0".parse()?),
88    /// );
89    /// # Ok(())
90    /// # }
91    /// ```
92    pub fn new(
93        id: IdentifierString,
94        version_id: Option<IdentifierString>,
95        variant_id: Option<IdentifierString>,
96        image_id: Option<IdentifierString>,
97        image_version: Option<IdentifierString>,
98    ) -> Self {
99        Self {
100            id,
101            version_id,
102            variant_id,
103            image_id,
104            image_version,
105        }
106    }
107
108    /// A [`String`] representation of this Os specifier.
109    ///
110    /// All parts are joined with `:`, trailing colons are omitted.
111    /// Parts that are unset are represented as empty strings.
112    ///
113    /// This function produces the exact representation specified in
114    /// <https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/#os>
115    pub fn os_to_string(&self) -> String {
116        let os = format!(
117            "{}:{}:{}:{}:{}",
118            &self.id,
119            self.version_id.as_deref().unwrap_or(""),
120            self.variant_id.as_deref().unwrap_or(""),
121            self.image_id.as_deref().unwrap_or(""),
122            self.image_version.as_deref().unwrap_or(""),
123        );
124
125        os.trim_end_matches(':').into()
126    }
127
128    /// A [`SegmentPath`] representation of this Os specifier.
129    ///
130    /// All parts are joined with `:`, trailing colons are omitted.
131    /// Parts that are unset are represented as empty strings.
132    pub(crate) fn path_segment(&self) -> Result<SegmentPath, Error> {
133        self.os_to_string().try_into()
134    }
135
136    /// Recognizes an [`Os`] in a string slice.
137    ///
138    /// Relies on [`winnow`] to parse `input` and recognizes the `id`, and the optional
139    /// `version_id`, `variant_id`, `image_id` and `image_version` components.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error, if
144    ///
145    /// - detection of one of the [`Os`] components fails,
146    /// - or there is a trailing colon (`:`) character.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use voa_core::identifiers::Os;
152    /// use winnow::Parser;
153    ///
154    /// # fn main() -> Result<(), voa_core::Error> {
155    /// Os::parser.parse("arch")?;
156    /// Os::parser.parse("debian:13:test-system:test-image:2025.01")?;
157    /// # Ok(())
158    /// # }
159    /// ```
160    pub fn parser(input: &mut &str) -> ModalResult<Self> {
161        // Advance the parser to beyond the `id` component (until either a colon character (":"), or
162        // EOF is reached), e.g.: "id:version_id:variant_id:image_id:image_version" ->
163        // ":version_id:variant_id:image_id:image_version"
164        let id = identifier_string_parser
165            .context(StrContext::Label("VOA OS ID"))
166            .parse_next(input)?;
167
168        // Consume leading colon, e.g. ":version_id:variant_id:image_id:image_version" ->
169        // "version_id:variant_id:image_id:image_version".
170        // If there is no colon character (":"), EOF is reached and there is only the `id`
171        // component.
172        if opt(":").parse_next(input)?.is_none() {
173            return Ok(Self {
174                id,
175                version_id: None,
176                variant_id: None,
177                image_id: None,
178                image_version: None,
179            });
180        }
181
182        // Advance the parser to beyond the optional `version_id` component (until either a colon
183        // character (":"), or EOF is reached), e.g.:
184        // "version_id:variant_id:image_id:image_version" -> ":variant_id:image_id:image_version"
185        let version_id = opt(identifier_string_parser)
186            .context(StrContext::Label("optional VOA OS VERSION_ID"))
187            .parse_next(input)?;
188
189        // Consume leading colon, e.g. ":variant_id:image_id:image_version" ->
190        // "variant_id:image_id:image_version".
191        //
192        // If there is no colon character (":"), EOF is reached and there are only the `id`
193        // component and the optional `version_id` component.
194        if opt(":").parse_next(input)?.is_none() {
195            return Ok(Self {
196                id,
197                version_id,
198                variant_id: None,
199                image_id: None,
200                image_version: None,
201            });
202        }
203
204        // Advance the parser to beyond the optional `variant_id` component (until either a colon
205        // character (":"), or EOF is reached), e.g.:
206        // "variant_id:image_id:image_version" -> ":image_id:image_version"
207        let variant_id = opt(identifier_string_parser)
208            .context(StrContext::Label("optional VOA OS VARIANT_ID"))
209            .parse_next(input)?;
210
211        // Consume leading colon, e.g. ":image_id:image_version" -> "image_id:image_version".
212        //
213        // If there is no colon character (":"), EOF is reached and there are only the `id`
214        // component and the optional `version_id` and `variant_id` components.
215        if opt(":").parse_next(input)?.is_none() {
216            return Ok(Self {
217                id,
218                version_id,
219                variant_id,
220                image_id: None,
221                image_version: None,
222            });
223        }
224
225        // Advance the parser to beyond the optional `image_id` component (until either a colon
226        // character (":"), or EOF is reached), e.g.:
227        // "image_id:image_version" -> ":image_version"
228        let image_id = opt(identifier_string_parser)
229            .context(StrContext::Label("optional VOA OS IMAGE_ID"))
230            .parse_next(input)?;
231
232        // Consume leading colon, e.g. ":image_version" -> "image_version".
233        //
234        // If there is no colon character (":"), EOF is reached and there are only the `id`
235        // component and the optional `version_id`, `variant_id` and `image_id` components.
236        if opt(":").parse_next(input)?.is_none() {
237            return Ok(Self {
238                id,
239                version_id,
240                variant_id,
241                image_id,
242                image_version: None,
243            });
244        }
245
246        // Advance the parser to beyond the optional `image_version` component (until either a colon
247        // character (":"), or EOF is reached), e.g.:
248        // "image_version" -> ""
249        let image_version = opt(identifier_string_parser)
250            .context(StrContext::Label("optional VOA OS IMAGE_VERSION"))
251            .parse_next(input)?;
252
253        // If there is still a trailing colon character, return an error.
254        not(":")
255            .context(StrContext::Expected(
256                winnow::error::StrContextValue::Description("no further colon"),
257            ))
258            .parse_next(input)?;
259
260        Ok(Self {
261            id,
262            version_id,
263            variant_id,
264            image_id,
265            image_version,
266        })
267    }
268}
269
270impl Display for Os {
271    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
272        write!(fmt, "{}", self.os_to_string())
273    }
274}
275
276impl FromStr for Os {
277    type Err = crate::Error;
278
279    /// Creates an [`Os`] from a string slice.
280    ///
281    /// # Note
282    ///
283    /// Delegates to [`Os::parser`].
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if [`Os::parser`] fails.
288    fn from_str(s: &str) -> Result<Self, Self::Err> {
289        Ok(Os::parser.parse(s)?)
290    }
291}
292
293impl TryFrom<&OsStr> for Os {
294    type Error = crate::Error;
295
296    fn try_from(value: &OsStr) -> Result<Self, Self::Error> {
297        Self::from_str(value.to_string_lossy().as_ref())
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use rstest::rstest;
304    use testresult::TestResult;
305
306    use super::*;
307
308    #[rstest]
309    #[case(Os::new("arch".parse()?, None, None, None, None), "arch")]
310    #[case(
311        Os::new(
312            "debian".parse()?,
313            Some("12".parse()?),
314            Some("workstation".parse()?),
315            Some("cashier-system".parse()?),
316            Some("1.0.0".parse()?),
317        ),
318        "debian:12:workstation:cashier-system:1.0.0"
319    )]
320    #[case(
321        Os::new(
322            "debian".parse()?,
323            Some("12".parse()?),
324            Some("workstation".parse()?),
325            None,
326            None,
327        ),
328        "debian:12:workstation"
329    )]
330    #[case(
331        Os::new(
332            "debian".parse()?,
333            None,
334            None,
335            None,
336            Some("25.01".parse()?),
337        ),
338        "debian::::25.01"
339    )]
340    fn os_display(#[case] os: Os, #[case] display: &str) -> testresult::TestResult {
341        assert_eq!(format!("{os}"), display);
342        Ok(())
343    }
344
345    #[rstest]
346    #[case::id_with_trailing_colons("id::::", Some("id"))]
347    #[case::all_components("id:version_id:variant_id:image_id:image_version", None)]
348    #[case::only_id("id", None)]
349    #[case::only_id_and_version_id("id:version_id", None)]
350    #[case::only_id_version_id_and_variant_id("id:version_id:variant_id", None)]
351    #[case::all_but_image_version("id:version_id:variant_id:image_id", None)]
352    #[case::all_but_image_id_and_image_version("id:version_id:variant_id", None)]
353    #[case::all_but_image_id_and_image_version("id:version_id:variant_id", None)]
354    #[case::only_id_and_variant_id("id::variant_id", None)]
355    #[case::only_id_and_image_id("id:::image_id", None)]
356    #[case::only_id_and_image_version("id::::image_version", None)]
357    fn os_from_str_valid_chars(
358        #[case] input: &str,
359        #[case] string_repr: Option<&str>,
360    ) -> TestResult {
361        match Os::from_str(input) {
362            Ok(id_string) => {
363                assert_eq!(id_string.to_string(), string_repr.unwrap_or(input));
364                Ok(())
365            }
366            Err(error) => {
367                panic!("Should have succeeded to parse {input} but failed: {error}");
368            }
369        }
370    }
371
372    #[rstest]
373    #[case::all_components_trailing_colon(
374        "id:version_id:variant_id:image_id:image_version:other",
375        "id:version_id:variant_id:image_id:image_version:other\n                                               ^\nexpected no further colon"
376    )]
377    #[case::all_caps_id(
378        "ID",
379        "ID\n^\ninvalid VOA OS ID\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
380    )]
381    #[case::all_caps_id(
382        "üd",
383        "üd\n^\ninvalid VOA OS ID\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
384    )]
385    fn os_from_str_invalid_chars(#[case] input: &str, #[case] error_msg: &str) -> TestResult {
386        match Os::from_str(input) {
387            Ok(id_string) => {
388                panic!("Should have failed to parse {input} but succeeded: {id_string}");
389            }
390            Err(error) => {
391                assert_eq!(error.to_string(), format!("Parser error:\n{error_msg}"));
392                Ok(())
393            }
394        }
395    }
396}