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