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, 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, Eq, PartialEq, Serialize, Deserialize)]
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 proptest::prelude::*;
306    use rstest::rstest;
307
308    use super::*;
309
310    #[rstest]
311    #[case(
312        "bar",
313        ["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(),
314        Ok(BuildTool::from_str("bar").unwrap()),
315    )]
316    #[case(
317        "bar",
318        ["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(),
319        Err(Error::ValueDoesNotMatchRestrictions {
320            restrictions: vec!["foo".to_string(), "foo".to_string()],
321        }),
322    )]
323    fn buildtool_new_with_restriction(
324        #[case] buildtool: &str,
325        #[case] restrictions: Vec<Name>,
326        #[case] result: Result<BuildTool, Error>,
327    ) {
328        assert_eq!(
329            BuildTool::new_with_restriction(buildtool, &restrictions),
330            result
331        );
332    }
333
334    #[rstest]
335    #[case("bar", ["foo".parse(), "bar".parse()].into_iter().flatten().collect::<Vec<Name>>(), true)]
336    #[case("bar", ["foo".parse(), "foo".parse()].into_iter().flatten().collect::<Vec<Name>>(), false)]
337    fn buildtool_matches_restriction(
338        #[case] buildtool: &str,
339        #[case] restrictions: Vec<Name>,
340        #[case] result: bool,
341    ) {
342        let buildtool = BuildTool::from_str(buildtool).unwrap();
343        assert_eq!(buildtool.matches_restriction(&restrictions), result);
344    }
345
346    #[rstest]
347    #[case("package_name_'''", "invalid character in package name")]
348    #[case("-package_with_leading_hyphen", "invalid first character")]
349    fn name_parse_error(#[case] input: &str, #[case] err_snippet: &str) {
350        let Err(Error::ParseError(err_msg)) = Name::from_str(input) else {
351            panic!("'{input}' erroneously parsed as a Name")
352        };
353        assert!(
354            err_msg.contains(err_snippet),
355            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
356        );
357    }
358
359    proptest! {
360        #![proptest_config(ProptestConfig::with_cases(1000))]
361
362        #[test]
363        fn valid_name_from_string(name_str in r"[a-zA-Z0-9_@+]+[a-zA-Z0-9\-._@+]*") {
364            let name = Name::from_str(&name_str).unwrap();
365            prop_assert_eq!(name_str, format!("{}", name));
366        }
367
368        #[test]
369        fn invalid_name_from_string_start(name_str in r"[-.][a-zA-Z0-9@._+-]*") {
370            let error = Name::from_str(&name_str).unwrap_err();
371            assert!(matches!(error, Error::ParseError(_)));
372        }
373
374        #[test]
375        fn invalid_name_with_invalid_characters(name_str in r"[^\w@._+-]+") {
376            let error = Name::from_str(&name_str).unwrap_err();
377            assert!(matches!(error, Error::ParseError(_)));
378        }
379    }
380
381    #[rstest]
382    #[case("example.so", SharedObjectName("example.so".parse().unwrap()))]
383    #[case("example.so.so", SharedObjectName("example.so.so".parse().unwrap()))]
384    #[case("libexample.1.so", SharedObjectName("libexample.1.so".parse().unwrap()))]
385    fn shared_object_name_parser(
386        #[case] input: &str,
387        #[case] expected_result: SharedObjectName,
388    ) -> testresult::TestResult<()> {
389        let shared_object_name = SharedObjectName::new(input)?;
390        assert_eq!(expected_result, shared_object_name);
391        assert_eq!(input, shared_object_name.as_str());
392        Ok(())
393    }
394
395    #[rstest]
396    #[case("noso", "expected shared object name suffix '.so'")]
397    #[case("example.so.1", "unexpected trailing content after shared object name")]
398    fn invalid_shared_object_name_parser(#[case] input: &str, #[case] error_snippet: &str) {
399        let result = SharedObjectName::from_str(input);
400        assert!(result.is_err(), "Expected SharedObjectName parsing to fail");
401        let err = result.unwrap_err();
402        let pretty_error = err.to_string();
403        assert!(
404            pretty_error.contains(error_snippet),
405            "Error:\n=====\n{pretty_error}\n=====\nshould contain snippet:\n\n{error_snippet}"
406        );
407    }
408}