voa_core/identifiers/
os.rs

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