1use std::fmt;
4use std::path::Path;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use serde::Deserialize;
9use serde::Serialize;
10use serde_with::DeserializeFromStr;
11use serde_with::SerializeDisplay;
12use thiserror::Error;
13use url::Url;
14
15use crate::lockfile::GitCommitish;
16use crate::lockfile::GitCommitishError;
17use crate::relative_path::RelativePath;
18use crate::relative_path::RelativePathError;
19use crate::version_requirement::VersionRequirement;
20use crate::version_requirement::VersionRequirementError;
21
22const GIT_PATHSPEC_CHARACTERS: [char; 5] = ['*', '?', '[', ']', '\\'];
24
25const GIT_PATHSPEC_PREFIXES: [char; 3] = [':', '!', '^'];
27
28#[derive(Clone, Debug, Eq, Error, PartialEq)]
30pub enum GitModulePathError {
31 #[error(transparent)]
33 Invalid(#[from] RelativePathError),
34
35 #[error("git module path must not be `.`")]
38 Dot,
39
40 #[error("git module path contains reserved git pathspec character `{0}`")]
42 Pathspec(char),
43}
44
45#[derive(
53 Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
54)]
55pub struct GitModulePath(RelativePath);
56
57impl GitModulePath {
58 pub fn as_str(&self) -> &str {
60 self.0.as_str()
61 }
62
63 pub fn as_path(&self) -> &Path {
65 self.0.as_path()
66 }
67
68 pub fn into_relative_path(self) -> RelativePath {
71 self.0
72 }
73}
74
75impl AsRef<str> for GitModulePath {
76 fn as_ref(&self) -> &str {
77 self.0.as_ref()
78 }
79}
80
81impl AsRef<Path> for GitModulePath {
82 fn as_ref(&self) -> &Path {
83 self.0.as_ref()
84 }
85}
86
87impl std::fmt::Display for GitModulePath {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(f, "{}", self.0)
90 }
91}
92
93impl From<GitModulePath> for String {
94 fn from(path: GitModulePath) -> Self {
95 path.0.into()
96 }
97}
98
99impl From<GitModulePath> for PathBuf {
100 fn from(path: GitModulePath) -> Self {
101 path.0.into()
102 }
103}
104
105impl FromStr for GitModulePath {
106 type Err = GitModulePathError;
107
108 fn from_str(s: &str) -> Result<Self, Self::Err> {
109 if s.is_empty() {
110 return Err(RelativePathError::Empty.into());
111 }
112 if s == "." {
113 return Err(GitModulePathError::Dot);
114 }
115 if let Some(character) = s
116 .chars()
117 .find(|character| GIT_PATHSPEC_CHARACTERS.contains(character))
118 .or_else(|| {
119 GIT_PATHSPEC_PREFIXES
120 .iter()
121 .copied()
122 .find(|prefix| s.starts_with(*prefix))
123 })
124 {
125 return Err(GitModulePathError::Pathspec(character));
126 }
127 Ok(Self(RelativePath::from_str(s)?))
128 }
129}
130
131impl TryFrom<&Path> for GitModulePath {
132 type Error = GitModulePathError;
133
134 fn try_from(path: &Path) -> Result<Self, Self::Error> {
135 path.to_str().ok_or(RelativePathError::NonUtf8)?.parse()
136 }
137}
138
139#[derive(Debug, Error)]
141pub enum DependencySourceError {
142 #[error(
149 "dependency source is invalid: {reason}; must specify either `path` for a local-path \
150 source, or `git` with exactly one of `version`, `tag`, `branch`, or `commit` for a Git \
151 source"
152 )]
153 InvalidSource {
154 reason: &'static str,
156 },
157
158 #[error(transparent)]
160 VersionRequirement(#[from] VersionRequirementError),
161
162 #[error(transparent)]
164 GitCommit(#[from] GitCommitishError),
165
166 #[error("invalid Git URL: {0}")]
168 InvalidUrl(String),
169
170 #[error("invalid `path` on Git dependency: {0}")]
172 InvalidGitPath(#[from] GitModulePathError),
173}
174
175#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(try_from = "DependencySourceFields", into = "DependencySourceFields")]
181pub enum DependencySource {
182 Git {
184 url: Url,
186 selector: GitSelector,
188 path: Option<GitModulePath>,
190 extra: serde_json::Map<String, serde_json::Value>,
192 },
193 LocalPath {
195 path: PathBuf,
197 extra: serde_json::Map<String, serde_json::Value>,
199 },
200}
201
202impl TryFrom<DependencySourceFields> for DependencySource {
203 type Error = DependencySourceError;
204
205 fn try_from(fields: DependencySourceFields) -> Result<Self, Self::Error> {
206 let DependencySourceFields {
207 git,
208 path,
209 version,
210 tag,
211 branch,
212 commit,
213 extra,
214 } = fields;
215
216 let selector_count = [&version, &tag, &branch, &commit]
217 .iter()
218 .filter(|s| s.is_some())
219 .count();
220
221 match (git, path) {
222 (Some(g), git_subpath) => {
223 if selector_count == 0 {
224 return Err(DependencySourceError::InvalidSource {
225 reason: "Git dependency is missing a selector",
226 });
227 }
228 if selector_count > 1 {
229 return Err(DependencySourceError::InvalidSource {
230 reason: "Git dependency specifies more than one selector",
231 });
232 }
233 let url =
234 Url::parse(&g).map_err(|e| DependencySourceError::InvalidUrl(e.to_string()))?;
235 let selector = if let Some(v) = version {
236 GitSelector::Version(v.parse::<VersionRequirement>()?)
237 } else if let Some(t) = tag {
238 GitSelector::Tag(t)
239 } else if let Some(b) = branch {
240 GitSelector::Branch(b)
241 } else if let Some(c) = commit {
242 GitSelector::Commit(GitCommitish::try_from(c)?)
243 } else {
244 unreachable!()
248 };
249 let validated_path = git_subpath
250 .as_deref()
251 .map(GitModulePath::try_from)
252 .transpose()?;
253 Ok(Self::Git {
254 url,
255 selector,
256 path: validated_path,
257 extra,
258 })
259 }
260 (None, Some(p)) => {
261 if selector_count > 0 {
262 return Err(DependencySourceError::InvalidSource {
263 reason: "local-path dependency cannot specify a selector",
264 });
265 }
266 Ok(Self::LocalPath { path: p, extra })
267 }
268 (None, None) => Err(DependencySourceError::InvalidSource {
269 reason: "neither `git` nor `path` was specified",
270 }),
271 }
272 }
273}
274
275#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "lowercase")]
278pub enum GitSelector {
279 Version(VersionRequirement),
281 Tag(String),
283 Branch(String),
285 Commit(GitCommitish),
288}
289
290impl GitSelector {
291 pub fn kind(&self) -> &'static str {
293 match self {
294 Self::Version(_) => "version",
295 Self::Tag(_) => "tag",
296 Self::Branch(_) => "branch",
297 Self::Commit(_) => "commit",
298 }
299 }
300}
301
302impl fmt::Display for GitSelector {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 match self {
305 Self::Version(requirement) => write!(f, "version {requirement}"),
306 Self::Tag(tag) => write!(f, "tag {tag}"),
307 Self::Branch(branch) => write!(f, "branch {branch}"),
308 Self::Commit(commit) => write!(f, "commit {commit}"),
309 }
310 }
311}
312
313#[derive(Debug, Default, Serialize, Deserialize)]
317struct DependencySourceFields {
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 git: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 path: Option<PathBuf>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 version: Option<String>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 tag: Option<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 branch: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 commit: Option<String>,
336 #[serde(flatten)]
338 extra: serde_json::Map<String, serde_json::Value>,
339}
340
341impl From<DependencySource> for DependencySourceFields {
342 fn from(source: DependencySource) -> Self {
343 match source {
344 DependencySource::Git {
345 url,
346 selector,
347 path,
348 extra,
349 } => {
350 let mut fields = DependencySourceFields {
351 git: Some(url.to_string()),
352 path: path.map(PathBuf::from),
353 extra,
354 ..Default::default()
355 };
356 match selector {
357 GitSelector::Version(v) => fields.version = Some(v.to_string()),
358 GitSelector::Tag(t) => fields.tag = Some(t),
359 GitSelector::Branch(b) => fields.branch = Some(b),
360 GitSelector::Commit(c) => fields.commit = Some(c.to_string()),
361 }
362 fields
363 }
364 DependencySource::LocalPath { path, extra } => DependencySourceFields {
365 path: Some(path),
366 extra,
367 ..Default::default()
368 },
369 }
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 fn parse(s: &str) -> Result<DependencySource, serde_json::Error> {
378 serde_json::from_str(s)
379 }
380
381 #[test]
382 fn parses_git_with_version() {
383 let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0"}"#).unwrap();
384 match dep {
385 DependencySource::Git {
386 selector: GitSelector::Version(_),
387 ..
388 } => {}
389 _ => panic!("expected `Version` selector"),
390 }
391 }
392
393 #[test]
394 fn parses_git_with_tag() {
395 let dep = parse(r#"{"git": "https://github.com/x/y", "tag": "v1.2.3"}"#).unwrap();
396 assert!(matches!(
397 dep,
398 DependencySource::Git {
399 selector: GitSelector::Tag(_),
400 ..
401 }
402 ));
403 }
404
405 #[test]
406 fn parses_git_with_branch() {
407 let dep = parse(r#"{"git": "https://github.com/x/y", "branch": "main"}"#).unwrap();
408 assert!(matches!(
409 dep,
410 DependencySource::Git {
411 selector: GitSelector::Branch(_),
412 ..
413 }
414 ));
415 }
416
417 #[test]
418 fn parses_git_with_commit() {
419 let dep = parse(
420 r#"{
421 "git": "https://github.com/x/y",
422 "commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
423 }"#,
424 )
425 .unwrap();
426 match dep {
427 DependencySource::Git {
428 selector: GitSelector::Commit(commit),
429 ..
430 } => assert_eq!(commit.as_str(), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"),
431 _ => panic!("expected `Commit` selector"),
432 }
433 }
434
435 #[test]
436 fn git_selector_reports_its_kind() {
437 let version = GitSelector::Version("*".parse().unwrap());
439 let commit = GitSelector::Commit("a1b2c3d".parse().unwrap());
441
442 assert_eq!(version.kind(), "version");
443 assert_eq!(GitSelector::Tag("v1.0.0".to_string()).kind(), "tag");
444 assert_eq!(GitSelector::Branch("main".to_string()).kind(), "branch");
445 assert_eq!(commit.kind(), "commit");
446 }
447
448 #[test]
449 fn parses_local_path() {
450 let dep = parse(r#"{"path": "../local"}"#).unwrap();
451 assert!(matches!(dep, DependencySource::LocalPath { .. }));
452 }
453
454 #[test]
455 fn parses_git_with_subpath() {
456 let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "path": "wdl"}"#)
457 .unwrap();
458 match dep {
459 DependencySource::Git {
460 selector: GitSelector::Version(_),
461 path: Some(p),
462 ..
463 } => assert_eq!(p.as_str(), "wdl"),
464 _ => panic!("expected Git source with sub-path"),
465 }
466 }
467
468 #[test]
469 fn rejects_invalid_git_subpaths() {
470 for bad in [
471 r#"{"git": "https://x/y", "version": "^1", "path": "/abs"}"#,
472 r#"{"git": "https://x/y", "version": "^1", "path": "../escape"}"#,
473 ] {
474 assert!(parse(bad).is_err(), "accepted `{bad}`");
475 }
476 }
477
478 #[test]
479 fn accepts_commit_prefix_selector() {
480 let dep = parse(r#"{"git": "https://github.com/x/y", "commit": "a1b2c3d"}"#).unwrap();
481 match dep {
482 DependencySource::Git {
483 selector: GitSelector::Commit(commit),
484 ..
485 } => {
486 assert_eq!(commit.as_str(), "a1b2c3d");
487 assert!(!commit.is_full());
488 }
489 _ => panic!("expected `Commit` selector"),
490 }
491 }
492
493 #[test]
494 fn rejects_too_short_commit_selector() {
495 let err = parse(r#"{"git": "https://x/y", "commit": "ab"}"#).unwrap_err();
497 assert!(
498 err.to_string()
499 .contains("must be 4 to 40 lowercase hex characters"),
500 "wrong error: {err}"
501 );
502 }
503
504 #[test]
505 fn captures_unknown_fields() {
506 let dep =
507 parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "deprecated": true}"#)
508 .unwrap();
509 match dep {
510 DependencySource::Git { extra, .. } => {
511 assert_eq!(
512 extra.get("deprecated"),
513 Some(&serde_json::Value::Bool(true))
514 );
515 }
516 _ => panic!("expected Git source"),
517 }
518 }
519
520 #[test]
521 fn rejects_invalid_structures() {
522 for bad in [
523 r#"{"git": "https://x/y", "version": "^1", "tag": "v1"}"#,
524 r#"{"git": "https://x/y"}"#,
525 r#"{"path": "p", "version": "^1"}"#,
526 r#"{}"#,
527 ] {
528 let err = parse(bad).unwrap_err();
529 assert!(
530 err.to_string().contains("dependency source is invalid"),
531 "wrong message for `{bad}`: {err}"
532 );
533 }
534 }
535
536 #[test]
537 fn rejects_absolute_git_path() {
538 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":"/etc/passwd"}"#).unwrap_err();
539 assert!(
540 err.to_string().contains("invalid `path` on Git dependency"),
541 "expected `InvalidGitPath` for absolute path; got: {err}"
542 );
543 }
544
545 #[test]
546 fn rejects_parent_traversal_git_path() {
547 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":"../module"}"#).unwrap_err();
548 assert!(
549 err.to_string().contains("invalid `path` on Git dependency"),
550 "expected `InvalidGitPath` for `../module`; got: {err}"
551 );
552 }
553
554 #[test]
555 fn rejects_nested_escape_git_path() {
556 let err =
557 parse(r#"{"git":"https://x/y","tag":"v1","path":"module/../../secret"}"#).unwrap_err();
558 assert!(
559 err.to_string().contains("invalid `path` on Git dependency"),
560 "expected `InvalidGitPath` for nested escape; got: {err}"
561 );
562 }
563
564 #[test]
565 fn rejects_dot_git_path() {
566 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":"."}"#).unwrap_err();
567 assert!(
568 err.to_string().contains("`.`"),
569 "expected dot rejection; got: {err}"
570 );
571 }
572
573 #[test]
574 fn rejects_empty_git_path() {
575 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":""}"#).unwrap_err();
576 assert!(
577 err.to_string().contains("invalid `path` on Git dependency"),
578 "expected `InvalidGitPath` for empty path; got: {err}"
579 );
580 }
581
582 #[test]
583 fn accepts_valid_git_subpath() {
584 let dep = parse(r#"{"git":"https://x/y","tag":"v1","path":"modules/csvkit"}"#).unwrap();
585 match dep {
586 DependencySource::Git { path: Some(p), .. } => {
587 assert_eq!(p.as_str(), "modules/csvkit");
588 }
589 _ => panic!("expected Git source with valid sub-path"),
590 }
591 }
592}
593
594#[cfg(test)]
595mod git_module_path_tests {
596 use super::*;
597
598 #[test]
599 fn accepts_valid_subpath() {
600 let p = GitModulePath::from_str("modules/csvkit").unwrap();
601 assert_eq!(p.as_str(), "modules/csvkit");
602 }
603
604 #[test]
605 fn rejects_empty_string() {
606 let err = GitModulePath::from_str("").unwrap_err();
607 assert!(
608 matches!(err, GitModulePathError::Invalid(RelativePathError::Empty)),
609 "expected `Invalid(Empty)` for empty string"
610 );
611 }
612
613 #[test]
614 fn rejects_dot() {
615 let err = GitModulePath::from_str(".").unwrap_err();
616 assert!(
617 matches!(err, GitModulePathError::Dot),
618 "expected `Dot` for `.`"
619 );
620 }
621
622 #[test]
623 fn rejects_absolute_path() {
624 let err = GitModulePath::from_str("/tmp/module").unwrap_err();
625 assert!(
626 matches!(
627 err,
628 GitModulePathError::Invalid(RelativePathError::Absolute(_))
629 ),
630 "expected `Invalid(Absolute)` for `/tmp/module`"
631 );
632 }
633
634 #[test]
635 fn rejects_parent_traversal() {
636 let err = GitModulePath::from_str("../module").unwrap_err();
637 assert!(
638 matches!(
639 err,
640 GitModulePathError::Invalid(RelativePathError::EscapesRoot(_))
641 ),
642 "expected `Invalid(EscapesRoot)` for `../module`"
643 );
644 }
645
646 #[test]
647 fn rejects_nested_escape() {
648 let err = GitModulePath::from_str("module/../../secret").unwrap_err();
649 assert!(
650 matches!(
651 err,
652 GitModulePathError::Invalid(RelativePathError::EscapesRoot(_))
653 ),
654 "expected `Invalid(EscapesRoot)` for `module/../../secret`"
655 );
656 }
657
658 #[test]
659 fn rejects_git_pathspec_syntax() {
660 for (path, expected) in [
661 ("*", '*'),
662 ("modules/[ab]", '['),
663 ("modules/?", '?'),
664 (":/modules", ':'),
665 ("!modules", '!'),
666 ("^modules", '^'),
667 ] {
668 assert_eq!(
669 GitModulePath::from_str(path),
670 Err(GitModulePathError::Pathspec(expected)),
671 "expected `{path}` to report its first Git pathspec character"
672 );
673 }
674 }
675
676 #[test]
677 fn round_trips_via_serde() {
678 let p = GitModulePath::from_str("modules/csvkit").unwrap();
679 let s = serde_json::to_string(&p).unwrap();
680 assert_eq!(s, "\"modules/csvkit\"");
681 let back: GitModulePath = serde_json::from_str(&s).unwrap();
682 assert_eq!(back, p);
683 }
684
685 #[test]
686 fn serde_rejects_dot() {
687 let err = serde_json::from_str::<GitModulePath>("\".\"").unwrap_err();
688 assert!(
689 err.to_string().contains("`.`"),
690 "expected dot rejection; got: {err}"
691 );
692 }
693}