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::dependency::DependencyName;
16use crate::dependency::DependencyNameError;
17use crate::dependency::GitModulePath;
18use crate::dependency::GitSelector;
19use crate::hash::ContentHash;
20use crate::signing::VerifyingKey;
21
22pub const LOCKFILE_VERSION: u32 = 1;
24
25#[derive(Debug, Error)]
27pub enum LockfileError {
28 #[error("invalid `module-lock.json` JSON")]
31 InvalidJson(#[from] serde_json::Error),
32
33 #[error(
35 "unsupported lockfile version `{0}`; this build only supports version `{LOCKFILE_VERSION}`"
36 )]
37 UnsupportedVersion(u32),
38
39 #[error(transparent)]
41 DependencyName(#[from] DependencyNameError),
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct Lockfile {
48 pub version: u32,
50 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 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 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 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 = ¤t.get(parent)?.dependencies;
90 }
91 current.get(name)
92 }
93}
94
95pub type DependencyMap = BTreeMap<DependencyName, DependencyEntry>;
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct DependencyEntry {
102 pub source: ResolvedSource,
104 pub version: Version,
106 pub checksum: ContentHash,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub signer: Option<VerifyingKey>,
111 pub dependencies: DependencyMap,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(untagged, deny_unknown_fields)]
118pub enum ResolvedSource {
119 Git {
121 git: Url,
123 commit: GitCommit,
125 selector: GitSelector,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
135 path: Option<GitModulePath>,
136 },
137 Path {
139 path: PathBuf,
141 },
142}
143
144impl ResolvedSource {
145 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 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#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
166#[serde(try_from = "String")]
167pub struct GitCommit(String);
168
169impl GitCommit {
170 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#[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}