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