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::iter_char_context;
8use serde::{Deserialize, Serialize};
9use winnow::{
10    ModalResult,
11    Parser,
12    combinator::{Repeat, alt, cut_err, eof, peek, repeat, repeat_till},
13    error::{StrContext, StrContextValue},
14    stream::Stream,
15    token::{any, one_of, rest},
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.inner()))
84    }
85
86    /// Return a reference to the inner type
87    pub fn inner(&self) -> &Name {
88        &self.0
89    }
90}
91
92impl FromStr for BuildTool {
93    type Err = Error;
94    /// Create a BuildTool from a string
95    fn from_str(s: &str) -> Result<BuildTool, Self::Err> {
96        Name::new(s).map(BuildTool)
97    }
98}
99
100impl Display for BuildTool {
101    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
102        write!(fmt, "{}", self.inner())
103    }
104}
105
106/// A package name
107///
108/// Package names may contain the characters `[a-zA-Z0-9\-._@+]`, but must not
109/// start with `[-.]` (see [alpm-package-name]).
110///
111/// ## Examples
112/// ```
113/// use std::str::FromStr;
114///
115/// use alpm_types::{Error, Name};
116///
117/// # fn main() -> Result<(), alpm_types::Error> {
118/// // create Name from &str
119/// assert_eq!(
120///     Name::from_str("test-123@.foo_+"),
121///     Ok(Name::new("test-123@.foo_+")?)
122/// );
123/// assert!(Name::from_str(".test").is_err());
124///
125/// // format as String
126/// assert_eq!("foo", format!("{}", Name::new("foo")?));
127/// # Ok(())
128/// # }
129/// ```
130///
131/// [alpm-package-name]: https://alpm.archlinux.page/specifications/alpm-package-name.7.html
132#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
133pub struct Name(String);
134
135impl Name {
136    /// Create a new `Name`
137    pub fn new(name: &str) -> Result<Self, Error> {
138        Self::from_str(name)
139    }
140
141    /// Return a reference to the inner type
142    pub fn inner(&self) -> &str {
143        &self.0
144    }
145
146    /// Recognizes a [`Name`] in a string slice.
147    ///
148    /// Consumes all of its input.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if `input` contains an invalid _alpm-package-name_.
153    pub fn parser(input: &mut &str) -> ModalResult<Self> {
154        let alphanum = |c: char| c.is_ascii_alphanumeric();
155        let special_first_chars = ['_', '@', '+'];
156        let first_char = one_of((alphanum, special_first_chars))
157            .context(StrContext::Label("first character of package name"))
158            .context(StrContext::Expected(StrContextValue::Description(
159                "ASCII alphanumeric character",
160            )))
161            .context_with(iter_char_context!(special_first_chars));
162
163        let never_first_special_chars = ['_', '@', '+', '-', '.'];
164        let never_first_char = one_of((alphanum, never_first_special_chars));
165
166        // no .context() because this is infallible due to `0..`
167        // note the empty tuple collection to avoid allocation
168        let remaining_chars: Repeat<_, _, _, (), _> = repeat(0.., never_first_char);
169
170        let full_parser = (
171            first_char,
172            remaining_chars,
173            // bad characters fall through to eof so we insert that context here
174            eof.context(StrContext::Label("character in package name"))
175                .context(StrContext::Expected(StrContextValue::Description(
176                    "ASCII alphanumeric character",
177                )))
178                .context_with(iter_char_context!(never_first_special_chars)),
179        );
180
181        full_parser
182            .take()
183            .map(|n: &str| Name(n.to_owned()))
184            .parse_next(input)
185    }
186}
187
188impl FromStr for Name {
189    type Err = Error;
190
191    /// Creates a [`Name`] from a string slice.
192    ///
193    /// Delegates to [`Name::parser`].
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if [`Name::parser`] fails.
198    fn from_str(s: &str) -> Result<Name, Self::Err> {
199        Ok(Self::parser.parse(s)?)
200    }
201}
202
203impl Display for Name {
204    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
205        write!(fmt, "{}", self.inner())
206    }
207}
208
209impl AsRef<str> for Name {
210    fn as_ref(&self) -> &str {
211        self.inner()
212    }
213}
214
215/// A shared object name.
216///
217/// This type wraps a [`Name`] and is used to represent the name of a shared object file
218/// that ends with the `.so` suffix.
219#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
220pub struct SharedObjectName(pub(crate) Name);
221
222impl SharedObjectName {
223    /// Creates a new [`SharedObjectName`].
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if the input does not end with `.so`.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use alpm_types::SharedObjectName;
233    ///
234    /// # fn main() -> Result<(), alpm_types::Error> {
235    /// let shared_object_name = SharedObjectName::new("example.so")?;
236    /// # Ok(())
237    /// # }
238    /// ```
239    pub fn new(name: &str) -> Result<Self, Error> {
240        Self::from_str(name)
241    }
242
243    /// Returns the name of the shared object as a string slice.
244    pub fn as_str(&self) -> &str {
245        self.0.as_ref()
246    }
247
248    /// Parses a [`SharedObjectName`] from a string slice.
249    pub fn parser(input: &mut &str) -> ModalResult<Self> {
250        // Make a checkpoint for parsing the full name in one go later on.
251        // The full name will later on include the `.so` extension, but we have to make sure first
252        // that the name has the correct structure.
253        // (a filename followed by one or more `.so` suffixes)
254        let checkpoint = input.checkpoint();
255
256        // Parse the name of the shared object until eof or the `.so` is hit.
257        repeat_till::<_, _, String, _, _, _, _>(1.., any, peek(alt((".so", eof))))
258            .context(StrContext::Label("name"))
259            .parse_next(input)?;
260
261        // Parse at least one or more `.so` suffix(es).
262        cut_err(repeat::<_, _, String, _, _>(1.., ".so").take())
263            .context(StrContext::Label("suffix"))
264            .context(StrContext::Expected(StrContextValue::Description(
265                "shared object name suffix '.so'",
266            )))
267            .parse_next(input)?;
268
269        // Ensure that there is no trailing content
270        cut_err(eof)
271            .context(StrContext::Label(
272                "unexpected trailing content after shared object name.",
273            ))
274            .context(StrContext::Expected(StrContextValue::Description(
275                "end of input.",
276            )))
277            .parse_next(input)?;
278
279        input.reset(&checkpoint);
280        let name = rest
281            .and_then(Name::parser)
282            .context(StrContext::Label("name"))
283            .parse_next(input)?;
284
285        Ok(SharedObjectName(name))
286    }
287}
288
289impl FromStr for SharedObjectName {
290    type Err = Error;
291    /// Create an [`SharedObjectName`] from a string and return it in a Result
292    fn from_str(s: &str) -> Result<Self, Self::Err> {
293        Ok(Self::parser.parse(s)?)
294    }
295}
296
297impl Display for SharedObjectName {
298    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
299        write!(fmt, "{}", self.0)
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use insta::assert_snapshot;
306    use proptest::prelude::*;
307    use rstest::rstest;
308
309    use super::*;
310    use crate::configure_insta;
311
312    #[rstest]
313    #[case(
314        "bar",
315        ["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(),
316        Ok(BuildTool::from_str("bar").unwrap()),
317    )]
318    #[case(
319        "bar",
320        ["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(),
321        Err(Error::ValueDoesNotMatchRestrictions {
322            restrictions: vec!["foo".to_string(), "foo".to_string()],
323        }),
324    )]
325    fn buildtool_new_with_restriction(
326        #[case] buildtool: &str,
327        #[case] restrictions: Vec<Name>,
328        #[case] result: Result<BuildTool, Error>,
329    ) {
330        assert_eq!(
331            BuildTool::new_with_restriction(buildtool, &restrictions),
332            result
333        );
334    }
335
336    #[rstest]
337    #[case("bar", ["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(), true)]
338    #[case("bar", ["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(), false)]
339    fn buildtool_matches_restriction(
340        #[case] buildtool: &str,
341        #[case] restrictions: Vec<Name>,
342        #[case] result: bool,
343    ) {
344        let buildtool = BuildTool::from_str(buildtool).unwrap();
345        assert_eq!(buildtool.matches_restriction(&restrictions), result);
346    }
347
348    #[rstest]
349    #[case("package_name_'''")]
350    #[case("-package_with_leading_hyphen")]
351    fn name_parse_error(#[case] input: &str) {
352        let Err(Error::ParseError(err_msg)) = Name::from_str(input) else {
353            panic!("'{input}' erroneously parsed as a Name")
354        };
355
356        let (test_name, _guard) = configure_insta();
357        assert_snapshot!(test_name, err_msg.to_string());
358    }
359
360    proptest! {
361        #![proptest_config(ProptestConfig::with_cases(1000))]
362
363        #[test]
364        fn valid_name_from_string(name_str in r"[a-zA-Z0-9_@+]+[a-zA-Z0-9\-._@+]*") {
365            let name = Name::from_str(&name_str).unwrap();
366            prop_assert_eq!(name_str, format!("{}", name));
367        }
368
369        #[test]
370        fn invalid_name_from_string_start(name_str in r"[-.][a-zA-Z0-9@._+-]*") {
371            let error = Name::from_str(&name_str).unwrap_err();
372            assert!(matches!(error, Error::ParseError(_)));
373        }
374
375        #[test]
376        fn invalid_name_with_invalid_characters(name_str in r"[^\w@._+-]+") {
377            let error = Name::from_str(&name_str).unwrap_err();
378            assert!(matches!(error, Error::ParseError(_)));
379        }
380    }
381
382    #[rstest]
383    #[case("example.so", SharedObjectName("example.so".parse().unwrap()))]
384    #[case("example.so.so", SharedObjectName("example.so.so".parse().unwrap()))]
385    #[case("libexample.1.so", SharedObjectName("libexample.1.so".parse().unwrap()))]
386    fn shared_object_name_parser(
387        #[case] input: &str,
388        #[case] expected_result: SharedObjectName,
389    ) -> testresult::TestResult<()> {
390        let shared_object_name = SharedObjectName::new(input)?;
391        assert_eq!(expected_result, shared_object_name);
392        assert_eq!(input, shared_object_name.as_str());
393        Ok(())
394    }
395
396    #[rstest]
397    #[case("noso")]
398    #[case("example.so.1")]
399    fn invalid_shared_object_name_parser(#[case] input: &str) {
400        let Err(Error::ParseError(err_msg)) = SharedObjectName::from_str(input) else {
401            panic!("'{input}' erroneously parsed as a SonameV2")
402        };
403
404        let (test_name, _guard) = configure_insta();
405        assert_snapshot!(test_name, err_msg.to_string());
406    }
407}