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.dependencies.get(&"spellbook".parse().unwrap()).unwrap();
249        assert!(matches!(spellbook.source, ResolvedSource::Git { .. }));
250        assert_eq!(spellbook.version.to_string(), "1.2.0");
251        assert_eq!(spellbook.dependencies.len(), 1);
252    }
253
254    #[test]
255    fn round_trips_lockfile() {
256        let original = parse(
257            r#"{
258                "version": 1,
259                "dependencies": {
260                    "local_utils": {
261                        "source": { "path": "../utils" },
262                        "version": "0.5.0",
263                        "checksum": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
264                        "dependencies": {}
265                    }
266                }
267            }"#,
268        )
269        .unwrap();
270
271        let mut buf = Vec::new();
272        original.write(&mut buf).unwrap();
273        let parsed = Lockfile::parse(&buf).unwrap();
274        assert_eq!(parsed, original);
275    }
276
277    #[test]
278    fn rejects_duplicate_keys() {
279        let err = parse(
280            r#"{
281                "version": 1,
282                "version": 2,
283                "dependencies": {}
284            }"#,
285        )
286        .unwrap_err();
287        assert!(
288            matches!(err, LockfileError::InvalidJson(e) if e.to_string().contains("duplicate"))
289        );
290    }
291
292    #[test]
293    fn rejects_unknown_top_level_fields() {
294        let err = parse(r#"{"version": 1, "dependencies": {}, "extra": 42}"#).unwrap_err();
295        assert!(matches!(err, LockfileError::InvalidJson(_)));
296    }
297
298    #[test]
299    fn rejects_wrong_version() {
300        let err = parse(r#"{"version": 2, "dependencies": {}}"#).unwrap_err();
301        assert!(matches!(err, LockfileError::UnsupportedVersion(2)));
302    }
303
304    #[test]
305    fn rejects_bad_commit_sha() {
306        let err = parse(
307            r#"{
308                "version": 1,
309                "dependencies": {
310                    "spellbook": {
311                        "source": {
312                            "git": "https://x/y",
313                            "commit": "not-a-sha",
314                            "selector": {"tag": "v1"}
315                        },
316                        "version": "1.0.0",
317                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
318                        "dependencies": {}
319                    }
320                }
321            }"#,
322        )
323        .unwrap_err();
324        assert!(matches!(err, LockfileError::InvalidJson(_)));
325    }
326
327    #[test]
328    fn rejects_bad_checksum() {
329        let err = parse(
330            r#"{
331                "version": 1,
332                "dependencies": {
333                    "local": {
334                        "source": { "path": "../utils" },
335                        "version": "0.1.0",
336                        "checksum": "md5:abc",
337                        "dependencies": {}
338                    }
339                }
340            }"#,
341        )
342        .unwrap_err();
343        assert!(matches!(err, LockfileError::InvalidJson(_)));
344    }
345
346    #[test]
347    fn parses_git_source_with_path() {
348        let l = parse(
349            r#"{
350                "version": 1,
351                "dependencies": {
352                    "csvcut": {
353                        "source": {
354                            "git": "https://github.com/openwdl/tasks",
355                            "commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
356                            "selector": {"tag": "v1.2.0"},
357                            "path": "csvcut"
358                        },
359                        "version": "1.2.0",
360                        "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
361                        "dependencies": {}
362                    }
363                }
364            }"#,
365        )
366        .unwrap();
367        let csvcut = l.dependencies.get(&"csvcut".parse().unwrap()).unwrap();
368        match &csvcut.source {
369            ResolvedSource::Git { path, .. } => {
370                assert_eq!(path.as_ref().map(|p| p.as_str()), Some("csvcut"));
371            }
372            _ => panic!("expected `Git` source"),
373        }
374    }
375}