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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/originweave-evidence/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

mod capture_manifest;
mod extraction_schema;
mod offline_replay;
mod sensitive_access;
mod warc_prov_bundle;
mod warc_resource_record;
Expand All @@ -23,6 +24,9 @@ pub use extraction_schema::{
ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType,
MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES,
};
pub use offline_replay::{
OfflineReplayVerification, OfflineReplayVerificationError, verify_offline_capture_package,
};
pub use sensitive_access::{
MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass,
SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome,
Expand Down
95 changes: 95 additions & 0 deletions crates/originweave-evidence/src/offline_replay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use std::fmt;

use crate::{
CaptureManifest, CaptureManifestValueBinding, CaptureManifestVerificationError,
ExtractionSchema, WarcProvBundle, WarcResourceRecord,
};

/// Credential-safe receipt proving one in-memory capture package matched its persisted identity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineReplayVerification {
manifest_digest: String,
record_count: usize,
value_count: usize,
}

impl OfflineReplayVerification {
/// Return the SHA-256 identity of the exact deterministic capture manifest.
#[must_use]
pub fn manifest_digest(&self) -> &str {
&self.manifest_digest
}

/// Return the number of WARC/PROV record pairs verified by this receipt.
#[must_use]
pub const fn record_count(&self) -> usize {
self.record_count
}

/// Return the number of schema-bound structured-value identities verified by this receipt.
#[must_use]
pub const fn value_count(&self) -> usize {
self.value_count
}
}

/// A fail-closed offline capture-package verification failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfflineReplayVerificationError {
/// Persisted manifest bytes were not the exact deterministic serialization expected in memory.
ManifestBytes(CaptureManifestVerificationError),
/// Schema, WARC/PROV evidence, or structured-value identity did not match the expected manifest.
Evidence(CaptureManifestVerificationError),
}

impl fmt::Display for OfflineReplayVerificationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ManifestBytes(error) => write!(
formatter,
"offline replay persisted manifest bytes failed verification: {error}"
),
Self::Evidence(error) => write!(
formatter,
"offline replay capture evidence failed verification: {error}"
),
}
}
}

impl std::error::Error for OfflineReplayVerificationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ManifestBytes(error) | Self::Evidence(error) => Some(error),
}
}
}

/// Verify one already-materialized capture package without contacting or executing its source.
///
/// Verification first requires `persisted_manifest_bytes` to equal the expected deterministic
/// manifest serialization byte-for-byte. It then reconstructs and verifies the schema-bound
/// WARC/PROV/value identity through [`CaptureManifest::verify_with_warc_values`]. The operation is
/// deliberately in-memory only: it performs no DNS, network, browser, JavaScript, external-reference,
/// secret, persistence, retention, signing, or authorization action and does not establish factual
/// correctness beyond the supplied evidence contracts.
pub fn verify_offline_capture_package(
expected_manifest: &CaptureManifest,
persisted_manifest_bytes: &[u8],
schema: &ExtractionSchema,
records: &[(&WarcResourceRecord, &WarcProvBundle)],
values: &[CaptureManifestValueBinding],
) -> Result<OfflineReplayVerification, OfflineReplayVerificationError> {
expected_manifest
.verify_serialized_json(persisted_manifest_bytes)
.map_err(OfflineReplayVerificationError::ManifestBytes)?;
expected_manifest
.verify_with_warc_values(schema, records, values)
.map_err(OfflineReplayVerificationError::Evidence)?;

Ok(OfflineReplayVerification {
manifest_digest: expected_manifest.manifest_digest(),
record_count: expected_manifest.records().len(),
value_count: expected_manifest.values().len(),
})
}
195 changes: 195 additions & 0 deletions crates/originweave-evidence/tests/offline_capture_replay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
#![allow(clippy::expect_used)]

use originweave_evidence::{
CaptureManifest, CaptureManifestError, CaptureManifestValueBinding,
CaptureManifestVerificationError, EvidenceSourceKind, ExtractionCardinality, ExtractionField,
ExtractionSchema, ExtractionSourceChannel, ExtractionValueType, OfflineReplayVerificationError,
ProvenanceRecord, VerificationResult, WarcProvBundle, WarcResourceRecord,
verify_offline_capture_package,
};

const SOURCE_HASH: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const VALUE_HASH: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const DRIFTED_VALUE_HASH: &str =
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const RECORD_ID: &str = "urn:uuid:123e4567-e89b-12d3-a456-426614174000";
const DATE: &str = "2026-08-26T00:00:00Z";
const SOFTWARE_COMMIT_SHA: &str = "0123456789abcdef0123456789abcdef01234567";

fn schema() -> ExtractionSchema {
let title = ExtractionField::new(
"title",
ExtractionValueType::Text,
ExtractionCardinality::One,
true,
&[ExtractionSourceChannel::NetworkResponse],
)
.expect("title field");
ExtractionSchema::new("catalog-v3", vec![title]).expect("schema")
}

fn resource_record() -> WarcResourceRecord {
let provenance = ProvenanceRecord::new(
"https://example.com/item",
"body",
SOURCE_HASH,
EvidenceSourceKind::NetworkResponse,
VerificationResult::Verified,
)
.expect("verified provenance");
WarcResourceRecord::new(
RECORD_ID,
DATE,
"https://example.com/item",
"text/plain",
b"captured-payload".to_vec(),
provenance,
)
.expect("WARC record")
}

#[test]
fn offline_replay_verifies_exact_manifest_evidence_and_structured_result() {
let schema = schema();
let record = resource_record();
let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle");
let value =
CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding");
let manifest = CaptureManifest::new_with_warc_values(
&schema,
&[(&record, &bundle)],
std::slice::from_ref(&value),
)
.expect("manifest");
let serialized_manifest = manifest.to_json();

let verification = verify_offline_capture_package(
&manifest,
serialized_manifest.as_bytes(),
&schema,
&[(&record, &bundle)],
std::slice::from_ref(&value),
)
.expect("offline replay verification");

assert_eq!(verification.manifest_digest(), manifest.manifest_digest());
assert_eq!(verification.record_count(), 1);
assert_eq!(verification.value_count(), 1);
}

#[test]
fn offline_replay_rejects_persisted_manifest_byte_drift_before_evidence_replay() {
let schema = schema();
let record = resource_record();
let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle");
let value =
CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding");
let manifest = CaptureManifest::new_with_warc_values(
&schema,
&[(&record, &bundle)],
std::slice::from_ref(&value),
)
.expect("manifest");
let mut serialized_manifest = manifest.to_json().into_bytes();
serialized_manifest.push(b' ');

assert_eq!(
verify_offline_capture_package(
&manifest,
&serialized_manifest,
&schema,
&[(&record, &bundle)],
std::slice::from_ref(&value),
),
Err(OfflineReplayVerificationError::ManifestBytes(
CaptureManifestVerificationError::IdentityMismatch,
))
);
}

#[test]
fn offline_replay_rejects_structured_result_identity_drift() {
let schema = schema();
let record = resource_record();
let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle");
let value =
CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding");
let drifted_value = CaptureManifestValueBinding::new("title", DRIFTED_VALUE_HASH, RECORD_ID)
.expect("drifted value binding");
let manifest = CaptureManifest::new_with_warc_values(
&schema,
&[(&record, &bundle)],
std::slice::from_ref(&value),
)
.expect("manifest");
let serialized_manifest = manifest.to_json();

assert_eq!(
verify_offline_capture_package(
&manifest,
serialized_manifest.as_bytes(),
&schema,
&[(&record, &bundle)],
std::slice::from_ref(&drifted_value),
),
Err(OfflineReplayVerificationError::Evidence(
CaptureManifestVerificationError::IdentityMismatch,
))
);
}

#[test]
fn offline_replay_rejects_missing_warc_evidence() {
let schema = schema();
let record = resource_record();
let bundle = WarcProvBundle::new(&record, SOFTWARE_COMMIT_SHA).expect("PROV bundle");
let value =
CaptureManifestValueBinding::new("title", VALUE_HASH, RECORD_ID).expect("value binding");
let manifest = CaptureManifest::new_with_warc_values(
&schema,
&[(&record, &bundle)],
std::slice::from_ref(&value),
)
.expect("manifest");
let serialized_manifest = manifest.to_json();

assert_eq!(
verify_offline_capture_package(
&manifest,
serialized_manifest.as_bytes(),
&schema,
&[],
std::slice::from_ref(&value),
),
Err(OfflineReplayVerificationError::Evidence(
CaptureManifestVerificationError::InvalidCandidate(CaptureManifestError::MissingRecord),
))
);
}

#[test]
fn offline_replay_errors_preserve_typed_diagnostics_and_sources() {
let manifest_error = OfflineReplayVerificationError::ManifestBytes(
CaptureManifestVerificationError::IdentityMismatch,
);
let evidence_error = OfflineReplayVerificationError::Evidence(
CaptureManifestVerificationError::IdentityMismatch,
);

assert_eq!(
manifest_error.to_string(),
"offline replay persisted manifest bytes failed verification: capture manifest identity does not match"
);
assert_eq!(
evidence_error.to_string(),
"offline replay capture evidence failed verification: capture manifest identity does not match"
);
assert_eq!(
std::error::Error::source(&manifest_error).map(ToString::to_string),
Some("capture manifest identity does not match".to_owned())
);
assert_eq!(
std::error::Error::source(&evidence_error).map(ToString::to_string),
Some("capture manifest identity does not match".to_owned())
);
}
Loading