use semver::Version;
use crate::Lockfile;
use crate::Manifest;
use crate::dependency::DependencyName;
use crate::dependency::DependencySource;
use crate::dependency::GitSelector;
use crate::lockfile::DependencyEntry;
use crate::lockfile::DependencyMap;
use crate::lockfile::ResolvedSource;
use crate::resolver::types::ResolvedDependency;
use crate::resolver::types::ResolvedTree;
use crate::signing::VerifyingKey;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LockfileDiff {
pub new_signers: Vec<NewSigner>,
pub unsigned_added: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewSigner {
pub dep_chain: Vec<DependencyName>,
pub key: VerifyingKey,
}
impl NewSigner {
pub fn dep(&self) -> &DependencyName {
self.dep_chain.last().unwrap()
}
}
impl LockfileDiff {
pub fn compute(previous: &Lockfile, new: &Lockfile) -> Self {
let mut diff = Self::default();
walk_dep_map(
Some(&previous.dependencies),
&new.dependencies,
&mut Vec::new(),
&mut diff,
);
diff
}
pub fn requires_confirmation(&self) -> bool {
!self.new_signers.is_empty()
}
}
fn walk_dep_map(
prev: Option<&DependencyMap>,
new: &DependencyMap,
chain: &mut Vec<DependencyName>,
diff: &mut LockfileDiff,
) {
for (dep, entry) in new {
chain.push(dep.clone());
let prev_entry = prev.and_then(|p| p.get(dep));
let prev_signer = prev_entry.and_then(|e| e.signer);
match (entry.signer, prev_signer) {
(Some(new_key), Some(prev_key)) if new_key == prev_key => {}
(Some(new_key), _) => diff.new_signers.push(NewSigner {
dep_chain: chain.clone(),
key: new_key,
}),
(None, _) => {
if prev_entry.is_none() {
diff.unsigned_added += 1;
}
}
}
let prev_nested = prev_entry.map(|e| &e.dependencies);
walk_dep_map(prev_nested, &entry.dependencies, chain, diff);
chain.pop();
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RelockOutcome {
pub lockfile: Lockfile,
pub stats: RelockStats,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RelockStats {
pub kept: usize,
pub added: Vec<DependencyChange>,
pub removed: Vec<DependencyChange>,
pub updated: Vec<DependencyUpdate>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DependencyChange {
pub name: DependencyName,
pub version: Option<Version>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DependencyUpdate {
pub name: DependencyName,
pub from: Option<Version>,
pub to: Option<Version>,
}
pub fn partial_relock(
consumer: &Manifest,
existing: &Lockfile,
freshly_resolved: &ResolvedTree,
) -> Result<RelockOutcome, crate::resolver::error::ResolverError> {
let mut lockfile = Lockfile::default();
let mut stats = RelockStats::default();
for (name, source) in &consumer.dependencies {
let existing_entry = existing.dependencies.get(name);
if let Some(prev) = existing_entry
&& satisfies(prev, source)
{
lockfile.dependencies.insert(name.clone(), prev.clone());
stats.kept += 1;
continue;
}
let Some(resolved) = freshly_resolved.dependencies.get(name) else {
return Err(
crate::resolver::error::ResolverError::MissingFreshDependency {
dep: name.manifest().to_string(),
},
);
};
let new_entry = resolved_to_lockfile_entry(resolved);
let new_version = primary_version(&new_entry);
match existing_entry {
Some(prev) => stats.updated.push(DependencyUpdate {
name: name.clone(),
from: primary_version(prev),
to: new_version,
}),
None => stats.added.push(DependencyChange {
name: name.clone(),
version: new_version,
}),
}
lockfile.dependencies.insert(name.clone(), new_entry);
}
for (name, entry) in &existing.dependencies {
if !consumer.dependencies.contains_key(name) {
stats.removed.push(DependencyChange {
name: name.clone(),
version: Some(entry.version.clone()),
});
}
}
Ok(RelockOutcome { lockfile, stats })
}
fn primary_version(entry: &DependencyEntry) -> Option<Version> {
Some(entry.version.clone())
}
fn satisfies(entry: &DependencyEntry, source: &DependencySource) -> bool {
match (source, &entry.source) {
(
DependencySource::Git {
url,
selector,
path,
..
},
ResolvedSource::Git {
git,
commit: locked_commit,
path: locked_path,
..
},
) => {
if url != git {
return false;
}
if path.as_ref() != locked_path.as_ref() {
return false;
}
match selector {
GitSelector::Version(req) => req.matches(&entry.version),
GitSelector::Commit(c) => c.as_str() == locked_commit.as_str(),
GitSelector::Tag(_) | GitSelector::Branch(_) => false,
}
}
(DependencySource::LocalPath { .. }, ResolvedSource::Path { .. }) => false,
_ => false,
}
}
fn resolved_to_lockfile_entry(dep: &ResolvedDependency) -> DependencyEntry {
DependencyEntry {
source: dep.source.clone(),
version: dep.version.clone(),
checksum: dep.checksum,
signer: dep.signer,
dependencies: dep
.dependencies
.iter()
.map(|(n, d)| (n.clone(), resolved_to_lockfile_entry(d)))
.collect(),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use semver::Version;
use super::*;
use crate::hash::ContentHash;
use crate::lockfile::DependencyEntry;
use crate::lockfile::ResolvedSource;
fn dn(s: &str) -> DependencyName {
s.parse().unwrap()
}
fn checksum() -> ContentHash {
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.parse()
.unwrap()
}
fn entry(version: &str, signer: Option<VerifyingKey>) -> DependencyEntry {
DependencyEntry {
source: ResolvedSource::Path { path: ".".into() },
version: Version::parse(version).unwrap(),
checksum: checksum(),
signer,
dependencies: BTreeMap::new(),
}
}
fn key(seed: u64) -> VerifyingKey {
crate::signing::test_utils::signing_key_from_seed(seed).verifying_key()
}
#[test]
fn empty_diff_for_identical_lockfiles() {
let mut lock = Lockfile::default();
lock.dependencies
.insert(dn("openwdl"), entry("1.0.0", None));
let diff = LockfileDiff::compute(&lock, &lock);
assert!(diff.new_signers.is_empty());
assert_eq!(diff.unsigned_added, 0);
assert!(!diff.requires_confirmation());
}
#[test]
fn lists_added_signer() {
let prev = Lockfile::default();
let mut new = Lockfile::default();
new.dependencies
.insert(dn("openwdl"), entry("1.0.0", Some(key(7))));
let diff = LockfileDiff::compute(&prev, &new);
assert_eq!(diff.new_signers.len(), 1);
assert_eq!(diff.unsigned_added, 0);
assert!(diff.requires_confirmation());
}
#[test]
fn lists_changed_signer() {
let mut prev = Lockfile::default();
prev.dependencies
.insert(dn("openwdl"), entry("1.0.0", Some(key(7))));
let mut new = Lockfile::default();
new.dependencies
.insert(dn("openwdl"), entry("1.0.0", Some(key(99))));
let diff = LockfileDiff::compute(&prev, &new);
assert_eq!(diff.new_signers.len(), 1);
assert_eq!(diff.new_signers[0].key, key(99));
}
#[test]
fn unchanged_signer_does_not_appear() {
let signed = entry("1.0.0", Some(key(7)));
let mut prev = Lockfile::default();
prev.dependencies.insert(dn("openwdl"), signed.clone());
let mut new = Lockfile::default();
new.dependencies.insert(dn("openwdl"), signed);
let diff = LockfileDiff::compute(&prev, &new);
assert!(diff.new_signers.is_empty());
}
fn manifest(name: &str, deps_toml: &str) -> Manifest {
let json = format!(
r#"{{
"name": "{name}",
"version": "1.0.0",
"license": "MIT",
"dependencies": {{ {deps_toml} }}
}}"#
);
Manifest::parse(json.as_bytes()).unwrap()
}
#[test]
fn relock_keeps_satisfying_entry() {
let consumer = manifest(
"consumer",
r#""foo": { "git": "https://x/y", "version": "^1" }"#,
);
let mut existing = Lockfile::default();
existing.dependencies.insert(
dn("foo"),
DependencyEntry {
source: ResolvedSource::Git {
git: "https://x/y".parse().unwrap(),
commit: "0000000000000000000000000000000000000001".parse().unwrap(),
path: None,
selector: GitSelector::Version("^1".parse().unwrap()),
},
version: Version::parse("1.0.0").unwrap(),
checksum: checksum(),
signer: None,
dependencies: BTreeMap::new(),
},
);
let outcome = partial_relock(&consumer, &existing, &ResolvedTree::default()).unwrap();
let kept = outcome.lockfile.dependencies.get(&dn("foo")).unwrap();
assert_eq!(kept.version, Version::parse("1.0.0").unwrap());
assert_eq!(outcome.stats.kept, 1);
assert!(outcome.stats.added.is_empty());
assert!(outcome.stats.updated.is_empty());
assert!(outcome.stats.removed.is_empty());
}
#[test]
fn relock_replaces_stale_entry() {
let consumer = manifest(
"consumer",
r#""foo": { "git": "https://x/y", "version": "^2" }"#,
);
let mut existing = Lockfile::default();
existing.dependencies.insert(
dn("foo"),
DependencyEntry {
source: ResolvedSource::Git {
git: "https://x/y".parse().unwrap(),
commit: "0000000000000000000000000000000000000001".parse().unwrap(),
path: None,
selector: GitSelector::Version("^1".parse().unwrap()),
},
version: Version::parse("1.0.0").unwrap(),
checksum: checksum(),
signer: None,
dependencies: BTreeMap::new(),
},
);
let mut freshly = ResolvedTree::default();
freshly.dependencies.insert(
dn("foo"),
ResolvedDependency {
source: ResolvedSource::Git {
git: "https://x/y".parse().unwrap(),
commit: "0000000000000000000000000000000000000002".parse().unwrap(),
path: None,
selector: GitSelector::Version("^2".parse().unwrap()),
},
version: Version::parse("2.0.0").unwrap(),
checksum: checksum(),
signer: None,
dependencies: BTreeMap::new(),
},
);
let outcome = partial_relock(&consumer, &existing, &freshly).unwrap();
let entry = outcome.lockfile.dependencies.get(&dn("foo")).unwrap();
assert_eq!(entry.version, Version::parse("2.0.0").unwrap());
assert_eq!(outcome.stats.updated.len(), 1);
let update = &outcome.stats.updated[0];
assert_eq!(update.name, dn("foo"));
assert_eq!(update.from, Some(Version::parse("1.0.0").unwrap()));
assert_eq!(update.to, Some(Version::parse("2.0.0").unwrap()));
}
#[test]
fn relock_records_added_dep() {
let consumer = manifest(
"consumer",
r#""foo": { "git": "https://x/y", "version": "^1" }"#,
);
let existing = Lockfile::default();
let mut freshly = ResolvedTree::default();
freshly.dependencies.insert(
dn("foo"),
ResolvedDependency {
source: ResolvedSource::Git {
git: "https://x/y".parse().unwrap(),
commit: "0000000000000000000000000000000000000001".parse().unwrap(),
path: None,
selector: GitSelector::Version("^1".parse().unwrap()),
},
version: Version::parse("1.0.0").unwrap(),
checksum: checksum(),
signer: None,
dependencies: BTreeMap::new(),
},
);
let outcome = partial_relock(&consumer, &existing, &freshly).unwrap();
assert_eq!(outcome.stats.added.len(), 1);
let added = &outcome.stats.added[0];
assert_eq!(added.name, dn("foo"));
assert_eq!(added.version, Some(Version::parse("1.0.0").unwrap()));
}
#[test]
fn relock_errors_when_consumer_dep_missing_from_fresh_tree() {
let consumer = manifest("consumer", r#""foo": {"git":"https://x/y","tag":"v1"}"#);
let existing = Lockfile::default();
let fresh = ResolvedTree::default();
let err = partial_relock(&consumer, &existing, &fresh).unwrap_err();
assert!(
matches!(
err,
crate::resolver::error::ResolverError::MissingFreshDependency { .. }
),
"got: {err}"
);
}
#[test]
fn relock_drops_removed_deps_and_records_them() {
let consumer = manifest("consumer", "");
let mut existing = Lockfile::default();
existing
.dependencies
.insert(dn("removed"), entry("1.0.0", None));
let outcome = partial_relock(&consumer, &existing, &ResolvedTree::default()).unwrap();
assert!(outcome.lockfile.dependencies.is_empty());
assert_eq!(outcome.stats.removed.len(), 1);
assert_eq!(outcome.stats.removed[0].name, dn("removed"));
assert_eq!(
outcome.stats.removed[0].version,
Some(Version::parse("1.0.0").unwrap())
);
}
#[test]
fn signer_diff_recurses_through_nested_dependencies() {
let previous = Lockfile::default();
let signer = crate::signing::test_utils::signing_key_from_seed(11).verifying_key();
let nested_entry = DependencyEntry {
source: ResolvedSource::Path {
path: "/nested".into(),
},
version: Version::parse("1.0.0").unwrap(),
checksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
.parse()
.unwrap(),
signer: Some(signer),
dependencies: BTreeMap::new(),
};
let outer_entry = DependencyEntry {
source: ResolvedSource::Path {
path: "/outer".into(),
},
version: Version::parse("1.0.0").unwrap(),
checksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
.parse()
.unwrap(),
signer: None,
dependencies: BTreeMap::from([(dn("bar"), nested_entry)]),
};
let mut new = Lockfile::default();
new.dependencies.insert(dn("foo"), outer_entry);
let diff = LockfileDiff::compute(&previous, &new);
assert_eq!(diff.new_signers.len(), 1);
assert_eq!(diff.new_signers[0].dep_chain, vec![dn("foo"), dn("bar")]);
assert!(diff.requires_confirmation());
}
#[test]
fn counts_unsigned_additions_only_for_new_entries() {
let mut prev = Lockfile::default();
prev.dependencies.insert(dn("kept"), entry("1.0.0", None));
let mut new = Lockfile::default();
new.dependencies.insert(dn("kept"), entry("1.0.0", None));
new.dependencies.insert(dn("added"), entry("1.0.0", None));
let diff = LockfileDiff::compute(&prev, &new);
assert_eq!(diff.unsigned_added, 1);
assert!(diff.new_signers.is_empty());
}
}