1use std::path::Path;
4use std::path::PathBuf;
5use std::str::FromStr;
6
7use serde::Deserialize;
8use serde::Serialize;
9use serde_with::DeserializeFromStr;
10use serde_with::SerializeDisplay;
11use thiserror::Error;
12use url::Url;
13
14use crate::lockfile::GitCommit;
15use crate::lockfile::GitCommitError;
16use crate::relative_path::RelativePath;
17use crate::relative_path::RelativePathError;
18use crate::version_requirement::VersionRequirement;
19use crate::version_requirement::VersionRequirementError;
20
21#[derive(Clone, Debug, Eq, Error, PartialEq)]
23pub enum GitModulePathError {
24 #[error(transparent)]
26 Invalid(#[from] RelativePathError),
27
28 #[error("git module path must not be `.`")]
31 Dot,
32}
33
34#[derive(
42 Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
43)]
44pub struct GitModulePath(RelativePath);
45
46impl GitModulePath {
47 pub fn as_str(&self) -> &str {
49 self.0.as_str()
50 }
51
52 pub fn as_path(&self) -> &Path {
54 self.0.as_path()
55 }
56
57 pub fn into_relative_path(self) -> RelativePath {
60 self.0
61 }
62}
63
64impl AsRef<str> for GitModulePath {
65 fn as_ref(&self) -> &str {
66 self.0.as_ref()
67 }
68}
69
70impl AsRef<Path> for GitModulePath {
71 fn as_ref(&self) -> &Path {
72 self.0.as_ref()
73 }
74}
75
76impl std::fmt::Display for GitModulePath {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 write!(f, "{}", self.0)
79 }
80}
81
82impl From<GitModulePath> for String {
83 fn from(path: GitModulePath) -> Self {
84 path.0.into()
85 }
86}
87
88impl From<GitModulePath> for PathBuf {
89 fn from(path: GitModulePath) -> Self {
90 path.0.into()
91 }
92}
93
94impl FromStr for GitModulePath {
95 type Err = GitModulePathError;
96
97 fn from_str(s: &str) -> Result<Self, Self::Err> {
98 if s.is_empty() {
99 return Err(RelativePathError::Empty.into());
100 }
101 if s == "." {
102 return Err(GitModulePathError::Dot);
103 }
104 Ok(Self(RelativePath::from_str(s)?))
105 }
106}
107
108impl TryFrom<&Path> for GitModulePath {
109 type Error = GitModulePathError;
110
111 fn try_from(path: &Path) -> Result<Self, Self::Error> {
112 path.to_str().ok_or(RelativePathError::NonUtf8)?.parse()
113 }
114}
115
116#[derive(Debug, Error)]
118pub enum DependencySourceError {
119 #[error(
126 "dependency source is invalid: {reason}; must specify either `path` for a local-path \
127 source, or `git` with exactly one of `version`, `tag`, `branch`, or `commit` for a Git \
128 source"
129 )]
130 InvalidSource {
131 reason: &'static str,
133 },
134
135 #[error(transparent)]
137 VersionRequirement(#[from] VersionRequirementError),
138
139 #[error(transparent)]
141 GitCommit(#[from] GitCommitError),
142
143 #[error("invalid Git URL: {0}")]
145 InvalidUrl(String),
146
147 #[error("invalid `path` on Git dependency: {0}")]
149 InvalidGitPath(#[from] GitModulePathError),
150}
151
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(try_from = "DependencySourceFields", into = "DependencySourceFields")]
158pub enum DependencySource {
159 Git {
161 url: Url,
163 selector: GitSelector,
165 path: Option<GitModulePath>,
167 extra: serde_json::Map<String, serde_json::Value>,
169 },
170 LocalPath {
172 path: PathBuf,
174 extra: serde_json::Map<String, serde_json::Value>,
176 },
177}
178
179impl TryFrom<DependencySourceFields> for DependencySource {
180 type Error = DependencySourceError;
181
182 fn try_from(fields: DependencySourceFields) -> Result<Self, Self::Error> {
183 let DependencySourceFields {
184 git,
185 path,
186 version,
187 tag,
188 branch,
189 commit,
190 extra,
191 } = fields;
192
193 let selector_count = [&version, &tag, &branch, &commit]
194 .iter()
195 .filter(|s| s.is_some())
196 .count();
197
198 match (git, path) {
199 (Some(g), git_subpath) => {
200 if selector_count == 0 {
201 return Err(DependencySourceError::InvalidSource {
202 reason: "Git dependency is missing a selector",
203 });
204 }
205 if selector_count > 1 {
206 return Err(DependencySourceError::InvalidSource {
207 reason: "Git dependency specifies more than one selector",
208 });
209 }
210 let url =
211 Url::parse(&g).map_err(|e| DependencySourceError::InvalidUrl(e.to_string()))?;
212 let selector = if let Some(v) = version {
213 GitSelector::Version(v.parse::<VersionRequirement>()?)
214 } else if let Some(t) = tag {
215 GitSelector::Tag(t)
216 } else if let Some(b) = branch {
217 GitSelector::Branch(b)
218 } else if let Some(c) = commit {
219 GitSelector::Commit(GitCommit::try_from(c)?)
220 } else {
221 unreachable!()
225 };
226 let validated_path = git_subpath
227 .as_deref()
228 .map(GitModulePath::try_from)
229 .transpose()?;
230 Ok(Self::Git {
231 url,
232 selector,
233 path: validated_path,
234 extra,
235 })
236 }
237 (None, Some(p)) => {
238 if selector_count > 0 {
239 return Err(DependencySourceError::InvalidSource {
240 reason: "local-path dependency cannot specify a selector",
241 });
242 }
243 Ok(Self::LocalPath { path: p, extra })
244 }
245 (None, None) => Err(DependencySourceError::InvalidSource {
246 reason: "neither `git` nor `path` was specified",
247 }),
248 }
249 }
250}
251
252#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "lowercase")]
255pub enum GitSelector {
256 Version(VersionRequirement),
258 Tag(String),
260 Branch(String),
262 Commit(GitCommit),
264}
265
266#[derive(Debug, Default, Serialize, Deserialize)]
270struct DependencySourceFields {
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 git: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 path: Option<PathBuf>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 version: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 tag: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 branch: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 commit: Option<String>,
289 #[serde(flatten)]
291 extra: serde_json::Map<String, serde_json::Value>,
292}
293
294impl From<DependencySource> for DependencySourceFields {
295 fn from(source: DependencySource) -> Self {
296 match source {
297 DependencySource::Git {
298 url,
299 selector,
300 path,
301 extra,
302 } => {
303 let mut fields = DependencySourceFields {
304 git: Some(url.to_string()),
305 path: path.map(PathBuf::from),
306 extra,
307 ..Default::default()
308 };
309 match selector {
310 GitSelector::Version(v) => fields.version = Some(v.to_string()),
311 GitSelector::Tag(t) => fields.tag = Some(t),
312 GitSelector::Branch(b) => fields.branch = Some(b),
313 GitSelector::Commit(c) => fields.commit = Some(c.to_string()),
314 }
315 fields
316 }
317 DependencySource::LocalPath { path, extra } => DependencySourceFields {
318 path: Some(path),
319 extra,
320 ..Default::default()
321 },
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn parse(s: &str) -> Result<DependencySource, serde_json::Error> {
331 serde_json::from_str(s)
332 }
333
334 #[test]
335 fn parses_git_with_version() {
336 let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0"}"#).unwrap();
337 match dep {
338 DependencySource::Git {
339 selector: GitSelector::Version(_),
340 ..
341 } => {}
342 _ => panic!("expected `Version` selector"),
343 }
344 }
345
346 #[test]
347 fn parses_git_with_tag() {
348 let dep = parse(r#"{"git": "https://github.com/x/y", "tag": "v1.2.3"}"#).unwrap();
349 assert!(matches!(
350 dep,
351 DependencySource::Git {
352 selector: GitSelector::Tag(_),
353 ..
354 }
355 ));
356 }
357
358 #[test]
359 fn parses_git_with_branch() {
360 let dep = parse(r#"{"git": "https://github.com/x/y", "branch": "main"}"#).unwrap();
361 assert!(matches!(
362 dep,
363 DependencySource::Git {
364 selector: GitSelector::Branch(_),
365 ..
366 }
367 ));
368 }
369
370 #[test]
371 fn parses_git_with_commit() {
372 let dep = parse(
373 r#"{
374 "git": "https://github.com/x/y",
375 "commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
376 }"#,
377 )
378 .unwrap();
379 match dep {
380 DependencySource::Git {
381 selector: GitSelector::Commit(commit),
382 ..
383 } => assert_eq!(commit.as_str(), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"),
384 _ => panic!("expected `Commit` selector"),
385 }
386 }
387
388 #[test]
389 fn parses_local_path() {
390 let dep = parse(r#"{"path": "../local"}"#).unwrap();
391 assert!(matches!(dep, DependencySource::LocalPath { .. }));
392 }
393
394 #[test]
395 fn parses_git_with_subpath() {
396 let dep = parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "path": "wdl"}"#)
397 .unwrap();
398 match dep {
399 DependencySource::Git {
400 selector: GitSelector::Version(_),
401 path: Some(p),
402 ..
403 } => assert_eq!(p.as_str(), "wdl"),
404 _ => panic!("expected Git source with sub-path"),
405 }
406 }
407
408 #[test]
409 fn rejects_invalid_git_subpaths() {
410 for bad in [
411 r#"{"git": "https://x/y", "version": "^1", "path": "/abs"}"#,
412 r#"{"git": "https://x/y", "version": "^1", "path": "../escape"}"#,
413 ] {
414 assert!(parse(bad).is_err(), "accepted `{bad}`");
415 }
416 }
417
418 #[test]
419 fn rejects_short_commit_selector() {
420 let err = parse(r#"{"git": "https://x/y", "commit": "abc123"}"#).unwrap_err();
421 assert!(
422 err.to_string()
423 .contains("must be exactly 40 lowercase hex characters"),
424 "wrong error: {err}"
425 );
426 }
427
428 #[test]
429 fn captures_unknown_fields() {
430 let dep =
431 parse(r#"{"git": "https://github.com/x/y", "version": "^1.0.0", "deprecated": true}"#)
432 .unwrap();
433 match dep {
434 DependencySource::Git { extra, .. } => {
435 assert_eq!(
436 extra.get("deprecated"),
437 Some(&serde_json::Value::Bool(true))
438 );
439 }
440 _ => panic!("expected Git source"),
441 }
442 }
443
444 #[test]
445 fn rejects_invalid_structures() {
446 for bad in [
447 r#"{"git": "https://x/y", "version": "^1", "tag": "v1"}"#,
448 r#"{"git": "https://x/y"}"#,
449 r#"{"path": "p", "version": "^1"}"#,
450 r#"{}"#,
451 ] {
452 let err = parse(bad).unwrap_err();
453 assert!(
454 err.to_string().contains("dependency source is invalid"),
455 "wrong message for `{bad}`: {err}"
456 );
457 }
458 }
459
460 #[test]
461 fn rejects_absolute_git_path() {
462 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":"/etc/passwd"}"#).unwrap_err();
463 assert!(
464 err.to_string().contains("invalid `path` on Git dependency"),
465 "expected `InvalidGitPath` for absolute path; got: {err}"
466 );
467 }
468
469 #[test]
470 fn rejects_parent_traversal_git_path() {
471 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":"../module"}"#).unwrap_err();
472 assert!(
473 err.to_string().contains("invalid `path` on Git dependency"),
474 "expected `InvalidGitPath` for `../module`; got: {err}"
475 );
476 }
477
478 #[test]
479 fn rejects_nested_escape_git_path() {
480 let err =
481 parse(r#"{"git":"https://x/y","tag":"v1","path":"module/../../secret"}"#).unwrap_err();
482 assert!(
483 err.to_string().contains("invalid `path` on Git dependency"),
484 "expected `InvalidGitPath` for nested escape; got: {err}"
485 );
486 }
487
488 #[test]
489 fn rejects_dot_git_path() {
490 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":"."}"#).unwrap_err();
491 assert!(
492 err.to_string().contains("`.`"),
493 "expected dot rejection; got: {err}"
494 );
495 }
496
497 #[test]
498 fn rejects_empty_git_path() {
499 let err = parse(r#"{"git":"https://x/y","tag":"v1","path":""}"#).unwrap_err();
500 assert!(
501 err.to_string().contains("invalid `path` on Git dependency"),
502 "expected `InvalidGitPath` for empty path; got: {err}"
503 );
504 }
505
506 #[test]
507 fn accepts_valid_git_subpath() {
508 let dep = parse(r#"{"git":"https://x/y","tag":"v1","path":"modules/csvkit"}"#).unwrap();
509 match dep {
510 DependencySource::Git { path: Some(p), .. } => {
511 assert_eq!(p.as_str(), "modules/csvkit");
512 }
513 _ => panic!("expected Git source with valid sub-path"),
514 }
515 }
516}
517
518#[cfg(test)]
519mod git_module_path_tests {
520 use super::*;
521
522 #[test]
523 fn accepts_valid_subpath() {
524 let p = GitModulePath::from_str("modules/csvkit").unwrap();
525 assert_eq!(p.as_str(), "modules/csvkit");
526 }
527
528 #[test]
529 fn rejects_empty_string() {
530 let err = GitModulePath::from_str("").unwrap_err();
531 assert!(
532 matches!(err, GitModulePathError::Invalid(RelativePathError::Empty)),
533 "expected `Invalid(Empty)` for empty string"
534 );
535 }
536
537 #[test]
538 fn rejects_dot() {
539 let err = GitModulePath::from_str(".").unwrap_err();
540 assert!(
541 matches!(err, GitModulePathError::Dot),
542 "expected `Dot` for `.`"
543 );
544 }
545
546 #[test]
547 fn rejects_absolute_path() {
548 let err = GitModulePath::from_str("/tmp/module").unwrap_err();
549 assert!(
550 matches!(
551 err,
552 GitModulePathError::Invalid(RelativePathError::Absolute(_))
553 ),
554 "expected `Invalid(Absolute)` for `/tmp/module`"
555 );
556 }
557
558 #[test]
559 fn rejects_parent_traversal() {
560 let err = GitModulePath::from_str("../module").unwrap_err();
561 assert!(
562 matches!(
563 err,
564 GitModulePathError::Invalid(RelativePathError::EscapesRoot(_))
565 ),
566 "expected `Invalid(EscapesRoot)` for `../module`"
567 );
568 }
569
570 #[test]
571 fn rejects_nested_escape() {
572 let err = GitModulePath::from_str("module/../../secret").unwrap_err();
573 assert!(
574 matches!(
575 err,
576 GitModulePathError::Invalid(RelativePathError::EscapesRoot(_))
577 ),
578 "expected `Invalid(EscapesRoot)` for `module/../../secret`"
579 );
580 }
581
582 #[test]
583 fn round_trips_via_serde() {
584 let p = GitModulePath::from_str("modules/csvkit").unwrap();
585 let s = serde_json::to_string(&p).unwrap();
586 assert_eq!(s, "\"modules/csvkit\"");
587 let back: GitModulePath = serde_json::from_str(&s).unwrap();
588 assert_eq!(back, p);
589 }
590
591 #[test]
592 fn serde_rejects_dot() {
593 let err = serde_json::from_str::<GitModulePath>("\".\"").unwrap_err();
594 assert!(
595 err.to_string().contains("`.`"),
596 "expected dot rejection; got: {err}"
597 );
598 }
599}