diff --git a/crates/commandf-pkg/src/cache.rs b/crates/commandf-pkg/src/cache.rs index 57f09791..325bfaf8 100644 --- a/crates/commandf-pkg/src/cache.rs +++ b/crates/commandf-pkg/src/cache.rs @@ -61,6 +61,10 @@ impl PackageCache { } pub fn verify(&self, digest: &str) -> Result<(), PackageError> { + self.read_verified(digest).map(|_| ()) + } + + pub fn read_verified(&self, digest: &str) -> Result, PackageError> { validate_digest(digest)?; let path = self.object_path(digest); let bytes = fs::read(&path).map_err(|error| { @@ -78,7 +82,7 @@ impl PackageCache { found, }); } - Ok(()) + Ok(bytes) } } diff --git a/crates/commandf-pkg/src/context.rs b/crates/commandf-pkg/src/context.rs new file mode 100644 index 00000000..258f4555 --- /dev/null +++ b/crates/commandf-pkg/src/context.rs @@ -0,0 +1,574 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; + +use crate::artifact_scan::scan_package_resources; +use crate::{ + inspect_package, ArtifactError, CanonicalReferenceRelation, CanonicalResolutionStatus, + ContextArtifactIdentity, ContextArtifactNode, ContextCanonicalReferenceEdge, ContextCoverage, + ContextGraphError, ContextGraphReport, ContextPackageDependencyEdge, ContextPackageIdentity, + ContextPackageNode, Lockfile, PackageCache, +}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct PendingCanonicalReference { + source: ContextArtifactIdentity, + relation: CanonicalReferenceRelation, + source_path: String, + source_element_id: Option, + canonical: String, +} + +pub fn build_context_graph( + lock: &Lockfile, + cache: &PackageCache, +) -> Result { + if lock.schema != Lockfile::SCHEMA_V2 { + return Err(ContextGraphError::RequiresLockV2 { found: lock.schema }); + } + lock.validate_v2()?; + + let mut packages = Vec::new(); + let mut package_identities = BTreeMap::new(); + for package in &lock.packages { + let identity = ContextPackageIdentity { + name: package.name.clone(), + version: package.version.clone(), + sha256: package.sha256.clone(), + }; + package_identities.insert( + (package.name.clone(), package.version.clone()), + identity.clone(), + ); + packages.push(ContextPackageNode { + identity, + source: package.source.clone(), + }); + } + packages.sort(); + + let mut package_dependency_edges = Vec::new(); + for edge in &lock.resolved_dependencies { + let from = package_identities + .get(&(edge.from_name.clone(), edge.from_version.clone())) + .cloned() + .ok_or_else(|| { + crate::PackageError::InvalidLockfile(format!( + "context graph source package {}@{} disappeared after lock validation", + edge.from_name, edge.from_version + )) + })?; + let to = package_identities + .get(&(edge.to_name.clone(), edge.to_version.clone())) + .cloned() + .ok_or_else(|| { + crate::PackageError::InvalidLockfile(format!( + "context graph target package {}@{} disappeared after lock validation", + edge.to_name, edge.to_version + )) + })?; + package_dependency_edges.push(ContextPackageDependencyEdge { + from, + to, + declared_constraint: edge.declared_constraint.clone(), + }); + } + package_dependency_edges.sort(); + package_dependency_edges.dedup(); + + let mut artifacts = Vec::new(); + let mut pending_references = Vec::new(); + let mut present_resource_types = BTreeSet::new(); + + for package in &lock.packages { + let archive_bytes = cache.read_verified(&package.sha256)?; + let inspection = inspect_package( + package.name.clone(), + package.version.clone(), + package.sha256.clone(), + &archive_bytes, + )?; + let scanned_resources = scan_package_resources(&archive_bytes)?; + let inspected_by_filename = inspection + .resources + .iter() + .map(|resource| (resource.filename.as_str(), resource)) + .collect::>(); + + for scanned in scanned_resources { + let inspected = inspected_by_filename + .get(scanned.filename.as_str()) + .ok_or_else(|| ContextGraphError::ArtifactInventoryMismatch { + file: scanned.filename.clone(), + })?; + present_resource_types.insert(inspected.resource_type.clone()); + + let package_identity = package_identities + .get(&(package.name.clone(), package.version.clone())) + .cloned() + .ok_or_else(|| { + crate::PackageError::InvalidLockfile(format!( + "context graph package {}@{} disappeared after lock validation", + package.name, package.version + )) + })?; + let artifact_identity = ContextArtifactIdentity { + package: package_identity, + filename: inspected.filename.clone(), + sha256: inspected.sha256.clone(), + }; + artifacts.push(ContextArtifactNode { + identity: artifact_identity.clone(), + resource_type: inspected.resource_type.clone(), + id: inspected.id.clone(), + canonical_url: inspected.canonical_url.clone(), + canonical_version: inspected.canonical_version.clone(), + }); + + let value: Value = serde_json::from_slice(&scanned.bytes).map_err(|source| { + ContextGraphError::Artifact(ArtifactError::Json { + file: scanned.filename.clone(), + source, + }) + })?; + let object = value + .as_object() + .ok_or_else(|| invalid_field(&scanned.filename, "$", "a JSON object"))?; + extract_references( + &artifact_identity, + &scanned.filename, + &inspected.resource_type, + object, + &mut pending_references, + )?; + } + } + + artifacts.sort(); + artifacts.dedup(); + pending_references.sort(); + pending_references.dedup(); + + let canonical_index = build_canonical_index(&artifacts); + let mut canonical_reference_edges = Vec::new(); + for pending in pending_references { + canonical_reference_edges.push(resolve_reference(pending, &canonical_index)?); + } + canonical_reference_edges.sort(); + canonical_reference_edges.dedup(); + + let supported_source_resource_types = vec![ + "CodeSystem".to_owned(), + "StructureDefinition".to_owned(), + "ValueSet".to_owned(), + ]; + let supported = supported_source_resource_types + .iter() + .map(String::as_str) + .collect::>(); + let unsupported_source_resource_types = present_resource_types + .into_iter() + .filter(|resource_type| !supported.contains(resource_type.as_str())) + .collect::>(); + + Ok(ContextGraphReport { + schema: ContextGraphReport::SCHEMA_V1, + lock_schema: lock.schema, + root_requests: lock.roots.clone(), + packages, + artifacts, + package_dependency_edges, + canonical_reference_edges, + coverage: ContextCoverage { + extractor_schema: 1, + supported_source_resource_types, + unsupported_source_resource_types, + }, + }) +} + +fn build_canonical_index( + artifacts: &[ContextArtifactNode], +) -> BTreeMap, ContextArtifactIdentity)>> { + let mut index = BTreeMap::, ContextArtifactIdentity)>>::new(); + for artifact in artifacts { + let Some(url) = &artifact.canonical_url else { + continue; + }; + index.entry(url.clone()).or_default().push(( + artifact.canonical_version.clone(), + artifact.identity.clone(), + )); + } + for candidates in index.values_mut() { + candidates.sort(); + candidates.dedup(); + } + index +} + +fn resolve_reference( + pending: PendingCanonicalReference, + index: &BTreeMap, ContextArtifactIdentity)>>, +) -> Result { + let (target_url, explicit_version) = parse_canonical_target( + &pending.canonical, + &pending.source.filename, + &pending.source_path, + )?; + let mut candidates = index + .get(target_url) + .into_iter() + .flatten() + .filter(|(candidate_version, _)| match explicit_version { + Some(version) => candidate_version.as_deref() == Some(version), + None => true, + }) + .map(|(_, identity)| identity.clone()) + .collect::>(); + candidates.sort(); + candidates.dedup(); + + let resolution = match candidates.len() { + 0 => CanonicalResolutionStatus::External, + 1 => CanonicalResolutionStatus::Resolved, + _ => CanonicalResolutionStatus::Ambiguous, + }; + + Ok(ContextCanonicalReferenceEdge { + source: pending.source, + relation: pending.relation, + source_path: pending.source_path, + source_element_id: pending.source_element_id, + canonical: pending.canonical, + resolution, + candidates, + }) +} + +fn parse_canonical_target<'a>( + canonical: &'a str, + file: &str, + path: &str, +) -> Result<(&'a str, Option<&'a str>), ContextGraphError> { + let without_fragment = canonical + .split_once('#') + .map(|(target, _)| target) + .unwrap_or(canonical); + if without_fragment.is_empty() { + return Err(ContextGraphError::EmptyCanonicalTarget { + file: file.to_owned(), + path: path.to_owned(), + }); + } + if let Some((url, version)) = without_fragment.rsplit_once('|') { + if url.is_empty() { + return Err(ContextGraphError::EmptyCanonicalTarget { + file: file.to_owned(), + path: path.to_owned(), + }); + } + if version.is_empty() { + return Err(ContextGraphError::EmptyCanonicalVersion { + file: file.to_owned(), + path: path.to_owned(), + }); + } + Ok((url, Some(version))) + } else { + Ok((without_fragment, None)) + } +} + +fn extract_references( + source: &ContextArtifactIdentity, + file: &str, + resource_type: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + match resource_type { + "StructureDefinition" => extract_structure_definition(source, file, object, output), + "ValueSet" => extract_value_set(source, file, object, output), + "CodeSystem" => extract_code_system(source, file, object, output), + _ => Ok(()), + } +} + +fn extract_structure_definition( + source: &ContextArtifactIdentity, + file: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + if let Some(canonical) = optional_string(object, "baseDefinition", file, "baseDefinition")? { + push_reference( + output, + source, + CanonicalReferenceRelation::StructureBaseDefinition, + "baseDefinition".to_owned(), + None, + canonical, + ); + } + + let Some(differential) = optional_object(object, "differential", file, "differential")? else { + return Ok(()); + }; + let elements = required_array(differential, "element", file, "differential.element")?; + for (element_index, element) in elements.iter().enumerate() { + let element_path = format!("differential.element[{element_index}]"); + let element = element + .as_object() + .ok_or_else(|| invalid_field(file, &element_path, "a JSON object"))?; + let element_id = required_string(element, "id", file, &format!("{element_path}.id"))?; + + if let Some(types) = optional_array(element, "type", file, &format!("{element_path}.type"))? + { + for (type_index, type_value) in types.iter().enumerate() { + let type_path = format!("{element_path}.type[{type_index}]"); + let type_object = type_value + .as_object() + .ok_or_else(|| invalid_field(file, &type_path, "a JSON object"))?; + extract_canonical_array( + output, + source, + type_object, + "profile", + file, + &type_path, + &element_id, + CanonicalReferenceRelation::StructureTypeProfile, + )?; + extract_canonical_array( + output, + source, + type_object, + "targetProfile", + file, + &type_path, + &element_id, + CanonicalReferenceRelation::StructureTypeTargetProfile, + )?; + } + } + + if let Some(binding) = + optional_object(element, "binding", file, &format!("{element_path}.binding"))? + { + if let Some(canonical) = optional_string( + binding, + "valueSet", + file, + &format!("{element_path}.binding.valueSet"), + )? { + push_reference( + output, + source, + CanonicalReferenceRelation::StructureBindingValueSet, + format!("{element_path}.binding.valueSet"), + Some(element_id.clone()), + canonical, + ); + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn extract_canonical_array( + output: &mut Vec, + source: &ContextArtifactIdentity, + object: &Map, + field: &str, + file: &str, + parent_path: &str, + element_id: &str, + relation: CanonicalReferenceRelation, +) -> Result<(), ContextGraphError> { + let field_path = format!("{parent_path}.{field}"); + let Some(values) = optional_array(object, field, file, &field_path)? else { + return Ok(()); + }; + for (index, value) in values.iter().enumerate() { + let source_path = format!("{field_path}[{index}]"); + let canonical = value + .as_str() + .ok_or_else(|| invalid_field(file, &source_path, "a string"))?; + push_reference( + output, + source, + relation, + source_path, + Some(element_id.to_owned()), + canonical.to_owned(), + ); + } + Ok(()) +} + +fn extract_value_set( + source: &ContextArtifactIdentity, + file: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + let Some(compose) = optional_object(object, "compose", file, "compose")? else { + return Ok(()); + }; + for (field, system_relation, value_set_relation) in [ + ( + "include", + CanonicalReferenceRelation::ValueSetIncludeSystem, + CanonicalReferenceRelation::ValueSetIncludeValueSet, + ), + ( + "exclude", + CanonicalReferenceRelation::ValueSetExcludeSystem, + CanonicalReferenceRelation::ValueSetExcludeValueSet, + ), + ] { + let field_path = format!("compose.{field}"); + let Some(clauses) = optional_array(compose, field, file, &field_path)? else { + continue; + }; + for (clause_index, clause) in clauses.iter().enumerate() { + let clause_path = format!("{field_path}[{clause_index}]"); + let clause = clause + .as_object() + .ok_or_else(|| invalid_field(file, &clause_path, "a JSON object"))?; + if let Some(system) = + optional_string(clause, "system", file, &format!("{clause_path}.system"))? + { + push_reference( + output, + source, + system_relation, + format!("{clause_path}.system"), + None, + system, + ); + } + let value_set_path = format!("{clause_path}.valueSet"); + if let Some(imports) = optional_array(clause, "valueSet", file, &value_set_path)? { + for (import_index, import) in imports.iter().enumerate() { + let source_path = format!("{value_set_path}[{import_index}]"); + let canonical = import + .as_str() + .ok_or_else(|| invalid_field(file, &source_path, "a string"))?; + push_reference( + output, + source, + value_set_relation, + source_path, + None, + canonical.to_owned(), + ); + } + } + } + } + Ok(()) +} + +fn extract_code_system( + source: &ContextArtifactIdentity, + file: &str, + object: &Map, + output: &mut Vec, +) -> Result<(), ContextGraphError> { + if let Some(canonical) = optional_string(object, "supplements", file, "supplements")? { + push_reference( + output, + source, + CanonicalReferenceRelation::CodeSystemSupplements, + "supplements".to_owned(), + None, + canonical, + ); + } + Ok(()) +} + +fn push_reference( + output: &mut Vec, + source: &ContextArtifactIdentity, + relation: CanonicalReferenceRelation, + source_path: String, + source_element_id: Option, + canonical: String, +) { + output.push(PendingCanonicalReference { + source: source.clone(), + relation, + source_path, + source_element_id, + canonical, + }); +} + +fn optional_string( + object: &Map, + field: &str, + file: &str, + path: &str, +) -> Result, ContextGraphError> { + match object.get(field) { + None => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(invalid_field(file, path, "a string")), + } +} + +fn required_string( + object: &Map, + field: &str, + file: &str, + path: &str, +) -> Result { + optional_string(object, field, file, path)?.ok_or_else(|| invalid_field(file, path, "a string")) +} + +fn optional_array<'a>( + object: &'a Map, + field: &str, + file: &str, + path: &str, +) -> Result>, ContextGraphError> { + match object.get(field) { + None => Ok(None), + Some(Value::Array(values)) => Ok(Some(values)), + Some(_) => Err(invalid_field(file, path, "an array")), + } +} + +fn required_array<'a>( + object: &'a Map, + field: &str, + file: &str, + path: &str, +) -> Result<&'a Vec, ContextGraphError> { + optional_array(object, field, file, path)?.ok_or_else(|| invalid_field(file, path, "an array")) +} + +fn optional_object<'a>( + object: &'a Map, + field: &str, + file: &str, + path: &str, +) -> Result>, ContextGraphError> { + match object.get(field) { + None => Ok(None), + Some(Value::Object(value)) => Ok(Some(value)), + Some(_) => Err(invalid_field(file, path, "a JSON object")), + } +} + +fn invalid_field(file: &str, path: &str, expected: &'static str) -> ContextGraphError { + ContextGraphError::InvalidResourceField { + file: file.to_owned(), + path: path.to_owned(), + expected, + } +} diff --git a/crates/commandf-pkg/src/context_error.rs b/crates/commandf-pkg/src/context_error.rs new file mode 100644 index 00000000..012c8972 --- /dev/null +++ b/crates/commandf-pkg/src/context_error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +use crate::{ArtifactError, PackageError}; + +#[derive(Debug, Error)] +pub enum ContextGraphError { + #[error("commandf context requires commandf.lock schema 2; found schema {found}")] + RequiresLockV2 { found: u32 }, + #[error("context graph package cache error: {0}")] + Package(#[from] PackageError), + #[error("context graph artifact inspection error: {0}")] + Artifact(#[from] ArtifactError), + #[error("context graph resource {file} field {path} must be {expected}")] + InvalidResourceField { + file: String, + path: String, + expected: &'static str, + }, + #[error("context graph artifact inventory mismatch for {file}")] + ArtifactInventoryMismatch { file: String }, + #[error("context graph canonical reference in {file} at {path} has an empty target URL")] + EmptyCanonicalTarget { file: String, path: String }, + #[error("context graph canonical reference in {file} at {path} has an empty explicit version")] + EmptyCanonicalVersion { file: String, path: String }, +} diff --git a/crates/commandf-pkg/src/context_model.rs b/crates/commandf-pkg/src/context_model.rs new file mode 100644 index 00000000..906cec0e --- /dev/null +++ b/crates/commandf-pkg/src/context_model.rs @@ -0,0 +1,99 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextPackageIdentity { + pub name: String, + pub version: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextPackageNode { + pub identity: ContextPackageIdentity, + pub source: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextArtifactIdentity { + pub package: ContextPackageIdentity, + pub filename: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextArtifactNode { + pub identity: ContextArtifactIdentity, + pub resource_type: String, + pub id: Option, + pub canonical_url: Option, + pub canonical_version: Option, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextPackageDependencyEdge { + pub from: ContextPackageIdentity, + pub to: ContextPackageIdentity, + pub declared_constraint: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanonicalReferenceRelation { + StructureBaseDefinition, + StructureTypeProfile, + StructureTypeTargetProfile, + StructureBindingValueSet, + ValueSetIncludeSystem, + ValueSetIncludeValueSet, + ValueSetExcludeSystem, + ValueSetExcludeValueSet, + CodeSystemSupplements, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanonicalResolutionStatus { + Resolved, + External, + Ambiguous, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct ContextCanonicalReferenceEdge { + pub source: ContextArtifactIdentity, + pub relation: CanonicalReferenceRelation, + pub source_path: String, + pub source_element_id: Option, + pub canonical: String, + pub resolution: CanonicalResolutionStatus, + pub candidates: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ContextCoverage { + pub extractor_schema: u32, + pub supported_source_resource_types: Vec, + pub unsupported_source_resource_types: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ContextGraphReport { + pub schema: u32, + pub lock_schema: u32, + pub root_requests: Vec, + pub packages: Vec, + pub artifacts: Vec, + pub package_dependency_edges: Vec, + pub canonical_reference_edges: Vec, + pub coverage: ContextCoverage, +} + +impl ContextGraphReport { + pub const SCHEMA_V1: u32 = 1; + + pub fn to_json_bytes(&self) -> Result, serde_json::Error> { + let mut bytes = serde_json::to_vec_pretty(self)?; + bytes.push(b'\n'); + Ok(bytes) + } +} diff --git a/crates/commandf-pkg/src/lib.rs b/crates/commandf-pkg/src/lib.rs index 1c8722a8..3c1a1b91 100644 --- a/crates/commandf-pkg/src/lib.rs +++ b/crates/commandf-pkg/src/lib.rs @@ -19,6 +19,9 @@ mod compatibility; mod compatibility_error; mod compatibility_model; mod compatibility_validate; +mod context; +mod context_error; +mod context_model; mod error; mod lock; mod model; @@ -63,6 +66,13 @@ pub use compatibility_model::{ CompatibilityDirection, CompatibilityFinding, CompatibilityReport, CompatibilitySeverity, }; pub use compatibility_validate::classify_structural_diff; +pub use context::build_context_graph; +pub use context_error::ContextGraphError; +pub use context_model::{ + CanonicalReferenceRelation, CanonicalResolutionStatus, ContextArtifactIdentity, + ContextArtifactNode, ContextCanonicalReferenceEdge, ContextCoverage, ContextGraphReport, + ContextPackageDependencyEdge, ContextPackageIdentity, ContextPackageNode, +}; pub use error::PackageError; pub use lock::{LockedPackage, Lockfile, ResolvedDependency}; pub use model::{PackageName, PackageRequest, VersionConstraint}; diff --git a/crates/commandf-pkg/src/lock.rs b/crates/commandf-pkg/src/lock.rs index 84acd79b..9c56e679 100644 --- a/crates/commandf-pkg/src/lock.rs +++ b/crates/commandf-pkg/src/lock.rs @@ -87,11 +87,18 @@ impl Lockfile { pub fn to_bytes(&self) -> Result, PackageError> { let mut bytes = match self.schema { - Self::SCHEMA_V1 => serde_json::to_vec_pretty(&LockfileV1 { - schema: self.schema, - roots: &self.roots, - packages: &self.packages, - })?, + 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 { @@ -157,7 +164,7 @@ impl Lockfile { Ok(()) } - fn validate_v2(&self) -> Result<(), PackageError> { + pub(crate) fn validate_v2(&self) -> Result<(), PackageError> { let mut canonical_roots = self.roots.clone(); canonical_roots.sort(); canonical_roots.dedup(); diff --git a/crates/commandf-pkg/tests/context_graph.rs b/crates/commandf-pkg/tests/context_graph.rs new file mode 100644 index 00000000..0e126919 --- /dev/null +++ b/crates/commandf-pkg/tests/context_graph.rs @@ -0,0 +1,378 @@ +use std::collections::BTreeMap; +use std::io::Cursor; + +use commandf_pkg::{ + build_context_graph, CanonicalReferenceRelation, CanonicalResolutionStatus, ContextGraphError, + LockedPackage, Lockfile, PackageCache, PackageError, ResolvedDependency, +}; +use flate2::write::GzEncoder; +use flate2::Compression; +use tar::{Builder, Header}; +use tempfile::tempdir; + +#[test] +fn builds_deterministic_context_graph_with_explicit_resolution_states() { + let refs_archive = package_archive( + "acme.refs", + "1.0.0", + &[ + ( + "package/StructureDefinition-profile.json", + br#"{ + "resourceType":"StructureDefinition", + "id":"profile", + "url":"https://example.org/StructureDefinition/profile", + "version":"1.0.0", + "baseDefinition":"https://example.org/StructureDefinition/base|1.0.0", + "differential":{"element":[{ + "id":"Observation.subject", + "type":[{ + "code":"Reference", + "profile":["https://example.org/StructureDefinition/type|1.0.0"], + "targetProfile":["https://example.org/StructureDefinition/target"] + }], + "binding":{"valueSet":"https://example.org/ValueSet/binding#allowed"} + }]} + }"#, + ), + ( + "package/ValueSet-refs.json", + br#"{ + "resourceType":"ValueSet", + "id":"refs", + "url":"https://example.org/ValueSet/refs", + "version":"1.0.0", + "compose":{ + "include":[{ + "system":"https://example.org/CodeSystem/system", + "valueSet":["https://example.org/ValueSet/imported"] + }], + "exclude":[{ + "system":"https://example.org/CodeSystem/missing", + "valueSet":["https://example.org/ValueSet/imported|2.0.0"] + }] + } + }"#, + ), + ( + "package/CodeSystem-supplement.json", + br#"{ + "resourceType":"CodeSystem", + "id":"supplement", + "url":"https://example.org/CodeSystem/supplement", + "version":"1.0.0", + "supplements":"https://example.org/CodeSystem/base|1.0.0" + }"#, + ), + ( + "package/Patient-unsupported.json", + br#"{"resourceType":"Patient","id":"unsupported"}"#, + ), + ], + ); + let targets_archive = package_archive( + "acme.targets", + "1.0.0", + &[ + canonical_resource( + "StructureDefinition", + "base", + "https://example.org/StructureDefinition/base", + "1.0.0", + ), + canonical_resource( + "StructureDefinition", + "type", + "https://example.org/StructureDefinition/type", + "1.0.0", + ), + canonical_resource( + "StructureDefinition", + "target", + "https://example.org/StructureDefinition/target", + "1.0.0", + ), + canonical_resource( + "ValueSet", + "binding", + "https://example.org/ValueSet/binding", + "1.0.0", + ), + canonical_resource( + "CodeSystem", + "system", + "https://example.org/CodeSystem/system", + "1.0.0", + ), + canonical_resource( + "ValueSet", + "imported-v1", + "https://example.org/ValueSet/imported", + "1.0.0", + ), + canonical_resource( + "ValueSet", + "imported-v2", + "https://example.org/ValueSet/imported", + "2.0.0", + ), + canonical_resource( + "CodeSystem", + "base-system", + "https://example.org/CodeSystem/base", + "1.0.0", + ), + ], + ); + + let dir = tempdir().unwrap(); + let cache = PackageCache::new(dir.path()); + let refs_sha = cache.put(&refs_archive).unwrap(); + let targets_sha = cache.put(&targets_archive).unwrap(); + let mut refs_dependencies = BTreeMap::new(); + refs_dependencies.insert("acme.targets".to_owned(), "1.0.0".to_owned()); + let lock = Lockfile::new_v2( + vec!["acme.refs@1.0.0".to_owned()], + vec![ + locked_package("acme.refs", "1.0.0", &refs_sha, refs_dependencies), + locked_package("acme.targets", "1.0.0", &targets_sha, BTreeMap::new()), + ], + vec![ResolvedDependency { + from_name: "acme.refs".to_owned(), + from_version: "1.0.0".to_owned(), + to_name: "acme.targets".to_owned(), + to_version: "1.0.0".to_owned(), + declared_constraint: "1.0.0".to_owned(), + }], + ); + + let first = build_context_graph(&lock, &cache).unwrap(); + let second = build_context_graph(&lock, &cache).unwrap(); + assert_eq!( + first.to_json_bytes().unwrap(), + second.to_json_bytes().unwrap() + ); + assert_eq!(first.schema, 1); + assert_eq!(first.lock_schema, Lockfile::SCHEMA_V2); + assert_eq!(first.packages.len(), 2); + assert_eq!(first.package_dependency_edges.len(), 1); + assert_eq!( + first.coverage.unsupported_source_resource_types, + vec!["Patient"] + ); + + assert_resolution( + &first, + CanonicalReferenceRelation::StructureBaseDefinition, + "https://example.org/StructureDefinition/base|1.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::StructureTypeProfile, + "https://example.org/StructureDefinition/type|1.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::StructureTypeTargetProfile, + "https://example.org/StructureDefinition/target", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::StructureBindingValueSet, + "https://example.org/ValueSet/binding#allowed", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetIncludeSystem, + "https://example.org/CodeSystem/system", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetIncludeValueSet, + "https://example.org/ValueSet/imported", + CanonicalResolutionStatus::Ambiguous, + 2, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetExcludeSystem, + "https://example.org/CodeSystem/missing", + CanonicalResolutionStatus::External, + 0, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::ValueSetExcludeValueSet, + "https://example.org/ValueSet/imported|2.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); + assert_resolution( + &first, + CanonicalReferenceRelation::CodeSystemSupplements, + "https://example.org/CodeSystem/base|1.0.0", + CanonicalResolutionStatus::Resolved, + 1, + ); +} + +#[test] +fn rejects_schema_v1_instead_of_inferring_resolved_edges() { + let lock = Lockfile::new(Vec::new(), Vec::new()); + let dir = tempdir().unwrap(); + let error = build_context_graph(&lock, &PackageCache::new(dir.path())).unwrap_err(); + + assert!(matches!( + error, + ContextGraphError::RequiresLockV2 { + found: Lockfile::SCHEMA_V1 + } + )); +} + +#[test] +fn fails_closed_when_cached_archive_bytes_are_corrupted() { + let archive = package_archive("acme.root", "1.0.0", &[]); + let dir = tempdir().unwrap(); + let cache = PackageCache::new(dir.path()); + let digest = cache.put(&archive).unwrap(); + let lock = Lockfile::new_v2( + vec!["acme.root@1.0.0".to_owned()], + vec![locked_package( + "acme.root", + "1.0.0", + &digest, + BTreeMap::new(), + )], + vec![], + ); + let object_path = dir.path().join("sha256").join(format!("{digest}.tgz")); + std::fs::write(object_path, b"corrupted").unwrap(); + + let error = build_context_graph(&lock, &cache).unwrap_err(); + assert!(matches!( + error, + ContextGraphError::Package(PackageError::CacheDigestMismatch { .. }) + )); +} + +#[test] +fn rejects_malformed_supported_reference_shape() { + let archive = package_archive( + "acme.root", + "1.0.0", + &[( + "package/StructureDefinition-bad.json", + br#"{ + "resourceType":"StructureDefinition", + "id":"bad", + "url":"https://example.org/StructureDefinition/bad", + "version":"1.0.0", + "differential":{"element":[{ + "id":"Observation.subject", + "type":[{"code":"Reference","profile":"not-an-array"}] + }]} + }"#, + )], + ); + let dir = tempdir().unwrap(); + let cache = PackageCache::new(dir.path()); + let digest = cache.put(&archive).unwrap(); + let lock = Lockfile::new_v2( + vec!["acme.root@1.0.0".to_owned()], + vec![locked_package( + "acme.root", + "1.0.0", + &digest, + BTreeMap::new(), + )], + vec![], + ); + + let error = build_context_graph(&lock, &cache).unwrap_err(); + assert!(matches!( + error, + ContextGraphError::InvalidResourceField { .. } + )); +} + +fn assert_resolution( + report: &commandf_pkg::ContextGraphReport, + relation: CanonicalReferenceRelation, + canonical: &str, + status: CanonicalResolutionStatus, + candidates: usize, +) { + let edge = report + .canonical_reference_edges + .iter() + .find(|edge| edge.relation == relation && edge.canonical == canonical) + .unwrap(); + assert_eq!(edge.resolution, status); + assert_eq!(edge.candidates.len(), candidates); +} + +fn locked_package( + name: &str, + version: &str, + sha256: &str, + dependencies: BTreeMap, +) -> LockedPackage { + LockedPackage { + name: name.to_owned(), + version: version.to_owned(), + sha256: sha256.to_owned(), + source: "memory:test".to_owned(), + dependencies, + } +} + +fn canonical_resource( + resource_type: &'static str, + id: &'static str, + url: &'static str, + version: &'static str, +) -> (&'static str, &'static [u8]) { + let filename = Box::leak(format!("package/{resource_type}-{id}.json").into_boxed_str()); + let body = Box::leak( + format!( + "{{\"resourceType\":\"{resource_type}\",\"id\":\"{id}\",\"url\":\"{url}\",\"version\":\"{version}\"}}" + ) + .into_bytes() + .into_boxed_slice(), + ); + (filename, body) +} + +fn package_archive(name: &str, version: &str, resources: &[(&str, &[u8])]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut builder = Builder::new(&mut encoder); + let manifest = format!("{{\"name\":\"{name}\",\"version\":\"{version}\"}}"); + append_entry(&mut builder, "package/package.json", manifest.as_bytes()); + for (path, body) in resources { + append_entry(&mut builder, path, body); + } + builder.finish().unwrap(); + } + encoder.finish().unwrap() +} + +fn append_entry(builder: &mut Builder<&mut GzEncoder>>, path: &str, body: &[u8]) { + let mut header = Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, Cursor::new(body)).unwrap(); +} diff --git a/crates/commandf-pkg/tests/lock_schema.rs b/crates/commandf-pkg/tests/lock_schema.rs index 776b0b43..ff29cb17 100644 --- a/crates/commandf-pkg/tests/lock_schema.rs +++ b/crates/commandf-pkg/tests/lock_schema.rs @@ -22,6 +22,21 @@ fn schema_v1_rejects_resolved_dependency_evidence() { 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":[]}"#;