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 semver::Version;
10use serde::Deserialize;
11use serde::Serialize;
12use thiserror::Error;
13use url::Url;
14
15use crate::dependency::DependencyName;
16use crate::dependency::DependencyNameError;
17use crate::dependency::GitModulePath;
18use crate::dependency::GitSelector;
19use crate::hash::ContentHash;
20use crate::signing::VerifyingKey;
21
22/// The current lockfile schema version.
23pub const LOCKFILE_VERSION: u32 = 1;
24
25/// An error parsing a [`Lockfile`].
26#[derive(Debug, Error)]
27pub enum LockfileError {
28    /// The bytes did not parse as JSON or did not match the lockfile
29    /// schema.
30    #[error("invalid `module-lock.json` JSON")]
31    InvalidJson(#[from] serde_json::Error),
32
33    /// The lockfile declares a `version` other than [`LOCKFILE_VERSION`].
34    #[error(
35        "unsupported lockfile version `{0}`; this build only supports version `{LOCKFILE_VERSION}`"
36    )]
37    UnsupportedVersion(u32),
38
39    /// A `dependencies` key is not a valid WDL identifier.
40    #[error(transparent)]
41    DependencyName(#[from] DependencyNameError),
42}
43
44/// A parsed `module-lock.json`.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct Lockfile {
48    /// The lockfile schema version. Currently always [`LOCKFILE_VERSION`].
49    pub version: u32,
50    /// The top-level dependency map, keyed by consumer-chosen name.
51    pub dependencies: DependencyMap,
52}
53
54impl Default for Lockfile {
55    fn default() -> Self {
56        Self {
57            version: LOCKFILE_VERSION,
58            dependencies: DependencyMap::new(),
59        }
60    }
61}
62
63impl Lockfile {
64    /// Parses a `module-lock.json` from raw bytes.
65    pub fn parse(bytes: &[u8]) -> Result<Self, LockfileError> {
66        let lockfile: Lockfile = crate::strict_json::from_slice(bytes)?;
67        if lockfile.version != LOCKFILE_VERSION {
68            return Err(LockfileError::UnsupportedVersion(lockfile.version));
69        }
70        Ok(lockfile)
71    }
72
73    /// Writes the lockfile as pretty-printed JSON.
74    pub fn write(&self, w: impl Write) -> std::io::Result<()> {
75        serde_json::to_writer_pretty(w, self).map_err(std::io::Error::other)
76    }
77
78    /// Looks up a dependency entry by walking the nested `dependencies`
79    /// tree along `scope` (the chain of consumer dependency names from the
80    /// top-level consumer down to the entry's parent), then resolving
81    /// `name` in that scope. An empty `scope` looks up a top-level entry.
82    pub fn find_scoped(
83        &self,
84        scope: &[DependencyName],
85        name: &DependencyName,
86    ) -> Option<&DependencyEntry> {
87        let mut current = &self.dependencies;
88        for parent in scope {
89            current = &current.get(parent)?.dependencies;
90        }
91        current.get(name)
92    }
93}
94
95/// A `dependencies` map keyed by consumer-chosen dependency names.
96pub type DependencyMap = BTreeMap<DependencyName, DependencyEntry>;
97
98/// One entry in a [`DependencyMap`].
99#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct DependencyEntry {
102    /// The resolved source for the dependency.
103    pub source: ResolvedSource,
104    /// The module's version at lock time.
105    pub version: Version,
106    /// The module's content hash.
107    pub checksum: ContentHash,
108    /// The signer's public key, if the module was signed at lock time.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub signer: Option<VerifyingKey>,
111    /// The module's transitive dependencies.
112    pub dependencies: DependencyMap,
113}
114
115/// The resolved source of a dependency.
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(untagged, deny_unknown_fields)]
118pub enum ResolvedSource {
119    /// A Git source resolved to a specific commit.
120    Git {
121        /// The Git repository URL.
122        git: Url,
123        /// The 40-character lowercase hex commit SHA.
124        commit: GitCommit,
125        /// The selector from `module.json` that produced this entry.
126        ///
127        /// Tag and branch selectors carry mutable refs that cannot be
128        /// validated from the resolved commit alone, so this field is
129        /// required to allow integrity checks without a full relock.
130        selector: GitSelector,
131        /// The sub-path within the repository where the module lives.
132        ///
133        /// Omitted when the module sits at the repository root.
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        path: Option<GitModulePath>,
136    },
137    /// A local filesystem source.
138    Path {
139        /// The local path to the module directory.
140        path: PathBuf,
141    },
142}
143
144impl ResolvedSource {
145    /// Returns the source URL as a string suitable for trust-store
146    /// lookups.
147    pub fn source_url(&self) -> String {
148        match self {
149            Self::Git { git, .. } => git.to_string(),
150            Self::Path { path } => path.display().to_string(),
151        }
152    }
153
154    /// Returns the sub-path within the source, or `None` when the
155    /// module sits at the source root.
156    pub fn source_path(&self) -> Option<&str> {
157        match self {
158            Self::Git { path: Some(p), .. } => Some(p.as_str()),
159            _ => None,
160        }
161    }
162}
163
164/// A 40-character lowercase hex Git commit SHA.
165#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
166#[serde(try_from = "String")]
167pub struct GitCommit(String);
168
169impl GitCommit {
170    /// Returns the commit SHA as a string slice.
171    pub fn as_str(&self) -> &str {
172        &self.0
173    }
174}
175
176impl fmt::Display for GitCommit {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(&self.0)
179    }
180}
181
182impl TryFrom<String> for GitCommit {
183    type Error = GitCommitError;
184
185    fn try_from(s: String) -> Result<Self, Self::Error> {
186        if s.len() == 40
187            && s.bytes()
188                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
189        {
190            Ok(Self(s))
191        } else {
192            Err(GitCommitError(s))
193        }
194    }
195}
196
197impl FromStr for GitCommit {
198    type Err = GitCommitError;
199
200    fn from_str(s: &str) -> Result<Self, Self::Err> {
201        Self::try_from(s.to_string())
202    }
203}
204
205/// An error parsing a [`GitCommit`].
206#[derive(Debug, Error)]
207#[error("git commit `{0}` must be exactly 40 lowercase hex characters")]
208pub struct GitCommitError(String);
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn parse(s: &str) -> Result<Lockfile, LockfileError> {
215        Lockfile::parse(s.as_bytes())
216    }
217
218    #[test]
219    fn parses_minimal_lockfile() {
220        let l = parse(r#"{"version": 1, "dependencies": {}}"#).unwrap();
221        assert_eq!(l.version, 1);
222        assert!(l.dependencies.is_empty());
223    }
224
225    #[test]
226    fn parses_recursive_lockfile() {
227        let l = parse(
228            r#"{
229                "version": 1,
230                "dependencies": {
231                    "spellbook": {
232                        "source": {
233                            "git": "https://github.com/openwdl/spellbook",
234                            "commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
235                            "selector": {"version": "^1"}
236                        },
237                        "version": "1.2.0",
238                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
239                        "dependencies": {
240                            "common": {
241                                "source": {
242                                    "git": "https://github.com/openwdl/common",
243                                    "commit": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
244                                    "selector": {"version": "^0.3"}
245                                },
246                                "version": "0.3.0",
247                                "checksum": "sha256:4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865",
248                                "dependencies": {}
249                            }
250                        }
251                    },
252                    "local_utils": {
253                        "source": { "path": "../utils" },
254                        "version": "0.5.0",
255                        "checksum": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
256                        "dependencies": {}
257                    }
258                }
259            }"#,
260        )
261        .unwrap();
262
263        assert_eq!(l.dependencies.len(), 2);
264        let spellbook = l.dependencies.get(&"spellbook".parse().unwrap()).unwrap();
265        assert!(matches!(spellbook.source, ResolvedSource::Git { .. }));
266        assert_eq!(spellbook.version.to_string(), "1.2.0");
267        assert_eq!(spellbook.dependencies.len(), 1);
268    }
269
270    #[test]
271    fn round_trips_lockfile() {
272        let original = parse(
273            r#"{
274                "version": 1,
275                "dependencies": {
276                    "local_utils": {
277                        "source": { "path": "../utils" },
278                        "version": "0.5.0",
279                        "checksum": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
280                        "dependencies": {}
281                    }
282                }
283            }"#,
284        )
285        .unwrap();
286
287        let mut buf = Vec::new();
288        original.write(&mut buf).unwrap();
289        let parsed = Lockfile::parse(&buf).unwrap();
290        assert_eq!(parsed, original);
291    }
292
293    #[test]
294    fn rejects_duplicate_keys() {
295        let err = parse(
296            r#"{
297                "version": 1,
298                "version": 2,
299                "dependencies": {}
300            }"#,
301        )
302        .unwrap_err();
303        assert!(
304            matches!(err, LockfileError::InvalidJson(e) if e.to_string().contains("duplicate"))
305        );
306    }
307
308    #[test]
309    fn rejects_unknown_top_level_fields() {
310        let err = parse(r#"{"version": 1, "dependencies": {}, "extra": 42}"#).unwrap_err();
311        assert!(matches!(err, LockfileError::InvalidJson(_)));
312    }
313
314    #[test]
315    fn rejects_wrong_version() {
316        let err = parse(r#"{"version": 2, "dependencies": {}}"#).unwrap_err();
317        assert!(matches!(err, LockfileError::UnsupportedVersion(2)));
318    }
319
320    #[test]
321    fn rejects_bad_commit_sha() {
322        let err = parse(
323            r#"{
324                "version": 1,
325                "dependencies": {
326                    "spellbook": {
327                        "source": {
328                            "git": "https://x/y",
329                            "commit": "not-a-sha",
330                            "selector": {"tag": "v1"}
331                        },
332                        "version": "1.0.0",
333                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
334                        "dependencies": {}
335                    }
336                }
337            }"#,
338        )
339        .unwrap_err();
340        assert!(matches!(err, LockfileError::InvalidJson(_)));
341    }
342
343    #[test]
344    fn rejects_bad_checksum() {
345        let err = parse(
346            r#"{
347                "version": 1,
348                "dependencies": {
349                    "local": {
350                        "source": { "path": "../utils" },
351                        "version": "0.1.0",
352                        "checksum": "md5:abc",
353                        "dependencies": {}
354                    }
355                }
356            }"#,
357        )
358        .unwrap_err();
359        assert!(matches!(err, LockfileError::InvalidJson(_)));
360    }
361
362    #[test]
363    fn parses_git_source_with_path() {
364        let l = parse(
365            r#"{
366                "version": 1,
367                "dependencies": {
368                    "csvcut": {
369                        "source": {
370                            "git": "https://github.com/openwdl/tasks",
371                            "commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
372                            "selector": {"tag": "v1.2.0"},
373                            "path": "csvcut"
374                        },
375                        "version": "1.2.0",
376                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
377                        "dependencies": {}
378                    }
379                }
380            }"#,
381        )
382        .unwrap();
383        let csvcut = l.dependencies.get(&"csvcut".parse().unwrap()).unwrap();
384        match &csvcut.source {
385            ResolvedSource::Git { path, .. } => {
386                assert_eq!(path.as_ref().map(|p| p.as_str()), Some("csvcut"));
387            }
388            _ => panic!("expected `Git` source"),
389        }
390    }
391}