diff --git a/CHANGELOG.md b/CHANGELOG.md index 991cdc3ca..fcc66b67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,9 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. -- Credential-safe verified action-outcome evidence that binds action kind, canonical target origin, complete action-intent digest, bounded browser post-condition kind, monotonic dispatch and observation timestamps with a bounded delay, and exact provenance; clock provenance is not independently verified, and construction rejects unverified or rejected post-conditions and any observation timestamp that predates action dispatch while allowing equal coarse-clock ticks. +- Credential-safe verified action-outcome evidence that binds action kind, canonical target origin, complete action-intent digest, bounded browser post-condition kind, a same-clock monotonic dispatch-to-observation window with a caller-selected positive freshness budget, and exact provenance; construction rejects unverified or rejected post-conditions, pre-dispatch observations, stale observations beyond that budget, and generic `NodeStateChanged` evidence without the exact governed `ObservedNodeHandle`, while node-state construction additionally requires canonical provenance origin equality and the independently observed node to match the exact governed session/context/origin/document-epoch/node identity; equal coarse-clock ticks and canonical-equivalent source origins remain valid. +- Credential-safe structured-value evidence that binds a bounded field identifier and lowercase SHA-256 value digest to one exact OriginWeave node plus independently verified same-origin DOM/accessibility and network-response provenance, without carrying the raw extracted value. +- Immutable capture-manifest evidence that binds a bounded capture identifier and extraction-schema identifier to one structured-value proof plus canonical SHA-256 identities for its durable WARC record set and provenance graph, without carrying raw extracted values or artifact bytes. - Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, fail-closed schema validation, and deterministic `Display`/`std::error::Error` contracts for public schema failures. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. @@ -103,4 +105,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index d59a60339..2e47119c9 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -32,7 +32,7 @@ pub use sensitive_handle_lifecycle::{ use std::collections::BTreeMap; -use originweave_core::Origin; +use originweave_core::{ObservedNodeHandle, Origin}; const REDACTED: &str = "[REDACTED]"; @@ -48,6 +48,10 @@ pub const MAX_METADATA_NAME_BYTES: usize = 256; pub const MAX_METADATA_VALUE_BYTES: usize = 8_192; /// Maximum source URL or source-locator size retained in provenance metadata. pub const MAX_PROVENANCE_TEXT_BYTES: usize = 8_192; +/// Maximum byte length of one structured extracted-field identifier. +pub const MAX_STRUCTURED_FIELD_NAME_BYTES: usize = 128; +/// Maximum byte length of one capture or extraction-schema identifier. +pub const MAX_CAPTURE_IDENTIFIER_BYTES: usize = MAX_STRUCTURED_FIELD_NAME_BYTES; /// An HTTP method recorded for network evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -405,3 +409,234 @@ fn valid_sha256(source_hash: &str) -> bool { .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } + +/// A credential-safe proof bundle for one extracted structured value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StructuredValueEvidence { + field_name: String, + value_hash: String, + source_node: ObservedNodeHandle, + node_provenance: ProvenanceRecord, + network_provenance: ProvenanceRecord, +} + +impl StructuredValueEvidence { + /// Bind one structured field digest to exact node and network provenance. + pub fn new( + field_name: &str, + value_hash: &str, + source_node: ObservedNodeHandle, + node_provenance: ProvenanceRecord, + network_provenance: ProvenanceRecord, + ) -> Result { + if !valid_structured_field_name(field_name) { + return Err(StructuredValueEvidenceError::InvalidFieldName); + } + if !valid_sha256(value_hash) { + return Err(StructuredValueEvidenceError::InvalidValueHash); + } + if node_provenance.verification_result() != VerificationResult::Verified { + return Err(StructuredValueEvidenceError::NodeProvenanceNotVerified); + } + if !matches!( + node_provenance.source_kind(), + EvidenceSourceKind::DomTree | EvidenceSourceKind::AccessibilityTree + ) { + return Err(StructuredValueEvidenceError::NodeProvenanceKindMismatch); + } + if network_provenance.verification_result() != VerificationResult::Verified { + return Err(StructuredValueEvidenceError::NetworkProvenanceNotVerified); + } + if network_provenance.source_kind() != EvidenceSourceKind::NetworkResponse { + return Err(StructuredValueEvidenceError::NetworkProvenanceKindMismatch); + } + if node_provenance.source_origin() != source_node.origin() + || network_provenance.source_origin() != source_node.origin() + { + return Err(StructuredValueEvidenceError::SourceOriginMismatch); + } + Ok(Self { + field_name: field_name.to_owned(), + value_hash: value_hash.to_owned(), + source_node, + node_provenance, + network_provenance, + }) + } + + /// Return the bounded structured field identifier. + #[must_use] + pub fn field_name(&self) -> &str { + &self.field_name + } + + /// Return the lowercase SHA-256 digest of the canonical extracted value bytes. + #[must_use] + pub fn value_hash(&self) -> &str { + &self.value_hash + } + + /// Return the exact OriginWeave-owned source node. + #[must_use] + pub const fn source_node(&self) -> &ObservedNodeHandle { + &self.source_node + } + + /// Return the independently verified DOM/accessibility provenance for the source node. + #[must_use] + pub const fn node_provenance(&self) -> &ProvenanceRecord { + &self.node_provenance + } + + /// Return the independently verified network provenance associated with the value. + #[must_use] + pub const fn network_provenance(&self) -> &ProvenanceRecord { + &self.network_provenance + } +} + +/// A fail-closed reason why structured extraction evidence could not be constructed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructuredValueEvidenceError { + /// The structured field identifier was empty, oversized, or contained unsupported bytes. + InvalidFieldName, + /// The value digest was not a canonical lowercase SHA-256 identifier. + InvalidValueHash, + /// The node provenance did not carry independent verified status. + NodeProvenanceNotVerified, + /// The node provenance was not a DOM or accessibility observation. + NodeProvenanceKindMismatch, + /// The network provenance did not carry independent verified status. + NetworkProvenanceNotVerified, + /// The network provenance was not a structured network-response observation. + NetworkProvenanceKindMismatch, + /// Node or network provenance belonged to a different canonical origin than the source node. + SourceOriginMismatch, +} + +impl std::fmt::Display for StructuredValueEvidenceError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidFieldName => "invalid structured field name", + Self::InvalidValueHash => "invalid structured value hash", + Self::NodeProvenanceNotVerified => "node provenance is not verified", + Self::NodeProvenanceKindMismatch => { + "node provenance is not DOM or accessibility evidence" + } + Self::NetworkProvenanceNotVerified => "network provenance is not verified", + Self::NetworkProvenanceKindMismatch => { + "network provenance is not network-response evidence" + } + Self::SourceOriginMismatch => "provenance origin does not match source node origin", + }) + } +} + +impl std::error::Error for StructuredValueEvidenceError {} + +fn valid_structured_field_name(field_name: &str) -> bool { + if field_name.is_empty() || field_name.len() > MAX_STRUCTURED_FIELD_NAME_BYTES { + return false; + } + field_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + && field_name.bytes().any(|byte| byte.is_ascii_alphanumeric()) +} + +/// Immutable identity binding for one structured extraction and its durable evidence artifacts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CaptureManifest { + capture_id: String, + extraction_schema_id: String, + structured_value: StructuredValueEvidence, + warc_record_set_hash: String, + provenance_graph_hash: String, +} + +impl CaptureManifest { + /// Bind one structured extraction to bounded capture/schema identifiers and durable artifact digests. + pub fn new( + capture_id: &str, + extraction_schema_id: &str, + structured_value: StructuredValueEvidence, + warc_record_set_hash: &str, + provenance_graph_hash: &str, + ) -> Result { + if !valid_structured_field_name(capture_id) { + return Err(CaptureManifestError::InvalidCaptureId); + } + if !valid_structured_field_name(extraction_schema_id) { + return Err(CaptureManifestError::InvalidExtractionSchemaId); + } + if !valid_sha256(warc_record_set_hash) { + return Err(CaptureManifestError::InvalidWarcRecordSetHash); + } + if !valid_sha256(provenance_graph_hash) { + return Err(CaptureManifestError::InvalidProvenanceGraphHash); + } + Ok(Self { + capture_id: capture_id.to_owned(), + extraction_schema_id: extraction_schema_id.to_owned(), + structured_value, + warc_record_set_hash: warc_record_set_hash.to_owned(), + provenance_graph_hash: provenance_graph_hash.to_owned(), + }) + } + + /// Return the bounded capture identifier. + #[must_use] + pub fn capture_id(&self) -> &str { + &self.capture_id + } + + /// Return the bounded extraction-schema identifier. + #[must_use] + pub fn extraction_schema_id(&self) -> &str { + &self.extraction_schema_id + } + + /// Return the structured value evidence bound into this capture. + #[must_use] + pub const fn structured_value(&self) -> &StructuredValueEvidence { + &self.structured_value + } + + /// Return the lowercase SHA-256 digest of the durable WARC record set. + #[must_use] + pub fn warc_record_set_hash(&self) -> &str { + &self.warc_record_set_hash + } + + /// Return the lowercase SHA-256 digest of the durable provenance graph. + #[must_use] + pub fn provenance_graph_hash(&self) -> &str { + &self.provenance_graph_hash + } +} + +/// A fail-closed reason why a capture manifest could not be constructed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureManifestError { + /// The capture identifier was empty, oversized, or contained unsupported bytes. + InvalidCaptureId, + /// The extraction-schema identifier was empty, oversized, or contained unsupported bytes. + InvalidExtractionSchemaId, + /// The WARC record-set digest was not a canonical lowercase SHA-256 identifier. + InvalidWarcRecordSetHash, + /// The provenance-graph digest was not a canonical lowercase SHA-256 identifier. + InvalidProvenanceGraphHash, +} + +impl std::fmt::Display for CaptureManifestError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidCaptureId => "invalid capture identifier", + Self::InvalidExtractionSchemaId => "invalid extraction schema identifier", + Self::InvalidWarcRecordSetHash => "invalid WARC record-set hash", + Self::InvalidProvenanceGraphHash => "invalid provenance graph hash", + }) + } +} + +impl std::error::Error for CaptureManifestError {} diff --git a/crates/originweave-evidence/tests/capture_manifest.rs b/crates/originweave-evidence/tests/capture_manifest.rs new file mode 100644 index 000000000..30e0391d9 --- /dev/null +++ b/crates/originweave-evidence/tests/capture_manifest.rs @@ -0,0 +1,168 @@ +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, +}; +use originweave_evidence::{ + CaptureManifest, CaptureManifestError, EvidenceSourceKind, MAX_CAPTURE_IDENTIFIER_BYTES, + ProvenanceRecord, StructuredValueEvidence, VerificationResult, +}; + +const VALUE_HASH: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const SOURCE_HASH: &str = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const WARC_HASH: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const PROV_HASH: &str = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + +fn structured_value() -> Result { + let origin = Origin::parse("https://app.example").map_err(|error| format!("{error:?}"))?; + let node = ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + origin, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string())?; + let node_provenance = ProvenanceRecord::new( + "https://app.example/form", + "ax:submit-result", + SOURCE_HASH, + EvidenceSourceKind::AccessibilityTree, + VerificationResult::Verified, + ) + .map_err(|error| format!("{error:?}"))?; + let network_provenance = ProvenanceRecord::new( + "https://app.example/api/task", + "response:task-status", + SOURCE_HASH, + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + ) + .map_err(|error| format!("{error:?}"))?; + + StructuredValueEvidence::new( + "task_status", + VALUE_HASH, + node, + node_provenance, + network_provenance, + ) + .map_err(|error| error.to_string()) +} + +#[test] +fn capture_manifest_binds_structured_result_to_durable_artifact_digests() -> Result<(), String> { + let manifest = CaptureManifest::new( + "capture_2026_08_21_0001", + "task_status_v1", + structured_value()?, + WARC_HASH, + PROV_HASH, + ) + .map_err(|error| error.to_string())?; + + assert_eq!(manifest.capture_id(), "capture_2026_08_21_0001"); + assert_eq!(manifest.extraction_schema_id(), "task_status_v1"); + assert_eq!(manifest.structured_value().field_name(), "task_status"); + assert_eq!(manifest.structured_value().value_hash(), VALUE_HASH); + assert_eq!(manifest.warc_record_set_hash(), WARC_HASH); + assert_eq!(manifest.provenance_graph_hash(), PROV_HASH); + Ok(()) +} + +#[test] +fn capture_and_schema_identifiers_fail_closed() -> Result<(), String> { + let invalid_identifiers = ["", "---", "capture id", "capture/id"]; + for capture_id in invalid_identifiers { + let error = CaptureManifest::new( + capture_id, + "task_status_v1", + structured_value()?, + WARC_HASH, + PROV_HASH, + ) + .err() + .ok_or_else(|| format!("invalid capture id unexpectedly accepted: {capture_id:?}"))?; + assert_eq!(error, CaptureManifestError::InvalidCaptureId); + } + + let oversized = "a".repeat(MAX_CAPTURE_IDENTIFIER_BYTES + 1); + let error = CaptureManifest::new( + &oversized, + "task_status_v1", + structured_value()?, + WARC_HASH, + PROV_HASH, + ) + .err() + .ok_or_else(|| "oversized capture id unexpectedly accepted".to_owned())?; + assert_eq!(error, CaptureManifestError::InvalidCaptureId); + + for schema_id in ["", "___", "task status", "task/status"] { + let error = CaptureManifest::new( + "capture_2026_08_21_0001", + schema_id, + structured_value()?, + WARC_HASH, + PROV_HASH, + ) + .err() + .ok_or_else(|| format!("invalid schema id unexpectedly accepted: {schema_id:?}"))?; + assert_eq!(error, CaptureManifestError::InvalidExtractionSchemaId); + } + Ok(()) +} + +#[test] +fn durable_artifact_hashes_must_be_canonical_sha256() -> Result<(), String> { + let warc_error = CaptureManifest::new( + "capture_2026_08_21_0001", + "task_status_v1", + structured_value()?, + "sha256:ABCDEF", + PROV_HASH, + ) + .err() + .ok_or_else(|| "malformed WARC digest unexpectedly accepted".to_owned())?; + assert_eq!(warc_error, CaptureManifestError::InvalidWarcRecordSetHash); + + let provenance_error = CaptureManifest::new( + "capture_2026_08_21_0001", + "task_status_v1", + structured_value()?, + WARC_HASH, + "sha256:short", + ) + .err() + .ok_or_else(|| "malformed PROV digest unexpectedly accepted".to_owned())?; + assert_eq!( + provenance_error, + CaptureManifestError::InvalidProvenanceGraphHash + ); + Ok(()) +} + +#[test] +fn capture_manifest_errors_are_stable_and_source_free() { + let cases = [ + ( + CaptureManifestError::InvalidCaptureId, + "invalid capture identifier", + ), + ( + CaptureManifestError::InvalidExtractionSchemaId, + "invalid extraction schema identifier", + ), + ( + CaptureManifestError::InvalidWarcRecordSetHash, + "invalid WARC record-set hash", + ), + ( + CaptureManifestError::InvalidProvenanceGraphHash, + "invalid provenance graph hash", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(std::error::Error::source(&error).is_none()); + } +} diff --git a/crates/originweave-evidence/tests/structured_value_provenance.rs b/crates/originweave-evidence/tests/structured_value_provenance.rs new file mode 100644 index 000000000..c5a28ba91 --- /dev/null +++ b/crates/originweave-evidence/tests/structured_value_provenance.rs @@ -0,0 +1,357 @@ +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, +}; +use originweave_evidence::{ + EvidenceSourceKind, MAX_STRUCTURED_FIELD_NAME_BYTES, ProvenanceRecord, StructuredValueEvidence, + StructuredValueEvidenceError, VerificationResult, +}; + +const VALID_VALUE_HASH: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const VALID_SOURCE_HASH: &str = + "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + +fn origin(value: &str) -> Result { + Origin::parse(value).map_err(|error| format!("{error:?}")) +} + +fn node(source_origin: &Origin) -> Result { + ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + source_origin.clone(), + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string()) +} + +fn provenance( + source_url: &str, + locator: &str, + source_kind: EvidenceSourceKind, + verification_result: VerificationResult, +) -> Result { + ProvenanceRecord::new( + source_url, + locator, + VALID_SOURCE_HASH, + source_kind, + verification_result, + ) + .map_err(|error| format!("{error:?}")) +} + +fn valid_evidence() -> Result { + let source_origin = origin("https://app.example")?; + StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + provenance( + "https://app.example/form", + "ax:submit-result", + EvidenceSourceKind::AccessibilityTree, + VerificationResult::Verified, + )?, + provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + ) + .map_err(|error| error.to_string()) +} + +#[test] +fn structured_value_binds_digest_exact_node_and_verified_network_source() -> Result<(), String> { + let evidence = valid_evidence()?; + + assert_eq!(evidence.field_name(), "task_status"); + assert_eq!(evidence.value_hash(), VALID_VALUE_HASH); + assert_eq!(evidence.source_node().node_id(), 17); + assert_eq!( + evidence.node_provenance().source_kind(), + EvidenceSourceKind::AccessibilityTree + ); + assert_eq!( + evidence.network_provenance().source_kind(), + EvidenceSourceKind::NetworkResponse + ); + assert_eq!( + evidence.node_provenance().verification_result(), + VerificationResult::Verified + ); + assert_eq!( + evidence.network_provenance().verification_result(), + VerificationResult::Verified + ); + Ok(()) +} + +#[test] +fn malformed_field_identifiers_fail_closed() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let node_source = provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?; + let network_source = provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?; + + for field_name in ["", "---", "task status", "task/status"] { + let error = StructuredValueEvidence::new( + field_name, + VALID_VALUE_HASH, + node(&source_origin)?, + node_source.clone(), + network_source.clone(), + ) + .err() + .ok_or_else(|| format!("invalid field name unexpectedly accepted: {field_name:?}"))?; + assert_eq!(error, StructuredValueEvidenceError::InvalidFieldName); + } + + let oversized = "a".repeat(MAX_STRUCTURED_FIELD_NAME_BYTES + 1); + let error = StructuredValueEvidence::new( + &oversized, + VALID_VALUE_HASH, + node(&source_origin)?, + node_source, + network_source, + ) + .err() + .ok_or_else(|| "oversized field name unexpectedly accepted".to_owned())?; + assert_eq!(error, StructuredValueEvidenceError::InvalidFieldName); + Ok(()) +} + +#[test] +fn malformed_value_digest_fails_closed() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let error = StructuredValueEvidence::new( + "task_status", + "sha256:ABCDEF", + node(&source_origin)?, + provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?, + provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + ) + .err() + .ok_or_else(|| "malformed value digest unexpectedly accepted".to_owned())?; + + assert_eq!(error, StructuredValueEvidenceError::InvalidValueHash); + Ok(()) +} + +#[test] +fn node_provenance_must_be_verified_node_evidence() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let network_source = provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?; + + let unverified_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + provenance( + "https://app.example/form", + "ax:submit-result", + EvidenceSourceKind::AccessibilityTree, + VerificationResult::Unverified, + )?, + network_source.clone(), + ) + .err() + .ok_or_else(|| "unverified node provenance unexpectedly accepted".to_owned())?; + assert_eq!( + unverified_error, + StructuredValueEvidenceError::NodeProvenanceNotVerified + ); + + let wrong_kind_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + provenance( + "https://app.example/api/task", + "response:other", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + network_source, + ) + .err() + .ok_or_else(|| "network evidence unexpectedly accepted as node provenance".to_owned())?; + assert_eq!( + wrong_kind_error, + StructuredValueEvidenceError::NodeProvenanceKindMismatch + ); + Ok(()) +} + +#[test] +fn network_provenance_must_be_verified_network_evidence() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let node_source = provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?; + + let unverified_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + node_source.clone(), + provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Rejected, + )?, + ) + .err() + .ok_or_else(|| "rejected network provenance unexpectedly accepted".to_owned())?; + assert_eq!( + unverified_error, + StructuredValueEvidenceError::NetworkProvenanceNotVerified + ); + + let wrong_kind_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + node(&source_origin)?, + node_source.clone(), + provenance( + "https://app.example/form", + "dom:#network-lookalike", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?, + ) + .err() + .ok_or_else(|| "DOM evidence unexpectedly accepted as network provenance".to_owned())?; + assert_eq!( + wrong_kind_error, + StructuredValueEvidenceError::NetworkProvenanceKindMismatch + ); + Ok(()) +} + +#[test] +fn provenance_from_another_origin_cannot_prove_the_node_value() -> Result<(), String> { + let source_origin = origin("https://app.example")?; + let source_node = node(&source_origin)?; + let valid_node_source = provenance( + "https://app.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?; + let valid_network_source = provenance( + "https://app.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?; + + let node_origin_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + source_node.clone(), + provenance( + "https://other.example/form", + "dom:#status", + EvidenceSourceKind::DomTree, + VerificationResult::Verified, + )?, + valid_network_source.clone(), + ) + .err() + .ok_or_else(|| "cross-origin node provenance unexpectedly accepted".to_owned())?; + assert_eq!( + node_origin_error, + StructuredValueEvidenceError::SourceOriginMismatch + ); + + let network_origin_error = StructuredValueEvidence::new( + "task_status", + VALID_VALUE_HASH, + source_node, + valid_node_source, + provenance( + "https://other.example/api/task", + "response:task-status", + EvidenceSourceKind::NetworkResponse, + VerificationResult::Verified, + )?, + ) + .err() + .ok_or_else(|| "cross-origin network provenance unexpectedly accepted".to_owned())?; + assert_eq!( + network_origin_error, + StructuredValueEvidenceError::SourceOriginMismatch + ); + Ok(()) +} + +#[test] +fn structured_value_errors_are_stable_and_source_free() { + let cases = [ + ( + StructuredValueEvidenceError::InvalidFieldName, + "invalid structured field name", + ), + ( + StructuredValueEvidenceError::InvalidValueHash, + "invalid structured value hash", + ), + ( + StructuredValueEvidenceError::NodeProvenanceNotVerified, + "node provenance is not verified", + ), + ( + StructuredValueEvidenceError::NodeProvenanceKindMismatch, + "node provenance is not DOM or accessibility evidence", + ), + ( + StructuredValueEvidenceError::NetworkProvenanceNotVerified, + "network provenance is not verified", + ), + ( + StructuredValueEvidenceError::NetworkProvenanceKindMismatch, + "network provenance is not network-response evidence", + ), + ( + StructuredValueEvidenceError::SourceOriginMismatch, + "provenance origin does not match source node origin", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(std::error::Error::source(&error).is_none()); + } +}