Skip to main content

alpm_types/
name.rs

1use std::{
2    fmt::{Display, Formatter},
3    str::FromStr,
4    string::ToString,
5};
6
7use alpm_parsers::{
8    iter_char_context,
9    traits::{AlpmParser, ParserUntil},
10};
11#[cfg(feature = "serde")]
12use serde::Serialize;
13#[cfg(feature = "serde")]
14use serde_with::DeserializeFromStr;
15use winnow::{
16    ModalResult,
17    Parser,
18    combinator::{Repeat, alt, eof, peek, repeat, repeat_till},
19    error::{ContextError, ErrMode, StrContext, StrContextValue},
20    token::one_of,
21};
22
23use crate::Error;
24
25/// A build tool name
26///
27/// The same character restrictions as with `Name` apply.
28/// Further name restrictions may be enforced on an existing instances using
29/// `matches_restriction()`.
30///
31/// ## Examples
32/// ```
33/// use std::str::FromStr;
34///
35/// use alpm_types::{BuildTool, Error, Name};
36///
37/// # fn main() -> Result<(), alpm_types::Error> {
38/// // create BuildTool from &str
39/// assert!(BuildTool::from_str("test-123@.foo_+").is_ok());
40/// assert!(BuildTool::from_str(".test").is_err());
41///
42/// // format as String
43/// assert_eq!("foo", format!("{}", BuildTool::from_str("foo")?));
44///
45/// // validate that BuildTool follows naming restrictions
46/// let buildtool = BuildTool::from_str("foo")?;
47/// let restrictions = vec![Name::from_str("foo")?, Name::from_str("bar")?];
48/// assert!(buildtool.matches_restriction(&restrictions));
49/// # Ok(())
50/// # }
51/// ```
52#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
53pub struct BuildTool(Name);
54
55impl BuildTool {
56    /// Create a new BuildTool
57    pub fn new(name: Name) -> Self {
58        BuildTool(name)
59    }
60
61    /// Create a new BuildTool in a Result, which matches one Name in a list of restrictions
62    ///
63    /// ## Examples
64    /// ```
65    /// use alpm_types::{BuildTool, Error, Name};
66    ///
67    /// # fn main() -> Result<(), alpm_types::Error> {
68    /// assert!(BuildTool::new_with_restriction("foo", &[Name::new("foo")?]).is_ok());
69    /// assert!(BuildTool::new_with_restriction("foo", &[Name::new("bar")?]).is_err());
70    /// # Ok(())
71    /// # }
72    /// ```
73    pub fn new_with_restriction(name: &str, restrictions: &[Name]) -> Result<Self, Error> {
74        let buildtool = BuildTool::from_str(name)?;
75        if buildtool.matches_restriction(restrictions) {
76            Ok(buildtool)
77        } else {
78            Err(Error::ValueDoesNotMatchRestrictions {
79                restrictions: restrictions.iter().map(ToString::to_string).collect(),
80            })
81        }
82    }
83
84    /// Validate that the BuildTool has a name matching one Name in a list of restrictions
85    pub fn matches_restriction(&self, restrictions: &[Name]) -> bool {
86        restrictions
87            .iter()
88            .any(|restriction| restriction.eq(self.inner()))
89    }
90
91    /// Return a reference to the inner type
92    pub fn inner(&self) -> &Name {
93        &self.0
94    }
95}
96
97impl FromStr for BuildTool {
98    type Err = Error;
99    /// Create a BuildTool from a string
100    fn from_str(s: &str) -> Result<BuildTool, Self::Err> {
101        Name::new(s).map(BuildTool)
102    }
103}
104
105impl Display for BuildTool {
106    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
107        write!(fmt, "{}", self.inner())
108    }
109}
110
111/// A package name
112///
113/// Package names may contain the characters `[a-zA-Z0-9\-._@+]`, but must not
114/// start with `[-.]` (see [alpm-package-name]).
115///
116/// ## Examples
117/// ```
118/// use std::str::FromStr;
119///
120/// use alpm_types::{Error, Name};
121///
122/// # fn main() -> Result<(), alpm_types::Error> {
123/// // create Name from &str
124/// assert_eq!(
125///     Name::from_str("test-123@.foo_+"),
126///     Ok(Name::new("test-123@.foo_+")?)
127/// );
128/// assert!(Name::from_str(".test").is_err());
129///
130/// // format as String
131/// assert_eq!("foo", format!("{}", Name::new("foo")?));
132/// # Ok(())
133/// # }
134/// ```
135///
136/// [alpm-package-name]: https://alpm.archlinux.page/specifications/alpm-package-name.7.html
137#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138#[cfg_attr(feature = "serde", derive(DeserializeFromStr, Serialize))]
139pub struct Name(String);
140
141impl Name {
142    /// The subset of special characters that are allowed as first character of a [`Name`].
143    const SPECIAL_FIRST_CHARS: [char; 3] = ['_', '@', '+'];
144    /// The set of characters allowed anywhere in a [`Name`], **except** as first character.
145    const NEVER_FIRST_CHAR: [char; 5] = ['_', '@', '+', '-', '.'];
146
147    /// Create a new `Name`
148    pub fn new(name: &str) -> Result<Self, Error> {
149        Self::from_str(name)
150    }
151
152    /// Return a reference to the inner type
153    pub fn inner(&self) -> &str {
154        &self.0
155    }
156}
157
158impl Name {
159    /// Recognizes a [`Name`] as part of an [`InstalledPackage`](`crate::InstalledPackage`).
160    ///
161    /// # Warning
162    ///
163    /// This parser is designed **specifically** for the internal
164    /// [`InstalledPackage`](`crate::InstalledPackage`) parser.
165    ///
166    /// [`InstalledPackage`](`crate::InstalledPackage`) is a very special use-case, as it uses
167    /// dashes (`-`) as delimiter. However, dashes are also valid characters in a [`Name`].
168    /// As such, the [`Name`] parser must be aware of how many dashes are expected to be inside the
169    /// input string to parse.
170    ///
171    /// This is a necessary, albeit cursed hack due to
172    /// [`InstalledPackage`](`crate::InstalledPackage`)'s dash-based delimiter design.
173    ///
174    /// In contrast to [`Name::parser`], this function expects the final character to be a `-`,
175    /// which it **does not consume**.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if `input` does not begin with a valid [alpm-package-name].
180    ///
181    /// [alpm-package-name]: https://alpm.archlinux.page/specifications/alpm-package-name.7.html
182    pub(crate) fn parse_name_followed_by_version<'a>(
183        delimiter_count: usize,
184    ) -> impl Parser<&'a str, Self, ErrMode<ContextError>> {
185        let never_first_char_list = ['_', '@', '+', '.'];
186
187        let alphanum = |c: char| c.is_ascii_alphanumeric();
188        let first_char = one_of((alphanum, Self::SPECIAL_FIRST_CHARS))
189            .context(StrContext::Label("first character of package name"))
190            .context(StrContext::Expected(StrContextValue::Description(
191                "ASCII alphanumeric character",
192            )))
193            .context_with(iter_char_context!(Self::SPECIAL_FIRST_CHARS));
194
195        let never_first_char = one_of((alphanum, never_first_char_list));
196
197        // The following is used to parse expressions such as this:
198        // `example-package-name-1:45.2.0-x86_64`
199        //
200        // The parser will be called with `delimiters = 3`.
201        // The `part` parser consumes all valid characters, except `-`.
202        // `parts` then chains `part` 2 (`3-1`) times, where each part is expected to be followed by
203        // a `-`.
204        // This effectively consumes: `example-package-`
205        //
206        // If any invalid characters are in this section, `part` will terminate, `-` will not match
207        // and a respective error message is thrown that points to that specific char.
208        let part: Repeat<_, _, _, (), _> = repeat(0.., never_first_char);
209        let parts: Repeat<_, _, _, (), _> = repeat(
210            delimiter_count - 1,
211            (
212                part,
213                '-'.context(StrContext::Label("character in package name"))
214                    .context(StrContext::Expected(StrContextValue::Description(
215                        "ASCII alphanumeric character",
216                    )))
217                    .context_with(iter_char_context!(Self::NEVER_FIRST_CHAR)),
218            ),
219        );
220
221        // Reconstruct the `part` parser, as we need it for the final step.
222        let alphanum = |c: char| c.is_ascii_alphanumeric();
223        let never_first_char = one_of((alphanum, never_first_char_list));
224        let part: Repeat<_, _, _, (), _> = repeat(0.., never_first_char);
225
226        // This is the final full parser. Let's go through it piece-by-piece.
227        // `example-package-name-1:45.2.0-x86_64`
228        let full_parser = (
229            // Extracts `e`
230            // `xample-package-name-1:45.2.0-x86_64`
231            first_char,
232            // Extracts the first two parts (and the following delimiters)
233            // `name-1:45.2.0-x86_64`
234            parts,
235            // Extracts the single final part
236            // `-1:45.2.0-x86_64`
237            part,
238            // Ensures the part is followed by a delimiter and not by an invalid char.
239            // `-1:45.2.0-x86_64`
240            peek('-')
241                .context(StrContext::Label("character in package name"))
242                .context(StrContext::Expected(StrContextValue::Description(
243                    "ASCII alphanumeric character",
244                )))
245                .context_with(iter_char_context!(Self::NEVER_FIRST_CHAR)),
246        );
247
248        full_parser.take().map(|n: &str| Name(n.to_owned()))
249    }
250}
251
252impl AlpmParser for Name {
253    /// Recognizes a [`Name`] in a string slice.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if `input` does not begin with a [alpm-package-name].
258    ///
259    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-name.7.html
260    fn parser(input: &mut &str) -> ModalResult<Self> {
261        let alphanum = |c: char| c.is_ascii_alphanumeric();
262        let first_char = one_of((alphanum, Self::SPECIAL_FIRST_CHARS))
263            .context(StrContext::Label("first character of package name"))
264            .context(StrContext::Expected(StrContextValue::Description(
265                "ASCII alphanumeric character",
266            )))
267            .context_with(iter_char_context!(Self::SPECIAL_FIRST_CHARS));
268
269        let never_first_char = one_of((alphanum, Self::NEVER_FIRST_CHAR));
270
271        // no .context() because this is infallible due to `0..`
272        // note the empty tuple collection to avoid allocation
273        let remaining_chars: Repeat<_, _, _, (), _> = repeat(0.., never_first_char);
274
275        let full_parser = (first_char, remaining_chars);
276
277        full_parser
278            .take()
279            .map(|n: &str| Name(n.to_owned()))
280            .parse_next(input)
281    }
282
283    fn delimiter_error_context<'a, O, P>(
284        parser: P,
285    ) -> impl Parser<&'a str, O, ErrMode<ContextError>>
286    where
287        P: Parser<&'a str, O, ErrMode<ContextError>>,
288    {
289        parser
290            .context(StrContext::Label("character in package name"))
291            .context(StrContext::Expected(StrContextValue::Description(
292                "ASCII alphanumeric character",
293            )))
294            .context_with(iter_char_context!(Self::NEVER_FIRST_CHAR))
295    }
296}
297
298impl FromStr for Name {
299    type Err = Error;
300
301    /// Creates a [`Name`] from a string slice.
302    ///
303    /// Delegates to [`Name::parser`].
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if [`Name::parser`] fails.
308    fn from_str(s: &str) -> Result<Name, Self::Err> {
309        Ok(Self::parser_until_eof.parse(s)?)
310    }
311}
312
313impl Display for Name {
314    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
315        write!(fmt, "{}", self.inner())
316    }
317}
318
319impl AsRef<str> for Name {
320    fn as_ref(&self) -> &str {
321        self.inner()
322    }
323}
324
325/// A shared object name.
326///
327/// This type wraps a [`Name`] and is used to represent the name of a shared object file
328/// that ends with the `.so` suffix.
329#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
330#[cfg_attr(feature = "serde", derive(DeserializeFromStr, Serialize))]
331pub struct SharedObjectName(pub(crate) String);
332
333impl SharedObjectName {
334    /// Creates a new [`SharedObjectName`].
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if the input does not end with `.so`.
339    ///
340    /// # Examples
341    ///
342    /// ```
343    /// use alpm_types::SharedObjectName;
344    ///
345    /// # fn main() -> Result<(), alpm_types::Error> {
346    /// let shared_object_name = SharedObjectName::new("example.so")?;
347    /// # Ok(())
348    /// # }
349    /// ```
350    pub fn new(name: &str) -> Result<Self, Error> {
351        Self::from_str(name)
352    }
353
354    /// Returns the name of the shared object as a string slice.
355    pub fn as_str(&self) -> &str {
356        self.0.as_ref()
357    }
358}
359
360impl AlpmParser for SharedObjectName {
361    /// Recognizes a [`SharedObjectName`] in a string slice.
362    ///
363    /// # Errors
364    ///
365    /// Returns an error, if `input` does not begin with a valid [`SharedObjectName`].
366    fn parser(input: &mut &str) -> ModalResult<Self> {
367        // The SharedObjectName is basically a `Name` with extra restrictions, as it requires a
368        // `.so` extension.
369        // As such, we re-implement the `Name` logic to ensure proper error handling.
370        let alphanum = |c: char| c.is_ascii_alphanumeric();
371
372        let never_first_char = one_of((alphanum, Name::NEVER_FIRST_CHAR));
373
374        (
375            // The first character, which has special restrictions
376            one_of((alphanum, Name::SPECIAL_FIRST_CHARS))
377                .context(StrContext::Label("first character of name"))
378                .context(StrContext::Expected(StrContextValue::Description(
379                    "ASCII alphanumeric character",
380                )))
381                .context_with(iter_char_context!(Name::SPECIAL_FIRST_CHARS)),
382            // Parse the name of the shared object until an `.so`, eof or an invalid character is
383            // hit.
384            repeat_till::<_, _, String, _, _, _, _>(1.., never_first_char, peek(alt((".so", eof))))
385                .context(StrContext::Label("name")),
386            // Then make sure that there's at least one or more `.so` suffix(es).
387            repeat::<_, _, String, _, _>(1.., ".so")
388                .take()
389                .context(StrContext::Label("suffix"))
390                .context(StrContext::Expected(StrContextValue::Description(
391                    "shared object name suffix '.so'",
392                ))),
393        )
394            .take()
395            .map(|n: &str| SharedObjectName(n.to_owned()))
396            .parse_next(input)
397    }
398
399    fn delimiter_error_context<'a, O, P>(
400        parser: P,
401    ) -> impl Parser<&'a str, O, ErrMode<ContextError>>
402    where
403        P: Parser<&'a str, O, ErrMode<ContextError>>,
404    {
405        parser
406            .context(StrContext::Label("shared object name"))
407            .context(StrContext::Expected(StrContextValue::Description(
408                "end of input.",
409            )))
410    }
411}
412
413impl FromStr for SharedObjectName {
414    type Err = Error;
415    /// Create an [`SharedObjectName`] from a string and return it in a Result
416    fn from_str(s: &str) -> Result<Self, Self::Err> {
417        Ok(Self::parser_until_eof.parse(s)?)
418    }
419}
420
421impl Display for SharedObjectName {
422    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
423        write!(fmt, "{}", self.0)
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use insta::assert_snapshot;
430    use proptest::prelude::*;
431    use rstest::rstest;
432
433    use super::*;
434    use crate::configure_insta;
435
436    #[rstest]
437    #[case(
438        "bar",
439        ["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(),
440        Ok(BuildTool::from_str("bar").unwrap()),
441    )]
442    #[case(
443        "bar",
444        ["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(),
445        Err(Error::ValueDoesNotMatchRestrictions {
446            restrictions: vec!["foo".to_string(), "foo".to_string()],
447        }),
448    )]
449    fn buildtool_new_with_restriction(
450        #[case] buildtool: &str,
451        #[case] restrictions: Vec<Name>,
452        #[case] result: Result<BuildTool, Error>,
453    ) {
454        assert_eq!(
455            BuildTool::new_with_restriction(buildtool, &restrictions),
456            result
457        );
458    }
459
460    #[rstest]
461    #[case("bar", ["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(), true)]
462    #[case("bar", ["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(), false)]
463    fn buildtool_matches_restriction(
464        #[case] buildtool: &str,
465        #[case] restrictions: Vec<Name>,
466        #[case] result: bool,
467    ) {
468        let buildtool = BuildTool::from_str(buildtool).unwrap();
469        assert_eq!(buildtool.matches_restriction(&restrictions), result);
470    }
471
472    #[rstest]
473    #[case("package_name_'''")]
474    #[case("-package_with_leading_hyphen")]
475    fn name_parse_error(#[case] input: &str) {
476        let Err(Error::ParseError(err_msg)) = Name::from_str(input) else {
477            panic!("'{input}' erroneously parsed as a Name")
478        };
479
480        let (test_name, _guard) = configure_insta();
481        assert_snapshot!(test_name, err_msg.to_string());
482    }
483
484    /// Make sure that invalid names don't deserialize.
485    #[cfg(feature = "serde")]
486    #[rstest]
487    #[case("package_name_'''")]
488    #[case("-package_with_leading_hyphen")]
489    fn name_deserialize_error(#[case] input: &str) {
490        let Err(serde_json::Error { .. }) = serde_json::from_str::<Name>(&format!("\"{input}\""))
491        else {
492            panic!("'{input}' erroneously deserialized as a Name")
493        };
494    }
495
496    proptest! {
497        #![proptest_config(ProptestConfig::with_cases(1000))]
498
499        #[test]
500        fn valid_name_from_string(name_str in r"[a-zA-Z0-9_@+]+[a-zA-Z0-9\-._@+]*") {
501            let name = Name::from_str(&name_str).unwrap();
502            prop_assert_eq!(name_str, format!("{}", name));
503        }
504
505        #[test]
506        fn invalid_name_from_string_start(name_str in r"[-.][a-zA-Z0-9@._+-]*") {
507            let error = Name::from_str(&name_str).unwrap_err();
508            assert!(matches!(error, Error::ParseError(_)));
509        }
510
511        #[test]
512        fn invalid_name_with_invalid_characters(name_str in r"[^\w@._+-]+") {
513            let error = Name::from_str(&name_str).unwrap_err();
514            assert!(matches!(error, Error::ParseError(_)));
515        }
516    }
517
518    #[rstest]
519    #[case("example.so", SharedObjectName("example.so".parse().unwrap()))]
520    #[case("example.so.so", SharedObjectName("example.so.so".parse().unwrap()))]
521    #[case("libexample.1.so", SharedObjectName("libexample.1.so".parse().unwrap()))]
522    fn shared_object_name_parser(
523        #[case] input: &str,
524        #[case] expected_result: SharedObjectName,
525    ) -> testresult::TestResult<()> {
526        let shared_object_name = SharedObjectName::new(input)?;
527        assert_eq!(expected_result, shared_object_name);
528        assert_eq!(input, shared_object_name.as_str());
529        Ok(())
530    }
531
532    #[rstest]
533    #[case("noso")]
534    #[case("example.so.1")]
535    fn invalid_shared_object_name_parser(#[case] input: &str) {
536        let Err(Error::ParseError(err_msg)) = SharedObjectName::from_str(input) else {
537            panic!("'{input}' erroneously parsed as a SonameV2")
538        };
539
540        let (test_name, _guard) = configure_insta();
541        assert_snapshot!(test_name, err_msg.to_string());
542    }
543
544    /// Make sure that invalid shared object names don't deserialize.
545    #[cfg(feature = "serde")]
546    #[rstest]
547    #[case("noso")]
548    #[case("example.so.1")]
549    fn shared_object_deserialize_error(#[case] input: &str) {
550        let Err(serde_json::Error { .. }) =
551            serde_json::from_str::<SharedObjectName>(&format!("\"{input}\""))
552        else {
553            panic!("'{input}' erroneously deserialized as a SharedObjectName")
554        };
555    }
556}