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