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}