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}