Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/commandf-pkg/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand Down
2 changes: 1 addition & 1 deletion crates/commandf-pkg/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
293 changes: 276 additions & 17 deletions crates/commandf-pkg/src/lock.rs
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>,
Comment on lines +13 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Direct serde breaks v1 🐞 Bug ≡ Correctness

Because Lockfile still derives Serialize, serializing a schema-v1 value created by
Lockfile::new directly through serde now emits "resolved_dependencies":[].
Lockfile::from_slice explicitly rejects that field for schema v1, so the public type no longer
round-trips through its implemented serde traits even though it did before this change.
Agent Prompt
## Issue description
Direct serde serialization of a schema-v1 `Lockfile` emits the newly added `resolved_dependencies` field, while the public lock decoder rejects that field for v1.

## Issue Context
`to_bytes` avoids the problem with schema-specific wrapper structs, but `Lockfile` remains publicly `Serialize`, so callers can serialize it directly. Implement schema-aware serialization for `Lockfile` (or otherwise prevent the invalid v1 field from being emitted) while retaining the required explicit empty field for schema v2, and add a direct serde round-trip regression test for both schemas.

## Fix Focus Areas
- crates/commandf-pkg/src/lock.rs[8-15]
- crates/commandf-pkg/src/lock.rs[88-109]
- crates/commandf-pkg/tests/lock_schema.rs[6-23]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
Expand All @@ -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)
}
Comment thread
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> {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Duplicate-edge rejection remains untested 📘 Rule violation ▣ Testability

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
## Issue description
Add deterministic coverage for the schema-v2 branch that rejects more than one resolved target for the same parent dependency.

## Issue Context
Construct a canonical v2 lock containing two edges with the same `from_name`, `from_version`, and `to_name` but distinct target versions, then assert the specific `InvalidLockfile` message so the intended conflict branch is proven.

## Fix Focus Areas
- crates/commandf-pkg/src/lock.rs[259-263]
- crates/commandf-pkg/tests/lock_schema.rs[83-162]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
}

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))
});
}
Loading
Loading