Skip to main content

wdl_modules/dependency/
source.rs

1//! Dependency-source parsing for `modules.json`.
2
3use 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/// An error constructing a [`GitModulePath`].
22#[derive(Clone, Debug, Eq, Error, PartialEq)]
23pub enum GitModulePathError {
24    /// The underlying [`RelativePath`] validation failed.
25    #[error(transparent)]
26    Invalid(#[from] RelativePathError),
27
28    /// The path is `"."`, which is equivalent to no sub-path and
29    /// therefore disallowed.
30    #[error("git module path must not be `.`")]
31    Dot,
32}
33
34/// A validated, canonical sub-path within a Git-backed dependency.
35///
36/// Represents the `path` field on a Git dependency source—the relative
37/// directory within the repository that contains the module's
38/// `module.json`. Wraps [`RelativePath`] and additionally rejects `"."`
39/// and empty strings, both of which are semantically equivalent to "no
40/// sub-path" (i.e., the module sits at the repository root).
41#[derive(
42    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
43)]
44pub struct GitModulePath(RelativePath);
45
46impl GitModulePath {
47    /// Returns the path as a `/`-separated string slice.
48    pub fn as_str(&self) -> &str {
49        self.0.as_str()
50    }
51
52    /// Returns the path as a [`Path`].
53    pub fn as_path(&self) -> &Path {
54        self.0.as_path()
55    }
56
57    /// Consumes the [`GitModulePath`] and returns the underlying
58    /// [`RelativePath`].
59    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/// An error parsing a [`DependencySource`].
117#[derive(Debug, Error)]
118pub enum DependencySourceError {
119    /// The dependency does not name a valid source.
120    ///
121    /// The dependency must specify exactly one of `path` for a local-path
122    /// source, or `git` with exactly one of `version`, `tag`, `branch`, or
123    /// `commit` for a Git source. The `reason` describes which rule was
124    /// violated by the input.
125    #[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        /// A short description of which validation rule was violated.
132        reason: &'static str,
133    },
134
135    /// A version requirement on a Git dependency was invalid.
136    #[error(transparent)]
137    VersionRequirement(#[from] VersionRequirementError),
138
139    /// A Git commit selector was invalid.
140    #[error(transparent)]
141    GitCommit(#[from] GitCommitError),
142
143    /// The Git URL did not parse.
144    #[error("invalid Git URL: {0}")]
145    InvalidUrl(String),
146
147    /// The `path` field on a Git dependency was invalid.
148    #[error("invalid `path` on Git dependency: {0}")]
149    InvalidGitPath(#[from] GitModulePathError),
150}
151
152/// A dependency source.
153///
154/// The two possible sources are a Git repository (with one of four
155/// selectors plus an optional sub-path) or a local filesystem path.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(try_from = "DependencySourceFields", into = "DependencySourceFields")]
158pub enum DependencySource {
159    /// A Git-backed dependency.
160    Git {
161        /// The Git repository URL.
162        url: Url,
163        /// The selector controlling which revision to resolve to.
164        selector: GitSelector,
165        /// Optional sub-path within the repository where the module lives.
166        path: Option<GitModulePath>,
167        /// Unknown fields, preserved for round-trip and inspection.
168        extra: serde_json::Map<String, serde_json::Value>,
169    },
170    /// A local filesystem dependency.
171    LocalPath {
172        /// The path to the local module directory.
173        path: PathBuf,
174        /// Unknown fields, preserved for round-trip and inspection.
175        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                    // SAFETY: `selector_count` is 1 in this branch, and the
222                    // four `if let Some(...)` arms above cover every selector
223                    // field, so one of them must match.
224                    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/// A Git revision selector.
253#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "lowercase")]
255pub enum GitSelector {
256    /// A semver requirement matched against the repository's tags.
257    Version(VersionRequirement),
258    /// An exact Git tag name.
259    Tag(String),
260    /// A Git branch name.
261    Branch(String),
262    /// A full Git commit SHA.
263    Commit(GitCommit),
264}
265
266/// Flat field set of a dependency declaration as it appears in
267/// `module.json`, before mutual-exclusion validation projects it onto
268/// [`DependencySource`].
269#[derive(Debug, Default, Serialize, Deserialize)]
270struct DependencySourceFields {
271    /// The Git URL, if the dependency is Git-backed.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    git: Option<String>,
274    /// Either the local-path source, or a sub-path within a Git source.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    path: Option<PathBuf>,
277    /// The semver requirement on a Git source.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    version: Option<String>,
280    /// The Git tag selector.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    tag: Option<String>,
283    /// The Git branch selector.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    branch: Option<String>,
286    /// The Git commit selector.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    commit: Option<String>,
289    /// Unknown fields, preserved for round-trip and inspection.
290    #[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}