Skip to main content

wdl_modules/dependency/
source.rs

1//! Dependency-source parsing for `modules.json`.
2
3use 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
22/// Characters with special meaning in Git pathspecs.
23const GIT_PATHSPEC_CHARACTERS: [char; 5] = ['*', '?', '[', ']', '\\'];
24
25/// Leading characters with special meaning in Git pathspecs.
26const GIT_PATHSPEC_PREFIXES: [char; 3] = [':', '!', '^'];
27
28/// An error constructing a [`GitModulePath`].
29#[derive(Clone, Debug, Eq, Error, PartialEq)]
30pub enum GitModulePathError {
31    /// The underlying [`RelativePath`] validation failed.
32    #[error(transparent)]
33    Invalid(#[from] RelativePathError),
34
35    /// The path is `"."`, which is equivalent to no sub-path and
36    /// therefore disallowed.
37    #[error("git module path must not be `.`")]
38    Dot,
39
40    /// The path contains syntax interpreted specially by git pathspecs.
41    #[error("git module path contains reserved git pathspec character `{0}`")]
42    Pathspec(char),
43}
44
45/// A validated, canonical sub-path within a Git-backed dependency.
46///
47/// Represents the `path` field on a Git dependency source—the relative
48/// directory within the repository that contains the module's
49/// `module.json`. Wraps [`RelativePath`] and additionally rejects `"."`
50/// and empty strings, both of which are semantically equivalent to "no
51/// sub-path" (i.e., the module sits at the repository root).
52#[derive(
53    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
54)]
55pub struct GitModulePath(RelativePath);
56
57impl GitModulePath {
58    /// Returns the path as a `/`-separated string slice.
59    pub fn as_str(&self) -> &str {
60        self.0.as_str()
61    }
62
63    /// Returns the path as a [`Path`].
64    pub fn as_path(&self) -> &Path {
65        self.0.as_path()
66    }
67
68    /// Consumes the [`GitModulePath`] and returns the underlying
69    /// [`RelativePath`].
70    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/// An error parsing a [`DependencySource`].
140#[derive(Debug, Error)]
141pub enum DependencySourceError {
142    /// The dependency does not name a valid source.
143    ///
144    /// The dependency must specify exactly one of `path` for a local-path
145    /// source, or `git` with exactly one of `version`, `tag`, `branch`, or
146    /// `commit` for a Git source. The `reason` describes which rule was
147    /// violated by the input.
148    #[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        /// A short description of which validation rule was violated.
155        reason: &'static str,
156    },
157
158    /// A version requirement on a Git dependency was invalid.
159    #[error(transparent)]
160    VersionRequirement(#[from] VersionRequirementError),
161
162    /// A Git commit selector was invalid.
163    #[error(transparent)]
164    GitCommit(#[from] GitCommitishError),
165
166    /// The Git URL did not parse.
167    #[error("invalid Git URL: {0}")]
168    InvalidUrl(String),
169
170    /// The `path` field on a Git dependency was invalid.
171    #[error("invalid `path` on Git dependency: {0}")]
172    InvalidGitPath(#[from] GitModulePathError),
173}
174
175/// A dependency source.
176///
177/// The two possible sources are a Git repository (with one of four
178/// selectors plus an optional sub-path) or a local filesystem path.
179#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(try_from = "DependencySourceFields", into = "DependencySourceFields")]
181pub enum DependencySource {
182    /// A Git-backed dependency.
183    Git {
184        /// The Git repository URL.
185        url: Url,
186        /// The selector controlling which revision to resolve to.
187        selector: GitSelector,
188        /// Optional sub-path within the repository where the module lives.
189        path: Option<GitModulePath>,
190        /// Unknown fields, preserved for round-trip and inspection.
191        extra: serde_json::Map<String, serde_json::Value>,
192    },
193    /// A local filesystem dependency.
194    LocalPath {
195        /// The path to the local module directory.
196        path: PathBuf,
197        /// Unknown fields, preserved for round-trip and inspection.
198        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                    // `selector_count` is 1 in this branch, and the
245                    // four `if let Some(...)` arms above cover every selector
246                    // field, so one of them must match.
247                    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/// A Git revision selector.
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "lowercase")]
278pub enum GitSelector {
279    /// A semver requirement matched against the repository's tags.
280    Version(VersionRequirement),
281    /// An exact Git tag name.
282    Tag(String),
283    /// A Git branch name.
284    Branch(String),
285    /// A Git commit SHA, or any unique prefix of one (4 to 40 hex chars).
286    /// The resolver expands a prefix to the full SHA at lock time.
287    Commit(GitCommitish),
288}
289
290impl GitSelector {
291    /// Returns the selector kind used in diagnostics and command output.
292    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/// Flat field set of a dependency declaration as it appears in
314/// `module.json`, before mutual-exclusion validation projects it onto
315/// [`DependencySource`].
316#[derive(Debug, Default, Serialize, Deserialize)]
317struct DependencySourceFields {
318    /// The Git URL, if the dependency is Git-backed.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    git: Option<String>,
321    /// Either the local-path source, or a sub-path within a Git source.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    path: Option<PathBuf>,
324    /// The semver requirement on a Git source.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    version: Option<String>,
327    /// The Git tag selector.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    tag: Option<String>,
330    /// The Git branch selector.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    branch: Option<String>,
333    /// The Git commit selector.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    commit: Option<String>,
336    /// Unknown fields, preserved for round-trip and inspection.
337    #[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        // SAFETY: `*` is a valid version requirement.
438        let version = GitSelector::Version("*".parse().unwrap());
439        // SAFETY: this is a valid abbreviated Git commit.
440        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        // Fewer than 4 hex characters is rejected.
496        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}