1use std::collections::BTreeMap;
4use std::fmt;
5use std::io::Write;
6use std::path::PathBuf;
7use std::str::FromStr;
8
9use serde::Deserialize;
10use serde::Serialize;
11use thiserror::Error;
12use url::Url;
13
14#[cfg(feature = "git-resolver")]
15use crate::Manifest;
16use crate::dependency::DependencyName;
17use crate::dependency::DependencyNameError;
18use crate::dependency::GitModulePath;
19use crate::dependency::GitSelector;
20use crate::hash::ContentHash;
21use crate::signing::VerifyingKey;
22
23pub const LOCKFILE_VERSION: u32 = 1;
25
26#[derive(Debug, Error)]
28pub enum LockfileError {
29 #[error("invalid `module-lock.json` JSON")]
32 InvalidJson(#[from] serde_json::Error),
33
34 #[error(
36 "unsupported lockfile version `{0}`; this build only supports version `{LOCKFILE_VERSION}`"
37 )]
38 UnsupportedVersion(u32),
39
40 #[error(transparent)]
42 DependencyName(#[from] DependencyNameError),
43
44 #[error("lockfile entry for `{0}` has a Git source but no `checksum`")]
46 MissingChecksum(String),
47
48 #[error(
51 "lockfile entry for `{0}` has a local path source but carries a `checksum` or `signer`"
52 )]
53 PathSourceIntegrity(String),
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Lockfile {
60 pub version: u32,
62 pub dependencies: DependencyMap,
64}
65
66impl Default for Lockfile {
67 fn default() -> Self {
68 Self {
69 version: LOCKFILE_VERSION,
70 dependencies: DependencyMap::new(),
71 }
72 }
73}
74
75impl Lockfile {
76 pub fn parse(bytes: &[u8]) -> Result<Self, LockfileError> {
78 let lockfile: Lockfile = crate::strict_json::from_slice(bytes)?;
79 if lockfile.version != LOCKFILE_VERSION {
80 return Err(LockfileError::UnsupportedVersion(lockfile.version));
81 }
82 validate_integrity_fields(&lockfile.dependencies)?;
83 Ok(lockfile)
84 }
85
86 pub fn write(&self, w: impl Write) -> std::io::Result<()> {
88 serde_json::to_writer_pretty(w, self).map_err(std::io::Error::other)
89 }
90
91 pub fn find_scoped(
96 &self,
97 scope: &[DependencyName],
98 name: &DependencyName,
99 ) -> Option<&DependencyEntry> {
100 let mut current = &self.dependencies;
101 for parent in scope {
102 current = ¤t.get(parent)?.dependencies;
103 }
104 current.get(name)
105 }
106
107 #[cfg(feature = "git-resolver")]
111 pub fn satisfies_manifest(&self, manifest: &Manifest) -> bool {
112 manifest.dependencies.iter().all(|(name, source)| {
113 self.find_scoped(&[], name)
114 .is_some_and(|entry| crate::resolver::lock::satisfies(entry, source))
115 }) && self
116 .dependencies
117 .keys()
118 .all(|name| manifest.dependencies.contains_key(name))
119 }
120}
121
122fn validate_integrity_fields(deps: &DependencyMap) -> Result<(), LockfileError> {
125 for (name, entry) in deps {
126 match &entry.source {
127 ResolvedSource::Git { .. } => {
128 if entry.checksum.is_none() {
129 return Err(LockfileError::MissingChecksum(name.manifest().to_string()));
130 }
131 }
132 ResolvedSource::Path { .. } => {
133 if entry.checksum.is_some() || entry.signer.is_some() {
134 return Err(LockfileError::PathSourceIntegrity(
135 name.manifest().to_string(),
136 ));
137 }
138 }
139 }
140 validate_integrity_fields(&entry.dependencies)?;
141 }
142 Ok(())
143}
144
145pub type DependencyMap = BTreeMap<DependencyName, DependencyEntry>;
147
148#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(deny_unknown_fields)]
151pub struct DependencyEntry {
152 pub source: ResolvedSource,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub checksum: Option<ContentHash>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub signer: Option<VerifyingKey>,
166 pub dependencies: DependencyMap,
168}
169
170impl DependencyEntry {
171 pub fn source_path(&self) -> Option<&str> {
173 self.source.source_path()
174 }
175
176 pub fn git_sha(&self) -> Option<&GitCommit> {
178 match &self.source {
179 ResolvedSource::Git { sha, .. } => Some(sha),
180 ResolvedSource::Path { .. } => None,
181 }
182 }
183
184 pub fn git_selector(&self) -> Option<&GitSelector> {
186 match &self.source {
187 ResolvedSource::Git { selector, .. } => Some(selector),
188 ResolvedSource::Path { .. } => None,
189 }
190 }
191}
192
193#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(untagged, deny_unknown_fields)]
196pub enum ResolvedSource {
197 Git {
199 git: Url,
201 sha: GitCommit,
204 selector: GitSelector,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
214 path: Option<GitModulePath>,
215 },
216 Path {
218 path: PathBuf,
220 },
221}
222
223impl ResolvedSource {
224 pub fn source_url(&self) -> String {
227 match self {
228 Self::Git { git, .. } => git.to_string(),
229 Self::Path { path } => path.display().to_string(),
230 }
231 }
232
233 pub fn source_path(&self) -> Option<&str> {
236 match self {
237 Self::Git { path: Some(p), .. } => Some(p.as_str()),
238 _ => None,
239 }
240 }
241
242 pub fn coordinates(&self) -> SourceCoordinates<'_> {
250 match self {
251 Self::Git { git, path, .. } => SourceCoordinates::Git {
252 git: git.as_str(),
253 path: path.as_ref().map(GitModulePath::as_str),
254 },
255 Self::Path { path } => SourceCoordinates::Path(path.as_path()),
256 }
257 }
258}
259
260#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum SourceCoordinates<'a> {
264 Git {
266 git: &'a str,
268 path: Option<&'a str>,
270 },
271 Path(&'a std::path::Path),
273}
274
275#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
277#[serde(try_from = "String")]
278pub struct GitCommit(String);
279
280impl GitCommit {
281 pub fn as_str(&self) -> &str {
283 &self.0
284 }
285}
286
287impl fmt::Display for GitCommit {
288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 f.write_str(&self.0)
290 }
291}
292
293impl TryFrom<String> for GitCommit {
294 type Error = GitCommitError;
295
296 fn try_from(s: String) -> Result<Self, Self::Error> {
297 if s.len() == 40
298 && s.bytes()
299 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
300 {
301 Ok(Self(s))
302 } else {
303 Err(GitCommitError(s))
304 }
305 }
306}
307
308impl FromStr for GitCommit {
309 type Err = GitCommitError;
310
311 fn from_str(s: &str) -> Result<Self, Self::Err> {
312 Self::try_from(s.to_string())
313 }
314}
315
316#[derive(Debug, Error)]
318#[error("git commit `{0}` must be exactly 40 lowercase hex characters")]
319pub struct GitCommitError(String);
320
321#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
325#[serde(try_from = "String")]
326pub struct GitCommitish(String);
327
328impl GitCommitish {
329 pub fn as_str(&self) -> &str {
331 &self.0
332 }
333
334 pub fn is_full(&self) -> bool {
336 self.0.len() == 40
337 }
338}
339
340impl fmt::Display for GitCommitish {
341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342 f.write_str(&self.0)
343 }
344}
345
346impl TryFrom<String> for GitCommitish {
347 type Error = GitCommitishError;
348
349 fn try_from(s: String) -> Result<Self, Self::Error> {
350 if (4..=40).contains(&s.len())
351 && s.bytes()
352 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
353 {
354 Ok(Self(s))
355 } else {
356 Err(GitCommitishError(s))
357 }
358 }
359}
360
361impl FromStr for GitCommitish {
362 type Err = GitCommitishError;
363
364 fn from_str(s: &str) -> Result<Self, Self::Err> {
365 Self::try_from(s.to_string())
366 }
367}
368
369#[derive(Debug, Error)]
371#[error("git commit `{0}` must be 4 to 40 lowercase hex characters")]
372pub struct GitCommitishError(String);
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn parse(s: &str) -> Result<Lockfile, LockfileError> {
379 Lockfile::parse(s.as_bytes())
380 }
381
382 #[cfg(feature = "git-resolver")]
383 fn parse_manifest(s: &str) -> Manifest {
384 Manifest::parse(s.as_bytes()).unwrap()
385 }
386
387 #[test]
388 fn parses_minimal_lockfile() {
389 let l = parse(r#"{"version": 1, "dependencies": {}}"#).unwrap();
390 assert_eq!(l.version, 1);
391 assert!(l.dependencies.is_empty());
392 }
393
394 #[test]
395 fn parses_recursive_lockfile() {
396 let l = parse(
397 r#"{
398 "version": 1,
399 "dependencies": {
400 "spellbook": {
401 "source": {
402 "git": "https://github.com/openwdl/spellbook",
403 "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
404 "selector": {"version": "^1"}
405 },
406 "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
407 "dependencies": {
408 "common": {
409 "source": {
410 "git": "https://github.com/openwdl/common",
411 "sha": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
412 "selector": {"version": "^0.3"}
413 },
414 "checksum": "sha256:4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865",
415 "dependencies": {}
416 }
417 }
418 },
419 "local_utils": {
420 "source": { "path": "../utils" },
421 "dependencies": {}
422 }
423 }
424 }"#,
425 )
426 .unwrap();
427
428 assert_eq!(l.dependencies.len(), 2);
429 let spellbook = l.dependencies.get(&"spellbook".parse().unwrap()).unwrap();
430 assert!(matches!(spellbook.source, ResolvedSource::Git { .. }));
431 assert_eq!(spellbook.dependencies.len(), 1);
432 }
433
434 #[test]
435 fn round_trips_lockfile() {
436 let original = parse(
437 r#"{
438 "version": 1,
439 "dependencies": {
440 "local_utils": {
441 "source": { "path": "../utils" },
442 "dependencies": {}
443 }
444 }
445 }"#,
446 )
447 .unwrap();
448
449 let mut buf = Vec::new();
450 original.write(&mut buf).unwrap();
451 let parsed = Lockfile::parse(&buf).unwrap();
452 assert_eq!(parsed, original);
453 }
454
455 #[test]
456 fn rejects_duplicate_keys() {
457 let err = parse(
458 r#"{
459 "version": 1,
460 "version": 2,
461 "dependencies": {}
462 }"#,
463 )
464 .unwrap_err();
465 assert!(
466 matches!(err, LockfileError::InvalidJson(e) if e.to_string().contains("duplicate"))
467 );
468 }
469
470 #[test]
471 fn rejects_unknown_top_level_fields() {
472 let err = parse(r#"{"version": 1, "dependencies": {}, "extra": 42}"#).unwrap_err();
473 assert!(matches!(err, LockfileError::InvalidJson(_)));
474 }
475
476 #[test]
477 fn rejects_wrong_version() {
478 let err = parse(r#"{"version": 2, "dependencies": {}}"#).unwrap_err();
479 assert!(matches!(err, LockfileError::UnsupportedVersion(2)));
480 }
481
482 #[test]
483 fn rejects_bad_commit_sha() {
484 let err = parse(
485 r#"{
486 "version": 1,
487 "dependencies": {
488 "spellbook": {
489 "source": {
490 "git": "https://x/y",
491 "sha": "not-a-sha",
492 "selector": {"tag": "v1"}
493 },
494 "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
495 "dependencies": {}
496 }
497 }
498 }"#,
499 )
500 .unwrap_err();
501 assert!(matches!(err, LockfileError::InvalidJson(_)));
502 }
503
504 #[test]
505 fn rejects_bad_checksum() {
506 let err = parse(
507 r#"{
508 "version": 1,
509 "dependencies": {
510 "local": {
511 "source": { "path": "../utils" },
512 "checksum": "md5:abc",
513 "dependencies": {}
514 }
515 }
516 }"#,
517 )
518 .unwrap_err();
519 assert!(matches!(err, LockfileError::InvalidJson(_)));
520 }
521
522 #[test]
523 fn parses_git_source_with_path() {
524 let l = parse(
525 r#"{
526 "version": 1,
527 "dependencies": {
528 "csvcut": {
529 "source": {
530 "git": "https://github.com/openwdl/tasks",
531 "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
532 "selector": {"tag": "v1.2.0"},
533 "path": "csvcut"
534 },
535 "checksum": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
536 "dependencies": {}
537 }
538 }
539 }"#,
540 )
541 .unwrap();
542 let csvcut = l.dependencies.get(&"csvcut".parse().unwrap()).unwrap();
543 match &csvcut.source {
544 ResolvedSource::Git { path, .. } => {
545 assert_eq!(path.as_ref().map(|p| p.as_str()), Some("csvcut"));
546 }
547 _ => panic!("expected `Git` source"),
548 }
549 }
550
551 #[cfg(feature = "git-resolver")]
552 #[test]
553 fn satisfies_manifest_present_and_satisfied() {
554 let manifest = parse_manifest(
555 r#"{
556 "name":"consumer",
557 "license":"MIT",
558 "dependencies":{
559 "foo":{"git":"https://github.com/openwdl/foo","version":"^1"}
560 }
561 }"#,
562 );
563 let lock = parse(
564 r#"{
565 "version":1,
566 "dependencies":{
567 "foo":{
568 "source":{
569 "git":"https://github.com/openwdl/foo",
570 "sha":"0000000000000000000000000000000000000001",
571 "selector":{"version":"^1"}
572 },
573 "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
574 "dependencies":{}
575 }
576 }
577 }"#,
578 )
579 .unwrap();
580 assert!(lock.satisfies_manifest(&manifest));
581 }
582
583 #[cfg(feature = "git-resolver")]
584 #[test]
585 fn satisfies_manifest_true_for_same_branch_selector() {
586 let manifest = parse_manifest(
587 r#"{
588 "name":"consumer",
589 "license":"MIT",
590 "dependencies":{
591 "foo":{
592 "git":"https://github.com/openwdl/foo",
593 "branch":"main",
594 "path":"modules/foo"
595 }
596 }
597 }"#,
598 );
599 let lock = parse(
600 r#"{
601 "version":1,
602 "dependencies":{
603 "foo":{
604 "source":{
605 "git":"https://github.com/openwdl/foo",
606 "sha":"0000000000000000000000000000000000000001",
607 "selector":{"branch":"main"},
608 "path":"modules/foo"
609 },
610 "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
611 "dependencies":{}
612 }
613 }
614 }"#,
615 )
616 .unwrap();
617 assert!(lock.satisfies_manifest(&manifest));
618 }
619
620 #[cfg(feature = "git-resolver")]
621 #[test]
622 fn satisfies_manifest_false_when_branch_path_changes() {
623 let manifest = parse_manifest(
624 r#"{
625 "name":"consumer",
626 "license":"MIT",
627 "dependencies":{
628 "foo":{
629 "git":"https://github.com/openwdl/foo",
630 "branch":"main",
631 "path":"modules/new"
632 }
633 }
634 }"#,
635 );
636 let lock = parse(
637 r#"{
638 "version":1,
639 "dependencies":{
640 "foo":{
641 "source":{
642 "git":"https://github.com/openwdl/foo",
643 "sha":"0000000000000000000000000000000000000001",
644 "selector":{"branch":"main"},
645 "path":"modules/old"
646 },
647 "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
648 "dependencies":{}
649 }
650 }
651 }"#,
652 )
653 .unwrap();
654 assert!(!lock.satisfies_manifest(&manifest));
655 }
656
657 #[cfg(feature = "git-resolver")]
658 #[test]
659 fn satisfies_manifest_false_when_dep_missing_from_lock() {
660 let manifest = parse_manifest(
661 r#"{
662 "name":"consumer",
663 "license":"MIT",
664 "dependencies":{
665 "foo":{"git":"https://github.com/openwdl/foo","version":"^1"}
666 }
667 }"#,
668 );
669 let lock = parse(r#"{"version":1,"dependencies":{}}"#).unwrap();
670 assert!(!lock.satisfies_manifest(&manifest));
671 }
672
673 #[cfg(feature = "git-resolver")]
674 #[test]
675 fn satisfies_manifest_false_with_orphan_top_level_entry() {
676 let manifest = parse_manifest(
677 r#"{
678 "name":"consumer",
679 "license":"MIT"
680 }"#,
681 );
682 let lock = parse(
683 r#"{
684 "version":1,
685 "dependencies":{
686 "orphan":{
687 "source":{"path":"../orphan"},
688 "dependencies":{}
689 }
690 }
691 }"#,
692 )
693 .unwrap();
694 assert!(!lock.satisfies_manifest(&manifest));
695 }
696
697 #[cfg(feature = "git-resolver")]
698 #[test]
699 fn satisfies_manifest_true_with_nested_transitives_under_satisfied_top_level() {
700 let manifest = parse_manifest(
701 r#"{
702 "name":"consumer",
703 "license":"MIT",
704 "dependencies":{
705 "foo":{"git":"https://github.com/openwdl/foo","version":"^1"}
706 }
707 }"#,
708 );
709 let lock = parse(
710 r#"{
711 "version":1,
712 "dependencies":{
713 "foo":{
714 "source":{
715 "git":"https://github.com/openwdl/foo",
716 "sha":"0000000000000000000000000000000000000001",
717 "selector":{"version":"^1"}
718 },
719 "checksum":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
720 "dependencies":{
721 "bar":{
722 "source":{"path":"../bar"},
723 "dependencies":{}
724 }
725 }
726 }
727 }
728 }"#,
729 )
730 .unwrap();
731 assert!(lock.satisfies_manifest(&manifest));
732 }
733}