#![forbid(unsafe_code)]
#[path = "c2pa-cbor/lib.rs"]
#[allow(dead_code, reason = "production kernel mirror")]
mod c2pa_cbor;
#[path = "c2pa-core/lib.rs"]
#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
mod c2pa_core;
#[path = "c2pa-crypto/lib.rs"]
#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
mod c2pa_crypto;
#[path = "c2pa-formats/lib.rs"]
#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
mod c2pa_formats;
#[path = "c2pa-trust/lib.rs"]
#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
mod c2pa_trust;
#[path = "c2pa-validate/lib.rs"]
#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
mod c2pa_validate;
pub use c2pa_validate::external_discovery::{
locate_external_resources, locate_external_resources_with_manifest_store,
ExternalDidDocumentRequest, ExternalResourceKind, ExternalResourcePlan,
ExternalResourceRequest, ExternalResourceStopReason, GraphLimitKind,
};
pub use c2pa_validate::ingredient_graph::{GraphInputs, GraphLimits};
pub use c2pa_validate::refs::{ExternalResource, ExternalResources};
mod config_dir;
mod default_trust;
mod ertmp;
mod ertmp_wire;
mod native_online;
mod online;
mod online_consent;
pub use ertmp::{
verify_ertmp, verify_ertmp_with_options, ErtmpEvent, ErtmpInput, ErtmpManifestReport,
ErtmpMediaType, ErtmpSegmentReport, ErtmpTrack, ErtmpVerificationReport,
};
pub use ertmp_wire::{
ertmp_input_from_flv, ertmp_input_from_rtmp_connections, ErtmpConnectionContext,
ErtmpWireError, ErtmpWireEventContext, ErtmpWireInput, ErtmpWireLimits, FlvWireOptions,
RtmpWireConnection,
};
mod soft_binding;
pub use soft_binding::{
request_soft_binding_receipt_verification, resolve_soft_binding, RecoveredSoftBindingManifest,
SoftBindingAlgorithm, SoftBindingAlgorithms, SoftBindingCapabilities,
SoftBindingDisclosureConsent, SoftBindingDiscovery, SoftBindingFetchRequest,
SoftBindingHttpMethod, SoftBindingMatch, SoftBindingQuery, SoftBindingReceiptEvidence,
SoftBindingReceiptVerificationSession, SoftBindingResolution, SoftBindingResolverOptions,
SoftBindingResolverSession,
};
mod stream;
pub use stream::{
verify_stream, verify_stream_with_options, SegmentReport, StreamEncapsulation, StreamMethod,
StreamVerificationReport,
};
#[cfg(feature = "caller-verified-binding")]
mod caller_verified_binding;
mod telemetry;
mod telemetry_consent;
pub use c2pa_trust::TrustSource;
#[cfg(feature = "caller-verified-binding")]
pub use caller_verified_binding::{
binding_target, verify_fragmented_with_cawg, verify_manifest_store_only_with_cawg,
verify_with_caller_verified_binding, verify_with_caller_verified_binding_and_cawg,
verify_with_cawg, verify_with_manifest_store_and_cawg, CallerBindingEvidence,
CallerBindingVerdict, CawgEvaluation, CawgEvaluator, CawgProfile, CawgStoreHost,
ManifestBindingTarget,
};
pub use config_dir::config_directory;
pub use default_trust::SNAPSHOT_DATE as DEFAULT_TRUST_SNAPSHOT_DATE;
pub use online::{NetworkReport, NetworkRequest};
pub use online_consent::{
allow_interactive_online_consent, online_preference, set_online_preference, OnlinePreference,
OnlinePreferenceError,
};
pub use telemetry::{
validation_failure_telemetry, TelemetryOptions, ValidationFailureTelemetry,
DEFAULT_TELEMETRY_ENDPOINT,
};
pub use telemetry_consent::{
prompt_for_telemetry_consent, set_telemetry_enabled, telemetry_preference,
TelemetryPreferenceError,
};
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use crate::c2pa_core::{
spec::{canonicalize_mime, mimes_for_version},
ComplianceLevel, EngineProfile, OperatingMode, SpecVersion,
};
use crate::c2pa_trust::{AnchorPurpose, CawgTrustSource, TrustList};
use crate::c2pa_validate::{
verify_fragmented_with_cawg_options_and_expected_seeks_safe as verify_fragmented_safe,
verify_with_cawg_options_and_graph_inputs_safe as verify_graph_safe,
verify_with_cawg_options_safe as verify_safe, StatusCode as CoreStatus,
ValidationResults as CoreResults, VerifyInput, ASSERTION_BMFF_HASH_MALFORMED,
ASSERTION_BMFF_HASH_MATCH, ASSERTION_BMFF_HASH_MISMATCH, ASSERTION_BOXES_HASH_MALFORMED,
ASSERTION_BOXES_HASH_MATCH, ASSERTION_BOXES_HASH_MISMATCH, ASSERTION_COLLECTION_HASH_MALFORMED,
ASSERTION_COLLECTION_HASH_MATCH, ASSERTION_COLLECTION_HASH_MISMATCH, ASSERTION_DATA_HASH_MATCH,
ASSERTION_DATA_HASH_MISMATCH, ASSERTION_MULTI_ASSET_HASH_MALFORMED,
ASSERTION_MULTI_ASSET_HASH_MATCH, ASSERTION_MULTI_ASSET_HASH_MISMATCH,
CLAIM_HARD_BINDINGS_MISSING, CLAIM_SIGNATURE_MISMATCH, CLAIM_SIGNATURE_MISSING,
CLAIM_SIGNATURE_VALIDATED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_OCSP_NOT_REVOKED,
SIGNING_CREDENTIAL_OCSP_REVOKED, SIGNING_CREDENTIAL_TRUSTED, SIGNING_CREDENTIAL_UNTRUSTED,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
pub const REPORT_SCHEMA_VERSION: &str = "1.0";
pub const C2PA_PROFILE_2_2: &str = "c2pa-2.2";
pub const C2PA_PROFILE: &str = "c2pa-2.4";
pub const C2PA_PROFILE_2_5: &str = "c2pa-2.5";
pub const SPEC_VERSIONS: &[&str] = &SpecVersion::PUBLIC_POLICY_STRINGS;
const MAX_MANIFEST_STORE_BYTES: usize = 64 * 1024 * 1024;
const MAX_PATH_ASSET_BYTES: u64 = 128 * 1024 * 1024;
const MAX_OCSP_EVIDENCE_BYTES: usize = 64 * 1024;
const MAX_EXTERNAL_DATA_EVIDENCE_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VerifyOptions {
pub trust_pem: Option<String>,
pub claim_trust_sources: Option<Vec<TrustListSource>>,
pub tsa_trust_pem: Option<String>,
pub tsa_trust_sources: Option<Vec<TrustListSource>>,
pub allowed_list_pem: Option<String>,
pub cawg_trust_pem: Option<String>,
pub cawg_allowed_certs_pem: Option<String>,
pub cawg_document_signing_require_anchor: Option<bool>,
pub cawg_trust_configurations: Option<Vec<CawgTrustConfiguration>>,
pub trust_anchor_not_before: Option<String>,
pub trust_anchor_not_after: Option<String>,
pub no_default_trust: bool,
pub cawg_did_documents: Option<HashMap<String, Value>>,
pub cawg_ica_trusted_issuers: Option<Vec<String>>,
pub cawg_ica_trust_anchors: Option<Vec<String>>,
pub cawg_ica_status_lists: Option<HashMap<String, String>>,
pub ocsp_responses: Option<HashMap<String, String>>,
pub ocsp_unreachable: Option<Vec<String>>,
pub external_data: Option<HashMap<String, String>>,
pub external_data_media_types: Option<HashMap<String, String>>,
pub external_data_unreachable: Option<Vec<String>>,
pub expected_seek_positions: Vec<usize>,
pub spec_version: Option<String>,
pub strict_conformance: bool,
pub cawg_strict_encoding: bool,
pub validation_time: Option<String>,
pub stream_presentation_times: Option<Vec<String>>,
pub telemetry: TelemetryOptions,
pub online: Option<bool>,
pub online_allow_private_networks: bool,
}
#[derive(Debug, Default)]
pub struct ValidatedExternalData {
pub data: HashMap<String, Vec<u8>>,
pub media_types: HashMap<String, String>,
pub unreachable: Vec<String>,
}
impl VerifyOptions {
pub fn validate(&self) -> Result<(), Error> {
self.resolve_and_validate()?;
Ok(())
}
pub fn validate_with_external_data(&self) -> Result<ValidatedExternalData, Error> {
let resolved = self.resolve_and_validate()?;
Ok(ValidatedExternalData {
data: resolved.external_data,
media_types: resolved.external_data_media_types,
unreachable: resolved.external_data_unreachable,
})
}
fn resolve_and_validate(&self) -> Result<ResolvedOptions, Error> {
let resolved = ResolvedOptions::resolve(self)?;
for (index, raw) in self.stream_presentation_times.iter().flatten().enumerate() {
parse_stream_presentation_time(raw, index)?;
}
Ok(resolved)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TrustListSource {
pub uri: String,
#[serde(rename = "type")]
pub source_type: String,
#[serde(default)]
pub identifier: Option<String>,
pub certificates_pem: String,
#[serde(default)]
pub not_before: Option<String>,
#[serde(default)]
pub not_after: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CawgTrustConfiguration {
pub profile: CawgTrustProfile,
pub certificates_pem: String,
#[serde(default)]
pub not_before: Option<String>,
#[serde(default)]
pub not_after: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CawgTrustProfile {
Base,
SmimeInterim,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct VerificationStatus {
pub code: String,
#[serde(default)]
pub url: String,
#[serde(default, rename = "resourceUrl")]
pub resource_url: Option<String>,
pub explanation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl Serialize for VerificationStatus {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
crate::c2pa_validate::serialize_status(
serializer,
&self.code,
&self.url,
self.resource_url.as_deref(),
&self.explanation,
self.details.as_ref(),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ValidationResults {
pub success: Vec<VerificationStatus>,
pub informational: Vec<VerificationStatus>,
pub failure: Vec<VerificationStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trust_list_sources: Vec<TrustSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RevocationReport {
pub status: String,
pub source: String,
pub responder_signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FreshnessReport {
pub status: String,
pub as_of: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustReport {
pub status: String,
pub basis: String,
pub validation_time: String,
pub revocation: RevocationReport,
pub freshness: FreshnessReport,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationReport {
pub schema_version: String,
pub profile: String,
pub mime_type: String,
pub present: bool,
pub integrity: String,
pub signature: String,
pub hard_binding: String,
pub trust: TrustReport,
pub policy: Option<Value>,
pub managed_receipt: Option<Value>,
pub validation_state: String,
pub validation_results: ValidationResults,
pub manifest_report: Value,
pub content_credentials: Option<Value>,
#[serde(default)]
pub network: NetworkReport,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetachedManifestEvidence {
pub manifest_store: Vec<u8>,
pub manifest_store_sha256: String,
pub carrier: Vec<u8>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalEvidence {
pub report: VerificationReport,
#[serde(serialize_with = "serialize_optional_bytes")]
pub manifest_store: Option<Vec<u8>>,
pub manifest_store_sha256: Option<String>,
pub asset_sha256: String,
pub hard_binding: LocalHardBinding,
pub active_manifest_label: Option<String>,
pub store_manifests: Vec<ManifestDigest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ManifestDigest {
pub label: String,
pub sha256: String,
}
fn manifest_identity(manifest_store: Option<&[u8]>) -> (Option<String>, Vec<ManifestDigest>) {
let Some(store) = manifest_store else {
return (None, Vec::new());
};
match std::panic::catch_unwind(|| crate::c2pa_validate::store_manifest_identity(store)) {
Ok(Ok((active, digests))) => (
active,
digests
.into_iter()
.map(|digest| ManifestDigest {
label: digest.label,
sha256: hex::encode(digest.sha256),
})
.collect(),
),
_ => (None, Vec::new()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalHardBinding {
pub algorithm: Option<String>,
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StoreOnlyValidation {
pub report: VerificationReport,
pub active_manifest_label: Option<String>,
pub store_manifests: Vec<ManifestDigest>,
}
fn serialize_optional_bytes<S: serde::Serializer>(
value: &Option<Vec<u8>>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match value {
Some(bytes) => serializer.serialize_bytes(bytes),
None => serializer.serialize_none(),
}
}
#[derive(Default)]
struct PassCapture {
manifest_store: Option<Vec<u8>>,
hard_binding_label: Option<String>,
}
impl PassCapture {
fn take(output: &mut crate::c2pa_validate::VerifyOutput) -> Self {
Self {
manifest_store: output.manifest_store.take(),
hard_binding_label: output.hard_binding_label.take(),
}
}
}
impl VerificationReport {
pub fn to_json(&self) -> Result<String, Error> {
serde_json::to_string(self).map_err(Error::Serialize)
}
pub fn to_pretty_json(&self) -> Result<String, Error> {
serde_json::to_string_pretty(self).map_err(Error::Serialize)
}
pub fn cawg_statuses(&self) -> Vec<&VerificationStatus> {
self.validation_results
.success
.iter()
.chain(&self.validation_results.informational)
.chain(&self.validation_results.failure)
.filter(|status| status.code.starts_with("cawg."))
.collect()
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unsupported MIME type: {0}")]
UnsupportedMime(String),
#[error("invalid trust material: {0}")]
InvalidTrust(String),
#[error("invalid validation time: {0}")]
InvalidValidationTime(String),
#[error("unsupported spec version: {0}")]
UnsupportedSpecVersion(String),
#[error("verification failed: {0}")]
Verification(String),
#[error("could not read asset: {0}")]
Io(#[from] std::io::Error),
#[error(transparent)]
TelemetryPreference(#[from] TelemetryPreferenceError),
#[error(transparent)]
OnlinePreference(#[from] OnlinePreferenceError),
#[error("could not serialize report: {0}")]
Serialize(serde_json::Error),
}
impl Error {
pub fn code(&self) -> &'static str {
match self {
Self::UnsupportedMime(_) => "unsupported_mime",
Self::InvalidTrust(_) => "invalid_trust_material",
Self::InvalidValidationTime(_) => "invalid_validation_time",
Self::UnsupportedSpecVersion(_) => "unsupported_spec_version",
Self::Verification(_) => "verification_error",
Self::Io(_) => "io_error",
Self::OnlinePreference(_) => "online_preference_error",
Self::TelemetryPreference(_) => "telemetry_preference_error",
Self::Serialize(_) => "serialization_error",
}
}
}
pub fn verify(data: &[u8], mime_type: &str) -> Result<VerificationReport, Error> {
verify_with_options(data, mime_type, &VerifyOptions::default())
}
pub fn verify_fragmented(
init_segment: &[u8],
fragments: &[&[u8]],
mime_type: &str,
) -> Result<VerificationReport, Error> {
verify_fragmented_with_options(
init_segment,
fragments,
mime_type,
&VerifyOptions::default(),
)
}
pub fn detached_manifest_evidence(
data: &[u8],
mime_type: &str,
) -> Result<Option<DetachedManifestEvidence>, Error> {
detached_manifest_evidence_with_options(data, mime_type, &VerifyOptions::default())
}
pub fn detached_manifest_evidence_with_options(
data: &[u8],
mime_type: &str,
options: &VerifyOptions,
) -> Result<Option<DetachedManifestEvidence>, Error> {
let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
let mime = canonicalize_mime(mime_type);
require_embedded_mime(&mime, profile)?;
let format = crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime)
.ok_or_else(|| Error::UnsupportedMime(mime.clone()))?;
if !crate::c2pa_formats::supports_hash_mode(&mime) {
return Ok(None);
}
let Some(manifest_store) =
crate::c2pa_formats::extract_manifest_for_spec(format, data, profile.version)
.map_err(|error| Error::Verification(error.to_string()))?
else {
return Ok(None);
};
let spans =
crate::c2pa_formats::compute_data_hash_exclusions_for_spec(format, data, profile.version)
.map_err(|error| Error::Verification(error.to_string()))?;
let [span] = spans.as_slice() else {
return Ok(None);
};
let end = span
.start
.checked_add(span.length)
.filter(|end| *end <= data.len())
.ok_or_else(|| Error::Verification("manifest carrier exceeds asset bounds".into()))?;
let manifest_store_sha256 = hex::encode(Sha256::digest(&manifest_store));
Ok(Some(DetachedManifestEvidence {
manifest_store,
manifest_store_sha256,
carrier: data[span.start..end].to_vec(),
}))
}
pub fn verify_with_manifest_store(
asset: &[u8],
manifest_store: &[u8],
mime_type: &str,
options: &VerifyOptions,
) -> Result<VerificationReport, Error> {
let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
let result = verify_with_manifest_store_inner(asset, manifest_store, mime_type, options);
enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
result
}
pub fn verify_with_manifest_store_and_graph_inputs(
asset: &[u8],
manifest_store: &[u8],
mime_type: &str,
options: &VerifyOptions,
graph_inputs: GraphInputs<'_>,
) -> Result<VerificationReport, Error> {
let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
let result = (|| {
let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
let mime = resolve_detached_mime(mime_type, profile)?;
detached_pass(asset, manifest_store, &mime, options, graph_inputs)
.map(|(report, _, _)| report)
})();
enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
result
}
fn verify_with_manifest_store_inner(
asset: &[u8],
manifest_store: &[u8],
mime_type: &str,
options: &VerifyOptions,
) -> Result<VerificationReport, Error> {
let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
let mime = resolve_detached_mime(mime_type, profile)?;
if profile.version == SpecVersion::V2_5 {
let resolved = ResolvedOptions::resolve(options)?;
return verify_detached_online_v25(asset, manifest_store, &mime, options, resolved);
}
let (mut report, needs, _) = detached_pass(
asset,
manifest_store,
&mime,
options,
GraphInputs::default(),
)?;
let (network, evidence) = online::gather(options, &needs);
if evidence.is_empty() {
report.network = network;
return Ok(report);
}
let next = online::apply_evidence(options, &evidence);
let (mut report, _, _) =
detached_pass(asset, manifest_store, &mime, &next, GraphInputs::default())?;
report.network = network;
Ok(report)
}
fn detached_pass(
asset: &[u8],
manifest_store: &[u8],
mime: &str,
options: &VerifyOptions,
graph_inputs: GraphInputs<'_>,
) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
let resolved = ResolvedOptions::resolve(options)?;
detached_pass_resolved(
asset,
manifest_store,
mime,
options,
&resolved,
options.cawg_did_documents.as_ref(),
graph_inputs,
)
}
fn detached_pass_resolved(
asset: &[u8],
manifest_store: &[u8],
mime: &str,
options: &VerifyOptions,
resolved: &ResolvedOptions,
did_documents: Option<&HashMap<String, Value>>,
graph_inputs: GraphInputs<'_>,
) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
if manifest_store.is_empty() {
return Err(Error::Verification("manifest store is empty".into()));
}
if manifest_store.len() > MAX_MANIFEST_STORE_BYTES {
return Err(Error::Verification(format!(
"manifest store exceeds the {MAX_MANIFEST_STORE_BYTES} byte limit"
)));
}
let input = resolved.input(asset, mime);
let mut output = crate::c2pa_validate::verify_detached_safe(
manifest_store,
asset,
mime,
&input,
resolved.cawg_trust(),
resolved.cawg_allowed_certs(),
resolved.cawg_document_signing_require_anchor,
did_documents,
options.cawg_ica_trusted_issuers.as_deref(),
options.cawg_ica_trust_anchors.as_deref(),
options.cawg_ica_status_lists.as_ref(),
graph_inputs,
)
.map_err(map_validate_error)?;
let needs = online::network_needs(&output);
let capture = PassCapture::take(&mut output);
Ok((
report_from_output(output, mime.to_string(), resolved),
needs,
capture,
))
}
pub fn verify_manifest_store_only(
manifest_store: &[u8],
options: &VerifyOptions,
) -> Result<StoreOnlyValidation, Error> {
verify_manifest_store_only_with_graph_inputs(manifest_store, options, GraphInputs::default())
}
pub fn verify_manifest_store_only_with_graph_inputs(
manifest_store: &[u8],
options: &VerifyOptions,
graph_inputs: GraphInputs<'_>,
) -> Result<StoreOnlyValidation, Error> {
if options.online == Some(true) {
return Err(Error::Verification(
"online must not be Some(true): store-only validation never fetches".into(),
));
}
let resolved = ResolvedOptions::resolve(options)?;
if manifest_store.is_empty() {
return Err(Error::Verification("manifest store is empty".into()));
}
if manifest_store.len() > MAX_MANIFEST_STORE_BYTES {
return Err(Error::Verification(format!(
"manifest store exceeds the {MAX_MANIFEST_STORE_BYTES} byte limit"
)));
}
let input = resolved.input(&[], "application/c2pa");
let cawg_inputs = crate::c2pa_validate::CawgTrustInputs::new(
resolved.cawg_trust(),
resolved.cawg_allowed_certs(),
resolved.cawg_document_signing_require_anchor,
options.cawg_did_documents.as_ref(),
options.cawg_ica_trusted_issuers.as_deref(),
options.cawg_ica_trust_anchors.as_deref(),
options.cawg_ica_status_lists.as_ref(),
graph_inputs,
);
let output = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::c2pa_validate::verify_store_binding(
manifest_store,
crate::c2pa_validate::StoreBinding::StoreOnly,
&input,
cawg_inputs,
)
}))
.map_err(|_| Error::Verification("verification panicked".into()))?
.map_err(map_validate_error)?;
Ok(finish_store_only_validation(
manifest_store,
output,
&resolved,
))
}
fn finish_store_only_validation(
manifest_store: &[u8],
output: crate::c2pa_validate::VerifyOutput,
resolved: &ResolvedOptions,
) -> StoreOnlyValidation {
let mut report = report_from_output(output, "application/c2pa".to_string(), resolved);
report.hard_binding = "not_evaluated".to_string();
if report.integrity == "valid" {
report.integrity = "unbound".to_string();
if let Some(verdict) = report
.manifest_report
.pointer_mut("/provenance_verdict")
.and_then(Value::as_object_mut)
{
verdict.insert("integrity".to_string(), Value::from("unbound"));
}
}
let (active_manifest_label, store_manifests) = manifest_identity(Some(manifest_store));
StoreOnlyValidation {
report,
active_manifest_label,
store_manifests,
}
}
pub fn verify_with_graph_inputs(
data: &[u8],
mime_type: &str,
options: &VerifyOptions,
graph_inputs: GraphInputs<'_>,
) -> Result<VerificationReport, Error> {
let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
let result = (|| {
let resolved = ResolvedOptions::resolve(options)?;
embedded_graph_pass(data, None, mime_type, options, &resolved, graph_inputs)
})();
enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
result
}
pub fn verify_with_options(
data: &[u8],
mime_type: &str,
options: &VerifyOptions,
) -> Result<VerificationReport, Error> {
let resolved = ResolvedOptions::resolve(options)?;
verify_with_resolved_options(data, None, mime_type, options, resolved)
}
pub fn local_evidence_with_options(
data: &[u8],
mime_type: &str,
options: &VerifyOptions,
) -> Result<LocalEvidence, Error> {
let resolved = ResolvedOptions::resolve(options)?;
let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
let (result, capture) =
match verify_with_options_inner(data, None, mime_type, options, resolved) {
Ok((report, capture)) => (Ok(report), capture),
Err(error) => (Err(error), PassCapture::default()),
};
enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
let report = result?;
let status = match report.hard_binding.as_str() {
"match" => "match",
"mismatch" => "mismatch",
_ => "unknown",
};
let (active_manifest_label, store_manifests) =
manifest_identity(capture.manifest_store.as_deref());
Ok(LocalEvidence {
manifest_store_sha256: capture
.manifest_store
.as_deref()
.map(|store| hex::encode(Sha256::digest(store))),
manifest_store: capture.manifest_store,
asset_sha256: hex::encode(Sha256::digest(data)),
hard_binding: LocalHardBinding {
algorithm: capture.hard_binding_label,
status: status.to_string(),
},
active_manifest_label,
store_manifests,
report,
})
}
fn verify_with_resolved_options(
data: &[u8],
fragments: Option<&[&[u8]]>,
mime_type: &str,
options: &VerifyOptions,
resolved: ResolvedOptions,
) -> Result<VerificationReport, Error> {
let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
let result = verify_with_options_inner(data, fragments, mime_type, options, resolved)
.map(|(report, _)| report);
enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
result
}
fn enqueue_failure_telemetry(
mime_type: &str,
result: &Result<VerificationReport, Error>,
options: &VerifyOptions,
enabled: bool,
) {
if let Some(event) = telemetry::validation_failure_telemetry_with_enabled(
mime_type,
result,
&options.telemetry,
enabled,
) {
telemetry::enqueue(options.telemetry.endpoint(), event);
}
}
pub fn verify_fragmented_with_options(
init_segment: &[u8],
fragments: &[&[u8]],
mime_type: &str,
options: &VerifyOptions,
) -> Result<VerificationReport, Error> {
if options.stream_presentation_times.is_some() {
return Err(Error::InvalidValidationTime(
"stream_presentation_times: per-segment presentation times apply only to \
session-key live streams verified with verify_stream_with_options; \
Merkle-bound fragments are assessed at validation_time"
.into(),
));
}
let resolved = ResolvedOptions::resolve(options)?;
let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
let mime = canonicalize_mime(mime_type);
let result = if crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime)
== Some(crate::c2pa_formats::AssetFormat::Bmff)
{
verify_with_options_inner(init_segment, Some(fragments), &mime, options, resolved)
.map(|(report, _)| report)
} else {
Err(Error::UnsupportedMime(mime))
};
enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
result
}
pub fn verify_fragmented_with_graph_inputs(
init_segment: &[u8],
fragments: &[&[u8]],
mime_type: &str,
options: &VerifyOptions,
graph_inputs: GraphInputs<'_>,
) -> Result<VerificationReport, Error> {
if options.stream_presentation_times.is_some() {
return Err(Error::InvalidValidationTime(
"stream_presentation_times: per-segment presentation times apply only to \
session-key live streams verified with verify_stream_with_options; \
Merkle-bound fragments are assessed at validation_time"
.into(),
));
}
let resolved = ResolvedOptions::resolve(options)?;
let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
let mime = canonicalize_mime(mime_type);
let result = if crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime)
== Some(crate::c2pa_formats::AssetFormat::Bmff)
{
embedded_graph_pass(
init_segment,
Some(fragments),
&mime,
options,
&resolved,
graph_inputs,
)
} else {
Err(Error::UnsupportedMime(mime))
};
enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
result
}
pub(crate) struct ResolvedOptions {
claim_trust: Option<ResolvedTrust>,
tsa_trust: Option<ResolvedTrust>,
allowed_certs: Option<ResolvedTrust>,
cawg_trust: Option<ResolvedTrust>,
cawg_allowed_certs: Option<ResolvedTrust>,
validation_time: OffsetDateTime,
validation_time_text: String,
trust_basis: &'static str,
profile: EngineProfile,
cawg_strict_encoding: bool,
cawg_document_signing_require_anchor: bool,
ocsp_responses: HashMap<String, Vec<u8>>,
ocsp_unreachable: Vec<String>,
external_data: HashMap<String, Vec<u8>>,
external_data_media_types: HashMap<String, String>,
external_data_unreachable: Vec<String>,
}
impl ResolvedOptions {
pub(crate) fn resolve(options: &VerifyOptions) -> Result<Self, Error> {
let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
let use_defaults = !options.no_default_trust;
let validation_time = parse_validation_time(options.validation_time.as_deref())?;
let custom_claim_trust = options.trust_pem.is_some()
|| options
.claim_trust_sources
.as_ref()
.is_some_and(|sources| !sources.is_empty())
|| options.allowed_list_pem.is_some();
let bounds = (
parse_optional_instant(
options.trust_anchor_not_before.as_deref(),
"trust_anchor_not_before",
)?,
parse_optional_instant(
options.trust_anchor_not_after.as_deref(),
"trust_anchor_not_after",
)?,
);
let (cawg_anchors, cawg_direct) =
resolve_cawg_configurations(options.cawg_trust_configurations.as_deref())?;
Ok(Self {
claim_trust: resolve_trust(
options.trust_pem.as_deref(),
options.claim_trust_sources.as_deref(),
use_defaults.then(default_trust::claim_signing),
AnchorPurpose::ClaimSigning,
bounds,
)?,
tsa_trust: resolve_trust(
options.tsa_trust_pem.as_deref(),
options.tsa_trust_sources.as_deref(),
use_defaults.then(default_trust::timestamp_authorities),
AnchorPurpose::TimeStamping,
bounds,
)?,
allowed_certs: resolve_trust(
options.allowed_list_pem.as_deref(),
None,
use_defaults.then(default_trust::allowed_claim_signers),
AnchorPurpose::ClaimSigning,
bounds,
)?,
cawg_trust: extend_trust(
resolve_trust(
options.cawg_trust_pem.as_deref(),
None,
use_defaults.then(default_trust::cawg_identity),
AnchorPurpose::CawgIdentity,
bounds,
)?,
cawg_anchors,
),
cawg_allowed_certs: extend_trust(
resolve_trust(
options.cawg_allowed_certs_pem.as_deref(),
None,
use_defaults.then(default_trust::cawg_allowed_identities),
AnchorPurpose::CawgIdentity,
bounds,
)?,
cawg_direct,
),
validation_time,
validation_time_text: validation_time
.format(&Rfc3339)
.map_err(|error| Error::InvalidValidationTime(error.to_string()))?,
trust_basis: match (use_defaults, custom_claim_trust) {
(true, true) => "bundled_and_caller_supplied_static_material",
(true, false) => "bundled_static_material",
(false, true) => "caller_supplied_static_material",
(false, false) => "none",
},
profile,
cawg_strict_encoding: options.cawg_strict_encoding,
cawg_document_signing_require_anchor: options
.cawg_document_signing_require_anchor
.unwrap_or(true),
ocsp_responses: decode_base64_map(
options.ocsp_responses.as_ref(),
"ocsp_responses",
MAX_OCSP_EVIDENCE_BYTES,
)?,
ocsp_unreachable: options.ocsp_unreachable.clone().unwrap_or_default(),
external_data: decode_base64_map(
options.external_data.as_ref(),
"external_data",
MAX_EXTERNAL_DATA_EVIDENCE_BYTES,
)?,
external_data_media_types: options
.external_data_media_types
.clone()
.unwrap_or_default(),
external_data_unreachable: options
.external_data_unreachable
.clone()
.unwrap_or_default(),
})
}
fn take_external_data(&mut self) -> ValidatedExternalData {
ValidatedExternalData {
data: std::mem::take(&mut self.external_data),
media_types: std::mem::take(&mut self.external_data_media_types),
unreachable: std::mem::take(&mut self.external_data_unreachable),
}
}
fn apply_ocsp_update(&mut self, update: native_online::OcspUpdate) {
for (certificate, response) in update.responses {
self.ocsp_responses.entry(certificate).or_insert(response);
}
for certificate in update.unreachable {
if !self.ocsp_unreachable.contains(&certificate) {
self.ocsp_unreachable.push(certificate);
}
}
}
pub(crate) fn input<'a>(&'a self, data: &'a [u8], mime: &'a str) -> VerifyInput<'a> {
VerifyInput {
data,
mime,
claim_signer_trust: self.claim_trust.as_ref().map(ResolvedTrust::get),
tsa_trust: self.tsa_trust.as_ref().map(ResolvedTrust::get),
allowed_certs: self.allowed_certs.as_ref().map(ResolvedTrust::get),
validation_time: Some(self.validation_time),
profile: self.profile,
cawg_strict_encoding: self.cawg_strict_encoding,
evidence: crate::c2pa_validate::OnlineEvidence {
ocsp_responses: (!self.ocsp_responses.is_empty()).then_some(&self.ocsp_responses),
ocsp_unreachable: (!self.ocsp_unreachable.is_empty())
.then_some(self.ocsp_unreachable.as_slice()),
external_data: (!self.external_data.is_empty()).then_some(&self.external_data),
external_data_media_types: (!self.external_data_media_types.is_empty())
.then_some(&self.external_data_media_types),
external_data_unreachable: (!self.external_data_unreachable.is_empty())
.then_some(self.external_data_unreachable.as_slice()),
},
}
}
pub(crate) fn cawg_trust(&self) -> Option<&TrustList> {
self.cawg_trust.as_ref().map(ResolvedTrust::get)
}
pub(crate) fn cawg_allowed_certs(&self) -> Option<&TrustList> {
self.cawg_allowed_certs.as_ref().map(ResolvedTrust::get)
}
}
fn resolve_profile(spec_version: Option<&str>, strict: bool) -> Result<EngineProfile, Error> {
let version = match spec_version {
None => SpecVersion::V2_4,
Some(value) => SpecVersion::public_policy_from_str(value).ok_or_else(|| {
Error::UnsupportedSpecVersion(format!(
"spec_version {value:?} is not one of {}",
SpecVersion::PUBLIC_POLICIES
.iter()
.map(|version| format!("{:?}", version.version_str()))
.collect::<Vec<_>>()
.join(", ")
))
})?,
};
match (version, strict) {
(SpecVersion::V2_2, true) => Ok(EngineProfile::strict(SpecVersion::V2_2)),
(SpecVersion::V2_2, false) => Ok(EngineProfile {
version,
..EngineProfile::GENEROUS
}),
(SpecVersion::V2_4, true) => Ok(EngineProfile::strict(SpecVersion::V2_4)),
(SpecVersion::V2_4, false) => Ok(EngineProfile::GENEROUS),
(SpecVersion::V2_5, true) => Ok(EngineProfile::new(
SpecVersion::V2_5,
OperatingMode::Conformance,
)
.with_compliance(ComplianceLevel::CoreSpec)
.with_debug(true)),
(SpecVersion::V2_5, false) => Ok(EngineProfile {
version,
..EngineProfile::GENEROUS
}),
_ => Err(Error::UnsupportedSpecVersion(format!(
"spec_version {:?} is registered but has no public verification policy",
version.version_str()
))),
}
}
fn report_profile(profile: EngineProfile) -> &'static str {
match profile.version {
SpecVersion::V2_5 => C2PA_PROFILE_2_5,
SpecVersion::V2_2 => C2PA_PROFILE_2_2,
_ => C2PA_PROFILE,
}
}
fn decode_base64_map(
values: Option<&HashMap<String, String>>,
field: &str,
max_bytes: usize,
) -> Result<HashMap<String, Vec<u8>>, Error> {
let Some(values) = values else {
return Ok(HashMap::new());
};
let mut decoded = HashMap::with_capacity(values.len());
for (key, encoded) in values {
let bytes = crate::c2pa_formats::util::base64_decode(encoded)
.ok_or_else(|| Error::Verification(format!("{field}[{key}] is not standard base64")))?;
if bytes.len() > max_bytes {
return Err(Error::Verification(format!(
"{field}[{key}] exceeds the {max_bytes} byte limit"
)));
}
decoded.insert(key.clone(), bytes);
}
Ok(decoded)
}
pub(crate) fn resolve_mime(mime_type: &str, profile: EngineProfile) -> Result<String, Error> {
let mime = canonicalize_mime(mime_type);
if !crate::c2pa_core::spec::reader_permits(&mime, profile.version)
|| crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime).is_none()
{
return Err(Error::UnsupportedMime(mime));
}
Ok(mime)
}
fn resolve_detached_mime(mime_type: &str, profile: EngineProfile) -> Result<String, Error> {
let mime = crate::c2pa_formats::canonicalize_detached_mime(mime_type);
if !crate::c2pa_formats::detached_mime_permitted(&mime, profile.version) {
return Err(Error::UnsupportedMime(mime));
}
Ok(mime)
}
fn is_xml_comment_carrier(mime: &str) -> bool {
(matches!(mime, "application/xml" | "text/xml") || mime.ends_with("+xml"))
&& matches!(
crate::c2pa_formats::AssetFormat::from_canonical_mime(mime),
Some(crate::c2pa_formats::AssetFormat::TextStructured {
comment_prefix: "<!--",
..
})
)
}
fn require_embedded_mime(mime: &str, profile: EngineProfile) -> Result<(), Error> {
if profile.version == SpecVersion::V2_5 && is_xml_comment_carrier(mime) {
return Err(Error::UnsupportedMime(mime.to_owned()));
}
Ok(())
}
pub(crate) fn map_validate_error(error: crate::c2pa_validate::ValidateError) -> Error {
match error {
crate::c2pa_validate::ValidateError::UnsupportedMime(value) => {
Error::UnsupportedMime(value)
}
other => Error::Verification(other.to_string()),
}
}
pub(crate) fn report_from_output(
output: crate::c2pa_validate::VerifyOutput,
mime: String,
resolved: &ResolvedOptions,
) -> VerificationReport {
let present = output
.report_json
.pointer("/provenance_verdict/present")
.and_then(Value::as_bool)
.unwrap_or(false);
let integrity = output
.report_json
.pointer("/provenance_verdict/integrity")
.and_then(Value::as_str)
.unwrap_or(if present { "invalid" } else { "absent" })
.to_string();
let trust = trust_report(
&output.results,
present,
resolved.trust_basis,
resolved.validation_time_text.clone(),
);
VerificationReport {
schema_version: REPORT_SCHEMA_VERSION.to_string(),
profile: report_profile(resolved.profile).to_string(),
mime_type: mime,
present,
integrity,
signature: signature_status(&output.results),
hard_binding: hard_binding_status(&output.results),
trust,
policy: None,
managed_receipt: None,
validation_state: output.validation_state.as_str().to_string(),
validation_results: copy_results(&output.results),
manifest_report: output.report_json,
content_credentials: output.crjson,
network: NetworkReport::default(),
}
}
fn verify_with_options_inner(
data: &[u8],
fragments: Option<&[&[u8]]>,
mime_type: &str,
options: &VerifyOptions,
resolved: ResolvedOptions,
) -> Result<(VerificationReport, PassCapture), Error> {
let mime = resolve_mime(mime_type, resolved.profile)?;
require_embedded_mime(&mime, resolved.profile)?;
if resolved.profile.version == SpecVersion::V2_5 {
return verify_embedded_online_v25(data, fragments, &mime, options, resolved);
}
let (mut report, needs, capture) =
embedded_pass_resolved(data, fragments, &mime, options, &resolved)?;
let (mut network, evidence) = online::gather(options, &needs);
if evidence.is_empty() {
report.network = network;
return Ok((report, capture));
}
let next = online::apply_evidence(options, &evidence);
let second = match (evidence.manifest_store.as_deref(), fragments) {
(Some(store), None) => detached_pass(data, store, &mime, &next, GraphInputs::default()),
_ => embedded_pass(data, fragments, &mime, &next),
};
match second {
Ok((mut second, _, second_capture)) => {
second.network = network;
Ok((second, second_capture))
}
Err(error) => {
network.mark_unusable("remote_manifest", error.to_string());
report.network = network;
Ok((report, capture))
}
}
}
fn merged_did_documents(
pinned: Option<&HashMap<String, Value>>,
fetched: HashMap<String, Value>,
) -> Option<HashMap<String, Value>> {
if fetched.is_empty() {
return None;
}
let mut merged = pinned.cloned().unwrap_or_default();
for (did, document) in fetched {
merged.entry(did).or_insert(document);
}
Some(merged)
}
fn verify_embedded_online_v25(
data: &[u8],
fragments: Option<&[&[u8]]>,
mime: &str,
options: &VerifyOptions,
mut resolved: ResolvedOptions,
) -> Result<(VerificationReport, PassCapture), Error> {
let mut plan = locate_external_resources(data, mime, options, GraphLimits::default())?;
let terminal_uri = (!plan.manifest_store_present)
.then(|| {
plan.resources
.iter()
.find(|request| request.kind == ExternalResourceKind::SegmentManifestStore)
.map(|request| request.uri.clone())
})
.flatten();
let mut online = native_online::NativeOnline::new(resolved.take_external_data(), options);
while !plan.resources.is_empty() {
let frontier = plan.resources.clone();
for request in &frontier {
online.ensure_graph_resource(
options,
request,
terminal_uri.as_deref() == Some(request.uri.as_str()),
);
}
plan.continue_with(online.resources())?;
}
online.mark_discovery_stop(plan.stop_reason.as_ref());
let fetched_dids = online.resolve_did_documents(
options,
plan.did_documents(),
options.cawg_did_documents.as_ref(),
);
let merged_dids = merged_did_documents(options.cawg_did_documents.as_ref(), fetched_dids);
let did_documents = merged_dids.as_ref().or(options.cawg_did_documents.as_ref());
let graph_inputs = GraphInputs {
external: Some(online.resources()),
limits: GraphLimits::default(),
};
let (mut report, mut needs, mut capture) = embedded_graph_pass_resolved(
data,
fragments,
mime,
options,
&resolved,
did_documents,
graph_inputs,
)?;
let ocsp = online.fetch_ocsp(options, &needs);
if ocsp.has_evidence() {
resolved.apply_ocsp_update(ocsp);
let graph_inputs = GraphInputs {
external: Some(online.resources()),
limits: GraphLimits::default(),
};
(report, needs, capture) = embedded_graph_pass_resolved(
data,
fragments,
mime,
options,
&resolved,
did_documents,
graph_inputs,
)?;
}
report.network = online.finish(&needs);
Ok((report, capture))
}
fn verify_detached_online_v25(
asset: &[u8],
manifest_store: &[u8],
mime: &str,
options: &VerifyOptions,
mut resolved: ResolvedOptions,
) -> Result<VerificationReport, Error> {
let mut plan =
c2pa_validate::external_discovery::locate_external_resources_with_canonical_manifest_store(
asset,
manifest_store,
mime,
resolved.profile,
GraphLimits::default(),
)?;
let mut online = native_online::NativeOnline::new(resolved.take_external_data(), options);
while !plan.resources.is_empty() {
let frontier = plan.resources.clone();
for request in &frontier {
online.ensure_graph_resource(options, request, false);
}
plan.continue_with(online.resources())?;
}
online.mark_discovery_stop(plan.stop_reason.as_ref());
let fetched_dids = online.resolve_did_documents(
options,
plan.did_documents(),
options.cawg_did_documents.as_ref(),
);
let merged_dids = merged_did_documents(options.cawg_did_documents.as_ref(), fetched_dids);
let did_documents = merged_dids.as_ref().or(options.cawg_did_documents.as_ref());
let graph_inputs = GraphInputs {
external: Some(online.resources()),
limits: GraphLimits::default(),
};
let (mut report, mut needs, _) = detached_pass_resolved(
asset,
manifest_store,
mime,
options,
&resolved,
did_documents,
graph_inputs,
)?;
let ocsp = online.fetch_ocsp(options, &needs);
if ocsp.has_evidence() {
resolved.apply_ocsp_update(ocsp);
let graph_inputs = GraphInputs {
external: Some(online.resources()),
limits: GraphLimits::default(),
};
(report, needs, _) = detached_pass_resolved(
asset,
manifest_store,
mime,
options,
&resolved,
did_documents,
graph_inputs,
)?;
}
report.network = online.finish(&needs);
Ok(report)
}
fn embedded_pass(
data: &[u8],
fragments: Option<&[&[u8]]>,
mime: &str,
options: &VerifyOptions,
) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
let resolved = ResolvedOptions::resolve(options)?;
embedded_pass_resolved(data, fragments, mime, options, &resolved)
}
fn embedded_pass_resolved(
data: &[u8],
fragments: Option<&[&[u8]]>,
mime: &str,
options: &VerifyOptions,
resolved: &ResolvedOptions,
) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
let input = resolved.input(data, mime);
let mut output = match fragments {
Some(fragments) => verify_fragmented_safe(
&input,
fragments,
&options.expected_seek_positions,
resolved.cawg_trust(),
resolved.cawg_allowed_certs(),
resolved.cawg_document_signing_require_anchor,
options.cawg_did_documents.as_ref(),
options.cawg_ica_trusted_issuers.as_deref(),
options.cawg_ica_trust_anchors.as_deref(),
options.cawg_ica_status_lists.as_ref(),
GraphInputs::default(),
),
None => verify_safe(
&input,
resolved.cawg_trust(),
resolved.cawg_allowed_certs(),
resolved.cawg_document_signing_require_anchor,
options.cawg_did_documents.as_ref(),
options.cawg_ica_trusted_issuers.as_deref(),
options.cawg_ica_trust_anchors.as_deref(),
options.cawg_ica_status_lists.as_ref(),
),
}
.map_err(map_validate_error)?;
let needs = online::network_needs(&output);
let capture = PassCapture::take(&mut output);
Ok((
report_from_output(output, mime.to_string(), resolved),
needs,
capture,
))
}
fn embedded_graph_pass(
data: &[u8],
fragments: Option<&[&[u8]]>,
mime_type: &str,
options: &VerifyOptions,
resolved: &ResolvedOptions,
graph_inputs: GraphInputs<'_>,
) -> Result<VerificationReport, Error> {
let mime = resolve_mime(mime_type, resolved.profile)?;
require_embedded_mime(&mime, resolved.profile)?;
embedded_graph_pass_resolved(
data,
fragments,
&mime,
options,
resolved,
options.cawg_did_documents.as_ref(),
graph_inputs,
)
.map(|(report, _, _)| report)
}
#[allow(clippy::too_many_arguments)]
fn embedded_graph_pass_resolved(
data: &[u8],
fragments: Option<&[&[u8]]>,
mime: &str,
options: &VerifyOptions,
resolved: &ResolvedOptions,
did_documents: Option<&HashMap<String, Value>>,
graph_inputs: GraphInputs<'_>,
) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
let input = resolved.input(data, mime);
let mut output = match fragments {
Some(fragments) => verify_fragmented_safe(
&input,
fragments,
&options.expected_seek_positions,
resolved.cawg_trust(),
resolved.cawg_allowed_certs(),
resolved.cawg_document_signing_require_anchor,
did_documents,
options.cawg_ica_trusted_issuers.as_deref(),
options.cawg_ica_trust_anchors.as_deref(),
options.cawg_ica_status_lists.as_ref(),
graph_inputs,
),
None => verify_graph_safe(
&input,
resolved.cawg_trust(),
resolved.cawg_allowed_certs(),
resolved.cawg_document_signing_require_anchor,
did_documents,
options.cawg_ica_trusted_issuers.as_deref(),
options.cawg_ica_trust_anchors.as_deref(),
options.cawg_ica_status_lists.as_ref(),
graph_inputs,
),
}
.map_err(map_validate_error)?;
let needs = online::network_needs(&output);
let capture = PassCapture::take(&mut output);
Ok((
report_from_output(output, mime.to_string(), resolved),
needs,
capture,
))
}
pub fn verify_file(
path: impl AsRef<Path>,
mime_type: Option<&str>,
options: &VerifyOptions,
) -> Result<VerificationReport, Error> {
let resolved = ResolvedOptions::resolve(options)?;
let path = path.as_ref();
let mime = match mime_type {
Some(value) => value.to_string(),
None => mime_from_path(path)
.ok_or_else(|| Error::UnsupportedMime(path.display().to_string()))?
.to_string(),
};
let data = read_path_asset(path, MAX_PATH_ASSET_BYTES)?;
verify_with_resolved_options(&data, None, &mime, options, resolved)
}
fn read_path_asset(path: &Path, limit: u64) -> io::Result<Vec<u8>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC);
let mut file = options.open(path)?;
let opened_metadata = file.metadata()?;
validate_path_asset(path, &opened_metadata, limit)?;
read_bounded_file(&mut file, opened_metadata.len(), limit)
}
fn validate_path_asset(path: &Path, metadata: &fs::Metadata, limit: u64) -> io::Result<()> {
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("asset path is not a regular file: {}", path.display()),
));
}
if metadata.len() > limit {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("asset exceeds the 128 MiB path limit: {}", path.display()),
));
}
Ok(())
}
fn read_bounded_file<R: Read>(file: &mut R, expected_len: u64, limit: u64) -> io::Result<Vec<u8>> {
if expected_len > limit {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"asset exceeds the path size limit",
));
}
let expected_len = usize::try_from(expected_len)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "asset size is not addressable"))?;
let mut data = vec![0_u8; expected_len + 1];
let mut used = 0;
while used < expected_len {
let read = file.read(&mut data[used..expected_len])?;
if read == 0 {
break;
}
used += read;
}
if used == expected_len && file.read(&mut data[expected_len..expected_len + 1])? != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"asset grew while being read",
));
}
data.truncate(used);
Ok(data)
}
pub fn supported_mime_types() -> Vec<&'static str> {
let mut mimes: Vec<_> = mimes_for_version(SpecVersion::V2_4)
.into_iter()
.filter(|mime| crate::c2pa_formats::AssetFormat::from_mime(mime).is_some())
.collect();
mimes.sort_unstable();
mimes.dedup();
mimes
}
pub fn supported_mime_types_for(spec_version: Option<&str>) -> Result<Vec<&'static str>, Error> {
let profile = resolve_profile(spec_version, false)?;
let registry_version = match profile.version {
SpecVersion::V2_5 => SpecVersion::V2_4,
version => version,
};
let mut mimes: Vec<_> = mimes_for_version(registry_version)
.into_iter()
.chain(
(profile.version == SpecVersion::V2_5)
.then_some(crate::c2pa_core::spec::V2_5_READER_MIMES)
.into_iter()
.flatten()
.copied(),
)
.filter(|mime| crate::c2pa_formats::AssetFormat::from_mime(mime).is_some())
.filter(|mime| require_embedded_mime(mime, profile).is_ok())
.collect();
mimes.sort_unstable();
mimes.dedup();
Ok(mimes)
}
pub const SUPPORTED_EXTENSIONS: &[(&str, &str)] = &[
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("png", "image/png"),
("webp", "image/webp"),
("gif", "image/gif"),
("tif", "image/tiff"),
("tiff", "image/tiff"),
("dng", "image/x-adobe-dng"),
("heic", "image/heic"),
("heics", "image/heic-sequence"),
("heif", "image/heif"),
("heifs", "image/heif-sequence"),
("avif", "image/avif"),
("jxl", "image/jxl"),
("svg", "image/svg+xml"),
("mp4", "video/mp4"),
("m4v", "video/mp4"),
("mov", "video/quicktime"),
("avi", "video/x-msvideo"),
("wav", "audio/wav"),
("mp3", "audio/mpeg"),
("m4a", "audio/mp4"),
("aac", "audio/aac"),
("flac", "audio/flac"),
("ogg", "audio/ogg"),
("oga", "audio/ogg"),
("pdf", "application/pdf"),
("zip", "application/zip"),
("sketch", "application/zip"),
("epub", "application/epub+zip"),
(
"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
),
(
"xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
(
"pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
),
(
"dotx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
),
("docm", "application/vnd.ms-word.document.macroenabled.12"),
("dotm", "application/vnd.ms-word.template.macroenabled.12"),
(
"xltx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.template",
),
("xlsm", "application/vnd.ms-excel.sheet.macroenabled.12"),
("xltm", "application/vnd.ms-excel.template.macroenabled.12"),
(
"xlsb",
"application/vnd.ms-excel.sheet.binary.macroenabled.12",
),
(
"ppsx",
"application/vnd.openxmlformats-officedocument.presentationml.slideshow",
),
(
"potx",
"application/vnd.openxmlformats-officedocument.presentationml.template",
),
(
"pptm",
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
),
(
"ppsm",
"application/vnd.ms-powerpoint.slideshow.macroenabled.12",
),
(
"potm",
"application/vnd.ms-powerpoint.template.macroenabled.12",
),
("vsdx", "application/vnd.ms-visio.drawing"),
("vsdm", "application/vnd.ms-visio.drawing.macroenabled.12"),
("vssx", "application/vnd.ms-visio.stencil"),
("vssm", "application/vnd.ms-visio.stencil.macroenabled.12"),
("vstx", "application/vnd.ms-visio.template"),
("vstm", "application/vnd.ms-visio.template.macroenabled.12"),
("oxps", "application/oxps"),
("xps", "application/vnd.ms-xpsdocument"),
("odt", "application/vnd.oasis.opendocument.text"),
("ods", "application/vnd.oasis.opendocument.spreadsheet"),
("odp", "application/vnd.oasis.opendocument.presentation"),
("odg", "application/vnd.oasis.opendocument.graphics"),
("ttf", "font/ttf"),
("otf", "font/otf"),
("sfnt", "font/sfnt"),
("txt", "text/plain"),
("tsv", "text/tab-separated-values"),
("csv", "text/csv"),
("md", "text/markdown"),
("markdown", "text/markdown"),
("html", "text/html"),
("htm", "text/html"),
("xhtml", "application/xhtml+xml"),
("xml", "application/xml"),
("css", "text/css"),
("js", "application/javascript"),
("mjs", "application/javascript"),
("json", "application/json"),
("yaml", "application/yaml"),
("yml", "application/yaml"),
("toml", "application/toml"),
("py", "text/x-python"),
("warc", "application/warc"),
("mid", "audio/midi"),
("midi", "audio/midi"),
("ttml", "application/ttml+xml"),
("onnx", "application/onnx"),
("safetensors", "application/safetensors"),
("parquet", "application/vnd.apache.parquet"),
("facti", "image/vnd.blockfact.facti"),
("facta", "audio/vnd.blockfact.facta"),
("factv", "video/vnd.blockfact.factv"),
("jsonl", "application/jsonl"),
("ndjson", "application/jsonl"),
("vtt", "text/vtt"),
];
pub fn mime_from_path(path: &Path) -> Option<&'static str> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
SUPPORTED_EXTENSIONS
.iter()
.find(|(candidate, _)| *candidate == extension)
.map(|(_, mime)| *mime)
}
enum ResolvedTrust {
Bundled(&'static TrustList),
Owned(TrustList),
}
impl ResolvedTrust {
fn get(&self) -> &TrustList {
match self {
Self::Bundled(trust) => trust,
Self::Owned(trust) => trust,
}
}
}
fn resolve_trust(
custom_pem: Option<&str>,
sources: Option<&[TrustListSource]>,
bundled: Option<&'static TrustList>,
purpose: AnchorPurpose,
bounds: (Option<OffsetDateTime>, Option<OffsetDateTime>),
) -> Result<Option<ResolvedTrust>, Error> {
let mut custom = custom_pem
.map(|pem| TrustList::from_pem_for(purpose, pem))
.transpose()
.map_err(|error| Error::InvalidTrust(error.to_string()))?
.map(|trust| trust.with_bounds(bounds.0, bounds.1))
.map(|trust| match purpose {
AnchorPurpose::CawgIdentity => trust.with_cawg_source(CawgTrustSource::CallerSupplied),
_ => trust,
});
let mut windowed = TrustList::default();
for (index, source) in sources.unwrap_or_default().iter().enumerate() {
if source.uri.trim().is_empty() || url::Url::parse(&source.uri).is_err() {
return Err(Error::InvalidTrust(format!(
"trust source {index} has an invalid uri"
)));
}
if source.source_type.trim().is_empty() {
return Err(Error::InvalidTrust(format!(
"trust source {index} has an empty type"
)));
}
if source
.identifier
.as_ref()
.is_some_and(|value| value.trim().is_empty())
{
return Err(Error::InvalidTrust(format!(
"trust source {index} has an empty identifier"
)));
}
let instant = |value: Option<&str>, field: &str| {
value
.map(|raw| {
OffsetDateTime::parse(raw, &Rfc3339).map_err(|error| {
Error::InvalidTrust(format!("trust source {index}.{field}: {error}"))
})
})
.transpose()
};
let not_before = instant(source.not_before.as_deref(), "not_before")?;
let not_after = instant(source.not_after.as_deref(), "not_after")?;
if let (Some(start), Some(end)) = (not_before, not_after) {
if start > end {
return Err(Error::InvalidTrust(format!(
"trust source {index}: not_before is later than not_after"
)));
}
}
let not_before = not_before.into_iter().chain(bounds.0).max();
let not_after = not_after.into_iter().chain(bounds.1).min();
let mut metadata =
crate::c2pa_trust::TrustSource::new(source.uri.clone(), source.source_type.clone());
if let Some(identifier) = &source.identifier {
metadata = metadata.with_identifier(identifier.clone());
}
let sourced = TrustList::from_pem_for(purpose, &source.certificates_pem)
.map_err(|error| Error::InvalidTrust(format!("trust source {index}: {error}")))?
.with_bounds(not_before, not_after)
.with_source(metadata);
if source.not_before.is_some() || source.not_after.is_some() {
for anchor in sourced.anchors {
if let Some(existing) = windowed
.anchors
.iter_mut()
.find(|existing| existing.certificate == anchor.certificate)
{
existing.not_before = existing
.not_before
.into_iter()
.chain(anchor.not_before)
.max();
existing.not_after =
existing.not_after.into_iter().chain(anchor.not_after).min();
} else {
windowed.anchors.push(anchor);
}
}
} else {
match &mut custom {
Some(custom) => custom.anchors.extend(sourced.anchors),
None => custom = Some(sourced),
}
}
}
if !windowed.anchors.is_empty() {
let mut merged = bundled.cloned().unwrap_or_default();
if let Some(custom) = custom {
merged.anchors.extend(custom.anchors);
}
merged.anchors.retain(|anchor| {
!windowed
.anchors
.iter()
.any(|bounded| bounded.certificate == anchor.certificate)
});
merged.anchors.extend(windowed.anchors);
return Ok(Some(ResolvedTrust::Owned(merged)));
}
match (bundled, custom) {
(None, None) => Ok(None),
(Some(trust), None) => Ok(Some(ResolvedTrust::Bundled(trust))),
(None, Some(trust)) => Ok(Some(ResolvedTrust::Owned(trust))),
(Some(bundled), Some(custom)) => {
let mut merged = bundled.clone();
merged.anchors.extend(custom.anchors);
Ok(Some(ResolvedTrust::Owned(merged)))
}
}
}
fn resolve_cawg_configurations(
configurations: Option<&[CawgTrustConfiguration]>,
) -> Result<(TrustList, TrustList), Error> {
let mut anchors = TrustList::default();
let mut direct = TrustList::default();
for (index, configuration) in configurations.unwrap_or_default().iter().enumerate() {
let invalid = |field: &str, detail: &dyn std::fmt::Display| {
Error::InvalidTrust(format!(
"cawg_trust_configurations[{index}]{field}: {detail}"
))
};
let instant = |value: Option<&str>, field| {
value
.map(|raw| {
OffsetDateTime::parse(raw, &Rfc3339).map_err(|error| invalid(field, &error))
})
.transpose()
};
let not_before = instant(configuration.not_before.as_deref(), ".not_before")?;
let not_after = instant(configuration.not_after.as_deref(), ".not_after")?;
if let (Some(start), Some(end)) = (not_before, not_after) {
if start > end {
return Err(invalid("", &"not_before is later than not_after"));
}
}
let source = match configuration.profile {
CawgTrustProfile::Base => CawgTrustSource::CallerSupplied,
CawgTrustProfile::SmimeInterim => CawgTrustSource::SmimeInterim,
};
let (entry_anchors, entry_direct) =
TrustList::from_pem_for(AnchorPurpose::CawgIdentity, &configuration.certificates_pem)
.map_err(|error| invalid(".certificates_pem", &error))?
.with_bounds(not_before, not_after)
.with_cawg_source(source)
.split_certificate_authorities();
anchors.anchors.extend(entry_anchors.anchors);
direct.anchors.extend(entry_direct.anchors);
}
Ok((anchors, direct))
}
fn extend_trust(resolved: Option<ResolvedTrust>, extra: TrustList) -> Option<ResolvedTrust> {
if extra.anchors.is_empty() {
return resolved;
}
let mut merged = match resolved {
None => return Some(ResolvedTrust::Owned(extra)),
Some(ResolvedTrust::Bundled(bundled)) => bundled.clone(),
Some(ResolvedTrust::Owned(owned)) => owned,
};
merged.anchors.extend(extra.anchors);
Some(ResolvedTrust::Owned(merged))
}
fn parse_validation_time(value: Option<&str>) -> Result<OffsetDateTime, Error> {
match value {
Some(raw) => OffsetDateTime::parse(raw, &Rfc3339)
.map_err(|error| Error::InvalidValidationTime(error.to_string())),
None => Ok(OffsetDateTime::now_utc()),
}
}
fn parse_optional_instant(
value: Option<&str>,
field: &str,
) -> Result<Option<OffsetDateTime>, Error> {
value
.map(|raw| {
OffsetDateTime::parse(raw, &Rfc3339)
.map_err(|error| Error::InvalidValidationTime(format!("{field}: {error}")))
})
.transpose()
}
fn parse_stream_presentation_time(raw: &str, index: usize) -> Result<OffsetDateTime, Error> {
OffsetDateTime::parse(raw, &Rfc3339).map_err(|error| {
Error::InvalidValidationTime(format!("stream_presentation_times[{index}]: {error}"))
})
}
pub(crate) fn parse_stream_presentation_times(
values: Option<&[String]>,
) -> Result<Option<Vec<OffsetDateTime>>, Error> {
values
.map(|values| {
values
.iter()
.enumerate()
.map(|(index, raw)| parse_stream_presentation_time(raw, index))
.collect()
})
.transpose()
}
fn copy_status(status: &CoreStatus) -> VerificationStatus {
VerificationStatus {
code: status.code.clone(),
url: status.url.clone(),
resource_url: status.resource_url.clone(),
explanation: status.explanation.clone(),
details: status.details.clone(),
}
}
fn copy_results(results: &CoreResults) -> ValidationResults {
ValidationResults {
success: results.success.iter().map(copy_status).collect(),
informational: results.informational.iter().map(copy_status).collect(),
failure: results.failure.iter().map(copy_status).collect(),
trust_list_sources: results.trust_list_sources.clone(),
}
}
fn signature_status(results: &CoreResults) -> String {
if results.has_success(CLAIM_SIGNATURE_VALIDATED) {
"valid"
} else if results.has_failure(CLAIM_SIGNATURE_MISMATCH) {
"invalid"
} else if results.has_failure(CLAIM_SIGNATURE_MISSING) {
"missing"
} else {
"unknown"
}
.to_string()
}
fn hard_binding_status(results: &CoreResults) -> String {
const MATCHES: &[&str] = &[
ASSERTION_DATA_HASH_MATCH,
ASSERTION_BMFF_HASH_MATCH,
ASSERTION_BOXES_HASH_MATCH,
ASSERTION_COLLECTION_HASH_MATCH,
ASSERTION_MULTI_ASSET_HASH_MATCH,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MATCH,
];
const FAILURES: &[&str] = &[
ASSERTION_DATA_HASH_MISMATCH,
ASSERTION_BMFF_HASH_MISMATCH,
ASSERTION_BMFF_HASH_MALFORMED,
ASSERTION_BOXES_HASH_MISMATCH,
ASSERTION_BOXES_HASH_MALFORMED,
ASSERTION_COLLECTION_HASH_MISMATCH,
ASSERTION_COLLECTION_HASH_MALFORMED,
ASSERTION_MULTI_ASSET_HASH_MISMATCH,
ASSERTION_MULTI_ASSET_HASH_MALFORMED,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MISMATCH,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MALFORMED,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PARTIAL_COVERAGE,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PREDECESSOR_MISMATCH,
];
const UNBOUND: &[&str] = &[
crate::c2pa_validate::live_video::LIVEVIDEO_SEGMENT_INVALID,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MISMATCH,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MALFORMED,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PARTIAL_COVERAGE,
crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PREDECESSOR_MISMATCH,
crate::c2pa_validate::HARD_BINDING_CALLER_EVIDENCE_MISMATCH,
];
if UNBOUND.iter().any(|code| results.has_failure(code)) {
"mismatch"
} else if MATCHES.iter().any(|code| results.has_success(code)) {
"match"
} else if FAILURES.iter().any(|code| results.has_failure(code)) {
"mismatch"
} else if results.has_failure(CLAIM_HARD_BINDINGS_MISSING) {
"missing"
} else {
"unknown"
}
.to_string()
}
fn trust_report(
results: &CoreResults,
present: bool,
basis: &str,
validation_time: String,
) -> TrustReport {
let supplied = basis != "none";
let trusted = results.has_success(SIGNING_CREDENTIAL_TRUSTED);
let rejected = results.has_failure(SIGNING_CREDENTIAL_UNTRUSTED)
|| results.has_failure(SIGNING_CREDENTIAL_INVALID);
let revoked = results.has_failure(SIGNING_CREDENTIAL_OCSP_REVOKED);
let not_revoked = results.has_success(SIGNING_CREDENTIAL_OCSP_NOT_REVOKED);
TrustReport {
status: if trusted && !revoked {
"valid_for_supplied_material"
} else if present && supplied && (rejected || revoked) {
"not_valid_for_supplied_material"
} else {
"not_evaluated"
}
.to_string(),
basis: basis.to_string(),
validation_time,
revocation: RevocationReport {
status: if revoked {
"revoked"
} else if not_revoked {
"not_revoked"
} else {
"not_checked"
}
.to_string(),
source: if !(revoked || not_revoked) {
"none"
} else if results
.success
.iter()
.chain(&results.failure)
.filter(|status| {
status.code == SIGNING_CREDENTIAL_OCSP_REVOKED
|| status.code == SIGNING_CREDENTIAL_OCSP_NOT_REVOKED
})
.any(|status| {
status
.details
.as_ref()
.and_then(|details| details.get("source"))
.and_then(Value::as_str)
== Some("online_ocsp")
})
{
"online_ocsp"
} else {
"embedded_ocsp"
}
.to_string(),
responder_signature: if revoked || not_revoked {
"valid"
} else {
"not_applicable"
}
.to_string(),
},
freshness: FreshnessReport {
status: "unknown".to_string(),
as_of: None,
},
}
}
#[cfg(test)]
mod tests {
use super::{
detached_manifest_evidence, mime_from_path, read_bounded_file, supported_mime_types,
verify, verify_file, verify_fragmented, verify_fragmented_with_graph_inputs,
verify_manifest_store_only_with_graph_inputs, verify_with_manifest_store_and_graph_inputs,
verify_with_options, CawgTrustConfiguration, CawgTrustProfile, Error, GraphInputs,
GraphLimits, VerificationReport, VerifyOptions,
};
use std::{io::Cursor, path::Path};
#[test]
fn windowed_claim_sources_override_pem_duplicates_and_intersect() {
use crate::c2pa_crypto::extract_x5chain;
use crate::c2pa_trust::{validate_chain_with, AnchorPurpose, CertificateProfile};
use crate::c2pa_validate::signature_conformance_tests::Signer;
use time::macros::datetime;
let signer = Signer::conformant();
let other = Signer::conformant();
let pem = format!("{}{}", signer.root_pem(), other.root_pem());
let chain = extract_x5chain(&signer.sign(b"claim")).unwrap();
let other_chain = extract_x5chain(&other.sign(b"claim")).unwrap();
let source = |uri: &str, start: Option<&str>, end: Option<&str>| {
serde_json::from_value::<super::TrustListSource>(serde_json::json!({
"uri": uri, "type": "c2pa.trustList", "certificates_pem": signer.root_pem(),
"not_before": start, "not_after": end,
}))
.unwrap()
};
for reverse in [false, true] {
let mut sources = vec![
source("urn:fixture:start", Some("2026-06-01T00:00:00Z"), None),
source("urn:fixture:end", None, Some("2026-06-30T00:00:00Z")),
source("urn:fixture:unbounded", None, None),
];
if reverse {
sources.reverse();
}
let trust = super::resolve_trust(
Some(&pem),
Some(&sources),
None,
AnchorPurpose::ClaimSigning,
(None, None),
)
.unwrap()
.unwrap();
assert_eq!(trust.get().anchors.len(), 2);
let bounded = trust
.get()
.anchors
.iter()
.find(|anchor| anchor.certificate == *chain.last().unwrap())
.unwrap();
assert_eq!(bounded.not_before, Some(datetime!(2026-06-01 0:00 UTC)));
assert_eq!(bounded.not_after, Some(datetime!(2026-06-30 0:00 UTC)));
assert!(bounded.source.as_ref().unwrap().uri != "urn:fixture:unbounded");
for profile in [
CertificateProfile::V2_2,
CertificateProfile::V2_4,
CertificateProfile::V2_5,
] {
for (at, expected) in [
(datetime!(2026-05-31 23:59:59 UTC), false),
(datetime!(2026-06-01 0:00 UTC), true),
(datetime!(2026-06-30 0:00 UTC), true),
(datetime!(2026-06-30 0:00:01 UTC), false),
] {
let result = validate_chain_with(
&chain[0],
&chain[1..],
trust.get(),
AnchorPurpose::ClaimSigning,
Some(at),
profile,
);
assert_eq!(result.trusted, expected, "{at} {profile:?}: {result:?}");
let unaffected = validate_chain_with(
&other_chain[0],
&other_chain[1..],
trust.get(),
AnchorPurpose::ClaimSigning,
Some(at),
profile,
);
assert!(
unaffected.trusted,
"PEM-only anchor must stay trusted: {unaffected:?}"
);
}
}
}
}
#[test]
fn source_windows_are_validated_before_asset_io() {
for field in ["not_before", "not_after"] {
for list in ["claim_trust_sources", "tsa_trust_sources"] {
let mut source = serde_json::json!({
"uri": "urn:fixture:list",
"type": "c2pa.tsaTrustList",
"certificates_pem": include_str!("c2pa-trust/tests/fixtures/rfc3161_root.pem")
});
source[field] = serde_json::json!("not-an-rfc3339-instant");
let mut value = serde_json::json!({"no_default_trust": true});
value[list] = serde_json::json!([source]);
let options: VerifyOptions = serde_json::from_value(value).unwrap();
let error = options.validate().unwrap_err();
assert_eq!(error.code(), "invalid_trust_material");
assert!(error
.to_string()
.contains(&format!("trust source 0.{field}")));
}
}
}
#[test]
fn source_windows_preserve_global_bounds_and_refuse_reversed_dates() {
let source = |start: &str, end: &str| {
serde_json::from_value::<super::TrustListSource>(serde_json::json!({
"uri": "urn:fixture:list", "type": "c2pa.tsaTrustList",
"certificates_pem": include_str!("c2pa-trust/tests/fixtures/rfc3161_root.pem"),
"not_before": start, "not_after": end
}))
.unwrap()
};
let global_start = time::macros::datetime!(2026-01-01 0:00 UTC);
let global_end = time::macros::datetime!(2027-01-01 0:00 UTC);
let sources = [source("2025-01-01T00:00:00Z", "2028-01-01T00:00:00Z")];
let trust = super::resolve_trust(
None,
Some(&sources),
None,
super::AnchorPurpose::TimeStamping,
(Some(global_start), Some(global_end)),
)
.unwrap()
.unwrap();
assert_eq!(trust.get().anchors[0].not_before, Some(global_start));
assert_eq!(trust.get().anchors[0].not_after, Some(global_end));
let options = VerifyOptions {
tsa_trust_sources: Some(vec![source("2028-01-01T00:00:00Z", "2025-01-01T00:00:00Z")]),
..Default::default()
};
assert!(options
.validate()
.unwrap_err()
.to_string()
.contains("not_before is later than not_after"));
}
#[test]
fn known_filename_maps_to_mime() {
assert_eq!(
mime_from_path(Path::new("composition.MP4")),
Some("video/mp4")
);
assert_eq!(
mime_from_path(Path::new("drawing.odg")),
Some("application/vnd.oasis.opendocument.graphics")
);
assert_eq!(
mime_from_path(Path::new("data.tsv")),
Some("text/tab-separated-values")
);
assert_eq!(
mime_from_path(Path::new("archive.ZIP")),
Some("application/zip")
);
assert_eq!(
mime_from_path(Path::new("design.sketch")),
Some("application/zip")
);
}
#[test]
fn every_supported_format_is_reachable_from_an_extension() {
const ALIASES: &[&str] = &[
"application/font-sfnt",
"application/x-font-ttf",
"application/mp4",
"video/x-m4v",
"text/xml",
"application/x-zip-based",
];
let unreachable: Vec<_> = supported_mime_types()
.into_iter()
.filter(|mime| !ALIASES.contains(mime))
.filter(|mime| !super::SUPPORTED_EXTENSIONS.iter().any(|(_, m)| m == mime))
.collect();
assert!(
unreachable.is_empty(),
"no extension maps to {unreachable:?}"
);
assert_eq!(
mime_from_path(Path::new("report.OXPS")),
Some("application/oxps")
);
}
#[test]
fn revocation_source_distinguishes_online_from_embedded_ocsp() {
use crate::c2pa_validate::{StatusCode, ValidationResults};
let status = |source: Option<&str>| StatusCode {
code: crate::c2pa_validate::SIGNING_CREDENTIAL_OCSP_NOT_REVOKED.into(),
url: "self#jumbf=/c2pa/urn:c2pa:x/c2pa.signature".into(),
resource_url: None,
explanation: "not revoked".into(),
details: source.map(|source| serde_json::json!({ "source": source })),
};
let report = |source: Option<&str>| {
let results = ValidationResults {
success: vec![status(source)],
..ValidationResults::default()
};
super::trust_report(&results, true, "bundled_static_material", String::new())
.revocation
.source
};
assert_eq!(report(Some("online_ocsp")), "online_ocsp");
assert_eq!(report(None), "embedded_ocsp");
}
#[test]
fn format_list_is_sorted_and_contains_composition_formats() {
let formats = supported_mime_types();
assert_eq!(formats.len(), 73);
assert!(formats.windows(2).all(|pair| pair[0] < pair[1]));
assert!(formats.contains(&"video/mp4"));
assert!(formats.contains(&"image/jpeg"));
assert!(formats.contains(&"text/tab-separated-values"));
assert!(formats.contains(&"application/vnd.oasis.opendocument.graphics"));
assert!(formats.contains(&"application/zip"));
assert!(formats.contains(&"application/x-zip-based"));
}
#[test]
fn input_alias_is_reported_as_canonical_mime() {
let asset = b"\x00\x00\x00\x10ftypisom\x00\x00\x00\x00";
let report = verify(asset, "audio/aac; codecs=mp4a.40.2").unwrap();
assert_eq!(report.mime_type, "audio/mp4");
assert!(!report.present);
}
#[test]
fn strict_conformance_option_selects_the_program_profile() {
let asset = include_bytes!("../../../tests/fixtures/signed_test.jpg");
let report = verify_with_options(
asset,
"image/jpeg",
&VerifyOptions {
strict_conformance: true,
..VerifyOptions::default()
},
)
.unwrap();
assert_eq!(
report.manifest_report["engine_profile"]["operating_mode"],
"conformance"
);
assert_eq!(
report.manifest_report["engine_profile"]["compliance_level"],
"conformance-program"
);
}
#[test]
fn fragmented_entry_point_rejects_non_bmff_mime() {
let error = verify_fragmented(
include_bytes!("../../../tests/fixtures/signed_test.jpg"),
&[b"ignored fragment"],
"image/jpeg",
)
.unwrap_err();
assert!(matches!(error, Error::UnsupportedMime(mime) if mime == "image/jpeg"));
}
#[test]
fn detached_fragmented_and_store_only_routes_honor_one_graph_budget() {
let limited = || GraphInputs {
external: None,
limits: GraphLimits::lowered(32, 0, 16_384, 64 * 1024 * 1024),
};
let has_node_limit = |report: &VerificationReport| {
report
.validation_results
.informational
.iter()
.any(|status| {
status.code == "com.encypher.resource.graphLimitExceeded"
&& status
.details
.as_ref()
.and_then(|details| details.get("limit"))
.and_then(serde_json::Value::as_str)
== Some("nodes")
})
};
let jpeg = include_bytes!("../../../tests/fixtures/signed_test.jpg");
let store = detached_manifest_evidence(jpeg, "image/jpeg")
.expect("extract detached evidence")
.expect("signed JPEG manifest")
.manifest_store;
let detached = verify_with_manifest_store_and_graph_inputs(
jpeg,
&store,
"image/jpeg",
&VerifyOptions::default(),
limited(),
)
.expect("detached graph verification");
assert!(
has_node_limit(&detached),
"{:?}",
detached.validation_results
);
let store_only = verify_manifest_store_only_with_graph_inputs(
&store,
&VerifyOptions::default(),
limited(),
)
.expect("store-only graph verification");
assert_eq!(store_only.report.hard_binding, "not_evaluated");
assert!(has_node_limit(&store_only.report));
let mp4 = include_bytes!("../../../tests/fixtures/signed_test.mp4");
let fragmented = verify_fragmented_with_graph_inputs(
mp4,
&[],
"video/mp4",
&VerifyOptions::default(),
limited(),
)
.expect("fragmented graph verification");
assert!(
has_node_limit(&fragmented),
"{:?}",
fragmented.validation_results
);
}
#[test]
fn unratified_hostless_store_is_not_in_public_profile() {
let error = verify(b"jumb", "application/c2pa").unwrap_err();
assert!(matches!(error, Error::UnsupportedMime(_)));
}
#[test]
fn bounded_reader_accepts_exact_limit_without_large_allocation() {
let mut input = Cursor::new(b"1234");
assert_eq!(read_bounded_file(&mut input, 4, 4).unwrap(), b"1234");
}
#[test]
fn bounded_reader_detects_growth_at_limit_plus_one() {
let mut input = Cursor::new(b"12345");
let error = read_bounded_file(&mut input, 4, 4).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("grew while being read"));
}
#[test]
fn unsupported_mime_has_stable_error_code() {
let error = verify(b"not an asset", "application/x-unknown").unwrap_err();
assert!(matches!(error, Error::UnsupportedMime(_)));
assert_eq!(error.code(), "unsupported_mime");
}
#[test]
fn verify_file_resolves_options_before_opening_the_asset() {
let path = std::env::temp_dir().join(format!(
"encypher-c2pa-invalid-options-missing-{}.jpg",
std::process::id()
));
let options = VerifyOptions {
no_default_trust: true,
cawg_trust_configurations: Some(vec![CawgTrustConfiguration {
profile: CawgTrustProfile::Base,
certificates_pem: String::new(),
not_before: None,
not_after: None,
}]),
..VerifyOptions::default()
};
let error = verify_file(path, Some("image/jpeg"), &options)
.expect_err("invalid trust material must win over a missing asset");
assert_eq!(error.code(), "invalid_trust_material");
assert!(
error
.to_string()
.contains("cawg_trust_configurations[0].certificates_pem"),
"{error}"
);
}
#[test]
fn zip64_is_admitted_only_by_the_caller_selected_v2_5_policy() {
let (archive, _) = crate::c2pa_formats::zip64_test_asset();
for spec_version in [None, Some("2.4")] {
let error = verify_with_options(
&archive,
"application/zip",
&VerifyOptions {
spec_version: spec_version.map(str::to_string),
..VerifyOptions::default()
},
)
.expect_err("legacy policy must refuse ZIP64");
assert!(
matches!(error, Error::Verification(_)),
"{spec_version:?} returned {error:?}"
);
}
let error = verify_with_options(
&archive,
"application/zip",
&VerifyOptions {
spec_version: Some("2.2".into()),
..VerifyOptions::default()
},
)
.expect_err("2.2 must refuse a ZIP MIME outside its format set");
assert!(
matches!(&error, Error::UnsupportedMime(mime) if mime == "application/zip"),
"2.2 returned {error:?}"
);
let report = verify_with_options(
&archive,
"application/zip",
&VerifyOptions {
spec_version: Some("2.5".into()),
..VerifyOptions::default()
},
)
.expect("2.5 ZIP64 verification returns a report");
assert!(report.present, "2.5 did not reach the ZIP64 manifest");
}
#[test]
fn a_malformed_cawg_trust_configuration_fails_the_call_naming_its_entry() {
let root = include_str!("default_trust/encypher-identity-root.pem");
let resolve = |entry: serde_json::Value| {
let options: VerifyOptions = serde_json::from_value(serde_json::json!({
"no_default_trust": true,
"cawg_trust_configurations": [
{ "profile": "base", "certificates_pem": root },
entry,
],
}))
.map_err(|error| error.to_string())?;
super::ResolvedOptions::resolve(&options)
.map(|_| ())
.map_err(|error| format!("{}: {error}", error.code()))
};
for (entry, field) in [
(
serde_json::json!({ "profile": "smime_interim", "certificates_pem": "" }),
"cawg_trust_configurations[1].certificates_pem",
),
(
serde_json::json!({ "profile": "smime_interim", "certificates_pem": root, "not_after": "next spring" }),
"cawg_trust_configurations[1].not_after",
),
(
serde_json::json!({
"profile": "smime_interim",
"certificates_pem": root,
"not_before": "2027-01-02T00:00:00Z",
"not_after": "2027-01-01T00:00:00Z",
}),
"cawg_trust_configurations[1]",
),
] {
let error = resolve(entry).expect_err("malformed configuration");
assert!(error.starts_with("invalid_trust_material: "), "{error}");
assert!(error.contains(field), "{error}");
}
let unknown =
resolve(serde_json::json!({ "profile": "trusted", "certificates_pem": root }))
.expect_err("unknown profile");
assert!(unknown.contains("unknown variant `trusted`"), "{unknown}");
assert_eq!(
resolve(serde_json::json!({ "profile": "smime_interim", "certificates_pem": root })),
Ok(())
);
}
}