Skip to main content

wdl_modules/
lockfile.rs

1//! `module-lock.json` lockfile parsing and validation.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::io::Write;
6use std::path::PathBuf;
7use std::str::FromStr;
8
9use serde::Deserialize;
10use serde::Serialize;
11use thiserror::Error;
12use url::Url;
13
14#[cfg(feature = "git-resolver")]
15use crate::Manifest;
16use crate::dependency::DependencyName;
17use crate::dependency::DependencyNameError;
18use crate::dependency::GitModulePath;
19use crate::dependency::GitSelector;
20use crate::hash::ContentHash;
21use crate::signing::VerifyingKey;
22
23/// The current lockfile schema version.
24pub const LOCKFILE_VERSION: u32 = 1;
25
26/// An error parsing a [`Lockfile`].
27#[derive(Debug, Error)]
28pub enum LockfileError {
29    /// The bytes did not parse as JSON or did not match the lockfile
30    /// schema.
31    #[error("invalid `module-lock.json` JSON")]
32    InvalidJson(#[from] serde_json::Error),
33
34    /// The lockfile declares a `version` other than [`LOCKFILE_VERSION`].
35    #[error(
36        "unsupported lockfile version `{0}`; this build only supports version `{LOCKFILE_VERSION}`"
37    )]
38    UnsupportedVersion(u32),
39
40    /// A `dependencies` key is not a valid WDL identifier.
41    #[error(transparent)]
42    DependencyName(#[from] DependencyNameError),
43
44    /// A Git-sourced entry is missing its required `checksum`.
45    #[error("lockfile entry for `{0}` has a Git source but no `checksum`")]
46    MissingChecksum(String),
47
48    /// A local path entry carries a `checksum` or `signer`, which are only
49    /// valid for Git sources.
50    #[error(
51        "lockfile entry for `{0}` has a local path source but carries a `checksum` or `signer`"
52    )]
53    PathSourceIntegrity(String),
54}
55
56/// A parsed `module-lock.json`.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Lockfile {
60    /// The lockfile schema version. Currently always [`LOCKFILE_VERSION`].
61    pub version: u32,
62    /// The top-level dependency map, keyed by consumer-chosen name.
63    pub dependencies: DependencyMap,
64}
65
66impl Default for Lockfile {
67    fn default() -> Self {
68        Self {
69            version: LOCKFILE_VERSION,
70            dependencies: DependencyMap::new(),
71        }
72    }
73}
74
75impl Lockfile {
76    /// Parses a `module-lock.json` from raw bytes.
77    pub fn parse(bytes: &[u8]) -> Result<Self, LockfileError> {
78        let lockfile: Lockfile = crate::strict_json::from_slice(bytes)?;
79        if lockfile.version != LOCKFILE_VERSION {
80            return Err(LockfileError::UnsupportedVersion(lockfile.version));
81        }
82        validate_integrity_fields(&lockfile.dependencies)?;
83        Ok(lockfile)
84    }
85
86    /// Writes the lockfile as pretty-printed JSON.
87    pub fn write(&self, w: impl Write) -> std::io::Result<()> {
88        serde_json::to_writer_pretty(w, self).map_err(std::io::Error::other)
89    }
90
91    /// Looks up a dependency entry by walking the nested `dependencies`
92    /// tree along `scope` (the chain of consumer dependency names from the
93    /// top-level consumer down to the entry's parent), then resolving
94    /// `name` in that scope. An empty `scope` looks up a top-level entry.
95    pub fn find_scoped(
96        &self,
97        scope: &[DependencyName],
98        name: &DependencyName,
99    ) -> Option<&DependencyEntry> {
100        let mut current = &self.dependencies;
101        for parent in scope {
102            current = &current.get(parent)?.dependencies;
103        }
104        current.get(name)
105    }
106
107    /// Returns true when this lockfile's top-level dependencies exactly
108    /// match `manifest.dependencies` and each locked source still
109    /// satisfies the manifest declaration.
110    #[cfg(feature = "git-resolver")]
111    pub fn satisfies_manifest(&self, manifest: &Manifest) -> bool {
112        manifest.dependencies.iter().all(|(name, source)| {
113            self.find_scoped(&[], name)
114                .is_some_and(|entry| crate::resolver::lock::satisfies(entry, source))
115        }) && self
116            .dependencies
117            .keys()
118            .all(|name| manifest.dependencies.contains_key(name))
119    }
120}
121
122/// Recursively enforces that Git-sourced entries carry a `checksum` and
123/// that local path entries carry neither `checksum` nor `signer`.
124fn validate_integrity_fields(deps: &DependencyMap) -> Result<(), LockfileError> {
125    for (name, entry) in deps {
126        match &entry.source {
127            ResolvedSource::Git { .. } => {
128                if entry.checksum.is_none() {
129                    return Err(LockfileError::MissingChecksum(name.manifest().to_string()));
130                }
131            }
132            ResolvedSource::Path { .. } => {
133                if entry.checksum.is_some() || entry.signer.is_some() {
134                    return Err(LockfileError::PathSourceIntegrity(
135                        name.manifest().to_string(),
136                    ));
137                }
138            }
139        }
140        validate_integrity_fields(&entry.dependencies)?;
141    }
142    Ok(())
143}
144
145/// A `dependencies` map keyed by consumer-chosen dependency names.
146pub type DependencyMap = BTreeMap<DependencyName, DependencyEntry>;
147
148/// One entry in a [`DependencyMap`].
149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(deny_unknown_fields)]
151pub struct DependencyEntry {
152    /// The resolved source for the dependency.
153    pub source: ResolvedSource,
154    /// The module's content hash.
155    ///
156    /// Required for Git sources and absent for local path sources, whose
157    /// content is read as-is at execution time and carries no checksum.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub checksum: Option<ContentHash>,
160    /// The signer's public key, if the module was signed at lock time.
161    ///
162    /// Absent for unsigned modules and for local path sources, which are
163    /// not subject to signature verification.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub signer: Option<VerifyingKey>,
166    /// The module's transitive dependencies.
167    pub dependencies: DependencyMap,
168}
169
170impl DependencyEntry {
171    /// Returns the sub-path within the source.
172    pub fn source_path(&self) -> Option<&str> {
173        self.source.source_path()
174    }
175
176    /// Returns the resolved Git commit.
177    pub fn git_sha(&self) -> Option<&GitCommit> {
178        match &self.source {
179            ResolvedSource::Git { sha, .. } => Some(sha),
180            ResolvedSource::Path { .. } => None,
181        }
182    }
183
184    /// Returns the Git selector.
185    pub fn git_selector(&self) -> Option<&GitSelector> {
186        match &self.source {
187            ResolvedSource::Git { selector, .. } => Some(selector),
188            ResolvedSource::Path { .. } => None,
189        }
190    }
191}
192
193/// The resolved source of a dependency.
194#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(untagged, deny_unknown_fields)]
196pub enum ResolvedSource {
197    /// A Git source resolved to a specific commit.
198    Git {
199        /// The Git repository URL.
200        git: Url,
201        /// The full 40-character lowercase hex commit SHA the selector
202        /// resolved to at lock time.
203        sha: GitCommit,
204        /// The selector from `module.json` that produced this entry.
205        ///
206        /// Tag and branch selectors carry mutable refs that cannot be
207        /// validated from the resolved commit alone, so this field is
208        /// required to allow integrity checks without a full relock.
209        selector: GitSelector,
210        /// The sub-path within the repository where the module lives.
211        ///
212        /// Omitted when the module sits at the repository root.
213        #[serde(default, skip_serializing_if = "Option::is_none")]
214        path: Option<GitModulePath>,
215    },
216    /// A local filesystem source.
217    Path {
218        /// The local path to the module directory.
219        path: PathBuf,
220    },
221}
222
223impl ResolvedSource {
224    /// Returns the source URL as a string suitable for trust-store
225    /// lookups.
226    pub fn source_url(&self) -> String {
227        match self {
228            Self::Git { git, .. } => git.to_string(),
229            Self::Path { path } => path.display().to_string(),
230        }
231    }
232
233    /// Returns the sub-path within the source, or `None` when the
234    /// module sits at the source root.
235    pub fn source_path(&self) -> Option<&str> {
236        match self {
237            Self::Git { path: Some(p), .. } => Some(p.as_str()),
238            _ => None,
239        }
240    }
241
242    /// Returns the source's identity coordinates for cycle detection.
243    ///
244    /// For Git sources this is the repository URL and sub-path; for
245    /// local path sources it is the resolved directory. The resolved
246    /// commit and the selector are deliberately excluded so that a
247    /// module cannot transitively depend on itself even at a different
248    /// version or via a different selector.
249    pub fn coordinates(&self) -> SourceCoordinates<'_> {
250        match self {
251            Self::Git { git, path, .. } => SourceCoordinates::Git {
252                git: git.as_str(),
253                path: path.as_ref().map(GitModulePath::as_str),
254            },
255            Self::Path { path } => SourceCoordinates::Path(path.as_path()),
256        }
257    }
258}
259
260/// The identity coordinates of a [`ResolvedSource`], used for cycle
261/// detection. Excludes version and selector information.
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum SourceCoordinates<'a> {
264    /// A Git source, identified by repository URL and optional sub-path.
265    Git {
266        /// The Git repository URL.
267        git: &'a str,
268        /// The sub-path within the repository, if any.
269        path: Option<&'a str>,
270    },
271    /// A local path source, identified by its resolved directory.
272    Path(&'a std::path::Path),
273}
274
275/// A 40-character lowercase hex Git commit SHA.
276#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
277#[serde(try_from = "String")]
278pub struct GitCommit(String);
279
280impl GitCommit {
281    /// Returns the commit SHA as a string slice.
282    pub fn as_str(&self) -> &str {
283        &self.0
284    }
285}
286
287impl fmt::Display for GitCommit {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.write_str(&self.0)
290    }
291}
292
293impl TryFrom<String> for GitCommit {
294    type Error = GitCommitError;
295
296    fn try_from(s: String) -> Result<Self, Self::Error> {
297        if s.len() == 40
298            && s.bytes()
299                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
300        {
301            Ok(Self(s))
302        } else {
303            Err(GitCommitError(s))
304        }
305    }
306}
307
308impl FromStr for GitCommit {
309    type Err = GitCommitError;
310
311    fn from_str(s: &str) -> Result<Self, Self::Err> {
312        Self::try_from(s.to_string())
313    }
314}
315
316/// An error parsing a [`GitCommit`].
317#[derive(Debug, Error)]
318#[error("git commit `{0}` must be exactly 40 lowercase hex characters")]
319pub struct GitCommitError(String);
320
321/// A Git commit selector: any unique prefix of a commit SHA, from 4 to
322/// 40 lowercase hex characters (Git's own minimum abbreviation length).
323/// The resolver expands the prefix to a full [`GitCommit`] at lock time.
324#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
325#[serde(try_from = "String")]
326pub struct GitCommitish(String);
327
328impl GitCommitish {
329    /// Returns the commit-ish as a string slice.
330    pub fn as_str(&self) -> &str {
331        &self.0
332    }
333
334    /// Returns true when the selector is a full 40-character SHA.
335    pub fn is_full(&self) -> bool {
336        self.0.len() == 40
337    }
338}
339
340impl fmt::Display for GitCommitish {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        f.write_str(&self.0)
343    }
344}
345
346impl TryFrom<String> for GitCommitish {
347    type Error = GitCommitishError;
348
349    fn try_from(s: String) -> Result<Self, Self::Error> {
350        if (4..=40).contains(&s.len())
351            && s.bytes()
352                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
353        {
354            Ok(Self(s))
355        } else {
356            Err(GitCommitishError(s))
357        }
358    }
359}
360
361impl FromStr for GitCommitish {
362    type Err = GitCommitishError;
363
364    fn from_str(s: &str) -> Result<Self, Self::Err> {
365        Self::try_from(s.to_string())
366    }
367}
368
369/// An error parsing a [`GitCommitish`].
370#[derive(Debug, Error)]
371#[error("git commit `{0}` must be 4 to 40 lowercase hex characters")]
372pub struct GitCommitishError(String);
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn parse(s: &str) -> Result<Lockfile, LockfileError> {
379        Lockfile::parse(s.as_bytes())
380    }
381
382    #[cfg(feature = "git-resolver")]
383    fn parse_manifest(s: &str) -> Manifest {
384        Manifest::parse(s.as_bytes()).unwrap()
385    }
386
387    #[test]
388    fn parses_minimal_lockfile() {
389        let l = parse(r#"{"version": 1, "dependencies": {}}"#).unwrap();
390        assert_eq!(l.version, 1);
391        assert!(l.dependencies.is_empty());
392    }
393
394    #[test]
395    fn parses_recursive_lockfile() {
396        let l = parse(
397            r#"{
398                "version": 1,
399                "dependencies": {
400                    "spellbook": {
401                        "source": {
402                            "git": "https://github.com/openwdl/spellbook",
403                            "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
404                            "selector": {"version": "^1"}
405                        },
406                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
407                        "dependencies": {
408                            "common": {
409                                "source": {
410                                    "git": "https://github.com/openwdl/common",
411                                    "sha": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
412                                    "selector": {"version": "^0.3"}
413                                },
414                                "checksum": "sha256:4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865",
415                                "dependencies": {}
416                            }
417                        }
418                    },
419                    "local_utils": {
420                        "source": { "path": "../utils" },
421                        "dependencies": {}
422                    }
423                }
424            }"#,
425        )
426        .unwrap();
427
428        assert_eq!(l.dependencies.len(), 2);
429        let spellbook = l.dependencies.get(&"spellbook".parse().unwrap()).unwrap();
430        assert!(matches!(spellbook.source, ResolvedSource::Git { .. }));
431        assert_eq!(spellbook.dependencies.len(), 1);
432    }
433
434    #[test]
435    fn round_trips_lockfile() {
436        let original = parse(
437            r#"{
438                "version": 1,
439                "dependencies": {
440                    "local_utils": {
441                        "source": { "path": "../utils" },
442                        "dependencies": {}
443                    }
444                }
445            }"#,
446        )
447        .unwrap();
448
449        let mut buf = Vec::new();
450        original.write(&mut buf).unwrap();
451        let parsed = Lockfile::parse(&buf).unwrap();
452        assert_eq!(parsed, original);
453    }
454
455    #[test]
456    fn rejects_duplicate_keys() {
457        let err = parse(
458            r#"{
459                "version": 1,
460                "version": 2,
461                "dependencies": {}
462            }"#,
463        )
464        .unwrap_err();
465        assert!(
466            matches!(err, LockfileError::InvalidJson(e) if e.to_string().contains("duplicate"))
467        );
468    }
469
470    #[test]
471    fn rejects_unknown_top_level_fields() {
472        let err = parse(r#"{"version": 1, "dependencies": {}, "extra": 42}"#).unwrap_err();
473        assert!(matches!(err, LockfileError::InvalidJson(_)));
474    }
475
476    #[test]
477    fn rejects_wrong_version() {
478        let err = parse(r#"{"version": 2, "dependencies": {}}"#).unwrap_err();
479        assert!(matches!(err, LockfileError::UnsupportedVersion(2)));
480    }
481
482    #[test]
483    fn rejects_bad_commit_sha() {
484        let err = parse(
485            r#"{
486                "version": 1,
487                "dependencies": {
488                    "spellbook": {
489                        "source": {
490                            "git": "https://x/y",
491                            "sha": "not-a-sha",
492                            "selector": {"tag": "v1"}
493                        },
494                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
495                        "dependencies": {}
496                    }
497                }
498            }"#,
499        )
500        .unwrap_err();
501        assert!(matches!(err, LockfileError::InvalidJson(_)));
502    }
503
504    #[test]
505    fn rejects_bad_checksum() {
506        let err = parse(
507            r#"{
508                "version": 1,
509                "dependencies": {
510                    "local": {
511                        "source": { "path": "../utils" },
512                        "checksum": "md5:abc",
513                        "dependencies": {}
514                    }
515                }
516            }"#,
517        )
518        .unwrap_err();
519        assert!(matches!(err, LockfileError::InvalidJson(_)));
520    }
521
522    #[test]
523    fn parses_git_source_with_path() {
524        let l = parse(
525            r#"{
526                "version": 1,
527                "dependencies": {
528                    "csvcut": {
529                        "source": {
530                            "git": "https://github.com/openwdl/tasks",
531                            "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
532                            "selector": {"tag": "v1.2.0"},
533                            "path": "csvcut"
534                        },
535                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
536                        "dependencies": {}
537                    }
538                }
539            }"#,
540        )
541        .unwrap();
542        let csvcut = l.dependencies.get(&"csvcut".parse().unwrap()).unwrap();
543        match &csvcut.source {
544            ResolvedSource::Git { path, .. } => {
545                assert_eq!(path.as_ref().map(|p| p.as_str()), Some("csvcut"));
546            }
547            _ => panic!("expected `Git` source"),
548        }
549    }
550
551    #[cfg(feature = "git-resolver")]
552    #[test]
553    fn satisfies_manifest_present_and_satisfied() {
554        let manifest = parse_manifest(
555            r#"{
556                "name":"consumer",
557                "license":"MIT",
558                "dependencies":{
559                    "foo":{"git":"https://github.com/openwdl/foo","version":"^1"}
560                }
561            }"#,
562        );
563        let lock = parse(
564            r#"{
565                "version":1,
566                "dependencies":{
567                    "foo":{
568                        "source":{
569                            "git":"https://github.com/openwdl/foo",
570                            "sha":"0000000000000000000000000000000000000001",
571                            "selector":{"version":"^1"}
572                        },
573                        "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
574                        "dependencies":{}
575                    }
576                }
577            }"#,
578        )
579        .unwrap();
580        assert!(lock.satisfies_manifest(&manifest));
581    }
582
583    #[cfg(feature = "git-resolver")]
584    #[test]
585    fn satisfies_manifest_true_for_same_branch_selector() {
586        let manifest = parse_manifest(
587            r#"{
588                "name":"consumer",
589                "license":"MIT",
590                "dependencies":{
591                    "foo":{
592                        "git":"https://github.com/openwdl/foo",
593                        "branch":"main",
594                        "path":"modules/foo"
595                    }
596                }
597            }"#,
598        );
599        let lock = parse(
600            r#"{
601                "version":1,
602                "dependencies":{
603                    "foo":{
604                        "source":{
605                            "git":"https://github.com/openwdl/foo",
606                            "sha":"0000000000000000000000000000000000000001",
607                            "selector":{"branch":"main"},
608                            "path":"modules/foo"
609                        },
610                        "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
611                        "dependencies":{}
612                    }
613                }
614            }"#,
615        )
616        .unwrap();
617        assert!(lock.satisfies_manifest(&manifest));
618    }
619
620    #[cfg(feature = "git-resolver")]
621    #[test]
622    fn satisfies_manifest_false_when_branch_path_changes() {
623        let manifest = parse_manifest(
624            r#"{
625                "name":"consumer",
626                "license":"MIT",
627                "dependencies":{
628                    "foo":{
629                        "git":"https://github.com/openwdl/foo",
630                        "branch":"main",
631                        "path":"modules/new"
632                    }
633                }
634            }"#,
635        );
636        let lock = parse(
637            r#"{
638                "version":1,
639                "dependencies":{
640                    "foo":{
641                        "source":{
642                            "git":"https://github.com/openwdl/foo",
643                            "sha":"0000000000000000000000000000000000000001",
644                            "selector":{"branch":"main"},
645                            "path":"modules/old"
646                        },
647                        "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
648                        "dependencies":{}
649                    }
650                }
651            }"#,
652        )
653        .unwrap();
654        assert!(!lock.satisfies_manifest(&manifest));
655    }
656
657    #[cfg(feature = "git-resolver")]
658    #[test]
659    fn satisfies_manifest_false_when_dep_missing_from_lock() {
660        let manifest = parse_manifest(
661            r#"{
662                "name":"consumer",
663                "license":"MIT",
664                "dependencies":{
665                    "foo":{"git":"https://github.com/openwdl/foo","version":"^1"}
666                }
667            }"#,
668        );
669        let lock = parse(r#"{"version":1,"dependencies":{}}"#).unwrap();
670        assert!(!lock.satisfies_manifest(&manifest));
671    }
672
673    #[cfg(feature = "git-resolver")]
674    #[test]
675    fn satisfies_manifest_false_with_orphan_top_level_entry() {
676        let manifest = parse_manifest(
677            r#"{
678                "name":"consumer",
679                "license":"MIT"
680            }"#,
681        );
682        let lock = parse(
683            r#"{
684                "version":1,
685                "dependencies":{
686                    "orphan":{
687                        "source":{"path":"../orphan"},
688                        "dependencies":{}
689                    }
690                }
691            }"#,
692        )
693        .unwrap();
694        assert!(!lock.satisfies_manifest(&manifest));
695    }
696
697    #[cfg(feature = "git-resolver")]
698    #[test]
699    fn satisfies_manifest_true_with_nested_transitives_under_satisfied_top_level() {
700        let manifest = parse_manifest(
701            r#"{
702                "name":"consumer",
703                "license":"MIT",
704                "dependencies":{
705                    "foo":{"git":"https://github.com/openwdl/foo","version":"^1"}
706                }
707            }"#,
708        );
709        let lock = parse(
710            r#"{
711                "version":1,
712                "dependencies":{
713                    "foo":{
714                        "source":{
715                            "git":"https://github.com/openwdl/foo",
716                            "sha":"0000000000000000000000000000000000000001",
717                            "selector":{"version":"^1"}
718                        },
719                        "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
720                        "dependencies":{
721                            "bar":{
722                                "source":{"path":"../bar"},
723                                "dependencies":{}
724                            }
725                        }
726                    }
727                }
728            }"#,
729        )
730        .unwrap();
731        assert!(lock.satisfies_manifest(&manifest));
732    }
733}