use crate::error::WSError;
use crate::wasm_module::{CustomSection, Module, Section, SectionLike};
use base64::Engine;
pub use wsc_attestation::{
TRANSFORMATION_ATTESTATION_SECTION, TRANSFORMATION_AUDIT_TRAIL_SECTION,
BuildProvenance, ProvenanceBuilder,
TransformationType, ArtifactDescriptor, SignatureStatus,
InputSignatureInfo, ToolInfo, AttestationSignature,
InputArtifact, TransformationAttestation,
RootComponent, TransformationAuditTrail,
TransformationAttestationBuilder,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use x509_parser::prelude::FromDer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentRef {
pub id: String,
pub hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature_index: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositionManifest {
pub version: String,
pub tool: String,
pub tool_version: String,
pub timestamp: String,
pub components: Vec<ComponentRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub integrator: Option<IntegratorInfo>,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegratorInfo {
pub identity: String,
pub signature_index: usize,
pub verification_timestamp: String,
}
impl CompositionManifest {
pub fn new(tool: impl Into<String>, tool_version: impl Into<String>) -> Self {
Self {
version: "1.0".to_string(),
tool: tool.into(),
tool_version: tool_version.into(),
timestamp: chrono::Utc::now().to_rfc3339(),
components: Vec::new(),
integrator: None,
metadata: HashMap::new(),
}
}
pub fn add_component(&mut self, id: impl Into<String>, hash: impl Into<String>) {
self.components.push(ComponentRef {
id: id.into(),
hash: hash.into(),
source: None,
signature_index: None,
});
}
pub fn add_component_with_source(
&mut self,
id: impl Into<String>,
hash: impl Into<String>,
source: impl Into<String>,
) {
self.components.push(ComponentRef {
id: id.into(),
hash: hash.into(),
source: Some(source.into()),
signature_index: None,
});
}
pub fn set_integrator(&mut self, identity: impl Into<String>, signature_index: usize) {
self.integrator = Some(IntegratorInfo {
identity: identity.into(),
signature_index,
verification_timestamp: chrono::Utc::now().to_rfc3339(),
});
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
#[cfg(feature = "cbor")]
pub fn to_cbor(&self) -> Result<Vec<u8>, serde_cbor::Error> {
serde_cbor::to_vec(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
#[cfg(feature = "cbor")]
pub fn from_cbor(bytes: &[u8]) -> Result<Self, serde_cbor::Error> {
serde_cbor::from_slice(bytes)
}
}
pub const COMPOSITION_MANIFEST_SECTION: &str = "wsc.composition.manifest";
pub const BUILD_PROVENANCE_SECTION: &str = "wsc.build.provenance";
pub const SBOM_SECTION: &str = "wsc.sbom";
pub const INTOTO_ATTESTATION_SECTION: &str = "wsc.intoto.attestation";
#[derive(Debug, Clone)]
pub struct DependencyGraph {
dependencies: HashMap<String, Vec<String>>,
expected_hashes: HashMap<String, String>,
actual_hashes: HashMap<String, String>,
}
impl DependencyGraph {
pub fn new() -> Self {
Self {
dependencies: HashMap::new(),
expected_hashes: HashMap::new(),
actual_hashes: HashMap::new(),
}
}
pub fn add_component(&mut self, id: impl Into<String>, expected_hash: impl Into<String>) {
let id = id.into();
self.expected_hashes
.insert(id.clone(), expected_hash.into());
self.dependencies.entry(id).or_default();
}
pub fn add_dependency(&mut self, from: impl Into<String>, to: impl Into<String>) {
let from = from.into();
let to = to.into();
self.dependencies
.entry(from)
.or_default()
.push(to);
}
pub fn set_actual_hash(&mut self, id: impl Into<String>, actual_hash: impl Into<String>) {
self.actual_hashes.insert(id.into(), actual_hash.into());
}
pub fn from_manifest(manifest: &CompositionManifest) -> Self {
let mut graph = Self::new();
for component in &manifest.components {
graph.add_component(&component.id, &component.hash);
}
graph
}
pub fn detect_cycles(&self) -> Option<Vec<String>> {
let mut visited = HashMap::new();
let mut rec_stack = HashMap::new();
for node in self.dependencies.keys() {
if !visited.contains_key(node)
&& let Some(cycle) =
self.dfs_cycle_detection(node, &mut visited, &mut rec_stack, &mut Vec::new())
{
return Some(cycle);
}
}
None
}
fn dfs_cycle_detection(
&self,
node: &str,
visited: &mut HashMap<String, bool>,
rec_stack: &mut HashMap<String, bool>,
path: &mut Vec<String>,
) -> Option<Vec<String>> {
visited.insert(node.to_string(), true);
rec_stack.insert(node.to_string(), true);
path.push(node.to_string());
if let Some(neighbors) = self.dependencies.get(node) {
for neighbor in neighbors {
if !visited.contains_key(neighbor.as_str()) {
if let Some(cycle) =
self.dfs_cycle_detection(neighbor, visited, rec_stack, path)
{
return Some(cycle);
}
} else if *rec_stack.get(neighbor.as_str()).unwrap_or(&false) {
if let Some(cycle_start) = path.iter().position(|x| x == neighbor) {
let mut cycle = path[cycle_start..].to_vec();
cycle.push(neighbor.clone());
return Some(cycle);
}
}
}
}
rec_stack.insert(node.to_string(), false);
path.pop();
None
}
pub fn detect_substitutions(&self) -> Vec<ComponentSubstitution> {
let mut substitutions = Vec::new();
for (id, expected_hash) in &self.expected_hashes {
if let Some(actual_hash) = self.actual_hashes.get(id)
&& expected_hash != actual_hash {
substitutions.push(ComponentSubstitution {
component_id: id.clone(),
expected_hash: expected_hash.clone(),
actual_hash: actual_hash.clone(),
});
}
}
substitutions
}
pub fn validate(&self) -> Result<ValidationResult, ValidationError> {
let mut warnings = Vec::new();
let mut errors = Vec::new();
if let Some(cycle) = self.detect_cycles() {
errors.push(format!("Cycle detected: {}", cycle.join(" -> ")));
}
let substitutions = self.detect_substitutions();
if !substitutions.is_empty() {
for sub in &substitutions {
errors.push(format!(
"Component '{}' substituted: expected hash '{}', actual hash '{}'",
sub.component_id, sub.expected_hash, sub.actual_hash
));
}
}
for (id, deps) in &self.dependencies {
for dep in deps {
if !self.dependencies.contains_key(dep) {
warnings.push(format!(
"Component '{}' depends on missing component '{}'",
id, dep
));
}
}
}
Ok(ValidationResult {
valid: errors.is_empty(),
errors,
warnings,
})
}
pub fn topological_sort(&self) -> Option<Vec<String>> {
if self.detect_cycles().is_some() {
return None;
}
let mut result = Vec::new();
let mut visited = HashMap::new();
let mut temp_mark = HashMap::new();
for node in self.dependencies.keys() {
if !visited.contains_key(node) {
self.topological_visit(node, &mut visited, &mut temp_mark, &mut result);
}
}
Some(result)
}
fn topological_visit(
&self,
node: &str,
visited: &mut HashMap<String, bool>,
temp_mark: &mut HashMap<String, bool>,
result: &mut Vec<String>,
) {
if temp_mark.contains_key(node) {
return; }
if !visited.contains_key(node) {
temp_mark.insert(node.to_string(), true);
if let Some(neighbors) = self.dependencies.get(node) {
for neighbor in neighbors {
self.topological_visit(neighbor, visited, temp_mark, result);
}
}
visited.insert(node.to_string(), true);
temp_mark.remove(node);
result.push(node.to_string());
}
}
}
impl Default for DependencyGraph {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentSubstitution {
pub component_id: String,
pub expected_hash: String,
pub actual_hash: String,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error("Validation failed: {0}")]
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionConstraint {
Exact(String),
Minimum(String),
Maximum(String),
Range(String, String),
Any,
}
impl VersionConstraint {
pub fn satisfies(&self, version: &str) -> bool {
match self {
VersionConstraint::Exact(required) => version == required,
VersionConstraint::Minimum(min) => Self::compare_versions(version, min) >= 0,
VersionConstraint::Maximum(max) => Self::compare_versions(version, max) <= 0,
VersionConstraint::Range(min, max) => {
Self::compare_versions(version, min) >= 0
&& Self::compare_versions(version, max) <= 0
}
VersionConstraint::Any => true,
}
}
fn compare_versions(v1: &str, v2: &str) -> i32 {
let parse_version =
|v: &str| -> Vec<u32> { v.split('.').filter_map(|s| s.parse::<u32>().ok()).collect() };
let v1_parts = parse_version(v1);
let v2_parts = parse_version(v2);
for i in 0..v1_parts.len().max(v2_parts.len()) {
let p1 = v1_parts.get(i).copied().unwrap_or(0);
let p2 = v2_parts.get(i).copied().unwrap_or(0);
if p1 < p2 {
return -1;
} else if p1 > p2 {
return 1;
}
}
0
}
}
#[derive(Debug, Clone)]
pub struct VersionPolicy {
constraints: HashMap<String, VersionConstraint>,
}
impl VersionPolicy {
pub fn new() -> Self {
Self {
constraints: HashMap::new(),
}
}
pub fn require_exact(&mut self, component_id: impl Into<String>, version: impl Into<String>) {
self.constraints.insert(
component_id.into(),
VersionConstraint::Exact(version.into()),
);
}
pub fn require_minimum(&mut self, component_id: impl Into<String>, version: impl Into<String>) {
self.constraints.insert(
component_id.into(),
VersionConstraint::Minimum(version.into()),
);
}
pub fn require_maximum(&mut self, component_id: impl Into<String>, version: impl Into<String>) {
self.constraints.insert(
component_id.into(),
VersionConstraint::Maximum(version.into()),
);
}
pub fn require_range(
&mut self,
component_id: impl Into<String>,
min_version: impl Into<String>,
max_version: impl Into<String>,
) {
self.constraints.insert(
component_id.into(),
VersionConstraint::Range(min_version.into(), max_version.into()),
);
}
pub fn validate_version(&self, component_id: &str, version: &str) -> Result<(), String> {
if let Some(constraint) = self.constraints.get(component_id) {
if constraint.satisfies(version) {
Ok(())
} else {
Err(format!(
"Component '{}' version '{}' does not satisfy constraint {:?}",
component_id, version, constraint
))
}
} else {
Ok(())
}
}
}
impl Default for VersionPolicy {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct SourceAllowList {
allowed_sources: Vec<String>,
allow_no_source: bool,
}
impl SourceAllowList {
pub fn new() -> Self {
Self {
allowed_sources: Vec::new(),
allow_no_source: false,
}
}
pub fn add_source(&mut self, source: impl Into<String>) {
self.allowed_sources.push(source.into());
}
pub fn allow_no_source(&mut self, allow: bool) {
self.allow_no_source = allow;
}
pub fn is_allowed(&self, source: Option<&str>) -> bool {
match source {
None => self.allow_no_source,
Some(url) => self
.allowed_sources
.iter()
.any(|allowed| url.starts_with(allowed) || url == allowed),
}
}
pub fn validate_source(&self, component_id: &str, source: Option<&str>) -> Result<(), String> {
if self.is_allowed(source) {
Ok(())
} else {
Err(format!(
"Component '{}' source '{}' is not in allow-list",
component_id,
source.unwrap_or("<no source>")
))
}
}
}
impl Default for SourceAllowList {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct TimestampPolicy {
max_age_seconds: Option<i64>,
future_tolerance_seconds: i64,
require_timestamps: bool,
}
impl TimestampPolicy {
pub fn new() -> Self {
Self {
max_age_seconds: None,
future_tolerance_seconds: 300, require_timestamps: true,
}
}
pub fn with_max_age_seconds(mut self, seconds: i64) -> Self {
self.max_age_seconds = Some(seconds);
self
}
pub fn with_max_age_days(mut self, days: i64) -> Self {
self.max_age_seconds = Some(days * 86400);
self
}
pub fn with_future_tolerance_seconds(mut self, seconds: i64) -> Self {
self.future_tolerance_seconds = seconds;
self
}
pub fn require_timestamps(mut self, require: bool) -> Self {
self.require_timestamps = require;
self
}
pub fn validate_timestamp(&self, timestamp: &str, context: &str) -> Result<(), String> {
let parsed = chrono::DateTime::parse_from_rfc3339(timestamp)
.map_err(|e| format!("Invalid timestamp format for {}: {}", context, e))?;
let now = chrono::Utc::now();
let timestamp_utc = parsed.with_timezone(&chrono::Utc);
let future_limit = now + chrono::Duration::seconds(self.future_tolerance_seconds);
if timestamp_utc > future_limit {
return Err(format!(
"{} timestamp is too far in the future (more than {} seconds ahead)",
context, self.future_tolerance_seconds
));
}
if let Some(max_age) = self.max_age_seconds {
let age_limit = now - chrono::Duration::seconds(max_age);
if timestamp_utc < age_limit {
let age_days = max_age / 86400;
return Err(format!(
"{} timestamp is too old (older than {} days)",
context, age_days
));
}
}
Ok(())
}
pub fn validate_optional_timestamp(
&self,
timestamp: Option<&str>,
context: &str,
) -> Result<(), String> {
match timestamp {
Some(ts) => self.validate_timestamp(ts, context),
None => {
if self.require_timestamps {
Err(format!("{} timestamp is required but missing", context))
} else {
Ok(())
}
}
}
}
}
impl Default for TimestampPolicy {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationMode {
Lenient,
Strict,
}
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub mode: ValidationMode,
pub version_policy: Option<VersionPolicy>,
pub source_allow_list: Option<SourceAllowList>,
pub timestamp_policy: Option<TimestampPolicy>,
pub validate_transitive: bool,
}
impl ValidationConfig {
pub fn lenient() -> Self {
Self {
mode: ValidationMode::Lenient,
version_policy: None,
source_allow_list: None,
timestamp_policy: None,
validate_transitive: false,
}
}
pub fn strict() -> Self {
Self {
mode: ValidationMode::Strict,
version_policy: None,
source_allow_list: None,
timestamp_policy: None,
validate_transitive: false,
}
}
pub fn with_version_policy(mut self, policy: VersionPolicy) -> Self {
self.version_policy = Some(policy);
self
}
pub fn with_source_allow_list(mut self, allow_list: SourceAllowList) -> Self {
self.source_allow_list = Some(allow_list);
self
}
pub fn with_timestamp_policy(mut self, policy: TimestampPolicy) -> Self {
self.timestamp_policy = Some(policy);
self
}
pub fn with_transitive_validation(mut self, enable: bool) -> Self {
self.validate_transitive = enable;
self
}
}
impl Default for ValidationConfig {
fn default() -> Self {
Self::lenient()
}
}
impl DependencyGraph {
pub fn validate_with_config(
&self,
config: &ValidationConfig,
) -> Result<ValidationResult, ValidationError> {
let mut warnings = Vec::new();
let mut errors = Vec::new();
let basic_result = self.validate()?;
errors.extend(basic_result.errors);
warnings.extend(basic_result.warnings);
if config.mode == ValidationMode::Strict && !warnings.is_empty() {
for warning in &warnings {
errors.push(format!("STRICT MODE: {}", warning));
}
warnings.clear();
}
if let Some(_policy) = &config.version_policy {
for _component_id in self.expected_hashes.keys() {
}
}
if let Some(_allow_list) = &config.source_allow_list {
}
Ok(ValidationResult {
valid: errors.is_empty(),
errors,
warnings,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sbom {
#[serde(rename = "bomFormat")]
pub bom_format: String,
#[serde(rename = "specVersion")]
pub spec_version: String,
#[serde(rename = "serialNumber")]
pub serial_number: String,
pub version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<SbomMetadata>,
pub components: Vec<SbomComponent>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub dependencies: Vec<SbomDependency>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomMetadata {
pub timestamp: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tools: Vec<SbomTool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<SbomComponent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomTool {
#[serde(skip_serializing_if = "Option::is_none")]
pub vendor: Option<String>,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomComponent {
#[serde(rename = "type")]
pub component_type: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(rename = "bom-ref")]
pub bom_ref: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub hashes: Vec<SbomHash>,
#[serde(
rename = "externalReferences",
skip_serializing_if = "Vec::is_empty",
default
)]
pub external_references: Vec<SbomExternalReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomHash {
pub alg: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomExternalReference {
#[serde(rename = "type")]
pub ref_type: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomDependency {
#[serde(rename = "ref")]
pub component_ref: String,
#[serde(rename = "dependsOn", skip_serializing_if = "Vec::is_empty", default)]
pub depends_on: Vec<String>,
}
impl Sbom {
pub fn new(component_name: impl Into<String>, component_version: impl Into<String>) -> Self {
use uuid::Uuid;
let name = component_name.into();
let component = SbomComponent {
component_type: "application".to_string(),
name: name.clone(),
version: Some(component_version.into()),
bom_ref: format!("pkg:wasm/{}", name),
hashes: Vec::new(),
external_references: Vec::new(),
};
Self {
bom_format: "CycloneDX".to_string(),
spec_version: "1.5".to_string(),
serial_number: format!("urn:uuid:{}", Uuid::new_v4()),
version: 1,
metadata: Some(SbomMetadata {
timestamp: chrono::Utc::now().to_rfc3339(),
tools: vec![SbomTool {
vendor: Some("wsc".to_string()),
name: "wsc".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}],
component: Some(component),
}),
components: Vec::new(),
dependencies: Vec::new(),
}
}
pub fn add_component(
&mut self,
name: impl Into<String>,
version: impl Into<String>,
hash: impl Into<String>,
) {
let name_str = name.into();
let component = SbomComponent {
component_type: "library".to_string(),
name: name_str.clone(),
version: Some(version.into()),
bom_ref: format!("pkg:wasm/{}", name_str),
hashes: vec![SbomHash {
alg: "SHA-256".to_string(),
content: hash.into(),
}],
external_references: Vec::new(),
};
self.components.push(component);
}
pub fn add_component_with_source(
&mut self,
name: impl Into<String>,
version: impl Into<String>,
hash: impl Into<String>,
source_repo: impl Into<String>,
) {
let name_str = name.into();
let component = SbomComponent {
component_type: "library".to_string(),
name: name_str.clone(),
version: Some(version.into()),
bom_ref: format!("pkg:wasm/{}", name_str),
hashes: vec![SbomHash {
alg: "SHA-256".to_string(),
content: hash.into(),
}],
external_references: vec![SbomExternalReference {
ref_type: "vcs".to_string(),
url: source_repo.into(),
}],
};
self.components.push(component);
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InTotoAttestation {
#[serde(rename = "_type")]
pub payload_type: String,
pub subject: Vec<InTotoSubject>,
#[serde(rename = "predicateType")]
pub predicate_type: String,
pub predicate: InTotoPredicate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InTotoSubject {
pub name: String,
pub digest: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InTotoPredicate {
pub builder: InTotoBuilder,
#[serde(rename = "buildType")]
pub build_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub invocation: Option<InTotoInvocation>,
pub materials: Vec<InTotoMaterial>,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InTotoBuilder {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InTotoInvocation {
#[serde(rename = "configSource")]
pub config_source: InTotoConfigSource,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub parameters: HashMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub environment: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InTotoConfigSource {
#[serde(skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub digest: HashMap<String, String>,
#[serde(rename = "entryPoint", skip_serializing_if = "Option::is_none")]
pub entry_point: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InTotoMaterial {
pub uri: String,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub digest: HashMap<String, String>,
}
impl InTotoAttestation {
pub fn new_composition(
composed_name: impl Into<String>,
composed_hash: impl Into<String>,
builder_id: impl Into<String>,
) -> Self {
let mut digest = HashMap::new();
digest.insert("sha256".to_string(), composed_hash.into());
Self {
payload_type: "application/vnd.in-toto+json".to_string(),
subject: vec![InTotoSubject {
name: composed_name.into(),
digest,
}],
predicate_type: "https://wsc.dev/in-toto/composition/v1".to_string(),
predicate: InTotoPredicate {
builder: InTotoBuilder {
id: builder_id.into(),
},
build_type: "https://wsc.dev/composition@v1".to_string(),
invocation: None,
materials: Vec::new(),
metadata: HashMap::new(),
},
}
}
pub fn add_material(&mut self, uri: impl Into<String>, hash: impl Into<String>) {
let mut digest = HashMap::new();
digest.insert("sha256".to_string(), hash.into());
self.predicate.materials.push(InTotoMaterial {
uri: uri.into(),
digest,
});
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
pub fn embed_composition_manifest(
mut module: Module,
manifest: &CompositionManifest,
) -> Result<Module, WSError> {
let json = manifest.to_json().map_err(|e| {
WSError::InternalError(format!("Failed to serialize composition manifest: {}", e))
})?;
let custom_section = CustomSection::new(
COMPOSITION_MANIFEST_SECTION.to_string(),
json.as_bytes().to_vec(),
);
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_composition_manifest(
module: &Module,
) -> Result<Option<CompositionManifest>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == COMPOSITION_MANIFEST_SECTION {
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!("Invalid UTF-8 in composition manifest: {}", e))
})?;
let manifest = CompositionManifest::from_json(json).map_err(|e| {
WSError::InternalError(format!(
"Failed to deserialize composition manifest: {}",
e
))
})?;
return Ok(Some(manifest));
}
}
Ok(None)
}
pub fn embed_build_provenance(
mut module: Module,
provenance: &BuildProvenance,
) -> Result<Module, WSError> {
let json = serde_json::to_string_pretty(provenance).map_err(|e| {
WSError::InternalError(format!("Failed to serialize build provenance: {}", e))
})?;
let custom_section = CustomSection::new(
BUILD_PROVENANCE_SECTION.to_string(),
json.as_bytes().to_vec(),
);
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_build_provenance(module: &Module) -> Result<Option<BuildProvenance>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == BUILD_PROVENANCE_SECTION {
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!("Invalid UTF-8 in build provenance: {}", e))
})?;
let provenance = serde_json::from_str(json).map_err(|e| {
WSError::InternalError(format!("Failed to deserialize build provenance: {}", e))
})?;
return Ok(Some(provenance));
}
}
Ok(None)
}
pub fn embed_sbom(mut module: Module, sbom: &Sbom) -> Result<Module, WSError> {
let json = sbom
.to_json()
.map_err(|e| WSError::InternalError(format!("Failed to serialize SBOM: {}", e)))?;
let custom_section = CustomSection::new(SBOM_SECTION.to_string(), json.as_bytes().to_vec());
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_sbom(module: &Module) -> Result<Option<Sbom>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == SBOM_SECTION {
let json = std::str::from_utf8(custom.payload())
.map_err(|e| WSError::InternalError(format!("Invalid UTF-8 in SBOM: {}", e)))?;
let sbom = Sbom::from_json(json).map_err(|e| {
WSError::InternalError(format!("Failed to deserialize SBOM: {}", e))
})?;
return Ok(Some(sbom));
}
}
Ok(None)
}
pub fn embed_intoto_attestation(
mut module: Module,
attestation: &InTotoAttestation,
) -> Result<Module, WSError> {
let json = attestation.to_json().map_err(|e| {
WSError::InternalError(format!("Failed to serialize in-toto attestation: {}", e))
})?;
let custom_section = CustomSection::new(
INTOTO_ATTESTATION_SECTION.to_string(),
json.as_bytes().to_vec(),
);
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_intoto_attestation(module: &Module) -> Result<Option<InTotoAttestation>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == INTOTO_ATTESTATION_SECTION {
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!("Invalid UTF-8 in in-toto attestation: {}", e))
})?;
let attestation = InTotoAttestation::from_json(json).map_err(|e| {
WSError::InternalError(format!(
"Failed to deserialize in-toto attestation: {}",
e
))
})?;
return Ok(Some(attestation));
}
}
Ok(None)
}
pub fn embed_all_provenance(
module: Module,
manifest: &CompositionManifest,
provenance: &BuildProvenance,
sbom: &Sbom,
attestation: &InTotoAttestation,
) -> Result<Module, WSError> {
let module = embed_composition_manifest(module, manifest)?;
let module = embed_build_provenance(module, provenance)?;
let module = embed_sbom(module, sbom)?;
let module = embed_intoto_attestation(module, attestation)?;
Ok(module)
}
pub type AllProvenanceData = (
Option<CompositionManifest>,
Option<BuildProvenance>,
Option<Sbom>,
Option<InTotoAttestation>,
);
pub fn extract_all_provenance(module: &Module) -> Result<AllProvenanceData, WSError> {
let manifest = extract_composition_manifest(module)?;
let provenance = extract_build_provenance(module)?;
let sbom = extract_sbom(module)?;
let attestation = extract_intoto_attestation(module)?;
Ok((manifest, provenance, sbom, attestation))
}
pub fn embed_transformation_attestation(
mut module: Module,
attestation: &TransformationAttestation,
) -> Result<Module, WSError> {
let json = attestation.to_json().map_err(|e| {
WSError::InternalError(format!("Failed to serialize transformation attestation: {}", e))
})?;
let custom_section = CustomSection::new(
TRANSFORMATION_ATTESTATION_SECTION.to_string(),
json.as_bytes().to_vec(),
);
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_transformation_attestation(
module: &Module,
) -> Result<Option<TransformationAttestation>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == TRANSFORMATION_ATTESTATION_SECTION
{
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!(
"Invalid UTF-8 in transformation attestation: {}",
e
))
})?;
let attestation = TransformationAttestation::from_json(json).map_err(|e| {
WSError::InternalError(format!(
"Failed to deserialize transformation attestation: {}",
e
))
})?;
return Ok(Some(attestation));
}
}
Ok(None)
}
pub fn extract_all_transformation_attestations(
module: &Module,
) -> Result<Vec<TransformationAttestation>, WSError> {
let mut attestations = Vec::new();
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == TRANSFORMATION_ATTESTATION_SECTION
{
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!(
"Invalid UTF-8 in transformation attestation: {}",
e
))
})?;
let attestation = TransformationAttestation::from_json(json).map_err(|e| {
WSError::InternalError(format!(
"Failed to deserialize transformation attestation: {}",
e
))
})?;
attestations.push(attestation);
}
}
Ok(attestations)
}
pub fn embed_transformation_audit_trail(
mut module: Module,
trail: &TransformationAuditTrail,
) -> Result<Module, WSError> {
let json = trail.to_json().map_err(|e| {
WSError::InternalError(format!("Failed to serialize transformation audit trail: {}", e))
})?;
let custom_section = CustomSection::new(
TRANSFORMATION_AUDIT_TRAIL_SECTION.to_string(),
json.as_bytes().to_vec(),
);
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_transformation_audit_trail(
module: &Module,
) -> Result<Option<TransformationAuditTrail>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == TRANSFORMATION_AUDIT_TRAIL_SECTION
{
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!(
"Invalid UTF-8 in transformation audit trail: {}",
e
))
})?;
let trail = TransformationAuditTrail::from_json(json).map_err(|e| {
WSError::InternalError(format!(
"Failed to deserialize transformation audit trail: {}",
e
))
})?;
return Ok(Some(trail));
}
}
Ok(None)
}
pub fn remove_transformation_attestations(mut module: Module) -> Module {
module.sections.retain(|section| {
if let Section::Custom(custom) = section {
custom.name() != TRANSFORMATION_ATTESTATION_SECTION
&& custom.name() != TRANSFORMATION_AUDIT_TRAIL_SECTION
} else {
true
}
});
module
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainVerificationMode {
AllInputsSigned,
AnyInputSigned,
NoRootSignaturesRequired,
}
#[derive(Debug, Clone)]
pub struct TrustedPublicKey {
pub algorithm: String,
pub key: String,
pub key_id: Option<String>,
}
impl TrustedPublicKey {
pub fn ed25519(key: impl Into<String>, key_id: Option<String>) -> Self {
Self {
algorithm: "ed25519".to_string(),
key: key.into(),
key_id,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct KeylessVerificationConfig {
pub oidc_issuers: Vec<String>,
pub allowed_subjects: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct TrustedToolInfo {
pub min_version: Option<String>,
pub max_version: Option<String>,
pub required_hash: Option<String>,
pub public_keys: Vec<TrustedPublicKey>,
pub keyless: Option<KeylessVerificationConfig>,
}
impl TrustedToolInfo {
pub fn any_version() -> Self {
Self {
min_version: None,
max_version: None,
required_hash: None,
public_keys: Vec::new(),
keyless: None,
}
}
pub fn min_version(version: impl Into<String>) -> Self {
Self {
min_version: Some(version.into()),
max_version: None,
required_hash: None,
public_keys: Vec::new(),
keyless: None,
}
}
pub fn with_public_key(mut self, key: TrustedPublicKey) -> Self {
self.public_keys.push(key);
self
}
pub fn with_keyless(mut self, config: KeylessVerificationConfig) -> Self {
self.keyless = Some(config);
self
}
pub fn satisfies(&self, version: &str, tool_hash: Option<&str>) -> bool {
if let Some(required) = &self.required_hash {
if tool_hash != Some(required.as_str()) {
return false;
}
}
if let Some(min) = &self.min_version {
if version < min.as_str() {
return false;
}
}
if let Some(max) = &self.max_version {
if version > max.as_str() {
return false;
}
}
true
}
}
#[derive(Debug, Clone)]
pub struct ChainVerificationPolicy {
pub mode: ChainVerificationMode,
pub trusted_root_signers: std::collections::HashSet<String>,
pub trusted_tools: HashMap<String, TrustedToolInfo>,
pub trusted_attestation_signers: std::collections::HashSet<String>,
pub max_attestation_age: Option<std::time::Duration>,
pub verify_attestation_signatures: bool,
}
impl Default for ChainVerificationPolicy {
fn default() -> Self {
Self {
mode: ChainVerificationMode::AllInputsSigned,
trusted_root_signers: std::collections::HashSet::new(),
trusted_tools: HashMap::new(),
trusted_attestation_signers: std::collections::HashSet::new(),
max_attestation_age: None,
verify_attestation_signatures: false,
}
}
}
impl ChainVerificationPolicy {
pub fn lenient() -> Self {
Self {
mode: ChainVerificationMode::NoRootSignaturesRequired,
..Default::default()
}
}
pub fn strict() -> Self {
Self {
mode: ChainVerificationMode::AllInputsSigned,
..Default::default()
}
}
pub fn add_trusted_root_signer(mut self, signer: impl Into<String>) -> Self {
self.trusted_root_signers.insert(signer.into());
self
}
pub fn add_trusted_tool(mut self, name: impl Into<String>, info: TrustedToolInfo) -> Self {
self.trusted_tools.insert(name.into(), info);
self
}
pub fn add_trusted_attestation_signer(mut self, signer: impl Into<String>) -> Self {
self.trusted_attestation_signers.insert(signer.into());
self
}
pub fn with_max_attestation_age(mut self, age: std::time::Duration) -> Self {
self.max_attestation_age = Some(age);
self
}
}
#[derive(Debug, Clone)]
pub struct ChainVerificationResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub tools_used: Vec<String>,
pub transformation_count: usize,
pub root_components: Vec<String>,
}
impl ChainVerificationResult {
fn new() -> Self {
Self {
valid: true,
errors: Vec::new(),
warnings: Vec::new(),
tools_used: Vec::new(),
transformation_count: 0,
root_components: Vec::new(),
}
}
fn add_error(&mut self, error: impl Into<String>) {
self.valid = false;
self.errors.push(error.into());
}
fn add_warning(&mut self, warning: impl Into<String>) {
self.warnings.push(warning.into());
}
}
#[derive(Debug, Clone)]
pub enum AttestationSignatureResult {
Verified {
key_id: Option<String>,
algorithm: String,
},
Unsigned,
Invalid(String),
NoMatchingKey,
}
pub fn verify_attestation_signature(
attestation: &TransformationAttestation,
trusted_keys: &[TrustedPublicKey],
) -> AttestationSignatureResult {
use ct_codecs::{Base64, Decoder};
use ed25519_compact::{PublicKey, Signature};
let sig = &attestation.attestation_signature;
if sig.algorithm == "unsigned" || sig.signature.is_empty() {
return AttestationSignatureResult::Unsigned;
}
if sig.algorithm != "ed25519" {
return AttestationSignatureResult::Invalid(format!(
"Unsupported signature algorithm: {}",
sig.algorithm
));
}
let signature_bytes = match Base64::decode_to_vec(&sig.signature, None) {
Ok(bytes) => bytes,
Err(e) => {
return AttestationSignatureResult::Invalid(format!(
"Failed to decode signature: {}",
e
));
}
};
let signature = match Signature::from_slice(&signature_bytes) {
Ok(sig) => sig,
Err(e) => {
return AttestationSignatureResult::Invalid(format!(
"Invalid signature format: {}",
e
));
}
};
let mut attestation_for_signing = attestation.clone();
attestation_for_signing.attestation_signature.signature = String::new();
let canonical = match serde_json::to_string(&attestation_for_signing) {
Ok(json) => json,
Err(e) => {
return AttestationSignatureResult::Invalid(format!(
"Failed to serialize attestation: {}",
e
));
}
};
for trusted_key in trusted_keys {
if trusted_key.algorithm != "ed25519" {
continue;
}
if let Some(ref trusted_key_id) = trusted_key.key_id {
if let Some(ref attestation_key_id) = sig.key_id {
if trusted_key_id != attestation_key_id {
continue;
}
}
}
let pk_bytes = match Base64::decode_to_vec(&trusted_key.key, None) {
Ok(bytes) => bytes,
Err(_) => continue, };
let public_key = match PublicKey::from_slice(&pk_bytes) {
Ok(pk) => pk,
Err(_) => continue, };
if public_key.verify(canonical.as_bytes(), &signature).is_ok() {
return AttestationSignatureResult::Verified {
key_id: sig.key_id.clone(),
algorithm: sig.algorithm.clone(),
};
}
}
if let Some(ref embedded_pk) = sig.public_key {
let pk_bytes = match Base64::decode_to_vec(embedded_pk, None) {
Ok(bytes) => bytes,
Err(_) => return AttestationSignatureResult::NoMatchingKey,
};
let public_key = match PublicKey::from_slice(&pk_bytes) {
Ok(pk) => pk,
Err(_) => return AttestationSignatureResult::NoMatchingKey,
};
for trusted_key in trusted_keys {
if trusted_key.key == *embedded_pk && trusted_key.algorithm == "ed25519" {
if public_key.verify(canonical.as_bytes(), &signature).is_ok() {
return AttestationSignatureResult::Verified {
key_id: sig.key_id.clone(),
algorithm: sig.algorithm.clone(),
};
}
}
}
}
AttestationSignatureResult::NoMatchingKey
}
pub fn verify_transformation_chain(
attestation: &TransformationAttestation,
policy: &ChainVerificationPolicy,
) -> ChainVerificationResult {
let mut result = ChainVerificationResult::new();
verify_attestation_recursive(attestation, policy, &mut result, 0);
result
}
fn verify_attestation_recursive(
attestation: &TransformationAttestation,
policy: &ChainVerificationPolicy,
result: &mut ChainVerificationResult,
depth: usize,
) {
const MAX_CHAIN_DEPTH: usize = 100;
if depth > MAX_CHAIN_DEPTH {
result.add_error(format!(
"Chain depth exceeds maximum ({}) - possible cycle",
MAX_CHAIN_DEPTH
));
return;
}
result.transformation_count += 1;
let tool_name = &attestation.tool.name;
if !result.tools_used.contains(tool_name) {
result.tools_used.push(tool_name.clone());
}
let tool_info = policy.trusted_tools.get(tool_name);
if let Some(info) = tool_info {
if !info.satisfies(&attestation.tool.version, attestation.tool.tool_hash.as_deref()) {
result.add_error(format!(
"Tool '{}' version '{}' does not meet policy requirements",
tool_name, attestation.tool.version
));
}
} else if !policy.trusted_tools.is_empty() {
result.add_error(format!("Tool '{}' is not in trusted tools list", tool_name));
}
if policy.verify_attestation_signatures {
let trusted_keys = tool_info
.map(|info| info.public_keys.as_slice())
.unwrap_or(&[]);
if trusted_keys.is_empty() && tool_info.is_some() {
result.add_warning(format!(
"Tool '{}' has no public keys configured for signature verification",
tool_name
));
}
match verify_attestation_signature(attestation, trusted_keys) {
AttestationSignatureResult::Verified { key_id, algorithm } => {
log::debug!(
"Attestation signature verified for tool '{}' (algorithm: {}, key_id: {:?})",
tool_name,
algorithm,
key_id
);
}
AttestationSignatureResult::Unsigned => {
result.add_error(format!(
"Tool '{}' attestation is unsigned but signature verification is required",
tool_name
));
}
AttestationSignatureResult::Invalid(reason) => {
result.add_error(format!(
"Tool '{}' attestation signature is invalid: {}",
tool_name, reason
));
}
AttestationSignatureResult::NoMatchingKey => {
result.add_error(format!(
"Tool '{}' attestation signature does not match any trusted public key",
tool_name
));
}
}
}
if let Some(max_age) = policy.max_attestation_age {
if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(&attestation.timestamp) {
let now = chrono::Utc::now();
let age = now.signed_duration_since(timestamp);
if age > chrono::Duration::from_std(max_age).unwrap_or(chrono::TimeDelta::MAX) {
result.add_error(format!(
"Attestation timestamp {} is older than maximum allowed age",
attestation.timestamp
));
}
} else {
result.add_warning(format!(
"Could not parse attestation timestamp: {}",
attestation.timestamp
));
}
}
if !policy.trusted_attestation_signers.is_empty() {
let signer = attestation
.attestation_signature
.signer_identity
.as_ref()
.or(attestation.attestation_signature.key_id.as_ref());
if let Some(signer_id) = signer {
if !policy.trusted_attestation_signers.contains(signer_id) {
result.add_error(format!(
"Attestation signer '{}' is not trusted",
signer_id
));
}
} else {
result.add_error("Attestation has no signer identity or key ID");
}
}
let mut signed_inputs = 0;
let mut unsigned_inputs = 0;
for input in &attestation.inputs {
if let Some(prior_attestation) = &input.transformation_chain {
if input.artifact.hash != prior_attestation.output.hash {
result.add_error(format!(
"Hash mismatch in chain: input {} doesn't match prior output",
input.artifact.name
));
}
verify_attestation_recursive(prior_attestation, policy, result, depth + 1);
} else {
result.root_components.push(input.artifact.name.clone());
match input.signature_status {
SignatureStatus::Verified => {
signed_inputs += 1;
if !policy.trusted_root_signers.is_empty() {
if let Some(sig_info) = &input.signature_info {
let signer = sig_info
.signer_identity
.as_ref()
.or(sig_info.key_id.as_ref());
if let Some(signer_id) = signer {
if !policy.trusted_root_signers.contains(signer_id) {
result.add_warning(format!(
"Root signer '{}' for '{}' is not in trusted list",
signer_id, input.artifact.name
));
}
}
}
}
}
SignatureStatus::SignedUnverified => {
result.add_warning(format!(
"Root input '{}' has signature but was not verified",
input.artifact.name
));
}
SignatureStatus::Unsigned => {
unsigned_inputs += 1;
}
}
}
}
match policy.mode {
ChainVerificationMode::AllInputsSigned => {
if unsigned_inputs > 0 {
result.add_error(format!(
"Policy requires all inputs signed, but {} inputs are unsigned",
unsigned_inputs
));
}
}
ChainVerificationMode::AnyInputSigned => {
if signed_inputs == 0 && !attestation.inputs.is_empty() {
result.add_error("Policy requires at least one signed input, but none found");
}
}
ChainVerificationMode::NoRootSignaturesRequired => {
}
}
}
pub fn verify_audit_trail(
trail: &TransformationAuditTrail,
policy: &ChainVerificationPolicy,
) -> ChainVerificationResult {
let mut result = ChainVerificationResult::new();
for attestation in &trail.transformations {
let sub_result = verify_transformation_chain(attestation, policy);
result.errors.extend(sub_result.errors);
result.warnings.extend(sub_result.warnings);
result.tools_used.extend(sub_result.tools_used);
result.transformation_count += sub_result.transformation_count;
result.root_components.extend(sub_result.root_components);
}
result.tools_used.sort();
result.tools_used.dedup();
result.root_components.sort();
result.root_components.dedup();
result.valid = result.errors.is_empty();
result
}
pub fn validate_manifest_timestamps(
manifest: &CompositionManifest,
policy: &TimestampPolicy,
) -> Result<(), String> {
policy.validate_timestamp(&manifest.timestamp, "Composition")?;
if let Some(integrator) = &manifest.integrator {
policy.validate_timestamp(
&integrator.verification_timestamp,
"Integrator verification",
)?;
}
Ok(())
}
pub fn validate_provenance_timestamps(
provenance: &BuildProvenance,
policy: &TimestampPolicy,
) -> Result<(), String> {
policy.validate_timestamp(&provenance.build_timestamp, "Build")?;
Ok(())
}
pub fn validate_attestation_timestamps(
attestation: &InTotoAttestation,
policy: &TimestampPolicy,
) -> Result<(), String> {
if let Some(finished_on_value) = attestation.predicate.metadata.get("finishedOn")
&& let Some(finished_on) = finished_on_value.as_str() {
policy.validate_timestamp(finished_on, "Build completion")?;
}
Ok(())
}
pub fn validate_all_timestamps(
module: &Module,
policy: &TimestampPolicy,
) -> Result<ValidationResult, WSError> {
let mut warnings = Vec::new();
let mut errors = Vec::new();
let (manifest, provenance, _sbom, attestation) = extract_all_provenance(module)?;
if let Some(ref m) = manifest {
if let Err(e) = validate_manifest_timestamps(m, policy) {
errors.push(e);
}
} else if policy.require_timestamps {
warnings.push("No composition manifest found for timestamp validation".to_string());
}
if let Some(ref p) = provenance
&& let Err(e) = validate_provenance_timestamps(p, policy) {
errors.push(e);
}
if let Some(ref a) = attestation
&& let Err(e) = validate_attestation_timestamps(a, policy) {
errors.push(e);
}
Ok(ValidationResult {
valid: errors.is_empty(),
errors,
warnings,
})
}
#[derive(Debug, Clone)]
pub struct SignatureFreshnessPolicy {
max_signature_age_seconds: Option<i64>,
minimum_timestamp: Option<chrono::DateTime<chrono::Utc>>,
}
impl SignatureFreshnessPolicy {
pub fn new() -> Self {
Self {
max_signature_age_seconds: None,
minimum_timestamp: None,
}
}
pub fn with_max_age_seconds(mut self, seconds: i64) -> Self {
self.max_signature_age_seconds = Some(seconds);
self
}
pub fn with_max_age_days(mut self, days: i64) -> Self {
self.max_signature_age_seconds = Some(days * 86400);
self
}
pub fn with_minimum_timestamp(mut self, timestamp: chrono::DateTime<chrono::Utc>) -> Self {
self.minimum_timestamp = Some(timestamp);
self
}
pub fn validate(&self, timestamp: &str, context: &str) -> Result<(), String> {
let parsed = chrono::DateTime::parse_from_rfc3339(timestamp)
.map_err(|e| format!("Invalid timestamp format for {}: {}", context, e))?;
let timestamp_utc = parsed.with_timezone(&chrono::Utc);
let now = chrono::Utc::now();
if let Some(max_age) = self.max_signature_age_seconds {
let age = now.signed_duration_since(timestamp_utc);
if age.num_seconds() > max_age {
let age_days = max_age / 86400;
return Err(format!(
"{} signature is too old (created {} days ago, max age: {} days)",
context,
age.num_days(),
age_days
));
}
}
if let Some(min_ts) = &self.minimum_timestamp
&& timestamp_utc < *min_ts {
return Err(format!(
"{} signature was created before minimum acceptable time ({})",
context,
min_ts.to_rfc3339()
));
}
Ok(())
}
}
impl Default for SignatureFreshnessPolicy {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct CertificateValidityPolicy {
min_remaining_validity_seconds: Option<i64>,
allow_not_yet_valid: bool,
}
impl CertificateValidityPolicy {
pub fn new() -> Self {
Self {
min_remaining_validity_seconds: None,
allow_not_yet_valid: false,
}
}
pub fn with_min_remaining_validity_seconds(mut self, seconds: i64) -> Self {
self.min_remaining_validity_seconds = Some(seconds);
self
}
pub fn with_min_remaining_validity_days(mut self, days: i64) -> Self {
self.min_remaining_validity_seconds = Some(days * 86400);
self
}
pub fn allow_not_yet_valid(mut self, allow: bool) -> Self {
self.allow_not_yet_valid = allow;
self
}
pub fn validate_certificate_times(
&self,
not_before: &str,
not_after: &str,
context: &str,
) -> Result<(), String> {
let now = chrono::Utc::now();
let not_before_time = chrono::DateTime::parse_from_rfc3339(not_before)
.map_err(|e| format!("Invalid not_before timestamp for {}: {}", context, e))?
.with_timezone(&chrono::Utc);
let not_after_time = chrono::DateTime::parse_from_rfc3339(not_after)
.map_err(|e| format!("Invalid not_after timestamp for {}: {}", context, e))?
.with_timezone(&chrono::Utc);
if now < not_before_time
&& !self.allow_not_yet_valid {
return Err(format!(
"{} certificate is not yet valid (valid from: {})",
context,
not_before_time.to_rfc3339()
));
}
if now > not_after_time {
return Err(format!(
"{} certificate has expired (expired on: {})",
context,
not_after_time.to_rfc3339()
));
}
if let Some(min_remaining) = self.min_remaining_validity_seconds {
let remaining = not_after_time.signed_duration_since(now);
if remaining.num_seconds() < min_remaining {
let remaining_days = remaining.num_days();
let min_days = min_remaining / 86400;
return Err(format!(
"{} certificate expires too soon (remaining: {} days, minimum required: {} days)",
context, remaining_days, min_days
));
}
}
Ok(())
}
pub fn validate_certificate_der(&self, cert_der: &[u8], context: &str) -> Result<(), String> {
let (_, cert) = x509_parser::certificate::X509Certificate::from_der(cert_der)
.map_err(|e| format!("Failed to parse {} certificate: {:?}", context, e))?;
let not_before = cert.validity().not_before;
let not_after = cert.validity().not_after;
let not_before_chrono =
chrono::DateTime::<chrono::Utc>::from_timestamp(not_before.timestamp(), 0)
.ok_or_else(|| format!("Invalid not_before timestamp for {}", context))?;
let not_after_chrono =
chrono::DateTime::<chrono::Utc>::from_timestamp(not_after.timestamp(), 0)
.ok_or_else(|| format!("Invalid not_after timestamp for {}", context))?;
self.validate_certificate_times(
¬_before_chrono.to_rfc3339(),
¬_after_chrono.to_rfc3339(),
context,
)
}
pub fn validate_certificate_pem(&self, cert_pem: &str, context: &str) -> Result<(), String> {
let pem = pem::parse(cert_pem)
.map_err(|e| format!("Failed to parse {} PEM certificate: {}", context, e))?;
self.validate_certificate_der(pem.contents(), context)
}
}
impl Default for CertificateValidityPolicy {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceAttestation {
pub device_id: String,
pub attestation_type: String,
pub hardware_model: String,
pub attestation_data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub attestation_signature: Option<String>,
pub timestamp: String,
pub device_public_key: String,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub metadata: HashMap<String, String>,
}
impl DeviceAttestation {
pub fn new(
device_id: impl Into<String>,
attestation_type: impl Into<String>,
hardware_model: impl Into<String>,
) -> Self {
Self {
device_id: device_id.into(),
attestation_type: attestation_type.into(),
hardware_model: hardware_model.into(),
attestation_data: String::new(),
attestation_signature: None,
timestamp: chrono::Utc::now().to_rfc3339(),
device_public_key: String::new(),
metadata: HashMap::new(),
}
}
pub fn with_attestation_data(mut self, data: &[u8]) -> Self {
self.attestation_data = base64::engine::general_purpose::STANDARD.encode(data);
self
}
pub fn with_signature(mut self, signature: &[u8]) -> Self {
self.attestation_signature =
Some(base64::engine::general_purpose::STANDARD.encode(signature));
self
}
pub fn with_public_key(mut self, public_key: &[u8]) -> Self {
self.device_public_key = base64::engine::general_purpose::STANDARD.encode(public_key);
self
}
pub fn add_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HardwareCompositionManifest {
#[serde(flatten)]
pub manifest: CompositionManifest,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_attestation: Option<DeviceAttestation>,
pub security_level: u8,
}
impl HardwareCompositionManifest {
pub fn from_manifest(manifest: CompositionManifest) -> Self {
Self {
manifest,
device_attestation: None,
security_level: 0,
}
}
pub fn with_device_attestation(mut self, attestation: DeviceAttestation) -> Self {
self.device_attestation = Some(attestation);
self
}
pub fn with_security_level(mut self, level: u8) -> Self {
self.security_level = level.min(4); self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransparencyLogEntry {
pub log_index: u64,
pub uuid: String,
pub body: String,
pub integrated_time: i64,
pub log_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub inclusion_proof: Option<InclusionProof>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InclusionProof {
pub tree_size: u64,
pub root_hash: String,
pub hashes: Vec<String>,
pub log_index: u64,
}
impl TransparencyLogEntry {
pub fn new(log_index: u64, uuid: impl Into<String>) -> Self {
Self {
log_index,
uuid: uuid.into(),
body: String::new(),
integrated_time: chrono::Utc::now().timestamp(),
log_id: String::new(),
inclusion_proof: None,
}
}
pub fn with_body(mut self, body: &[u8]) -> Self {
self.body = base64::engine::general_purpose::STANDARD.encode(body);
self
}
pub fn with_inclusion_proof(mut self, proof: InclusionProof) -> Self {
self.inclusion_proof = Some(proof);
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
const DEVICE_ATTESTATION_SECTION: &str = "wsc.device_attestation";
const TRANSPARENCY_LOG_SECTION: &str = "wsc.transparency_log";
pub fn embed_device_attestation(
mut module: Module,
attestation: &DeviceAttestation,
) -> Result<Module, WSError> {
let json = attestation.to_json().map_err(|e| {
WSError::InternalError(format!("Failed to serialize device attestation: {}", e))
})?;
let custom_section = CustomSection::new(
DEVICE_ATTESTATION_SECTION.to_string(),
json.as_bytes().to_vec(),
);
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_device_attestation(module: &Module) -> Result<Option<DeviceAttestation>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == DEVICE_ATTESTATION_SECTION {
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!("Invalid UTF-8 in device attestation: {}", e))
})?;
let attestation = DeviceAttestation::from_json(json).map_err(|e| {
WSError::InternalError(format!(
"Failed to deserialize device attestation: {}",
e
))
})?;
return Ok(Some(attestation));
}
}
Ok(None)
}
pub fn embed_transparency_log_entry(
mut module: Module,
entry: &TransparencyLogEntry,
) -> Result<Module, WSError> {
let json = entry.to_json().map_err(|e| {
WSError::InternalError(format!("Failed to serialize transparency log entry: {}", e))
})?;
let custom_section = CustomSection::new(
TRANSPARENCY_LOG_SECTION.to_string(),
json.as_bytes().to_vec(),
);
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_transparency_log_entry(
module: &Module,
) -> Result<Option<TransparencyLogEntry>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section
&& custom.name() == TRANSPARENCY_LOG_SECTION {
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!(
"Invalid UTF-8 in transparency log entry: {}",
e
))
})?;
let entry = TransparencyLogEntry::from_json(json).map_err(|e| {
WSError::InternalError(format!(
"Failed to deserialize transparency log entry: {}",
e
))
})?;
return Ok(Some(entry));
}
}
Ok(None)
}
pub const DSSE_ATTESTATION_SECTION: &str = "wsc.attestation";
pub fn embed_slsa_provenance(
mut module: Module,
provenance: &crate::slsa::Provenance,
signer: &dyn crate::dsse::DsseSigner,
) -> Result<Module, WSError> {
use crate::dsse::DsseEnvelope;
use crate::intoto::{predicate_types, Statement, Subject};
use sha2::{Digest, Sha256};
let mut module_bytes = Vec::new();
module
.serialize(&mut module_bytes)
.map_err(|e| WSError::InternalError(format!("Failed to serialize module: {}", e)))?;
let module_hash = hex::encode(Sha256::digest(&module_bytes));
let statement = Statement::new(
vec![Subject::new("module.wasm", &module_hash)],
predicate_types::SLSA_PROVENANCE_V1,
provenance.clone(),
);
let payload = statement.to_json_bytes()?;
let envelope = DsseEnvelope::sign(&payload, crate::dsse::payload_types::IN_TOTO, signer)?;
let envelope_json = envelope.to_json()?;
let custom_section =
CustomSection::new(DSSE_ATTESTATION_SECTION.to_string(), envelope_json.into_bytes());
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn embed_transformation_dsse(
mut module: Module,
attestation: &wsc_attestation::TransformationAttestation,
signer: &dyn crate::dsse::DsseSigner,
) -> Result<Module, WSError> {
use crate::dsse::DsseEnvelope;
use crate::intoto::{predicate_types, Statement, Subject};
use sha2::{Digest, Sha256};
let mut module_bytes = Vec::new();
module
.serialize(&mut module_bytes)
.map_err(|e| WSError::InternalError(format!("Failed to serialize module: {}", e)))?;
let module_hash = hex::encode(Sha256::digest(&module_bytes));
let statement = Statement::new(
vec![Subject::new(&attestation.output.name, &module_hash)],
predicate_types::WSC_TRANSFORMATION_V1,
attestation.clone(),
);
let payload = statement.to_json_bytes()?;
let envelope = DsseEnvelope::sign(&payload, crate::dsse::payload_types::IN_TOTO, signer)?;
let envelope_json = envelope.to_json()?;
let custom_section =
CustomSection::new(DSSE_ATTESTATION_SECTION.to_string(), envelope_json.into_bytes());
module.sections.push(Section::Custom(custom_section));
Ok(module)
}
pub fn extract_dsse_attestation(module: &Module) -> Result<Option<crate::dsse::DsseEnvelope>, WSError> {
for section in &module.sections {
if let Section::Custom(custom) = section {
if custom.name() == DSSE_ATTESTATION_SECTION {
let json = std::str::from_utf8(custom.payload()).map_err(|e| {
WSError::InternalError(format!("Invalid UTF-8 in DSSE attestation: {}", e))
})?;
let envelope = crate::dsse::DsseEnvelope::from_json(json)?;
return Ok(Some(envelope));
}
}
}
Ok(None)
}
pub fn extract_and_verify_dsse(
module: &Module,
verifier: &dyn crate::dsse::DsseVerifier,
) -> Result<Option<Vec<u8>>, WSError> {
if let Some(envelope) = extract_dsse_attestation(module)? {
let payload = envelope.verify(verifier)?;
Ok(Some(payload))
} else {
Ok(None)
}
}
pub fn extract_slsa_provenance(
module: &Module,
verifier: &dyn crate::dsse::DsseVerifier,
) -> Result<Option<crate::slsa::Provenance>, WSError> {
use crate::intoto::Statement;
if let Some(payload) = extract_and_verify_dsse(module, verifier)? {
let statement: Statement<crate::slsa::Provenance> = Statement::from_json_bytes(&payload)?;
Ok(Some(statement.predicate))
} else {
Ok(None)
}
}
pub fn extract_transformation_from_dsse(
module: &Module,
verifier: &dyn crate::dsse::DsseVerifier,
) -> Result<Option<wsc_attestation::TransformationAttestation>, WSError> {
use crate::intoto::Statement;
if let Some(payload) = extract_and_verify_dsse(module, verifier)? {
let statement: Statement<wsc_attestation::TransformationAttestation> =
Statement::from_json_bytes(&payload)?;
Ok(Some(statement.predicate))
} else {
Ok(None)
}
}
pub fn validate_device_attestation(
attestation: &DeviceAttestation,
_expected_device_id: Option<&str>,
) -> Result<(), String> {
if attestation.device_id.is_empty() {
return Err("Device ID is required".to_string());
}
if attestation.device_public_key.is_empty() {
return Err("Device public key is required".to_string());
}
if let Some(expected) = _expected_device_id
&& attestation.device_id != expected {
return Err(format!(
"Device ID mismatch: expected '{}', got '{}'",
expected, attestation.device_id
));
}
if chrono::DateTime::parse_from_rfc3339(&attestation.timestamp).is_err() {
return Err(format!(
"Invalid timestamp format: {}",
attestation.timestamp
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wasm_module::Module;
#[test]
fn test_provenance_builder() {
let prov = ProvenanceBuilder::new()
.component_name("test-component")
.version("1.0.0")
.source_repo("https://github.com/test/comp")
.commit_sha("abc123")
.build_tool("cargo", "1.75.0")
.builder_identity("CI/CD")
.add_metadata("platform", "wasm32-wasi")
.build();
assert_eq!(prov.name, "test-component");
assert_eq!(prov.version, "1.0.0");
assert_eq!(
prov.source_repo,
Some("https://github.com/test/comp".to_string())
);
assert_eq!(prov.commit_sha, Some("abc123".to_string()));
assert_eq!(prov.build_tool, "cargo");
assert_eq!(
prov.metadata.get("platform"),
Some(&"wasm32-wasi".to_string())
);
}
#[test]
fn test_composition_manifest() {
let mut manifest = CompositionManifest::new("wac", "0.5.0");
manifest.add_component("comp-a", "sha256:abc123");
manifest.add_component_with_source(
"comp-b",
"sha256:def456",
"https://github.com/test/comp-b",
);
manifest.set_integrator("CN=Integrator, O=Test Corp", 2);
assert_eq!(manifest.components.len(), 2);
assert_eq!(manifest.components[0].id, "comp-a");
assert_eq!(
manifest.components[1].source,
Some("https://github.com/test/comp-b".to_string())
);
assert!(manifest.integrator.is_some());
}
#[test]
fn test_manifest_json_roundtrip() {
let mut manifest = CompositionManifest::new("wac", "0.5.0");
manifest.add_component("test", "sha256:123");
let json = manifest.to_json().unwrap();
let deserialized = CompositionManifest::from_json(&json).unwrap();
assert_eq!(deserialized.tool, "wac");
assert_eq!(deserialized.components.len(), 1);
assert_eq!(deserialized.components[0].id, "test");
}
#[test]
fn test_sbom_creation() {
let sbom = Sbom::new("composed-app", "1.0.0");
assert_eq!(sbom.bom_format, "CycloneDX");
assert_eq!(sbom.spec_version, "1.5");
assert!(sbom.serial_number.starts_with("urn:uuid:"));
assert_eq!(sbom.version, 1);
assert!(sbom.metadata.is_some());
}
#[test]
fn test_sbom_add_components() {
let mut sbom = Sbom::new("composed-app", "1.0.0");
sbom.add_component("component-a", "1.0.0", "abc123");
sbom.add_component_with_source(
"component-b",
"2.0.0",
"def456",
"https://github.com/test/component-b",
);
assert_eq!(sbom.components.len(), 2);
assert_eq!(sbom.components[0].name, "component-a");
assert_eq!(sbom.components[0].hashes[0].alg, "SHA-256");
assert_eq!(sbom.components[0].hashes[0].content, "abc123");
assert_eq!(sbom.components[1].name, "component-b");
assert_eq!(sbom.components[1].external_references.len(), 1);
assert_eq!(sbom.components[1].external_references[0].ref_type, "vcs");
}
#[test]
fn test_sbom_json_serialization() {
let mut sbom = Sbom::new("test-app", "1.0.0");
sbom.add_component("comp-a", "1.0.0", "hash123");
let json = sbom.to_json().unwrap();
assert!(json.contains("CycloneDX"));
assert!(json.contains("comp-a"));
let deserialized = Sbom::from_json(&json).unwrap();
assert_eq!(deserialized.components.len(), 1);
assert_eq!(deserialized.components[0].name, "comp-a");
}
#[test]
fn test_sbom_metadata() {
let sbom = Sbom::new("test-app", "1.0.0");
let metadata = sbom.metadata.as_ref().unwrap();
assert!(!metadata.timestamp.is_empty());
assert_eq!(metadata.tools.len(), 1);
assert_eq!(metadata.tools[0].name, "wsc");
assert!(metadata.tools[0].vendor.is_some());
assert!(metadata.component.is_some());
let component = metadata.component.as_ref().unwrap();
assert_eq!(component.name, "test-app");
assert_eq!(component.version.as_ref().unwrap(), "1.0.0");
}
#[test]
fn test_intoto_attestation_creation() {
let attestation =
InTotoAttestation::new_composition("composed.wasm", "abc123def456", "wsc-builder");
assert_eq!(attestation.payload_type, "application/vnd.in-toto+json");
assert_eq!(attestation.subject.len(), 1);
assert_eq!(attestation.subject[0].name, "composed.wasm");
assert_eq!(
attestation.subject[0].digest.get("sha256"),
Some(&"abc123def456".to_string())
);
assert_eq!(attestation.predicate.builder.id, "wsc-builder");
}
#[test]
fn test_intoto_add_materials() {
let mut attestation =
InTotoAttestation::new_composition("composed.wasm", "abc123", "builder");
attestation.add_material("component-a.wasm", "hash-a");
attestation.add_material("component-b.wasm", "hash-b");
assert_eq!(attestation.predicate.materials.len(), 2);
assert_eq!(attestation.predicate.materials[0].uri, "component-a.wasm");
assert_eq!(
attestation.predicate.materials[0].digest.get("sha256"),
Some(&"hash-a".to_string())
);
assert_eq!(attestation.predicate.materials[1].uri, "component-b.wasm");
}
#[test]
fn test_intoto_json_serialization() {
let mut attestation =
InTotoAttestation::new_composition("test.wasm", "hash123", "test-builder");
attestation.add_material("input.wasm", "input-hash");
let json = attestation.to_json().unwrap();
assert!(json.contains("application/vnd.in-toto+json"));
assert!(json.contains("test.wasm"));
assert!(json.contains("test-builder"));
let deserialized = InTotoAttestation::from_json(&json).unwrap();
assert_eq!(deserialized.subject.len(), 1);
assert_eq!(deserialized.predicate.materials.len(), 1);
}
#[test]
fn test_full_composition_workflow() {
let mut manifest = CompositionManifest::new("wac", "0.5.0");
manifest.add_component_with_source(
"component-a",
"sha256:abc123",
"https://github.com/owner/component-a",
);
manifest.add_component_with_source(
"component-b",
"sha256:def456",
"https://github.com/owner/component-b",
);
manifest.set_integrator("CN=Integrator, O=Test Corp", 2);
let mut sbom = Sbom::new("composed-app", "1.0.0");
for component in &manifest.components {
if let Some(source) = &component.source {
sbom.add_component_with_source(&component.id, "1.0.0", &component.hash, source);
} else {
sbom.add_component(&component.id, "1.0.0", &component.hash);
}
}
let mut attestation = InTotoAttestation::new_composition(
"composed-app.wasm",
"composed-hash-xyz",
"wsc-integrator",
);
for component in &manifest.components {
attestation.add_material(format!("{}.wasm", component.id), &component.hash);
}
assert_eq!(manifest.components.len(), 2);
assert_eq!(sbom.components.len(), 2);
assert_eq!(attestation.predicate.materials.len(), 2);
let _manifest_json = manifest.to_json().unwrap();
let _sbom_json = sbom.to_json().unwrap();
let _attestation_json = attestation.to_json().unwrap();
}
#[test]
fn test_cyclonedx_spec_compliance() {
let sbom = Sbom::new("test", "1.0.0");
let json = sbom.to_json().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["bomFormat"], "CycloneDX");
assert_eq!(parsed["specVersion"], "1.5");
assert!(
parsed["serialNumber"]
.as_str()
.unwrap()
.starts_with("urn:uuid:")
);
assert_eq!(parsed["version"], 1);
assert!(parsed["metadata"].is_object());
}
#[test]
fn test_intoto_predicate_type() {
let attestation = InTotoAttestation::new_composition("test", "hash", "builder");
assert_eq!(
attestation.predicate_type,
"https://wsc.dev/in-toto/composition/v1"
);
assert_eq!(
attestation.predicate.build_type,
"https://wsc.dev/composition@v1"
);
}
fn create_test_module() -> Module {
Module {
header: [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00],
sections: vec![],
}
}
#[test]
fn test_embed_extract_composition_manifest() {
let mut manifest = CompositionManifest::new("wac", "0.5.0");
manifest.add_component("comp-a", "hash-a");
manifest.add_component("comp-b", "hash-b");
let module = create_test_module();
let module_with_manifest = embed_composition_manifest(module, &manifest).unwrap();
assert_eq!(module_with_manifest.sections.len(), 1);
let extracted = extract_composition_manifest(&module_with_manifest).unwrap();
assert!(extracted.is_some());
let extracted_manifest = extracted.unwrap();
assert_eq!(extracted_manifest.tool, "wac");
assert_eq!(extracted_manifest.components.len(), 2);
assert_eq!(extracted_manifest.components[0].id, "comp-a");
}
#[test]
fn test_embed_extract_build_provenance() {
let provenance = ProvenanceBuilder::new()
.component_name("test-comp")
.version("1.0.0")
.source_repo("https://github.com/test/repo")
.commit_sha("abc123")
.build_tool("cargo", "1.75.0")
.build();
let module = create_test_module();
let module_with_prov = embed_build_provenance(module, &provenance).unwrap();
let extracted = extract_build_provenance(&module_with_prov).unwrap();
assert!(extracted.is_some());
let extracted_prov = extracted.unwrap();
assert_eq!(extracted_prov.name, "test-comp");
assert_eq!(extracted_prov.version, "1.0.0");
assert_eq!(extracted_prov.commit_sha, Some("abc123".to_string()));
}
#[test]
fn test_embed_extract_sbom() {
let mut sbom = Sbom::new("app", "1.0.0");
sbom.add_component("comp-a", "1.0.0", "hash-a");
sbom.add_component_with_source("comp-b", "2.0.0", "hash-b", "https://github.com/test/b");
let module = create_test_module();
let module_with_sbom = embed_sbom(module, &sbom).unwrap();
let extracted = extract_sbom(&module_with_sbom).unwrap();
assert!(extracted.is_some());
let extracted_sbom = extracted.unwrap();
assert_eq!(extracted_sbom.components.len(), 2);
assert_eq!(extracted_sbom.components[0].name, "comp-a");
assert_eq!(extracted_sbom.components[1].name, "comp-b");
}
#[test]
fn test_embed_extract_intoto_attestation() {
let mut attestation =
InTotoAttestation::new_composition("app.wasm", "final-hash", "integrator");
attestation.add_material("comp-a.wasm", "hash-a");
attestation.add_material("comp-b.wasm", "hash-b");
let module = create_test_module();
let module_with_att = embed_intoto_attestation(module, &attestation).unwrap();
let extracted = extract_intoto_attestation(&module_with_att).unwrap();
assert!(extracted.is_some());
let extracted_att = extracted.unwrap();
assert_eq!(extracted_att.subject.len(), 1);
assert_eq!(extracted_att.subject[0].name, "app.wasm");
assert_eq!(extracted_att.predicate.materials.len(), 2);
}
#[test]
fn test_embed_all_provenance() {
let manifest = CompositionManifest::new("wac", "0.5.0");
let provenance = ProvenanceBuilder::new()
.component_name("app")
.version("1.0.0")
.build();
let sbom = Sbom::new("app", "1.0.0");
let attestation = InTotoAttestation::new_composition("app.wasm", "hash", "builder");
let module = create_test_module();
let module_with_all =
embed_all_provenance(module, &manifest, &provenance, &sbom, &attestation).unwrap();
assert_eq!(module_with_all.sections.len(), 4);
let (m, p, s, a) = extract_all_provenance(&module_with_all).unwrap();
assert!(m.is_some());
assert!(p.is_some());
assert!(s.is_some());
assert!(a.is_some());
}
#[test]
fn test_extract_from_module_without_provenance() {
let module = create_test_module();
let manifest = extract_composition_manifest(&module).unwrap();
assert!(manifest.is_none());
let provenance = extract_build_provenance(&module).unwrap();
assert!(provenance.is_none());
let sbom = extract_sbom(&module).unwrap();
assert!(sbom.is_none());
let attestation = extract_intoto_attestation(&module).unwrap();
assert!(attestation.is_none());
}
#[test]
fn test_roundtrip_serialization() {
let manifest = CompositionManifest::new("wac", "0.5.0");
let provenance = ProvenanceBuilder::new()
.component_name("app")
.version("1.0.0")
.build();
let sbom = Sbom::new("app", "1.0.0");
let attestation = InTotoAttestation::new_composition("app.wasm", "hash", "builder");
let module = create_test_module();
let module_with_all =
embed_all_provenance(module, &manifest, &provenance, &sbom, &attestation).unwrap();
let mut buffer = Vec::new();
module_with_all.serialize(&mut buffer).unwrap();
let mut reader = std::io::Cursor::new(buffer);
let deserialized_module = Module::deserialize(&mut reader).unwrap();
let (m, p, s, a) = extract_all_provenance(&deserialized_module).unwrap();
assert!(m.is_some());
assert!(p.is_some());
assert!(s.is_some());
assert!(a.is_some());
assert_eq!(m.unwrap().tool, "wac");
assert_eq!(p.unwrap().name, "app");
assert_eq!(s.unwrap().bom_format, "CycloneDX");
assert_eq!(a.unwrap().predicate.builder.id, "builder");
}
#[test]
fn test_multiple_sections_preserved() {
let mut module = create_test_module();
let existing_section = CustomSection::new("existing".to_string(), vec![1, 2, 3]);
module.sections.push(Section::Custom(existing_section));
let manifest = CompositionManifest::new("wac", "0.5.0");
let module_with_prov = embed_composition_manifest(module, &manifest).unwrap();
assert_eq!(module_with_prov.sections.len(), 2);
let mut found_existing = false;
let mut found_manifest = false;
for section in &module_with_prov.sections {
if let Section::Custom(custom) = section {
if custom.name() == "existing" {
found_existing = true;
}
if custom.name() == COMPOSITION_MANIFEST_SECTION {
found_manifest = true;
}
}
}
assert!(found_existing, "Existing section was lost");
assert!(found_manifest, "Manifest section was not added");
}
#[test]
fn test_dependency_graph_creation() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_component("comp-b", "hash-b");
assert_eq!(graph.expected_hashes.len(), 2);
}
#[test]
fn test_dependency_graph_from_manifest() {
let mut manifest = CompositionManifest::new("wac", "0.5.0");
manifest.add_component("comp-a", "hash-a");
manifest.add_component("comp-b", "hash-b");
manifest.add_component("comp-c", "hash-c");
let graph = DependencyGraph::from_manifest(&manifest);
assert_eq!(graph.expected_hashes.len(), 3);
assert_eq!(
graph.expected_hashes.get("comp-a"),
Some(&"hash-a".to_string())
);
}
#[test]
fn test_cycle_detection_no_cycle() {
let mut graph = DependencyGraph::new();
graph.add_component("a", "hash-a");
graph.add_component("b", "hash-b");
graph.add_component("c", "hash-c");
graph.add_dependency("a", "b");
graph.add_dependency("b", "c");
let cycle = graph.detect_cycles();
assert!(cycle.is_none(), "No cycle should be detected");
}
#[test]
fn test_cycle_detection_simple_cycle() {
let mut graph = DependencyGraph::new();
graph.add_component("a", "hash-a");
graph.add_component("b", "hash-b");
graph.add_dependency("a", "b");
graph.add_dependency("b", "a");
let cycle = graph.detect_cycles();
assert!(cycle.is_some(), "Cycle should be detected");
let cycle = cycle.unwrap();
assert!(cycle.len() >= 2, "Cycle should have at least 2 nodes");
assert!(cycle.contains(&"a".to_string()));
assert!(cycle.contains(&"b".to_string()));
}
#[test]
fn test_cycle_detection_complex_cycle() {
let mut graph = DependencyGraph::new();
graph.add_component("a", "hash-a");
graph.add_component("b", "hash-b");
graph.add_component("c", "hash-c");
graph.add_component("d", "hash-d");
graph.add_dependency("a", "b");
graph.add_dependency("b", "c");
graph.add_dependency("c", "d");
graph.add_dependency("d", "b");
let cycle = graph.detect_cycles();
assert!(cycle.is_some(), "Cycle should be detected");
let cycle = cycle.unwrap();
assert!(cycle.contains(&"b".to_string()));
assert!(cycle.contains(&"c".to_string()));
assert!(cycle.contains(&"d".to_string()));
}
#[test]
fn test_substitution_detection_no_substitution() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_component("comp-b", "hash-b");
graph.set_actual_hash("comp-a", "hash-a");
graph.set_actual_hash("comp-b", "hash-b");
let substitutions = graph.detect_substitutions();
assert!(
substitutions.is_empty(),
"No substitutions should be detected"
);
}
#[test]
fn test_substitution_detection_with_substitution() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_component("comp-b", "hash-b");
graph.set_actual_hash("comp-a", "hash-a-modified");
graph.set_actual_hash("comp-b", "hash-b");
let substitutions = graph.detect_substitutions();
assert_eq!(
substitutions.len(),
1,
"One substitution should be detected"
);
let sub = &substitutions[0];
assert_eq!(sub.component_id, "comp-a");
assert_eq!(sub.expected_hash, "hash-a");
assert_eq!(sub.actual_hash, "hash-a-modified");
}
#[test]
fn test_substitution_detection_multiple() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_component("comp-b", "hash-b");
graph.add_component("comp-c", "hash-c");
graph.set_actual_hash("comp-a", "hash-a-wrong");
graph.set_actual_hash("comp-b", "hash-b");
graph.set_actual_hash("comp-c", "hash-c-wrong");
let substitutions = graph.detect_substitutions();
assert_eq!(
substitutions.len(),
2,
"Two substitutions should be detected"
);
}
#[test]
fn test_validation_success() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_component("comp-b", "hash-b");
graph.add_dependency("comp-a", "comp-b");
graph.set_actual_hash("comp-a", "hash-a");
graph.set_actual_hash("comp-b", "hash-b");
let result = graph.validate().unwrap();
assert!(result.valid, "Validation should pass");
assert!(result.errors.is_empty(), "No errors should be present");
}
#[test]
fn test_validation_cycle_error() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_component("comp-b", "hash-b");
graph.add_dependency("comp-a", "comp-b");
graph.add_dependency("comp-b", "comp-a");
let result = graph.validate().unwrap();
assert!(!result.valid, "Validation should fail due to cycle");
assert!(!result.errors.is_empty(), "Errors should be present");
assert!(result.errors[0].contains("Cycle detected"));
}
#[test]
fn test_validation_substitution_error() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.set_actual_hash("comp-a", "hash-wrong");
let result = graph.validate().unwrap();
assert!(!result.valid, "Validation should fail due to substitution");
assert!(!result.errors.is_empty(), "Errors should be present");
assert!(result.errors[0].contains("substituted"));
}
#[test]
fn test_validation_missing_dependency_warning() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_dependency("comp-a", "comp-b");
let result = graph.validate().unwrap();
assert!(result.valid, "Should still be valid (just a warning)");
assert!(!result.warnings.is_empty(), "Warning should be present");
assert!(result.warnings[0].contains("missing component"));
}
#[test]
fn test_topological_sort_simple() {
let mut graph = DependencyGraph::new();
graph.add_component("a", "hash-a");
graph.add_component("b", "hash-b");
graph.add_component("c", "hash-c");
graph.add_dependency("a", "b");
graph.add_dependency("b", "c");
let sorted = graph.topological_sort();
assert!(sorted.is_some(), "Should be able to sort");
let sorted = sorted.unwrap();
assert_eq!(sorted.len(), 3);
let c_pos = sorted.iter().position(|x| x == "c").unwrap();
let b_pos = sorted.iter().position(|x| x == "b").unwrap();
let a_pos = sorted.iter().position(|x| x == "a").unwrap();
assert!(
c_pos < b_pos,
"c (no deps) should come before b (depends on c)"
);
assert!(
b_pos < a_pos,
"b (depends on c) should come before a (depends on b)"
);
}
#[test]
fn test_topological_sort_with_cycle() {
let mut graph = DependencyGraph::new();
graph.add_component("a", "hash-a");
graph.add_component("b", "hash-b");
graph.add_dependency("a", "b");
graph.add_dependency("b", "a");
let sorted = graph.topological_sort();
assert!(sorted.is_none(), "Should not be able to sort with cycle");
}
#[test]
fn test_topological_sort_complex() {
let mut graph = DependencyGraph::new();
graph.add_component("a", "hash-a");
graph.add_component("b", "hash-b");
graph.add_component("c", "hash-c");
graph.add_component("d", "hash-d");
graph.add_dependency("a", "b");
graph.add_dependency("a", "c");
graph.add_dependency("b", "d");
graph.add_dependency("c", "d");
let sorted = graph.topological_sort();
assert!(sorted.is_some(), "Should be able to sort");
let sorted = sorted.unwrap();
assert_eq!(sorted.len(), 4);
let d_pos = sorted.iter().position(|x| x == "d").unwrap();
let a_pos = sorted.iter().position(|x| x == "a").unwrap();
assert!(d_pos < a_pos, "d should come before a");
}
#[test]
fn test_dependency_graph_comprehensive() {
let mut graph = DependencyGraph::new();
graph.add_component("http-client", "sha256:abc123");
graph.add_component("json-parser", "sha256:def456");
graph.add_component("app-logic", "sha256:ghi789");
graph.add_component("main-app", "sha256:jkl012");
graph.add_dependency("main-app", "app-logic");
graph.add_dependency("app-logic", "http-client");
graph.add_dependency("app-logic", "json-parser");
graph.set_actual_hash("http-client", "sha256:abc123");
graph.set_actual_hash("json-parser", "sha256:def456");
graph.set_actual_hash("app-logic", "sha256:ghi789");
graph.set_actual_hash("main-app", "sha256:jkl012");
let result = graph.validate().unwrap();
assert!(result.valid);
assert!(result.errors.is_empty());
let sorted = graph.topological_sort();
assert!(sorted.is_some());
let sorted = sorted.unwrap();
assert_eq!(sorted.last(), Some(&"main-app".to_string()));
}
#[test]
fn test_attack_scenario_substitution() {
let mut graph = DependencyGraph::new();
graph.add_component("crypto-lib", "sha256:trusted-hash");
graph.add_component("app", "sha256:app-hash");
graph.add_dependency("app", "crypto-lib");
graph.set_actual_hash("crypto-lib", "sha256:malicious-hash");
graph.set_actual_hash("app", "sha256:app-hash");
let result = graph.validate().unwrap();
assert!(!result.valid, "Attack should be detected");
assert!(!result.errors.is_empty());
assert!(result.errors[0].contains("crypto-lib"));
assert!(result.errors[0].contains("malicious-hash"));
}
#[test]
fn test_version_constraint_exact() {
let constraint = VersionConstraint::Exact("1.2.3".to_string());
assert!(constraint.satisfies("1.2.3"));
assert!(!constraint.satisfies("1.2.4"));
assert!(!constraint.satisfies("1.2.2"));
}
#[test]
fn test_version_constraint_minimum() {
let constraint = VersionConstraint::Minimum("1.0.0".to_string());
assert!(constraint.satisfies("1.0.0"));
assert!(constraint.satisfies("1.0.1"));
assert!(constraint.satisfies("2.0.0"));
assert!(!constraint.satisfies("0.9.9"));
}
#[test]
fn test_version_constraint_maximum() {
let constraint = VersionConstraint::Maximum("2.0.0".to_string());
assert!(constraint.satisfies("1.0.0"));
assert!(constraint.satisfies("2.0.0"));
assert!(!constraint.satisfies("2.0.1"));
assert!(!constraint.satisfies("3.0.0"));
}
#[test]
fn test_version_constraint_range() {
let constraint = VersionConstraint::Range("1.0.0".to_string(), "2.0.0".to_string());
assert!(!constraint.satisfies("0.9.9"));
assert!(constraint.satisfies("1.0.0"));
assert!(constraint.satisfies("1.5.0"));
assert!(constraint.satisfies("2.0.0"));
assert!(!constraint.satisfies("2.0.1"));
}
#[test]
fn test_version_comparison() {
assert_eq!(VersionConstraint::compare_versions("1.0.0", "1.0.0"), 0);
assert_eq!(VersionConstraint::compare_versions("1.0.0", "1.0.1"), -1);
assert_eq!(VersionConstraint::compare_versions("1.0.1", "1.0.0"), 1);
assert_eq!(VersionConstraint::compare_versions("2.0.0", "1.9.9"), 1);
assert_eq!(VersionConstraint::compare_versions("1.9.9", "2.0.0"), -1);
}
#[test]
fn test_version_policy_exact() {
let mut policy = VersionPolicy::new();
policy.require_exact("crypto-lib", "1.2.3");
assert!(policy.validate_version("crypto-lib", "1.2.3").is_ok());
assert!(policy.validate_version("crypto-lib", "1.2.4").is_err());
assert!(policy.validate_version("other-lib", "999.0.0").is_ok()); }
#[test]
fn test_version_policy_minimum() {
let mut policy = VersionPolicy::new();
policy.require_minimum("crypto-lib", "2.0.0");
assert!(policy.validate_version("crypto-lib", "1.9.9").is_err());
assert!(policy.validate_version("crypto-lib", "2.0.0").is_ok());
assert!(policy.validate_version("crypto-lib", "2.1.0").is_ok());
}
#[test]
fn test_version_policy_range() {
let mut policy = VersionPolicy::new();
policy.require_range("lib-a", "1.0.0", "2.0.0");
assert!(policy.validate_version("lib-a", "0.9.9").is_err());
assert!(policy.validate_version("lib-a", "1.0.0").is_ok());
assert!(policy.validate_version("lib-a", "1.5.0").is_ok());
assert!(policy.validate_version("lib-a", "2.0.0").is_ok());
assert!(policy.validate_version("lib-a", "2.0.1").is_err());
}
#[test]
fn test_source_allow_list_basic() {
let mut allow_list = SourceAllowList::new();
allow_list.add_source("https://github.com/trusted-org");
assert!(allow_list.is_allowed(Some("https://github.com/trusted-org/repo")));
assert!(allow_list.is_allowed(Some("https://github.com/trusted-org")));
assert!(!allow_list.is_allowed(Some("https://github.com/untrusted-org/repo")));
assert!(!allow_list.is_allowed(None)); }
#[test]
fn test_source_allow_list_with_no_source() {
let mut allow_list = SourceAllowList::new();
allow_list.add_source("https://github.com/trusted");
allow_list.allow_no_source(true);
assert!(allow_list.is_allowed(None));
assert!(allow_list.is_allowed(Some("https://github.com/trusted/repo")));
}
#[test]
fn test_source_allow_list_validation() {
let mut allow_list = SourceAllowList::new();
allow_list.add_source("https://internal.company.com");
assert!(
allow_list
.validate_source("comp-a", Some("https://internal.company.com/repo"))
.is_ok()
);
let result = allow_list.validate_source("comp-b", Some("https://external.com/repo"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not in allow-list"));
}
#[test]
fn test_validation_mode_lenient() {
let config = ValidationConfig::lenient();
assert_eq!(config.mode, ValidationMode::Lenient);
}
#[test]
fn test_validation_mode_strict() {
let config = ValidationConfig::strict();
assert_eq!(config.mode, ValidationMode::Strict);
}
#[test]
fn test_validation_config_builder() {
let mut policy = VersionPolicy::new();
policy.require_minimum("lib-a", "1.0.0");
let mut allow_list = SourceAllowList::new();
allow_list.add_source("https://github.com/trusted");
let config = ValidationConfig::strict()
.with_version_policy(policy)
.with_source_allow_list(allow_list)
.with_transitive_validation(true);
assert_eq!(config.mode, ValidationMode::Strict);
assert!(config.version_policy.is_some());
assert!(config.source_allow_list.is_some());
assert!(config.validate_transitive);
}
#[test]
fn test_strict_mode_converts_warnings_to_errors() {
let mut graph = DependencyGraph::new();
graph.add_component("comp-a", "hash-a");
graph.add_dependency("comp-a", "comp-b");
let lenient_config = ValidationConfig::lenient();
let lenient_result = graph.validate_with_config(&lenient_config).unwrap();
assert!(lenient_result.valid); assert!(!lenient_result.warnings.is_empty());
assert!(lenient_result.errors.is_empty());
let strict_config = ValidationConfig::strict();
let strict_result = graph.validate_with_config(&strict_config).unwrap();
assert!(!strict_result.valid); assert!(!strict_result.errors.is_empty());
assert!(strict_result.errors[0].contains("STRICT MODE"));
assert!(strict_result.warnings.is_empty());
}
#[test]
fn test_version_rollback_attack_detection() {
let mut policy = VersionPolicy::new();
policy.require_minimum("crypto-lib", "2.0.0");
let result = policy.validate_version("crypto-lib", "1.0.0");
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not satisfy constraint"));
}
#[test]
fn test_dependency_confusion_attack_detection() {
let mut allow_list = SourceAllowList::new();
allow_list.add_source("https://internal.company.com");
let result = allow_list.validate_source(
"internal-lib",
Some("https://public-registry.com/internal-lib"),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not in allow-list"));
}
#[test]
fn test_comprehensive_validation_config() {
let mut graph = DependencyGraph::new();
graph.add_component("crypto-lib", "sha256:hash-crypto");
graph.add_component("http-client", "sha256:hash-http");
graph.add_component("app", "sha256:hash-app");
graph.add_dependency("app", "crypto-lib");
graph.add_dependency("app", "http-client");
graph.set_actual_hash("crypto-lib", "sha256:hash-crypto");
graph.set_actual_hash("http-client", "sha256:hash-http");
graph.set_actual_hash("app", "sha256:hash-app");
let mut policy = VersionPolicy::new();
policy.require_minimum("crypto-lib", "2.0.0");
let mut allow_list = SourceAllowList::new();
allow_list.add_source("https://github.com/trusted-org");
let config = ValidationConfig::strict()
.with_version_policy(policy)
.with_source_allow_list(allow_list)
.with_transitive_validation(true);
let result = graph.validate_with_config(&config).unwrap();
assert!(result.valid); }
#[test]
fn test_version_constraint_any() {
let constraint = VersionConstraint::Any;
assert!(constraint.satisfies("0.0.1"));
assert!(constraint.satisfies("1.0.0"));
assert!(constraint.satisfies("999.999.999"));
}
#[test]
fn test_multiple_policies_combined() {
let mut policy = VersionPolicy::new();
policy.require_minimum("lib-a", "1.0.0");
policy.require_exact("lib-b", "2.5.0");
policy.require_range("lib-c", "1.0.0", "2.0.0");
assert!(policy.validate_version("lib-a", "1.5.0").is_ok());
assert!(policy.validate_version("lib-b", "2.5.0").is_ok());
assert!(policy.validate_version("lib-c", "1.5.0").is_ok());
assert!(policy.validate_version("lib-a", "0.9.0").is_err());
assert!(policy.validate_version("lib-b", "2.5.1").is_err());
assert!(policy.validate_version("lib-c", "2.1.0").is_err());
}
#[test]
fn test_timestamp_policy_valid() {
let policy = TimestampPolicy::new();
let now = chrono::Utc::now().to_rfc3339();
assert!(policy.validate_timestamp(&now, "Test").is_ok());
}
#[test]
fn test_timestamp_policy_future_within_tolerance() {
let policy = TimestampPolicy::new().with_future_tolerance_seconds(300);
let future = (chrono::Utc::now() + chrono::Duration::seconds(120)).to_rfc3339();
assert!(policy.validate_timestamp(&future, "Test").is_ok());
}
#[test]
fn test_timestamp_policy_future_exceeds_tolerance() {
let policy = TimestampPolicy::new().with_future_tolerance_seconds(300);
let future = (chrono::Utc::now() + chrono::Duration::seconds(600)).to_rfc3339();
let result = policy.validate_timestamp(&future, "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("too far in the future"));
}
#[test]
fn test_timestamp_policy_max_age() {
let policy = TimestampPolicy::new().with_max_age_days(30);
let recent = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
assert!(policy.validate_timestamp(&recent, "Test").is_ok());
let old = (chrono::Utc::now() - chrono::Duration::days(40)).to_rfc3339();
let result = policy.validate_timestamp(&old, "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("too old"));
}
#[test]
fn test_timestamp_policy_optional_missing_required() {
let policy = TimestampPolicy::new().require_timestamps(true);
let result = policy.validate_optional_timestamp(None, "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("required but missing"));
}
#[test]
fn test_timestamp_policy_optional_missing_allowed() {
let policy = TimestampPolicy::new().require_timestamps(false);
assert!(policy.validate_optional_timestamp(None, "Test").is_ok());
}
#[test]
fn test_timestamp_policy_optional_present() {
let policy = TimestampPolicy::new();
let now = chrono::Utc::now().to_rfc3339();
assert!(
policy
.validate_optional_timestamp(Some(&now), "Test")
.is_ok()
);
}
#[test]
fn test_timestamp_policy_invalid_format() {
let policy = TimestampPolicy::new();
let result = policy.validate_timestamp("not-a-timestamp", "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid timestamp format"));
}
#[test]
fn test_validate_manifest_timestamps() {
let policy = TimestampPolicy::new();
let now = chrono::Utc::now().to_rfc3339();
let manifest = CompositionManifest {
version: "1.0".to_string(),
tool: "wac".to_string(),
tool_version: "0.5.0".to_string(),
timestamp: now.clone(),
components: vec![],
integrator: Some(IntegratorInfo {
identity: "test@example.com".to_string(),
signature_index: 0,
verification_timestamp: now,
}),
metadata: HashMap::new(),
};
assert!(validate_manifest_timestamps(&manifest, &policy).is_ok());
}
#[test]
fn test_validate_manifest_timestamps_old() {
let policy = TimestampPolicy::new().with_max_age_days(1);
let old_time = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
let manifest = CompositionManifest {
version: "1.0".to_string(),
tool: "wac".to_string(),
tool_version: "0.5.0".to_string(),
timestamp: old_time,
components: vec![],
integrator: None,
metadata: HashMap::new(),
};
let result = validate_manifest_timestamps(&manifest, &policy);
assert!(result.is_err());
assert!(result.unwrap_err().contains("too old"));
}
#[test]
fn test_validate_provenance_timestamps() {
let policy = TimestampPolicy::new();
let now = chrono::Utc::now().to_rfc3339();
let provenance = BuildProvenance {
name: "test-component".to_string(),
version: "1.0.0".to_string(),
source_repo: None,
commit_sha: None,
build_tool: "cargo".to_string(),
build_tool_version: "1.75.0".to_string(),
builder: None,
build_timestamp: now,
metadata: HashMap::new(),
};
assert!(validate_provenance_timestamps(&provenance, &policy).is_ok());
}
#[test]
fn test_signature_freshness_no_restrictions() {
let policy = SignatureFreshnessPolicy::new();
let now = chrono::Utc::now().to_rfc3339();
assert!(policy.validate(&now, "Signature").is_ok());
}
#[test]
fn test_signature_freshness_max_age() {
let policy = SignatureFreshnessPolicy::new().with_max_age_days(30);
let recent = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
assert!(policy.validate(&recent, "Signature").is_ok());
let old = (chrono::Utc::now() - chrono::Duration::days(40)).to_rfc3339();
let result = policy.validate(&old, "Signature");
assert!(result.is_err());
assert!(result.unwrap_err().contains("too old"));
}
#[test]
fn test_signature_freshness_minimum_timestamp() {
let cutoff = chrono::Utc::now() - chrono::Duration::days(7);
let policy = SignatureFreshnessPolicy::new().with_minimum_timestamp(cutoff);
let recent = (chrono::Utc::now() - chrono::Duration::days(3)).to_rfc3339();
assert!(policy.validate(&recent, "Signature").is_ok());
let old = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
let result = policy.validate(&old, "Signature");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("before minimum acceptable time")
);
}
#[test]
fn test_signature_freshness_combined_policies() {
let cutoff = chrono::Utc::now() - chrono::Duration::days(60);
let policy = SignatureFreshnessPolicy::new()
.with_max_age_days(30)
.with_minimum_timestamp(cutoff);
let valid = (chrono::Utc::now() - chrono::Duration::days(15)).to_rfc3339();
assert!(policy.validate(&valid, "Signature").is_ok());
let too_old = (chrono::Utc::now() - chrono::Duration::days(45)).to_rfc3339();
assert!(policy.validate(&too_old, "Signature").is_err());
let before_cutoff = (chrono::Utc::now() - chrono::Duration::days(90)).to_rfc3339();
assert!(policy.validate(&before_cutoff, "Signature").is_err());
}
#[test]
fn test_certificate_validity_policy_valid() {
let policy = CertificateValidityPolicy::new();
let not_before = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
let not_after = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
assert!(
policy
.validate_certificate_times(¬_before, ¬_after, "Test")
.is_ok()
);
}
#[test]
fn test_certificate_validity_policy_expired() {
let policy = CertificateValidityPolicy::new();
let not_before = (chrono::Utc::now() - chrono::Duration::days(60)).to_rfc3339();
let not_after = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
let result = policy.validate_certificate_times(¬_before, ¬_after, "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("expired"));
}
#[test]
fn test_certificate_validity_policy_not_yet_valid() {
let policy = CertificateValidityPolicy::new();
let not_before = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
let not_after = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
let result = policy.validate_certificate_times(¬_before, ¬_after, "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not yet valid"));
}
#[test]
fn test_certificate_validity_policy_not_yet_valid_allowed() {
let policy = CertificateValidityPolicy::new().allow_not_yet_valid(true);
let not_before = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
let not_after = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
assert!(
policy
.validate_certificate_times(¬_before, ¬_after, "Test")
.is_ok()
);
}
#[test]
fn test_certificate_validity_policy_min_remaining() {
let policy = CertificateValidityPolicy::new().with_min_remaining_validity_days(10);
let not_before = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
let not_after = (chrono::Utc::now() + chrono::Duration::days(20)).to_rfc3339();
assert!(
policy
.validate_certificate_times(¬_before, ¬_after, "Test")
.is_ok()
);
let not_before2 = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
let not_after2 = (chrono::Utc::now() + chrono::Duration::days(5)).to_rfc3339();
let result = policy.validate_certificate_times(¬_before2, ¬_after2, "Test");
assert!(result.is_err());
assert!(result.unwrap_err().contains("expires too soon"));
}
#[test]
fn test_timestamp_validation_config_integration() {
let policy = TimestampPolicy::new().with_max_age_days(30);
let config = ValidationConfig::lenient().with_timestamp_policy(policy);
assert!(config.timestamp_policy.is_some());
}
#[test]
fn test_timestamp_validation_strict_mode() {
let policy = TimestampPolicy::new().with_max_age_days(7);
let config = ValidationConfig::strict().with_timestamp_policy(policy);
assert_eq!(config.mode, ValidationMode::Strict);
assert!(config.timestamp_policy.is_some());
}
#[test]
fn test_device_attestation_creation() {
let attestation = DeviceAttestation::new("device-12345", "SecureElement", "ATECC608");
assert_eq!(attestation.device_id, "device-12345");
assert_eq!(attestation.attestation_type, "SecureElement");
assert_eq!(attestation.hardware_model, "ATECC608");
assert!(!attestation.timestamp.is_empty());
}
#[test]
fn test_device_attestation_with_data() {
let test_data = b"attestation_data_here";
let attestation =
DeviceAttestation::new("device-1", "TPM", "TPM2.0").with_attestation_data(test_data);
let decoded = base64::engine::general_purpose::STANDARD
.decode(&attestation.attestation_data)
.unwrap();
assert_eq!(decoded, test_data);
}
#[test]
fn test_device_attestation_with_signature() {
let signature = b"signature_bytes";
let attestation =
DeviceAttestation::new("device-1", "SGX", "SGX-Enabled").with_signature(signature);
assert!(attestation.attestation_signature.is_some());
let decoded = base64::engine::general_purpose::STANDARD
.decode(attestation.attestation_signature.as_ref().unwrap())
.unwrap();
assert_eq!(decoded, signature);
}
#[test]
fn test_device_attestation_with_public_key() {
let pubkey = b"public_key_bytes_here_32_bytes!";
let attestation =
DeviceAttestation::new("device-1", "SecureElement", "ATECC608").with_public_key(pubkey);
let decoded = base64::engine::general_purpose::STANDARD
.decode(&attestation.device_public_key)
.unwrap();
assert_eq!(decoded, pubkey);
}
#[test]
fn test_device_attestation_with_metadata() {
let attestation = DeviceAttestation::new("device-1", "TrustZone", "ARMv8")
.add_metadata("firmware_version", "1.2.3")
.add_metadata("boot_time", "2025-11-15T10:00:00Z");
assert_eq!(
attestation.metadata.get("firmware_version"),
Some(&"1.2.3".to_string())
);
assert_eq!(
attestation.metadata.get("boot_time"),
Some(&"2025-11-15T10:00:00Z".to_string())
);
}
#[test]
fn test_device_attestation_serialization() {
let attestation = DeviceAttestation::new("device-1", "SecureElement", "ATECC608")
.with_attestation_data(b"test_data")
.with_public_key(b"public_key");
let json = attestation.to_json().unwrap();
let deserialized = DeviceAttestation::from_json(&json).unwrap();
assert_eq!(deserialized.device_id, attestation.device_id);
assert_eq!(deserialized.hardware_model, attestation.hardware_model);
assert_eq!(deserialized.attestation_data, attestation.attestation_data);
}
#[test]
fn test_hardware_composition_manifest() {
let base_manifest = CompositionManifest::new("wac", "0.5.0");
let hw_manifest =
HardwareCompositionManifest::from_manifest(base_manifest).with_security_level(4);
assert_eq!(hw_manifest.security_level, 4);
assert!(hw_manifest.device_attestation.is_none());
}
#[test]
fn test_hardware_composition_manifest_with_attestation() {
let base_manifest = CompositionManifest::new("wac", "0.5.0");
let attestation = DeviceAttestation::new("device-1", "ATECC608", "SecureElement");
let hw_manifest = HardwareCompositionManifest::from_manifest(base_manifest)
.with_device_attestation(attestation)
.with_security_level(4);
assert!(hw_manifest.device_attestation.is_some());
assert_eq!(hw_manifest.security_level, 4);
}
#[test]
fn test_hardware_composition_manifest_security_level_cap() {
let base_manifest = CompositionManifest::new("wac", "0.5.0");
let hw_manifest =
HardwareCompositionManifest::from_manifest(base_manifest).with_security_level(10);
assert_eq!(hw_manifest.security_level, 4); }
#[test]
fn test_transparency_log_entry_creation() {
let entry = TransparencyLogEntry::new(12345, "uuid-abcd-1234");
assert_eq!(entry.log_index, 12345);
assert_eq!(entry.uuid, "uuid-abcd-1234");
assert!(entry.body.is_empty());
assert!(entry.inclusion_proof.is_none());
}
#[test]
fn test_transparency_log_entry_with_body() {
let body_data = b"log_entry_body_data";
let entry = TransparencyLogEntry::new(100, "uuid-test").with_body(body_data);
let decoded = base64::engine::general_purpose::STANDARD
.decode(&entry.body)
.unwrap();
assert_eq!(decoded, body_data);
}
#[test]
fn test_transparency_log_entry_with_proof() {
let proof = InclusionProof {
tree_size: 1000,
root_hash: "abcd1234".to_string(),
hashes: vec!["hash1".to_string(), "hash2".to_string()],
log_index: 100,
};
let entry = TransparencyLogEntry::new(100, "uuid-test").with_inclusion_proof(proof.clone());
assert!(entry.inclusion_proof.is_some());
let included_proof = entry.inclusion_proof.unwrap();
assert_eq!(included_proof.tree_size, 1000);
assert_eq!(included_proof.log_index, 100);
assert_eq!(included_proof.hashes.len(), 2);
}
#[test]
fn test_transparency_log_entry_serialization() {
let entry = TransparencyLogEntry::new(123, "uuid-456").with_body(b"test_body");
let json = entry.to_json().unwrap();
let deserialized = TransparencyLogEntry::from_json(&json).unwrap();
assert_eq!(deserialized.log_index, entry.log_index);
assert_eq!(deserialized.uuid, entry.uuid);
assert_eq!(deserialized.body, entry.body);
}
#[test]
fn test_embed_and_extract_device_attestation() {
let module = Module::default();
let attestation = DeviceAttestation::new("device-test", "ATECC608", "SecureElement")
.with_attestation_data(b"test_data");
let module_with_attestation = embed_device_attestation(module, &attestation).unwrap();
let extracted = extract_device_attestation(&module_with_attestation).unwrap();
assert!(extracted.is_some());
let extracted_attestation = extracted.unwrap();
assert_eq!(extracted_attestation.device_id, "device-test");
assert_eq!(extracted_attestation.hardware_model, "SecureElement");
}
#[test]
fn test_extract_device_attestation_none() {
let module = Module::default();
let extracted = extract_device_attestation(&module).unwrap();
assert!(extracted.is_none());
}
#[test]
fn test_embed_and_extract_transparency_log() {
let module = Module::default();
let entry = TransparencyLogEntry::new(999, "uuid-transparency").with_body(b"log_data");
let module_with_log = embed_transparency_log_entry(module, &entry).unwrap();
let extracted = extract_transparency_log_entry(&module_with_log).unwrap();
assert!(extracted.is_some());
let extracted_entry = extracted.unwrap();
assert_eq!(extracted_entry.log_index, 999);
assert_eq!(extracted_entry.uuid, "uuid-transparency");
}
#[test]
fn test_extract_transparency_log_none() {
let module = Module::default();
let extracted = extract_transparency_log_entry(&module).unwrap();
assert!(extracted.is_none());
}
#[test]
fn test_validate_device_attestation_valid() {
let attestation = DeviceAttestation::new("device-1", "ATECC608", "SecureElement")
.with_public_key(b"pubkey");
let result = validate_device_attestation(&attestation, None);
assert!(result.is_ok());
}
#[test]
fn test_validate_device_attestation_missing_device_id() {
let mut attestation = DeviceAttestation::new("device-1", "ATECC608", "SecureElement");
attestation.device_id = String::new();
attestation.device_public_key = "key".to_string();
let result = validate_device_attestation(&attestation, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Device ID is required"));
}
#[test]
fn test_validate_device_attestation_missing_public_key() {
let attestation = DeviceAttestation::new("device-1", "ATECC608", "SecureElement");
let result = validate_device_attestation(&attestation, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("public key is required"));
}
#[test]
fn test_validate_device_attestation_device_id_mismatch() {
let attestation =
DeviceAttestation::new("device-1", "ATECC608", "SecureElement").with_public_key(b"key");
let result = validate_device_attestation(&attestation, Some("device-2"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Device ID mismatch"));
}
#[test]
fn test_validate_device_attestation_device_id_match() {
let attestation =
DeviceAttestation::new("device-1", "ATECC608", "SecureElement").with_public_key(b"key");
let result = validate_device_attestation(&attestation, Some("device-1"));
assert!(result.is_ok());
}
#[test]
fn test_multiple_provenance_types_embedded() {
let module = Module::default();
let manifest = CompositionManifest::new("wac", "0.5.0");
let module = embed_composition_manifest(module, &manifest).unwrap();
let attestation =
DeviceAttestation::new("device-1", "ATECC608", "SecureElement").with_public_key(b"key");
let module = embed_device_attestation(module, &attestation).unwrap();
let log_entry = TransparencyLogEntry::new(100, "uuid-test");
let module = embed_transparency_log_entry(module, &log_entry).unwrap();
let extracted_manifest = extract_composition_manifest(&module).unwrap();
let extracted_attestation = extract_device_attestation(&module).unwrap();
let extracted_log = extract_transparency_log_entry(&module).unwrap();
assert!(extracted_manifest.is_some());
assert!(extracted_attestation.is_some());
assert!(extracted_log.is_some());
}
#[test]
fn test_hardware_composition_manifest_serialization() {
let base_manifest = CompositionManifest::new("wac", "0.5.0");
let attestation = DeviceAttestation::new("device-1", "ATECC608", "SecureElement")
.with_public_key(b"test_key");
let hw_manifest = HardwareCompositionManifest::from_manifest(base_manifest)
.with_device_attestation(attestation)
.with_security_level(4);
let json = hw_manifest.to_json().unwrap();
let deserialized = HardwareCompositionManifest::from_json(&json).unwrap();
assert_eq!(deserialized.security_level, 4);
assert!(deserialized.device_attestation.is_some());
assert_eq!(
deserialized.device_attestation.as_ref().unwrap().device_id,
"device-1"
);
}
#[test]
fn test_verify_attestation_signature_unsigned() {
let attestation = TransformationAttestationBuilder::new_optimization("loom", "0.1.0")
.add_input_unsigned(b"test input", "input.wasm")
.build(b"test output", "output.wasm");
let result = verify_attestation_signature(&attestation, &[]);
assert!(matches!(result, AttestationSignatureResult::Unsigned));
}
#[test]
fn test_trusted_public_key_creation() {
let pk = TrustedPublicKey::ed25519("AAAA", Some("test-key".to_string()));
assert_eq!(pk.algorithm, "ed25519");
assert_eq!(pk.key, "AAAA");
assert_eq!(pk.key_id, Some("test-key".to_string()));
}
#[test]
fn test_trusted_tool_info_with_public_key() {
let info = TrustedToolInfo::min_version("0.1.0")
.with_public_key(TrustedPublicKey::ed25519("key1", Some("id1".to_string())))
.with_public_key(TrustedPublicKey::ed25519("key2", None));
assert_eq!(info.min_version, Some("0.1.0".to_string()));
assert_eq!(info.public_keys.len(), 2);
assert_eq!(info.public_keys[0].key, "key1");
assert_eq!(info.public_keys[1].key, "key2");
}
#[test]
fn test_chain_verification_with_attestation_signatures_unsigned() {
let attestation = TransformationAttestationBuilder::new_optimization("loom", "0.1.0")
.add_input_unsigned(b"test input", "input.wasm")
.build(b"test output", "output.wasm");
let mut policy = ChainVerificationPolicy::default();
policy.mode = ChainVerificationMode::NoRootSignaturesRequired;
policy.verify_attestation_signatures = true;
policy.trusted_tools.insert(
"loom".to_string(),
TrustedToolInfo::min_version("0.1.0")
.with_public_key(TrustedPublicKey::ed25519("dummy", None)),
);
let result = verify_transformation_chain(&attestation, &policy);
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("unsigned")));
}
#[test]
fn test_chain_verification_without_signature_requirement() {
let attestation = TransformationAttestationBuilder::new_optimization("loom", "0.1.0")
.add_input_unsigned(b"test input", "input.wasm")
.build(b"test output", "output.wasm");
let mut policy = ChainVerificationPolicy::default();
policy.mode = ChainVerificationMode::NoRootSignaturesRequired;
policy.verify_attestation_signatures = false;
policy.trusted_tools.insert(
"loom".to_string(),
TrustedToolInfo::min_version("0.1.0"),
);
let result = verify_transformation_chain(&attestation, &policy);
assert!(result.valid);
}
#[test]
fn test_keyless_verification_config() {
let config = KeylessVerificationConfig {
oidc_issuers: vec!["https://token.actions.githubusercontent.com".to_string()],
allowed_subjects: vec!["https://github.com/org/repo/*".to_string()],
};
let info = TrustedToolInfo::min_version("0.1.0").with_keyless(config);
assert!(info.keyless.is_some());
let kl = info.keyless.unwrap();
assert_eq!(kl.oidc_issuers.len(), 1);
assert_eq!(kl.allowed_subjects.len(), 1);
}
}