1use 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
20pub const LOCKFILE_VERSION: u32 = 1;
22
23#[derive(Debug, Error)]
25pub enum LockfileError {
26 #[error("invalid `module-lock.json` JSON")]
29 InvalidJson(#[from] serde_json::Error),
30
31 #[error(
33 "unsupported lockfile version `{0}`; this build only supports version `{LOCKFILE_VERSION}`"
34 )]
35 UnsupportedVersion(u32),
36
37 #[error(transparent)]
39 DependencyName(#[from] DependencyNameError),
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Lockfile {
46 pub version: u32,
48 pub dependencies: DependencyMap,
50}
51
52impl Lockfile {
53 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 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
68pub type DependencyMap = BTreeMap<DependencyName, DependencyEntry>;
70
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct DependencyEntry {
75 pub source: ResolvedSource,
77 pub version: Version,
79 pub checksum: ContentHash,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub signer: Option<VerifyingKey>,
84 pub dependencies: DependencyMap,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(untagged, deny_unknown_fields)]
91pub enum ResolvedSource {
92 Git {
94 git: Url,
96 commit: GitCommit,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 path: Option<PathBuf>,
102 },
103 Path {
105 path: PathBuf,
107 },
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
112#[serde(try_from = "String")]
113pub struct GitCommit(String);
114
115impl GitCommit {
116 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#[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}