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