-
Notifications
You must be signed in to change notification settings - Fork 0
feat(pkg): record exact resolved dependency edges #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
542c5af
feat(pkg): add lock v2 resolved dependency edges
TheHalfMoon 4aa08a4
feat(pkg): add invalid lockfile evidence error
TheHalfMoon 3c8bca2
feat(pkg): record exact resolved dependency edges
TheHalfMoon fa0e01d
feat(pkg): export resolved dependency evidence
TheHalfMoon c49b396
test(pkg): prove resolved dependency edge determinism
TheHalfMoon 398cdb3
test(pkg): cover lock v1 and v2 schema boundaries
TheHalfMoon f19f73c
fix(pkg): validate complete lock v2 edge evidence
TheHalfMoon 1500073
fix(pkg): validate resolved target against declared constraint
TheHalfMoon f97aa4c
test(pkg): strengthen lock v2 evidence invariants
TheHalfMoon 8a81e10
style(pkg): apply rustfmt to lock v2 validation
TheHalfMoon 379d7bd
style(pkg): apply rustfmt to lock schema tests
TheHalfMoon 4834b25
style(pkg): match rustfmt lock schema assertion
TheHalfMoon c46fcec
fix(pkg): reject v1 locks carrying resolved edges
TheHalfMoon 40983be
test(pkg): cover v1 resolved-edge serialization refusal
TheHalfMoon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String>, | ||
| pub packages: Vec<LockedPackage>, | ||
| #[serde(default)] | ||
| pub resolved_dependencies: Vec<ResolvedDependency>, | ||
| } | ||
|
|
||
| #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] | ||
|
|
@@ -20,39 +23,138 @@ pub struct LockedPackage { | |
| pub dependencies: BTreeMap<String, String>, | ||
| } | ||
|
|
||
| #[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<String>, | ||
| packages: Vec<LockedPackage>, | ||
| resolved_dependencies: Option<Vec<ResolvedDependency>>, | ||
| } | ||
|
|
||
| #[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<String>, mut packages: Vec<LockedPackage>) -> 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<String>, | ||
| mut packages: Vec<LockedPackage>, | ||
| mut resolved_dependencies: Vec<ResolvedDependency>, | ||
| ) -> 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<Vec<u8>, 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) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| pub fn from_slice(bytes: &[u8]) -> Result<Self, PackageError> { | ||
| 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::<Vec<_>>(); | ||
| let package_identities = package_order.iter().copied().collect::<BTreeSet<_>>(); | ||
| 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::<BTreeMap<_, _>>(); | ||
| 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 | ||
| ))); | ||
|
Comment on lines
+259
to
+263
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Duplicate-edge rejection remains untested The new validator rejects multiple resolved targets for one declared dependency, but no automated test constructs that conflict and asserts the resulting error. A regression in this conflict branch could therefore pass the current suite. Agent Prompt
|
||
| } | ||
| } | ||
|
|
||
| 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<String>, packages: &mut [LockedPackage]) { | ||
| roots.sort(); | ||
| roots.dedup(); | ||
| packages.sort_by(|left, right| { | ||
| left.name | ||
| .cmp(&right.name) | ||
| .then_with(|| left.version.cmp(&right.version)) | ||
| }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Direct serde breaks v1
🐞 Bug≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools