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
79pub type DependencyMap = BTreeMap<DependencyName, DependencyEntry>;
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct DependencyEntry {
86 pub source: ResolvedSource,
88 pub version: Version,
90 pub checksum: ContentHash,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub signer: Option<VerifyingKey>,
95 pub dependencies: DependencyMap,
97}
98
99#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(untagged, deny_unknown_fields)]
102pub enum ResolvedSource {
103 Git {
105 git: Url,
107 commit: GitCommit,
109 selector: GitSelector,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
119 path: Option<GitModulePath>,
120 },
121 Path {
123 path: PathBuf,
125 },
126}
127
128impl ResolvedSource {
129 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 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#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
150#[serde(try_from = "String")]
151pub struct GitCommit(String);
152
153impl GitCommit {
154 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#[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}