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#[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 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 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 pub fn ecosystem(&self) -> Option<Ecosystem> {
95 match self {
96 PackageId::Bare(_) => None,
97 PackageId::Prefixed { ecosystem, .. } => Some(*ecosystem),
98 }
99 }
100
101 pub fn name(&self) -> &str {
103 match self {
104 PackageId::Bare(name) => name,
105 PackageId::Prefixed { name, .. } => name,
106 }
107 }
108
109 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 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
163pub trait PackageIdentityResolver {
165 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#[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#[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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259#[serde(rename_all = "lowercase")]
260pub enum GroupKind {
261 Fixed,
262 Linked,
263}
264
265#[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 pub const CRATES_IO: &'static str = "cratesIo";
274 pub const NPM: &'static str = "npm";
276 pub const PYPI: &'static str = "pypi";
278 pub const NUGET: &'static str = "nuget";
280
281 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#[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 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 pub fn as_str(&self) -> &str {
323 &self.0
324 }
325
326 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 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 #[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 #[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}