diff --git a/crates/commandf-pkg/src/error.rs b/crates/commandf-pkg/src/error.rs index fde65f30..fe50a07d 100644 --- a/crates/commandf-pkg/src/error.rs +++ b/crates/commandf-pkg/src/error.rs @@ -28,8 +28,10 @@ pub enum PackageError { ManifestTooLarge, #[error("package identity mismatch: expected {expected}, found {found}")] IdentityMismatch { expected: String, found: String }, - #[error("unsupported commandf.lock schema {found}; expected {expected}")] + #[error("unsupported commandf.lock schema {found}; latest supported schema is {expected}")] UnsupportedLockSchema { found: u32, expected: u32 }, + #[error("invalid commandf.lock: {0}")] + InvalidLockfile(String), #[error("invalid SHA-256 digest: {0}")] InvalidDigest(String), #[error("cache object missing: {0}")] diff --git a/crates/commandf-pkg/src/lib.rs b/crates/commandf-pkg/src/lib.rs index 3f98df9c..1c8722a8 100644 --- a/crates/commandf-pkg/src/lib.rs +++ b/crates/commandf-pkg/src/lib.rs @@ -64,7 +64,7 @@ pub use compatibility_model::{ }; pub use compatibility_validate::classify_structural_diff; pub use error::PackageError; -pub use lock::{LockedPackage, Lockfile}; +pub use lock::{LockedPackage, Lockfile, ResolvedDependency}; pub use model::{PackageName, PackageRequest, VersionConstraint}; pub use oracle_error::OracleError; pub use oracle_model::{ diff --git a/crates/commandf-pkg/src/lock.rs b/crates/commandf-pkg/src/lock.rs index 656e0265..73d702b1 100644 --- a/crates/commandf-pkg/src/lock.rs +++ b/crates/commandf-pkg/src/lock.rs @@ -1,14 +1,17 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use semver::Version; use serde::{Deserialize, Serialize}; -use crate::{PackageCache, PackageError}; +use crate::{PackageCache, PackageError, PackageName, VersionConstraint}; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Lockfile { pub schema: u32, pub roots: Vec, pub packages: Vec, + #[serde(default)] + pub resolved_dependencies: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -20,39 +23,138 @@ pub struct LockedPackage { pub dependencies: BTreeMap, } +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ResolvedDependency { + pub from_name: String, + pub from_version: String, + pub to_name: String, + pub to_version: String, + pub declared_constraint: String, +} + +#[derive(Deserialize)] +struct RawLockfile { + schema: u32, + roots: Vec, + packages: Vec, + resolved_dependencies: Option>, +} + +#[derive(Serialize)] +struct LockfileV1<'a> { + schema: u32, + roots: &'a [String], + packages: &'a [LockedPackage], +} + +#[derive(Serialize)] +struct LockfileV2<'a> { + schema: u32, + roots: &'a [String], + packages: &'a [LockedPackage], + resolved_dependencies: &'a [ResolvedDependency], +} + impl Lockfile { pub const SCHEMA_V1: u32 = 1; + pub const SCHEMA_V2: u32 = 2; pub fn new(mut roots: Vec, mut packages: Vec) -> Self { - roots.sort(); - roots.dedup(); - packages.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then_with(|| left.version.cmp(&right.version)) - }); + canonicalize_roots_and_packages(&mut roots, &mut packages); Self { schema: Self::SCHEMA_V1, roots, packages, + resolved_dependencies: Vec::new(), + } + } + + pub fn new_v2( + mut roots: Vec, + mut packages: Vec, + mut resolved_dependencies: Vec, + ) -> Self { + canonicalize_roots_and_packages(&mut roots, &mut packages); + resolved_dependencies.sort(); + resolved_dependencies.dedup(); + Self { + schema: Self::SCHEMA_V2, + roots, + packages, + resolved_dependencies, } } pub fn to_bytes(&self) -> Result, PackageError> { - let mut bytes = serde_json::to_vec_pretty(self)?; + let mut bytes = match self.schema { + Self::SCHEMA_V1 => { + if !self.resolved_dependencies.is_empty() { + return Err(PackageError::InvalidLockfile( + "schema v1 must not contain resolved_dependencies".to_owned(), + )); + } + serde_json::to_vec_pretty(&LockfileV1 { + schema: self.schema, + roots: &self.roots, + packages: &self.packages, + })? + } + Self::SCHEMA_V2 => { + self.validate_v2()?; + serde_json::to_vec_pretty(&LockfileV2 { + schema: self.schema, + roots: &self.roots, + packages: &self.packages, + resolved_dependencies: &self.resolved_dependencies, + })? + } + found => { + return Err(PackageError::UnsupportedLockSchema { + found, + expected: Self::SCHEMA_V2, + }) + } + }; bytes.push(b'\n'); Ok(bytes) } pub fn from_slice(bytes: &[u8]) -> Result { - let lockfile: Self = serde_json::from_slice(bytes)?; - if lockfile.schema != Self::SCHEMA_V1 { - return Err(PackageError::UnsupportedLockSchema { - found: lockfile.schema, - expected: Self::SCHEMA_V1, - }); + let raw: RawLockfile = serde_json::from_slice(bytes)?; + match raw.schema { + Self::SCHEMA_V1 => { + if raw.resolved_dependencies.is_some() { + return Err(PackageError::InvalidLockfile( + "schema v1 must not contain resolved_dependencies".to_owned(), + )); + } + Ok(Self { + schema: raw.schema, + roots: raw.roots, + packages: raw.packages, + resolved_dependencies: Vec::new(), + }) + } + Self::SCHEMA_V2 => { + let resolved_dependencies = raw.resolved_dependencies.ok_or_else(|| { + PackageError::InvalidLockfile( + "schema v2 requires resolved_dependencies".to_owned(), + ) + })?; + let lockfile = Self { + schema: raw.schema, + roots: raw.roots, + packages: raw.packages, + resolved_dependencies, + }; + lockfile.validate_v2()?; + Ok(lockfile) + } + found => Err(PackageError::UnsupportedLockSchema { + found, + expected: Self::SCHEMA_V2, + }), } - Ok(lockfile) } pub fn verify_cache(&self, cache: &PackageCache) -> Result<(), PackageError> { @@ -61,4 +163,161 @@ impl Lockfile { } Ok(()) } + + fn validate_v2(&self) -> Result<(), PackageError> { + let mut canonical_roots = self.roots.clone(); + canonical_roots.sort(); + canonical_roots.dedup(); + if canonical_roots != self.roots { + return Err(PackageError::InvalidLockfile( + "schema v2 roots must be sorted and deduplicated".to_owned(), + )); + } + + let package_order = self + .packages + .iter() + .map(|package| (package.name.as_str(), package.version.as_str())) + .collect::>(); + let package_identities = package_order.iter().copied().collect::>(); + if package_identities.len() != self.packages.len() { + return Err(PackageError::InvalidLockfile( + "schema v2 packages contain a duplicate exact identity".to_owned(), + )); + } + let mut canonical_package_order = package_order.clone(); + canonical_package_order.sort(); + if canonical_package_order != package_order { + return Err(PackageError::InvalidLockfile( + "schema v2 packages must be sorted by name and version".to_owned(), + )); + } + + let mut canonical_edges = self.resolved_dependencies.clone(); + canonical_edges.sort(); + canonical_edges.dedup(); + if canonical_edges != self.resolved_dependencies { + return Err(PackageError::InvalidLockfile( + "schema v2 resolved_dependencies must be sorted and deduplicated".to_owned(), + )); + } + + let packages_by_identity = self + .packages + .iter() + .map(|package| ((package.name.as_str(), package.version.as_str()), package)) + .collect::>(); + let mut covered_dependencies = BTreeSet::new(); + + for edge in &self.resolved_dependencies { + let parent = packages_by_identity + .get(&(edge.from_name.as_str(), edge.from_version.as_str())) + .ok_or_else(|| { + PackageError::InvalidLockfile(format!( + "resolved dependency source {}@{} is not present in packages", + edge.from_name, edge.from_version + )) + })?; + if !package_identities.contains(&(edge.to_name.as_str(), edge.to_version.as_str())) { + return Err(PackageError::InvalidLockfile(format!( + "resolved dependency target {}@{} is not present in packages", + edge.to_name, edge.to_version + ))); + } + if edge.declared_constraint.is_empty() { + return Err(PackageError::InvalidLockfile(format!( + "resolved dependency {}@{} -> {}@{} has an empty declared constraint", + edge.from_name, edge.from_version, edge.to_name, edge.to_version + ))); + } + + let manifest_constraint = parent.dependencies.get(&edge.to_name).ok_or_else(|| { + PackageError::InvalidLockfile(format!( + "resolved dependency {}@{} -> {}@{} is not declared by the source package manifest", + edge.from_name, edge.from_version, edge.to_name, edge.to_version + )) + })?; + if manifest_constraint != &edge.declared_constraint { + return Err(PackageError::InvalidLockfile(format!( + "resolved dependency {}@{} -> {}@{} records constraint {:?}, but the source package declares {:?}", + edge.from_name, + edge.from_version, + edge.to_name, + edge.to_version, + edge.declared_constraint, + manifest_constraint + ))); + } + + validate_edge_target_matches_constraint(edge)?; + + let dependency_key = ( + edge.from_name.as_str(), + edge.from_version.as_str(), + edge.to_name.as_str(), + ); + if !covered_dependencies.insert(dependency_key) { + return Err(PackageError::InvalidLockfile(format!( + "schema v2 records more than one resolved target for dependency {}@{} -> {}", + edge.from_name, edge.from_version, edge.to_name + ))); + } + } + + for package in &self.packages { + for dependency_name in package.dependencies.keys() { + if !covered_dependencies.contains(&( + package.name.as_str(), + package.version.as_str(), + dependency_name.as_str(), + )) { + return Err(PackageError::InvalidLockfile(format!( + "schema v2 is missing resolved dependency evidence for {}@{} -> {}", + package.name, package.version, dependency_name + ))); + } + } + } + + Ok(()) + } +} + +fn validate_edge_target_matches_constraint(edge: &ResolvedDependency) -> Result<(), PackageError> { + let target_name = PackageName::parse(edge.to_name.clone()).map_err(|error| { + PackageError::InvalidLockfile(format!( + "resolved dependency target name {:?} is invalid: {error}", + edge.to_name + )) + })?; + let constraint = + VersionConstraint::parse(&target_name, &edge.declared_constraint).map_err(|error| { + PackageError::InvalidLockfile(format!( + "resolved dependency constraint {:?} for {} is invalid: {error}", + edge.declared_constraint, edge.to_name + )) + })?; + let target_version = Version::parse(&edge.to_version).map_err(|error| { + PackageError::InvalidLockfile(format!( + "resolved dependency target version {:?} for {} is invalid: {error}", + edge.to_version, edge.to_name + )) + })?; + if !constraint.matches(&target_version) { + return Err(PackageError::InvalidLockfile(format!( + "resolved dependency target {}@{} does not satisfy declared constraint {}", + edge.to_name, edge.to_version, edge.declared_constraint + ))); + } + Ok(()) +} + +fn canonicalize_roots_and_packages(roots: &mut Vec, packages: &mut [LockedPackage]) { + roots.sort(); + roots.dedup(); + packages.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.version.cmp(&right.version)) + }); } diff --git a/crates/commandf-pkg/src/resolver.rs b/crates/commandf-pkg/src/resolver.rs index 0671e755..362636b1 100644 --- a/crates/commandf-pkg/src/resolver.rs +++ b/crates/commandf-pkg/src/resolver.rs @@ -1,11 +1,19 @@ -use std::collections::{BTreeMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use semver::Version; use crate::archive::read_manifest; -use crate::lock::{LockedPackage, Lockfile}; +use crate::lock::{LockedPackage, Lockfile, ResolvedDependency}; use crate::{PackageCache, PackageError, PackageRequest, PackageSource, VersionConstraint}; +type PackageIdentity = (String, String); + +struct PendingRequest { + request: PackageRequest, + parent: Option, + declared_constraint: Option, +} + pub struct Resolver<'a, S: PackageSource> { source: &'a S, cache: &'a PackageCache, @@ -18,32 +26,57 @@ impl<'a, S: PackageSource> Resolver<'a, S> { pub fn resolve(&self, roots: Vec) -> Result { let root_labels = roots.iter().map(PackageRequest::display).collect(); - let mut queue: VecDeque = roots.into(); - let mut selected: BTreeMap<(String, String), LockedPackage> = BTreeMap::new(); + let mut queue = roots + .into_iter() + .map(|request| PendingRequest { + request, + parent: None, + declared_constraint: None, + }) + .collect::>(); + let mut selected: BTreeMap = BTreeMap::new(); + let mut resolved_dependencies = BTreeSet::new(); - while let Some(request) = queue.pop_front() { - let version = self.select_version(&request)?; - let identity = (request.name.to_string(), version.to_string()); + while let Some(pending) = queue.pop_front() { + let version = self.select_version(&pending.request)?; + let identity = (pending.request.name.to_string(), version.to_string()); + + if let Some((from_name, from_version)) = pending.parent { + let declared_constraint = pending.declared_constraint.ok_or_else(|| { + PackageError::InvalidLockfile( + "resolver dependency request is missing its declared constraint".to_owned(), + ) + })?; + resolved_dependencies.insert(ResolvedDependency { + from_name, + from_version, + to_name: identity.0.clone(), + to_version: identity.1.clone(), + declared_constraint, + }); + } if selected.contains_key(&identity) { continue; } - let archive = self.source.archive_with_source(&request.name, &version)?; + let archive = self + .source + .archive_with_source(&pending.request.name, &version)?; let manifest = read_manifest(&archive.bytes)?; let manifest_version = Version::parse(&manifest.version)?; - let expected = format!("{}@{}", request.name, version); + let expected = format!("{}@{}", pending.request.name, version); let found = format!("{}@{}", manifest.name, manifest_version); - if manifest.name != request.name.as_str() || manifest_version != version { + if manifest.name != pending.request.name.as_str() || manifest_version != version { return Err(PackageError::IdentityMismatch { expected, found }); } let digest = self.cache.put(&archive.bytes)?; let dependencies = manifest.dependencies; selected.insert( - identity, + identity.clone(), LockedPackage { - name: request.name.to_string(), + name: pending.request.name.to_string(), version: version.to_string(), sha256: digest, source: archive.source, @@ -52,13 +85,22 @@ impl<'a, S: PackageSource> Resolver<'a, S> { ); for (name, constraint) in dependencies { + let declared_constraint = constraint.clone(); let name = crate::PackageName::parse(name)?; let constraint = VersionConstraint::parse(&name, &constraint)?; - queue.push_back(PackageRequest { name, constraint }); + queue.push_back(PendingRequest { + request: PackageRequest { name, constraint }, + parent: Some(identity.clone()), + declared_constraint: Some(declared_constraint), + }); } } - Ok(Lockfile::new(root_labels, selected.into_values().collect())) + Ok(Lockfile::new_v2( + root_labels, + selected.into_values().collect(), + resolved_dependencies.into_iter().collect(), + )) } fn select_version(&self, request: &PackageRequest) -> Result { diff --git a/crates/commandf-pkg/tests/lock_schema.rs b/crates/commandf-pkg/tests/lock_schema.rs index ecf2f231..ff29cb17 100644 --- a/crates/commandf-pkg/tests/lock_schema.rs +++ b/crates/commandf-pkg/tests/lock_schema.rs @@ -1,8 +1,180 @@ -use commandf_pkg::{Lockfile, PackageError}; +use std::collections::BTreeMap; + +use commandf_pkg::{LockedPackage, Lockfile, PackageError, ResolvedDependency}; #[test] -fn unsupported_lock_schema_fails_closed() { +fn schema_v1_remains_supported_and_serializes_without_v2_evidence() { + let bytes = br#"{"schema":1,"roots":[],"packages":[]}"#; + let lock = Lockfile::from_slice(bytes).unwrap(); + + assert_eq!(lock.schema, Lockfile::SCHEMA_V1); + assert!(lock.resolved_dependencies.is_empty()); + assert_eq!( + lock.to_bytes().unwrap(), + b"{\n \"schema\": 1,\n \"roots\": [],\n \"packages\": []\n}\n" + ); +} + +#[test] +fn schema_v1_rejects_resolved_dependency_evidence() { + let bytes = br#"{"schema":1,"roots":[],"packages":[],"resolved_dependencies":[]}"#; + let error = Lockfile::from_slice(bytes).unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + +#[test] +fn schema_v1_refuses_to_serialize_resolved_dependency_evidence() { + let mut lock = Lockfile::new(Vec::new(), Vec::new()); + lock.resolved_dependencies.push(ResolvedDependency { + from_name: "acme.parent".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.child".to_owned(), + to_version: "1.0.0".to_owned(), + declared_constraint: "1.0.0".to_owned(), + }); + + let error = lock.to_bytes().unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + +#[test] +fn schema_v2_requires_resolved_dependency_field() { let bytes = br#"{"schema":2,"roots":[],"packages":[]}"#; let error = Lockfile::from_slice(bytes).unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + +#[test] +fn schema_v2_accepts_empty_edges_for_root_only_lock() { + let bytes = br#"{"schema":2,"roots":[],"packages":[],"resolved_dependencies":[]}"#; + let lock = Lockfile::from_slice(bytes).unwrap(); + + assert_eq!(lock.schema, Lockfile::SCHEMA_V2); + assert!(lock.resolved_dependencies.is_empty()); +} + +#[test] +fn schema_v2_round_trip_retains_exact_edge_evidence() { + let mut parent_dependencies = BTreeMap::new(); + parent_dependencies.insert("acme.child".to_owned(), "2.0.x".to_owned()); + let packages = vec![ + package("acme.child", "2.0.0", BTreeMap::new()), + package("acme.parent", "1.0.0", parent_dependencies), + ]; + let edge = ResolvedDependency { + from_name: "acme.parent".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.child".to_owned(), + to_version: "2.0.0".to_owned(), + declared_constraint: "2.0.x".to_owned(), + }; + let lock = Lockfile::new_v2( + vec!["acme.parent@1.0.0".to_owned()], + packages, + vec![edge.clone()], + ); + + let bytes = lock.to_bytes().unwrap(); + let decoded = Lockfile::from_slice(&bytes).unwrap(); + assert_eq!(decoded, lock); + assert_eq!(decoded.resolved_dependencies, vec![edge]); +} + +#[test] +fn schema_v2_rejects_edge_with_missing_endpoint() { + let mut dependencies = BTreeMap::new(); + dependencies.insert("acme.missing".to_owned(), "2.0.0".to_owned()); + let lock = Lockfile::new_v2( + vec!["acme.parent@1.0.0".to_owned()], + vec![package("acme.parent", "1.0.0", dependencies)], + vec![ResolvedDependency { + from_name: "acme.parent".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.missing".to_owned(), + to_version: "2.0.0".to_owned(), + declared_constraint: "2.0.0".to_owned(), + }], + ); + + let error = lock.to_bytes().unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + +#[test] +fn schema_v2_rejects_missing_edge_for_declared_dependency() { + let mut dependencies = BTreeMap::new(); + dependencies.insert("acme.child".to_owned(), "2.0.0".to_owned()); + let lock = Lockfile::new_v2( + vec!["acme.parent@1.0.0".to_owned()], + vec![ + package("acme.child", "2.0.0", BTreeMap::new()), + package("acme.parent", "1.0.0", dependencies), + ], + vec![], + ); + + let error = lock.to_bytes().unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + +#[test] +fn schema_v2_rejects_target_version_that_does_not_match_declared_constraint() { + let mut dependencies = BTreeMap::new(); + dependencies.insert("acme.child".to_owned(), "2.0.x".to_owned()); + let lock = Lockfile::new_v2( + vec!["acme.parent@1.0.0".to_owned()], + vec![ + package("acme.child", "3.0.0", BTreeMap::new()), + package("acme.parent", "1.0.0", dependencies), + ], + vec![ResolvedDependency { + from_name: "acme.parent".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.child".to_owned(), + to_version: "3.0.0".to_owned(), + declared_constraint: "2.0.x".to_owned(), + }], + ); + + let error = lock.to_bytes().unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + +#[test] +fn schema_v2_rejects_noncanonical_edge_order() { + let bytes = br#" + { + "schema": 2, + "roots": [], + "packages": [ + {"name":"acme.a","version":"1.0.0","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"memory:test","dependencies":{"acme.c":"1.0.0","acme.b":"1.0.0"}}, + {"name":"acme.b","version":"1.0.0","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source":"memory:test","dependencies":{}}, + {"name":"acme.c","version":"1.0.0","sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","source":"memory:test","dependencies":{}} + ], + "resolved_dependencies": [ + {"from_name":"acme.a","from_version":"1.0.0","to_name":"acme.c","to_version":"1.0.0","declared_constraint":"1.0.0"}, + {"from_name":"acme.a","from_version":"1.0.0","to_name":"acme.b","to_version":"1.0.0","declared_constraint":"1.0.0"} + ] + } + "#; + + let error = Lockfile::from_slice(bytes).unwrap_err(); + assert!(matches!(error, PackageError::InvalidLockfile(_))); +} + +#[test] +fn unsupported_lock_schema_fails_closed() { + let bytes = br#"{"schema":3,"roots":[],"packages":[],"resolved_dependencies":[]}"#; + let error = Lockfile::from_slice(bytes).unwrap_err(); assert!(matches!(error, PackageError::UnsupportedLockSchema { .. })); } + +fn package(name: &str, version: &str, dependencies: BTreeMap) -> LockedPackage { + LockedPackage { + name: name.to_owned(), + version: version.to_owned(), + sha256: "a".repeat(64), + source: "memory:test".to_owned(), + dependencies, + } +} diff --git a/crates/commandf-pkg/tests/resolution.rs b/crates/commandf-pkg/tests/resolution.rs index f06790d0..d8f78d20 100644 --- a/crates/commandf-pkg/tests/resolution.rs +++ b/crates/commandf-pkg/tests/resolution.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::io::Cursor; use commandf_pkg::{ - PackageCache, PackageError, PackageName, PackageRequest, PackageSource, Resolver, + Lockfile, PackageCache, PackageError, PackageName, PackageRequest, PackageSource, Resolver, }; use flate2::write::GzEncoder; use flate2::Compression; @@ -91,6 +91,7 @@ fn resolves_transitive_dependency_and_highest_stable_patch() { .resolve(vec![PackageRequest::parse("acme.root@1.0.0").unwrap()]) .unwrap(); + assert_eq!(lock.schema, Lockfile::SCHEMA_V2); assert_eq!(lock.packages.len(), 2); assert_eq!( lock.packages @@ -100,11 +101,18 @@ fn resolves_transitive_dependency_and_highest_stable_patch() { .version, "1.2.3" ); + assert_eq!(lock.resolved_dependencies.len(), 1); + let edge = &lock.resolved_dependencies[0]; + assert_eq!(edge.from_name, "acme.root"); + assert_eq!(edge.from_version, "1.0.0"); + assert_eq!(edge.to_name, "acme.dep"); + assert_eq!(edge.to_version, "1.2.3"); + assert_eq!(edge.declared_constraint, "1.2.x"); lock.verify_cache(&cache).unwrap(); } #[test] -fn resolves_branch_local_concrete_versions_of_same_package() { +fn resolves_branch_local_concrete_versions_of_same_package_with_exact_edges() { let mut source = MemorySource::default(); source.add("acme.left", "1.0.0", &[("acme.dep", "1.0.0")]); source.add("acme.right", "1.0.0", &[("acme.dep", "2.0.0")]); @@ -127,11 +135,35 @@ fn resolves_branch_local_concrete_versions_of_same_package() { .map(|package| package.version.as_str()) .collect::>(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); + assert_eq!(lock.resolved_dependencies.len(), 2); + + let left = &lock.resolved_dependencies[0]; + assert_eq!( + ( + left.from_name.as_str(), + left.from_version.as_str(), + left.to_name.as_str(), + left.to_version.as_str(), + left.declared_constraint.as_str(), + ), + ("acme.left", "1.0.0", "acme.dep", "1.0.0", "1.0.0") + ); + let right = &lock.resolved_dependencies[1]; + assert_eq!( + ( + right.from_name.as_str(), + right.from_version.as_str(), + right.to_name.as_str(), + right.to_version.as_str(), + right.declared_constraint.as_str(), + ), + ("acme.right", "1.0.0", "acme.dep", "2.0.0", "2.0.0") + ); lock.verify_cache(&cache).unwrap(); } #[test] -fn deduplicates_the_same_concrete_identity_across_branches() { +fn deduplicates_the_same_concrete_identity_across_branches_but_retains_both_edges() { let mut source = MemorySource::default(); source.add("acme.left", "1.0.0", &[("acme.dep", "1.0.0")]); source.add("acme.right", "1.0.0", &[("acme.dep", "1.0.0")]); @@ -152,6 +184,15 @@ fn deduplicates_the_same_concrete_identity_across_branches() { .count(), 1 ); + assert_eq!(lock.resolved_dependencies.len(), 2); + assert!(lock + .resolved_dependencies + .iter() + .any(|edge| edge.from_name == "acme.left" && edge.to_name == "acme.dep")); + assert!(lock + .resolved_dependencies + .iter() + .any(|edge| edge.from_name == "acme.right" && edge.to_name == "acme.dep")); } #[test] @@ -184,11 +225,12 @@ fn exact_and_patch_wildcard_requests_can_resolve_to_distinct_versions_determinis .collect::>(), vec![("acme.dep", "1.2.0"), ("acme.dep", "1.2.3")] ); + assert!(first.resolved_dependencies.is_empty()); assert_eq!(first.to_bytes().unwrap(), second.to_bytes().unwrap()); } #[test] -fn exact_identity_cycle_terminates_by_deduplication() { +fn exact_identity_cycle_terminates_by_deduplication_and_retains_closing_edge() { let mut source = MemorySource::default(); source.add("acme.a", "1.0.0", &[("acme.b", "1.0.0")]); source.add("acme.b", "1.0.0", &[("acme.a", "1.0.0")]); @@ -202,6 +244,19 @@ fn exact_identity_cycle_terminates_by_deduplication() { assert_eq!(lock.packages.len(), 2); assert_eq!(lock.packages[0].name, "acme.a"); assert_eq!(lock.packages[1].name, "acme.b"); + assert_eq!(lock.resolved_dependencies.len(), 2); + assert!(lock.resolved_dependencies.iter().any(|edge| { + edge.from_name == "acme.a" + && edge.from_version == "1.0.0" + && edge.to_name == "acme.b" + && edge.to_version == "1.0.0" + })); + assert!(lock.resolved_dependencies.iter().any(|edge| { + edge.from_name == "acme.b" + && edge.from_version == "1.0.0" + && edge.to_name == "acme.a" + && edge.to_version == "1.0.0" + })); lock.verify_cache(&cache).unwrap(); } @@ -232,3 +287,33 @@ fn lockfile_is_byte_stable_for_equivalent_root_sets() { assert_eq!(first, second); } + +#[test] +fn lockfile_is_byte_stable_for_equivalent_multi_version_dependency_graphs() { + let mut source = MemorySource::default(); + source.add("acme.left", "1.0.0", &[("acme.dep", "1.0.0")]); + source.add("acme.right", "1.0.0", &[("acme.dep", "2.0.0")]); + source.add("acme.dep", "1.0.0", &[]); + source.add("acme.dep", "2.0.0", &[]); + let first_dir = tempdir().unwrap(); + let second_dir = tempdir().unwrap(); + + let first = Resolver::new(&source, &PackageCache::new(first_dir.path())) + .resolve(vec![ + PackageRequest::parse("acme.right@1.0.0").unwrap(), + PackageRequest::parse("acme.left@1.0.0").unwrap(), + ]) + .unwrap() + .to_bytes() + .unwrap(); + let second = Resolver::new(&source, &PackageCache::new(second_dir.path())) + .resolve(vec![ + PackageRequest::parse("acme.left@1.0.0").unwrap(), + PackageRequest::parse("acme.right@1.0.0").unwrap(), + ]) + .unwrap() + .to_bytes() + .unwrap(); + + assert_eq!(first, second); +}