Skip to main content

callisto_model/
identity.rs

1use std::cmp::Ordering;
2use std::fmt;
3use std::str::FromStr;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::{Ecosystem, ModelError};
9
10/// Package identity across ecosystems.
11#[derive(Clone, Debug, PartialEq, Eq, Hash, JsonSchema)]
12#[schemars(with = "String")]
13pub enum PackageId {
14    Bare(String),
15    Prefixed { ecosystem: Ecosystem, name: String },
16}
17
18impl PackageId {
19    /// Parses package identity string.
20    ///
21    /// # Errors
22    ///
23    /// Returns `Err` if `s` is empty, starts with `/`, contains `..`, or has a known ecosystem prefix followed by an empty name.
24    pub fn parse(s: &str) -> Result<Self, PackageIdParseError> {
25        if s.is_empty() {
26            return Err(PackageIdParseError::Empty);
27        }
28        if s.starts_with('/') {
29            return Err(PackageIdParseError::LeadingSlash { raw: s.to_string() });
30        }
31        if s.starts_with('-') {
32            return Err(PackageIdParseError::LeadingHyphen { raw: s.to_string() });
33        }
34        if s.contains("..") {
35            return Err(PackageIdParseError::PathTraversal { raw: s.to_string() });
36        }
37
38        if let Some((prefix, remainder)) = s.split_once(':') {
39            if let Some(ecosystem) = Ecosystem::from_prefix(prefix) {
40                if remainder.is_empty() {
41                    return Err(PackageIdParseError::EmptyNameAfterPrefix {
42                        raw: s.to_string(),
43                        prefix: prefix.to_string(),
44                    });
45                }
46                if remainder.starts_with('-') {
47                    return Err(PackageIdParseError::LeadingHyphen { raw: s.to_string() });
48                }
49                if remainder.contains("..") {
50                    return Err(PackageIdParseError::PathTraversal { raw: s.to_string() });
51                }
52                return Ok(PackageId::Prefixed {
53                    ecosystem,
54                    name: remainder.to_string(),
55                });
56            }
57        }
58
59        if let Some((prefix, remainder)) = s.split_once('/') {
60            if let Some(ecosystem) = Ecosystem::from_prefix(prefix) {
61                if remainder.is_empty() {
62                    return Err(PackageIdParseError::EmptyNameAfterPrefix {
63                        raw: s.to_string(),
64                        prefix: prefix.to_string(),
65                    });
66                }
67                if remainder.starts_with('-') {
68                    return Err(PackageIdParseError::LeadingHyphen { raw: s.to_string() });
69                }
70                if remainder.contains("..") {
71                    return Err(PackageIdParseError::PathTraversal { raw: s.to_string() });
72                }
73                return Ok(PackageId::Prefixed {
74                    ecosystem,
75                    name: remainder.to_string(),
76                });
77            }
78        }
79
80        Ok(PackageId::Bare(s.to_string()))
81    }
82
83    /// Returns the canonical display form: bare names as-is, prefixed ids as `ecosystem/name`.
84    pub fn display_name(&self) -> String {
85        match self {
86            PackageId::Bare(name) => name.clone(),
87            PackageId::Prefixed { ecosystem, name } => {
88                format!("{}/{}", ecosystem.prefix(), name)
89            }
90        }
91    }
92
93    /// Returns the ecosystem this id is scoped to, or `None` for a [`PackageId::Bare`] id.
94    pub fn ecosystem(&self) -> Option<Ecosystem> {
95        match self {
96            PackageId::Bare(_) => None,
97            PackageId::Prefixed { ecosystem, .. } => Some(*ecosystem),
98        }
99    }
100
101    /// Returns the package name without any ecosystem prefix.
102    pub fn name(&self) -> &str {
103        match self {
104            PackageId::Bare(name) => name,
105            PackageId::Prefixed { name, .. } => name,
106        }
107    }
108
109    /// Weak "could be the same package" check, not strict equality.
110    ///
111    /// `Bare(x)` matches any `Prefixed(_, x)` (bare is an ecosystem wildcard);
112    /// `Prefixed(e1, x)` matches `Prefixed(e2, x)` only if `e1 == e2`; exact
113    /// equality always matches.
114    ///
115    /// **Caller contract**: in a polyglot workspace, a bare lookup can match
116    /// packages in two or more ecosystems (e.g. `foo` in both Cargo and npm).
117    /// Callers must collect *all* matches and error on 2+ — see
118    /// `aggregate::resolve_target_package`'s `AmbiguousName` pattern — never
119    /// take the first match silently. A plain existence check (`.any()`) is
120    /// safe only because the aggregation layer already rejects ambiguous
121    /// references upstream.
122    pub fn matches(&self, other: &Self) -> bool {
123        if self == other {
124            return true;
125        }
126        if self.name() == other.name() {
127            match (self.ecosystem(), other.ecosystem()) {
128                (None, _) | (_, None) => true,
129                (Some(e1), Some(e2)) => e1 == e2,
130            }
131        } else {
132            false
133        }
134    }
135
136    /// The single implementation of the collect-and-error pattern
137    /// [`matches`](Self::matches)'s own doc comment requires every caller
138    /// to hand-roll: filters `items` down to those whose id (via `id_of`)
139    /// matches `self`, then resolves the result to exactly one, none, or
140    /// reports the ambiguity.
141    ///
142    /// - `Ok(None)`: no item matches.
143    /// - `Ok(Some(item))`: exactly one item matches -- the unambiguous case.
144    /// - `Err(candidates)`: two or more items match. The caller decides how
145    ///   to report this (e.g. as a domain-specific "ambiguous name" error
146    ///   naming `self` and `candidates`) -- this method stays generic over
147    ///   any `T` (a `Package`, a bare `PackageId`, ...) rather than forcing
148    ///   a particular error type on every layer that needs this check.
149    pub fn resolve_unique<'a, T>(
150        &self,
151        items: impl Iterator<Item = &'a T>,
152        id_of: impl Fn(&'a T) -> &'a PackageId,
153    ) -> Result<Option<&'a T>, Vec<&'a T>> {
154        let matching: Vec<&'a T> = items.filter(|item| id_of(item).matches(self)).collect();
155        match matching.len() {
156            0 => Ok(None),
157            1 => Ok(Some(matching[0])),
158            _ => Err(matching),
159        }
160    }
161}
162
163/// Trait for package identity resolution across ecosystem boundaries.
164pub trait PackageIdentityResolver {
165    /// Returns true if two package IDs refer to the same logical package.
166    fn matches_id(&self, other: &PackageId) -> bool;
167}
168
169impl PackageIdentityResolver for PackageId {
170    fn matches_id(&self, other: &PackageId) -> bool {
171        self.matches(other)
172    }
173}
174
175impl fmt::Display for PackageId {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(&self.display_name())
178    }
179}
180
181impl FromStr for PackageId {
182    type Err = PackageIdParseError;
183
184    fn from_str(s: &str) -> Result<Self, Self::Err> {
185        PackageId::parse(s)
186    }
187}
188
189impl PartialOrd for PackageId {
190    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
191        Some(self.cmp(other))
192    }
193}
194
195impl Ord for PackageId {
196    fn cmp(&self, other: &Self) -> Ordering {
197        let key_self = (self.ecosystem(), self.name());
198        let key_other = (other.ecosystem(), other.name());
199        key_self.cmp(&key_other)
200    }
201}
202
203impl Serialize for PackageId {
204    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
205    where
206        S: Serializer,
207    {
208        serializer.serialize_str(&self.display_name())
209    }
210}
211
212impl<'de> Deserialize<'de> for PackageId {
213    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
214    where
215        D: Deserializer<'de>,
216    {
217        let s = String::deserialize(deserializer)?;
218        PackageId::parse(&s).map_err(serde::de::Error::custom)
219    }
220}
221
222/// Errors produced by [`PackageId::parse`].
223#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
224#[non_exhaustive]
225pub enum PackageIdParseError {
226    #[error("package identity is empty")]
227    Empty,
228    #[error("package identity `{raw}` has ecosystem prefix `{prefix}` but no name after it")]
229    EmptyNameAfterPrefix { raw: String, prefix: String },
230    #[error("`{raw}` starts with `/`")]
231    LeadingSlash { raw: String },
232    #[error("package identity `{raw}` contains path traversal `..`")]
233    PathTraversal { raw: String },
234    #[error("package identity `{raw}` starts with `-`, which could be misread as a command-line flag")]
235    LeadingHyphen { raw: String },
236}
237
238/// A group name for fixed or linked package groups.
239#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
240#[schemars(with = "String")]
241#[serde(transparent)]
242pub struct GroupName(pub String);
243
244impl GroupName {
245    /// Returns the group name as a string slice.
246    pub fn as_str(&self) -> &str {
247        &self.0
248    }
249}
250
251impl fmt::Display for GroupName {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        f.write_str(&self.0)
254    }
255}
256
257/// Group kind: Fixed vs Linked.
258#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "lowercase")]
260pub enum GroupKind {
261    Fixed,
262    Linked,
263}
264
265/// Registry key string e.g. "cratesIo", "npm".
266#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
267#[schemars(with = "String")]
268#[serde(transparent)]
269pub struct RegistryKey(pub String);
270
271impl RegistryKey {
272    /// Well-known registry key for crates.io.
273    pub const CRATES_IO: &'static str = "cratesIo";
274    /// Well-known registry key for the npm registry.
275    pub const NPM: &'static str = "npm";
276    /// Well-known registry key for PyPI.
277    pub const PYPI: &'static str = "pypi";
278    /// Well-known registry key for NuGet.
279    pub const NUGET: &'static str = "nuget";
280
281    /// Returns the registry key as a string slice.
282    pub fn as_str(&self) -> &str {
283        &self.0
284    }
285}
286
287impl fmt::Display for RegistryKey {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.write_str(&self.0)
290    }
291}
292
293/// A 40-character hex Git commit SHA.
294#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, JsonSchema)]
295#[schemars(with = "String")]
296pub struct CommitSha(String);
297
298impl AsRef<str> for CommitSha {
299    fn as_ref(&self) -> &str {
300        &self.0
301    }
302}
303
304impl CommitSha {
305    /// Parses a 40-character hexadecimal Git commit SHA, trimming surrounding whitespace.
306    ///
307    /// # Errors
308    ///
309    /// Returns `Err(ModelError::InvalidCommitSha)` if the trimmed input is not exactly 40 ASCII hex digits.
310    pub fn parse(s: &str) -> Result<Self, ModelError> {
311        let trimmed = s.trim();
312        if trimmed.len() != 40 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
313            return Err(ModelError::InvalidCommitSha {
314                raw: s.to_string(),
315                reason: "must be exactly 40 hexadecimal characters".to_string(),
316            });
317        }
318        Ok(CommitSha(trimmed.to_lowercase()))
319    }
320
321    /// Returns the full 40-character lowercase hex SHA.
322    pub fn as_str(&self) -> &str {
323        &self.0
324    }
325
326    /// Returns the first 7 characters of the SHA, matching git's short-ref convention.
327    pub fn short(&self) -> &str {
328        &self.0[..7]
329    }
330}
331
332impl Serialize for CommitSha {
333    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
334    where
335        S: Serializer,
336    {
337        serializer.serialize_str(&self.0)
338    }
339}
340
341impl<'de> Deserialize<'de> for CommitSha {
342    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
343    where
344        D: Deserializer<'de>,
345    {
346        let s = String::deserialize(deserializer)?;
347        CommitSha::parse(&s).map_err(serde::de::Error::custom)
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn parses_package_ids() {
357        assert_eq!(PackageId::parse("foo").unwrap(), PackageId::Bare("foo".to_string()));
358        assert_eq!(
359            PackageId::parse("@myorg/foo").unwrap(),
360            PackageId::Bare("@myorg/foo".to_string())
361        );
362        assert_eq!(
363            PackageId::parse("cargo/foo").unwrap(),
364            PackageId::Prefixed {
365                ecosystem: Ecosystem::Cargo,
366                name: "foo".to_string()
367            }
368        );
369        assert_eq!(
370            PackageId::parse("npm/@myorg/foo").unwrap(),
371            PackageId::Prefixed {
372                ecosystem: Ecosystem::Npm,
373                name: "@myorg/foo".to_string()
374            }
375        );
376        assert_eq!(
377            PackageId::parse("npm:@myorg/foo").unwrap(),
378            PackageId::Prefixed {
379                ecosystem: Ecosystem::Npm,
380                name: "@myorg/foo".to_string()
381            }
382        );
383    }
384
385    #[test]
386    fn test_package_id_matches() {
387        let bare = PackageId::parse("foo").unwrap();
388        let prefixed_cargo = PackageId::parse("cargo/foo").unwrap();
389        let prefixed_npm = PackageId::parse("npm/foo").unwrap();
390
391        assert!(bare.matches(&prefixed_cargo));
392        assert!(prefixed_cargo.matches(&bare));
393        assert!(bare.matches(&prefixed_npm));
394        assert!(!prefixed_cargo.matches(&prefixed_npm));
395    }
396
397    #[test]
398    fn resolve_unique_returns_none_when_nothing_matches() {
399        let ids = [
400            PackageId::parse("cargo/foo").unwrap(),
401            PackageId::parse("cargo/bar").unwrap(),
402        ];
403        let target = PackageId::parse("baz").unwrap();
404        let result = target.resolve_unique(ids.iter(), |id| id);
405        assert_eq!(result, Ok(None));
406    }
407
408    #[test]
409    fn resolve_unique_returns_the_single_match() {
410        let ids = [
411            PackageId::parse("cargo/foo").unwrap(),
412            PackageId::parse("cargo/bar").unwrap(),
413        ];
414        let target = PackageId::parse("foo").unwrap();
415        let result = target.resolve_unique(ids.iter(), |id| id);
416        assert_eq!(result, Ok(Some(&ids[0])));
417    }
418
419    #[test]
420    fn resolve_unique_errs_with_all_candidates_on_ambiguity() {
421        // A bare target name that exists in two ecosystems is exactly the
422        // polyglot-workspace ambiguity `matches()`'s doc comment warns
423        // about -- both must be returned, not silently the first one.
424        let ids = [
425            PackageId::parse("cargo/foo").unwrap(),
426            PackageId::parse("npm/foo").unwrap(),
427        ];
428        let target = PackageId::parse("foo").unwrap();
429        let result = target.resolve_unique(ids.iter(), |id| id);
430        let candidates = result.expect_err("two matches must be Err, not silently the first");
431        assert_eq!(candidates.len(), 2);
432        assert!(candidates.contains(&&ids[0]));
433        assert!(candidates.contains(&&ids[1]));
434    }
435
436    #[test]
437    fn resolve_unique_works_generically_over_a_wrapping_type() {
438        // Proves the `id_of` projection generalizes beyond bare PackageId
439        // (e.g. aggregate.rs's real use case: resolving against &Package,
440        // not &PackageId directly).
441        #[derive(Debug)]
442        struct Item {
443            id: PackageId,
444            label: &'static str,
445        }
446        let items = [
447            Item {
448                id: PackageId::parse("cargo/foo").unwrap(),
449                label: "first",
450            },
451            Item {
452                id: PackageId::parse("npm/bar").unwrap(),
453                label: "second",
454            },
455        ];
456        let target = PackageId::parse("bar").unwrap();
457        let result = target.resolve_unique(items.iter(), |item| &item.id);
458        assert_eq!(result.unwrap().unwrap().label, "second");
459    }
460
461    #[test]
462    fn package_id_rejects_path_traversal() {
463        assert!(PackageId::parse("/etc/passwd").is_err(), "must reject leading slashes");
464
465        let dotdot_err = PackageId::parse("../../secret").unwrap_err();
466        assert!(
467            matches!(dotdot_err, PackageIdParseError::PathTraversal { .. }),
468            "expected PathTraversal for a genuine `..` traversal, got: {dotdot_err:?}"
469        );
470        assert!(
471            dotdot_err.to_string().contains(".."),
472            "PathTraversal's message must actually reference the `..` it found, got: {dotdot_err}"
473        );
474    }
475
476    /// A leading `-` is not path traversal at all (no `..` anywhere in the
477    /// input) -- it's rejected because it could be misread as a CLI flag by
478    /// a shelled-out command downstream. The old shared `PathTraversal`
479    /// variant's message ("contains path traversal `..`") was factually
480    /// false for this input; it must get its own variant with an accurate
481    /// message, for the bare form and both prefixed forms.
482    #[test]
483    fn package_id_rejects_leading_hyphen_with_accurate_error() {
484        for input in ["-x", "cargo:-x", "cargo/-x"] {
485            let err = PackageId::parse(input).unwrap_err();
486            assert!(
487                matches!(err, PackageIdParseError::LeadingHyphen { .. }),
488                "expected LeadingHyphen for `{input}`, got: {err:?}"
489            );
490            let msg = err.to_string();
491            assert!(
492                !msg.contains(".."),
493                "LeadingHyphen's message must not falsely claim `..` path traversal, got: {msg}"
494            );
495            assert!(
496                msg.contains('-'),
497                "LeadingHyphen's message should reference the offending `-`, got: {msg}"
498            );
499        }
500    }
501
502    #[test]
503    fn parses_valid_commit_sha() {
504        let sha_str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
505        let sha = CommitSha::parse(sha_str).unwrap();
506        assert_eq!(sha.as_str(), sha_str);
507        assert_eq!(sha.short(), "a1b2c3d");
508    }
509
510    use proptest::prelude::*;
511    proptest! {
512        #[test]
513        fn proptest_package_id_parse_never_panics(s in "\\PC*") {
514            let _res = PackageId::parse(&s);
515        }
516
517        #[test]
518        fn proptest_package_id_matches_identity(name in "[a-z][a-z0-9_-]{0,29}") {
519            let bare = PackageId::parse(&name).unwrap();
520            let prefixed = PackageId::parse(&format!("cargo/{}", name)).unwrap();
521            prop_assert!(bare.matches(&prefixed));
522            prop_assert!(prefixed.matches(&bare));
523        }
524    }
525}