wasmer_config/package/
named_package_ident.rs

1use std::{borrow::Cow, fmt::Write, str::FromStr};
2
3use semver::VersionReq;
4
5use super::{NamedPackageId, PackageParseError};
6
7#[derive(PartialEq, Eq, Clone, Debug, Hash)]
8pub enum Tag {
9    Named(String),
10    VersionReq(semver::VersionReq),
11}
12
13impl Tag {
14    pub fn as_named(&self) -> Option<&String> {
15        if let Self::Named(v) = self {
16            Some(v)
17        } else {
18            None
19        }
20    }
21
22    pub fn as_version_req(&self) -> Option<&semver::VersionReq> {
23        if let Self::VersionReq(v) = self {
24            Some(v)
25        } else {
26            None
27        }
28    }
29}
30
31impl std::fmt::Display for Tag {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Tag::Named(n) => n.fmt(f),
35            Tag::VersionReq(v) => v.fmt(f),
36        }
37    }
38}
39
40impl std::str::FromStr for Tag {
41    type Err = PackageParseError;
42
43    fn from_str(s: &str) -> Result<Self, Self::Err> {
44        if s == "latest" {
45            Ok(Self::VersionReq(semver::VersionReq::STAR))
46        } else {
47            match semver::VersionReq::from_str(s) {
48                Ok(v) => {
49                    // A successful `VersionReq` parse silently drops any build metadata: a
50                    // `Comparator` has no build field and matching ignores it, so `1.2.3+rel.1`
51                    // would behave as `^1.2.3`. Within this arm a `+` can only be dropped build
52                    // metadata, so reject it.
53                    if s.contains('+') {
54                        return Err(PackageParseError::new(
55                            s,
56                            "build metadata (`+...`) is not supported in a version requirement: \
57                             it is ignored when matching versions and cannot select a specific \
58                             build, so pin an exact version or a package hash instead",
59                        ));
60                    }
61                    Ok(Self::VersionReq(v))
62                }
63                Err(_) => Ok(Self::Named(s.to_string())),
64            }
65        }
66    }
67}
68
69/// Parsed representation of a package identifier.
70///
71/// Format:
72/// `https?://<domain>/namespace/name@version`
73/// where the registry, namespace, and version components are optional.
74#[derive(PartialEq, Eq, Clone, Debug, Hash)]
75pub struct NamedPackageIdent {
76    pub registry: Option<String>,
77    pub namespace: Option<String>,
78    pub name: String,
79    pub tag: Option<Tag>,
80}
81
82impl NamedPackageIdent {
83    pub fn try_from_full_name_and_version(
84        full_name: &str,
85        version: &str,
86    ) -> Result<Self, PackageParseError> {
87        let (namespace, name) = match full_name.split_once('/') {
88            Some((ns, name)) => (Some(ns.to_owned()), name.to_owned()),
89            None => (None, full_name.to_owned()),
90        };
91
92        let version = version
93            .parse::<VersionReq>()
94            .map_err(|e| PackageParseError::new(version, e.to_string()))?;
95
96        Ok(Self {
97            registry: None,
98            namespace,
99            name,
100            tag: Some(Tag::VersionReq(version)),
101        })
102    }
103
104    pub fn tag_str(&self) -> Option<String> {
105        self.tag.as_ref().map(|x| x.to_string())
106    }
107
108    /// Namespaced name.
109    ///
110    /// Eg: "namespace/name"
111    pub fn full_name(&self) -> String {
112        if let Some(ns) = &self.namespace {
113            format!("{}/{}", ns, self.name)
114        } else {
115            self.name.clone()
116        }
117    }
118
119    pub fn version_opt(&self) -> Option<&VersionReq> {
120        match &self.tag {
121            Some(Tag::VersionReq(v)) => Some(v),
122            Some(Tag::Named(_)) | None => None,
123        }
124    }
125
126    pub fn version_or_default(&self) -> VersionReq {
127        match &self.tag {
128            Some(Tag::VersionReq(v)) => v.clone(),
129            Some(Tag::Named(_)) | None => semver::VersionReq::STAR,
130        }
131    }
132
133    pub fn registry_url(&self) -> Result<Option<url::Url>, PackageParseError> {
134        let Some(reg) = &self.registry else {
135            return Ok(None);
136        };
137
138        let reg = if !reg.starts_with("http://") && !reg.starts_with("https://") {
139            format!("https://{reg}")
140        } else {
141            reg.clone()
142        };
143
144        url::Url::parse(&reg)
145            .map_err(|e| PackageParseError::new(reg, e.to_string()))
146            .map(Some)
147    }
148
149    /// Build the ident for a package.
150    ///
151    /// Format: `NAMESPACE/NAME@tag`
152    /// where the namespace and tag components are optional.
153    pub fn build_identifier(&self) -> String {
154        let mut ident = if let Some(ns) = &self.namespace {
155            format!("{}/{}", ns, self.name)
156        } else {
157            self.name.to_string()
158        };
159
160        if let Some(tag) = &self.tag {
161            ident.push('@');
162            // Writing to a string only fails on memory allocation errors.
163            write!(&mut ident, "{tag}").unwrap();
164        }
165        ident
166    }
167
168    pub fn build(&self) -> String {
169        let mut out = String::new();
170        if let Some(url) = &self.registry {
171            // NOTE: writing to a String can only fail on allocation errors.
172            write!(&mut out, "{url}").unwrap();
173
174            if !out.ends_with('/') {
175                out.push(':');
176            }
177        }
178        if let Some(ns) = &self.namespace {
179            out.push_str(ns);
180            out.push('/');
181        }
182        out.push_str(&self.name);
183        if let Some(tag) = &self.tag {
184            out.push('@');
185            // Writing to a string only fails on memory allocation errors.
186            write!(&mut out, "{tag}").unwrap();
187        }
188
189        out
190    }
191
192    /// Returns true if this ident matches the given package id.
193    ///
194    /// Semver constraints are matched against the package id's version.
195    pub fn matches_id(&self, id: &NamedPackageId) -> bool {
196        if self.full_name() == id.full_name {
197            if let Some(tag) = &self.tag {
198                match tag {
199                    Tag::Named(n) => n == &id.version.to_string(),
200                    Tag::VersionReq(v) => v.matches(&id.version),
201                }
202            } else {
203                true
204            }
205        } else {
206            false
207        }
208    }
209}
210
211impl From<NamedPackageId> for NamedPackageIdent {
212    fn from(value: NamedPackageId) -> Self {
213        let (namespace, name) = match value.full_name.split_once('/') {
214            Some((ns, name)) => (Some(ns.to_owned()), name.to_owned()),
215            None => (None, value.full_name),
216        };
217
218        Self {
219            registry: None,
220            namespace,
221            name,
222            tag: Some(Tag::VersionReq(semver::VersionReq {
223                comparators: vec![semver::Comparator {
224                    op: semver::Op::Exact,
225                    major: value.version.major,
226                    minor: Some(value.version.minor),
227                    patch: Some(value.version.patch),
228                    pre: value.version.pre,
229                }],
230            })),
231        }
232    }
233}
234
235impl std::str::FromStr for NamedPackageIdent {
236    type Err = PackageParseError;
237
238    fn from_str(value: &str) -> Result<Self, Self::Err> {
239        let (rest, tag_opt) = value
240            .trim()
241            .rsplit_once('@')
242            .map(|(x, y)| (x, if y.is_empty() { None } else { Some(y) }))
243            .unwrap_or((value, None));
244
245        let tag = if let Some(v) = tag_opt.filter(|x| !x.is_empty()) {
246            Some(Tag::from_str(v)?)
247        } else {
248            None
249        };
250
251        let (rest, name) = if let Some((r, n)) = rest.rsplit_once('/') {
252            (r, n)
253        } else {
254            ("", rest)
255        };
256
257        let name = name.trim();
258        if name.is_empty() {
259            return Err(PackageParseError::new(value, "package name is required"));
260        }
261
262        let (rest, namespace) = if rest.is_empty() {
263            ("", None)
264        } else {
265            let (rest, ns) = rest.rsplit_once(':').unwrap_or(("", rest));
266
267            let ns = ns.trim();
268
269            if ns.is_empty() {
270                return Err(PackageParseError::new(value, "namespace can not be empty"));
271            }
272            (rest, Some(ns.to_string()))
273        };
274
275        let rest = rest.trim();
276        let registry = if rest.is_empty() {
277            None
278        } else {
279            Some(rest.to_string())
280        };
281
282        Ok(Self {
283            registry,
284            namespace,
285            name: name.to_string(),
286            tag,
287        })
288    }
289}
290
291impl std::fmt::Display for NamedPackageIdent {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        write!(f, "{}", self.build())
294    }
295}
296
297impl serde::Serialize for NamedPackageIdent {
298    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
299    where
300        S: serde::ser::Serializer,
301    {
302        self.to_string().serialize(serializer)
303    }
304}
305
306impl<'de> serde::Deserialize<'de> for NamedPackageIdent {
307    fn deserialize<D>(deserializer: D) -> Result<NamedPackageIdent, D::Error>
308    where
309        D: serde::de::Deserializer<'de>,
310    {
311        let s = String::deserialize(deserializer)?;
312        Self::from_str(&s).map_err(serde::de::Error::custom)
313    }
314}
315
316impl schemars::JsonSchema for NamedPackageIdent {
317    fn schema_name() -> Cow<'static, str> {
318        Cow::Borrowed("NamedPackageIdent")
319    }
320
321    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
322        String::json_schema(generator)
323    }
324
325    fn inline_schema() -> bool {
326        false
327    }
328
329    fn schema_id() -> Cow<'static, str> {
330        Self::schema_name()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use std::str::FromStr;
337
338    use crate::package::PackageParseError;
339
340    use super::*;
341
342    #[test]
343    fn test_parse_webc_ident() {
344        // Success cases.
345
346        assert_eq!(
347            NamedPackageIdent::from_str("ns/name").unwrap(),
348            NamedPackageIdent {
349                registry: None,
350                namespace: Some("ns".to_string()),
351                name: "name".to_string(),
352                tag: None,
353            }
354        );
355
356        assert_eq!(
357            NamedPackageIdent::from_str("ns/name@").unwrap(),
358            NamedPackageIdent {
359                registry: None,
360                namespace: Some("ns".to_string()),
361                name: "name".to_string(),
362                tag: None,
363            },
364            "empty tag should be parsed as None"
365        );
366
367        assert_eq!(
368            NamedPackageIdent::from_str("ns/name@tag").unwrap(),
369            NamedPackageIdent {
370                registry: None,
371                namespace: Some("ns".to_string()),
372                name: "name".to_string(),
373                tag: Some(Tag::Named("tag".to_string())),
374            }
375        );
376
377        assert_eq!(
378            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
379            NamedPackageIdent {
380                registry: Some("reg.com".to_string()),
381                namespace: Some("ns".to_string()),
382                name: "name".to_string(),
383                tag: None,
384            }
385        );
386
387        assert_eq!(
388            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
389            NamedPackageIdent {
390                registry: Some("reg.com".to_string()),
391                namespace: Some("ns".to_string()),
392                name: "name".to_string(),
393                tag: Some(Tag::Named("tag".to_string())),
394            }
395        );
396
397        assert_eq!(
398            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
399            NamedPackageIdent {
400                registry: Some("reg.com".to_string()),
401                namespace: Some("ns".to_string()),
402                name: "name".to_string(),
403                tag: None,
404            }
405        );
406
407        assert_eq!(
408            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
409            NamedPackageIdent {
410                registry: Some("reg.com".to_string()),
411                namespace: Some("ns".to_string()),
412                name: "name".to_string(),
413                tag: Some(Tag::Named("tag".to_string())),
414            }
415        );
416
417        assert_eq!(
418            NamedPackageIdent::from_str("reg.com:ns/name").unwrap(),
419            NamedPackageIdent {
420                registry: Some("reg.com".to_string()),
421                namespace: Some("ns".to_string()),
422                name: "name".to_string(),
423                tag: None,
424            }
425        );
426
427        assert_eq!(
428            NamedPackageIdent::from_str("reg.com:ns/name@tag").unwrap(),
429            NamedPackageIdent {
430                registry: Some("reg.com".to_string()),
431                namespace: Some("ns".to_string()),
432                name: "name".to_string(),
433                tag: Some(Tag::Named("tag".to_string())),
434            }
435        );
436
437        // Failure cases.
438
439        assert_eq!(
440            NamedPackageIdent::from_str("alpha").unwrap(),
441            NamedPackageIdent {
442                registry: None,
443                namespace: None,
444                name: "alpha".to_string(),
445                tag: None,
446            },
447        );
448
449        assert_eq!(
450            NamedPackageIdent::from_str(""),
451            Err(PackageParseError::new("", "package name is required"))
452        );
453    }
454
455    #[test]
456    fn test_reject_build_metadata_in_version_req() {
457        // Build metadata silently drops out of a `VersionReq`, so it must be rejected.
458        assert!(
459            Tag::from_str("1.2.3+rel.1").is_err(),
460            "build metadata must be rejected in a version requirement"
461        );
462        assert!(
463            NamedPackageIdent::from_str("ns/name@1.2.3+rel.1").is_err(),
464            "a package ident with build metadata in its version must be rejected"
465        );
466
467        // A plain version requirement (no build metadata) still parses.
468        assert_eq!(
469            NamedPackageIdent::from_str("ns/name@1.2.3").unwrap().tag,
470            Some(Tag::VersionReq(VersionReq::from_str("1.2.3").unwrap())),
471        );
472
473        // A `+` in a string that is not a valid version requirement still parses as a named tag.
474        assert_eq!(
475            Tag::from_str("my+tag").unwrap(),
476            Tag::Named("my+tag".to_string()),
477        );
478
479        // The rejection also surfaces through `PackageSource`, with the specific
480        // message rather than a generic "invalid package ident" error.
481        let err = crate::package::PackageSource::from_str("ns/name@1.2.3+rel.1")
482            .expect_err("build metadata must be rejected via PackageSource too");
483        assert!(
484            err.to_string().contains("build metadata"),
485            "user-facing error should explain the build metadata problem, got: {err}"
486        );
487    }
488
489    #[test]
490    fn test_serde_serialize_package_ident_with_repo() {
491        // Serialize
492        let ident = NamedPackageIdent {
493            registry: Some("wapm.io".to_string()),
494            namespace: Some("ns".to_string()),
495            name: "name".to_string(),
496            tag: None,
497        };
498
499        let raw = serde_json::to_string(&ident).unwrap();
500        assert_eq!(raw, "\"wapm.io:ns/name\"");
501
502        let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
503        assert_eq!(ident, ident2);
504    }
505
506    #[test]
507    fn test_serde_serialize_webc_str_ident_without_repo() {
508        // Serialize
509        let ident = NamedPackageIdent {
510            registry: None,
511            namespace: Some("ns".to_string()),
512            name: "name".to_string(),
513            tag: None,
514        };
515
516        let raw = serde_json::to_string(&ident).unwrap();
517        assert_eq!(raw, "\"ns/name\"");
518
519        let ident2 = serde_json::from_str::<NamedPackageIdent>(&raw).unwrap();
520        assert_eq!(ident, ident2);
521    }
522
523    #[test]
524    fn test_named_package_ident_matches_id() {
525        assert!(
526            NamedPackageIdent::from_str("ns/name")
527                .unwrap()
528                .matches_id(&NamedPackageId::try_new("ns/name", "0.1.0").unwrap())
529        );
530
531        assert!(
532            NamedPackageIdent::from_str("ns/name")
533                .unwrap()
534                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
535        );
536
537        assert!(
538            NamedPackageIdent::from_str("ns/name@1")
539                .unwrap()
540                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
541        );
542
543        assert!(
544            !NamedPackageIdent::from_str("ns/name@2")
545                .unwrap()
546                .matches_id(&NamedPackageId::try_new("ns/name", "1.0.1").unwrap())
547        );
548    }
549}