Skip to main content

encypher_c2pa/
lib.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Local-first, verification-only C2PA SDK.
5//!
6//! The public facade reads caller-provided bytes and reports integrity separately
7//! from trust. Network access occurs only after saved failure telemetry consent
8//! or an explicit per-call override.
9
10#![forbid(unsafe_code)]
11// The six modules below are the verification kernel. They derive from the
12// engine that runs in Encypher's production signing service, and are kept as
13// six crate-named directories so the two trees stay readable side by side.
14//
15// They are a derivative, not a copy, and the difference is the point of this
16// repository: the production tree carries manifest construction and container
17// writing, and this one does not. The line counts differ accordingly and by
18// design - production `c2pa-formats` is roughly twice this one.
19//
20// No automated comparison exists between the two trees. An earlier version of
21// this comment said a private drift gate compared them; there is no such gate
22// in the monorepo, and saying otherwise credited this code with a control
23// nobody had written. Divergence is currently caught by review alone. If that
24// is not good enough - and for a security boundary it probably is not - the
25// thing to build is a projection that maps production paths onto this layout
26// and diffs the shared functions, which would have to model the intended
27// removals rather than expect equality.
28//
29// They were separate published crates until this change. That made 22,890 lines
30// of implementation into semver-bound public API with no consumer - 81% of the
31// reviewed public surface - and it is why manifest construction needed a Cargo
32// feature to hide it. As private modules they are unreachable by construction,
33// so the writers are simply `cfg(test)` and no feature can expose them.
34//
35// Suppressing `dead_code`/`unused_imports` here is deliberate and narrow to
36// these mirrors.
37// The public verifier exercises a subset of the kernel: the rest is reached by
38// the production signer (`c2pa-sign`, `c2pa-cli` on the private side). Deleting
39// what this crate happens not to call would fork the shared source and destroy
40// the property the mirror exists for.
41//
42// `allow` rather than `expect`, which would be preferable: the dead set differs
43// between the two compilations of this crate. Under `cargo build` the writers
44// are absent and much of the kernel is unused; under `cargo test` the
45// `cfg(test)` code exercises them. A single `expect` cannot be fulfilled in
46// both, so it fails the `--all-targets` lint run. `allow` is scoped to these
47// six mirrors and to these two lints only; nothing else in the crate is
48// exempted, and the surface gate is what actually holds the boundary.
49#[path = "c2pa-cbor/lib.rs"]
50#[allow(dead_code, reason = "production kernel mirror")]
51mod c2pa_cbor;
52#[path = "c2pa-core/lib.rs"]
53#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
54mod c2pa_core;
55#[path = "c2pa-crypto/lib.rs"]
56#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
57mod c2pa_crypto;
58#[path = "c2pa-formats/lib.rs"]
59#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
60mod c2pa_formats;
61#[path = "c2pa-trust/lib.rs"]
62#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
63mod c2pa_trust;
64#[path = "c2pa-validate/lib.rs"]
65#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
66mod c2pa_validate;
67mod config_dir;
68mod default_trust;
69mod online;
70mod online_consent;
71mod stream;
72pub use stream::{
73    verify_stream, verify_stream_with_options, SegmentReport, StreamEncapsulation, StreamMethod,
74    StreamVerificationReport,
75};
76mod telemetry;
77mod telemetry_consent;
78pub use config_dir::config_directory;
79pub use default_trust::SNAPSHOT_DATE as DEFAULT_TRUST_SNAPSHOT_DATE;
80
81pub use online::{NetworkReport, NetworkRequest};
82pub use online_consent::{
83    allow_interactive_online_consent, online_preference, set_online_preference, OnlinePreference,
84    OnlinePreferenceError,
85};
86pub use telemetry::{
87    validation_failure_telemetry, TelemetryOptions, ValidationFailureTelemetry,
88    DEFAULT_TELEMETRY_ENDPOINT,
89};
90pub use telemetry_consent::{
91    prompt_for_telemetry_consent, set_telemetry_enabled, telemetry_preference,
92    TelemetryPreferenceError,
93};
94
95use std::collections::HashMap;
96use std::fs::{self, OpenOptions};
97use std::io::{self, Read};
98#[cfg(unix)]
99use std::os::unix::fs::OpenOptionsExt;
100use std::path::Path;
101
102use crate::c2pa_core::{
103    spec::{canonicalize_mime, mimes_for_version},
104    EngineProfile, SpecVersion,
105};
106use crate::c2pa_trust::{AnchorPurpose, CawgTrustSource, TrustList};
107use crate::c2pa_validate::{
108    verify_fragmented_with_cawg_options_and_expected_seeks_safe as verify_fragmented_safe,
109    verify_with_cawg_options_safe as verify_safe, StatusCode as CoreStatus,
110    ValidationResults as CoreResults, VerifyInput, ASSERTION_BMFF_HASH_MALFORMED,
111    ASSERTION_BMFF_HASH_MATCH, ASSERTION_BMFF_HASH_MISMATCH, ASSERTION_BOXES_HASH_MALFORMED,
112    ASSERTION_BOXES_HASH_MATCH, ASSERTION_BOXES_HASH_MISMATCH, ASSERTION_COLLECTION_HASH_MALFORMED,
113    ASSERTION_COLLECTION_HASH_MATCH, ASSERTION_COLLECTION_HASH_MISMATCH, ASSERTION_DATA_HASH_MATCH,
114    ASSERTION_DATA_HASH_MISMATCH, ASSERTION_MULTI_ASSET_HASH_MALFORMED,
115    ASSERTION_MULTI_ASSET_HASH_MATCH, ASSERTION_MULTI_ASSET_HASH_MISMATCH,
116    CLAIM_HARD_BINDINGS_MISSING, CLAIM_SIGNATURE_MISMATCH, CLAIM_SIGNATURE_MISSING,
117    CLAIM_SIGNATURE_VALIDATED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_OCSP_NOT_REVOKED,
118    SIGNING_CREDENTIAL_OCSP_REVOKED, SIGNING_CREDENTIAL_TRUSTED, SIGNING_CREDENTIAL_UNTRUSTED,
119};
120use serde::{Deserialize, Serialize};
121use serde_json::Value;
122use sha2::{Digest, Sha256};
123use time::{format_description::well_known::Rfc3339, OffsetDateTime};
124
125pub const REPORT_SCHEMA_VERSION: &str = "1.0";
126pub const C2PA_PROFILE: &str = "c2pa-2.4";
127const MAX_MANIFEST_STORE_BYTES: usize = 64 * 1024 * 1024;
128const MAX_PATH_ASSET_BYTES: u64 = 128 * 1024 * 1024;
129/// Largest DER `OCSPResponse` accepted as caller-supplied evidence.
130const MAX_OCSP_EVIDENCE_BYTES: usize = 64 * 1024;
131/// Largest external-data payload accepted as caller-supplied evidence.
132const MAX_EXTERNAL_DATA_EVIDENCE_BYTES: usize = 64 * 1024 * 1024;
133
134#[derive(Debug, Clone, Default, Deserialize)]
135#[serde(default, deny_unknown_fields)]
136pub struct VerifyOptions {
137    /// PEM bundle of claim-signing trust anchors.
138    pub trust_pem: Option<String>,
139    /// PEM bundle of timestamp-authority trust anchors.
140    pub tsa_trust_pem: Option<String>,
141    /// PEM bundle of directly allowed end-entity certificates.
142    pub allowed_list_pem: Option<String>,
143    /// PEM bundle of trust anchors for CAWG named-actor (identity) X.509
144    /// credentials. `None` leaves identity signers untrusted (their
145    /// well-formedness is still validated).
146    pub cawg_trust_pem: Option<String>,
147    /// PEM bundle of directly allowed CAWG end-entity certificates.
148    pub cawg_allowed_certs_pem: Option<String>,
149    /// RFC 3339 start of trust for every caller-supplied trust anchor. Before
150    /// this instant the caller's anchors do not validate a signature, whatever
151    /// the anchor certificate's own `notBefore` says. Bundled snapshots are
152    /// unaffected.
153    pub trust_anchor_not_before: Option<String>,
154    /// RFC 3339 end of trust for every caller-supplied trust anchor. After this
155    /// instant the caller's anchors no longer validate a signature. Bundled
156    /// snapshots are unaffected.
157    pub trust_anchor_not_after: Option<String>,
158    /// Disable the bundled C2PA, IPTC, and Encypher trust snapshots. By
159    /// default, caller-supplied PEM bundles extend those snapshots; setting
160    /// this to `true` evaluates only caller-supplied trust material.
161    pub no_default_trust: bool,
162    /// Pinned offline DID-document store for CAWG ICA issuers, keyed by
163    /// primary DID (no fragment). Resolution never touches the network.
164    pub cawg_did_documents: Option<HashMap<String, Value>>,
165    /// ICA issuer DIDs trusted directly by the caller.
166    pub cawg_ica_trusted_issuers: Option<Vec<String>>,
167    /// Trusted DID controller anchors. Issuers may trace to these anchors
168    /// through `controller` links in caller-pinned DID documents.
169    pub cawg_ica_trust_anchors: Option<Vec<String>>,
170    /// Offline, decompressed revocation bitstrings keyed by
171    /// `credentialStatus.statusListCredential` URI and encoded as standard
172    /// base64. Missing lists fail closed without network access.
173    pub cawg_ica_status_lists: Option<HashMap<String, String>>,
174    /// OCSP responses a caller obtained online, keyed by the lowercase hex
175    /// SHA-256 of the certificate's DER and encoded as standard base64 of the
176    /// DER `OCSPResponse`.
177    ///
178    /// The SDK never fetches these for you at this layer: supply them from
179    /// your own OCSP client, or let the SDK's consent-gated fetcher do it. A
180    /// response is trusted no further than any other input - it must still be
181    /// signed by an authorized responder for the certificate in question, and
182    /// it is evaluated under the C2PA 2.4 and CAWG 1.3 online rules.
183    pub ocsp_responses: Option<HashMap<String, String>>,
184    /// Certificates whose OCSP responder was queried without a usable answer,
185    /// by the same lowercase hex SHA-256 key.
186    ///
187    /// Each entry registers the `signingCredential.ocsp.inaccessible` (or
188    /// `cawg.x509.ocsp.inaccessible`) informational code in place of the
189    /// `ocsp.skipped` code a purely offline run reports.
190    pub ocsp_unreachable: Option<Vec<String>>,
191    /// Content of cloud-data and hashed external-reference assertions, keyed
192    /// by the URI the assertion declares and encoded as standard base64.
193    ///
194    /// The bytes are hashed against the assertion's own `alg`/`hash` pair
195    /// before anything else is done with them.
196    pub external_data: Option<HashMap<String, String>>,
197    /// Zero-based indexes into supplied fragments or stream segments where the
198    /// player expects a discontinuity. Values must be unique, strictly
199    /// increasing, and in bounds. Ignored by single-asset verification.
200    pub expected_seek_positions: Vec<usize>,
201    /// Apply the C2PA 2.4 Conformance Program posture instead of the default
202    /// generous core-spec read. This raises applicable SHOULD requirements to
203    /// the conformance bar and emits strict diagnostics in the reader report.
204    /// It also requires CAWG Identity 1.3 deterministic `signer_payload` CBOR;
205    /// the default accepts the CAWG 1.1 field order with
206    /// `com.encypher.cawg.legacyProfile`.
207    pub strict_conformance: bool,
208    /// Refuse the CAWG field-order `signer_payload` encoding that c2pa-rs
209    /// writes, without applying the rest of the conformance posture. When
210    /// unset, the default accepts it with the informational
211    /// `com.encypher.cawg.legacyProfile`; `strict_conformance` refuses it
212    /// either way.
213    pub cawg_strict_encoding: bool,
214    /// RFC 3339 validation instant. Current UTC time is used when omitted.
215    pub validation_time: Option<String>,
216    /// Failure telemetry override. `None` uses the saved per-user preference.
217    pub telemetry: TelemetryOptions,
218    /// Allow this verification to fetch what the asset references: a manifest
219    /// store held elsewhere, certificate revocation status, a `did:web`
220    /// document, externally stored assertion content. `None` reads the
221    /// `ENCYPHER_C2PA_ONLINE` environment variable and otherwise stays
222    /// offline.
223    ///
224    /// A library call never consults the saved per-user choice and never
225    /// prompts: the caller decides here, or the operator decides through the
226    /// environment. Whatever is fetched is used as evidence only. The verdict
227    /// is still reached by the same offline kernel.
228    pub online: Option<bool>,
229    /// Intranet mode: let online checks contact loopback, private, and
230    /// link-local addresses, and accept plaintext `http` for every purpose
231    /// rather than for OCSP alone.
232    ///
233    /// Off by default: a file that names an internal host would otherwise turn
234    /// this verifier into a probe of the network it runs on. Turn it on for a
235    /// deployment whose manifest repository or OCSP responder lives on a
236    /// private address, and for tests that serve from `127.0.0.1`. Setting it
237    /// declares that this machine's own network is trusted; do not set it on a
238    /// host that verifies files sent in by strangers.
239    pub online_allow_private_networks: bool,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243pub struct VerificationStatus {
244    pub code: String,
245    pub url: String,
246    pub explanation: String,
247    /// Machine-readable evidence for extension status codes (e.g. the CAWG
248    /// `payload_encoding` detail).
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub details: Option<Value>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254pub struct ValidationResults {
255    pub success: Vec<VerificationStatus>,
256    pub informational: Vec<VerificationStatus>,
257    pub failure: Vec<VerificationStatus>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261pub struct RevocationReport {
262    pub status: String,
263    pub source: String,
264    pub responder_signature: String,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
268pub struct FreshnessReport {
269    pub status: String,
270    pub as_of: Option<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274pub struct TrustReport {
275    pub status: String,
276    pub basis: String,
277    pub validation_time: String,
278    pub revocation: RevocationReport,
279    pub freshness: FreshnessReport,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct VerificationReport {
284    pub schema_version: String,
285    pub profile: String,
286    pub mime_type: String,
287    pub present: bool,
288    pub integrity: String,
289    pub signature: String,
290    pub hard_binding: String,
291    pub trust: TrustReport,
292    pub policy: Option<Value>,
293    pub managed_receipt: Option<Value>,
294    pub validation_state: String,
295    pub validation_results: ValidationResults,
296    pub manifest_report: Value,
297    pub content_credentials: Option<Value>,
298    /// What this verification did, or could have done, on the network.
299    #[serde(default)]
300    pub network: NetworkReport,
301}
302/// Raw, read-only evidence needed to validate an embedded manifest remotely
303/// without uploading the host asset.
304///
305/// `carrier` is the single contiguous format carrier that contains
306/// `manifest_store`. Formats whose manifest spans multiple disjoint carriers
307/// return no detached evidence; local verification still covers them.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct DetachedManifestEvidence {
310    pub manifest_store: Vec<u8>,
311    pub manifest_store_sha256: String,
312    pub carrier: Vec<u8>,
313}
314
315impl VerificationReport {
316    pub fn to_json(&self) -> Result<String, Error> {
317        serde_json::to_string(self).map_err(Error::Serialize)
318    }
319
320    pub fn to_pretty_json(&self) -> Result<String, Error> {
321        serde_json::to_string_pretty(self).map_err(Error::Serialize)
322    }
323
324    /// All CAWG identity / ICA credential statuses (`cawg.*` codes), across
325    /// the success, informational, and failure sections. CAWG failures are
326    /// assertion-scoped: they never invalidate C2PA manifest integrity.
327    pub fn cawg_statuses(&self) -> Vec<&VerificationStatus> {
328        self.validation_results
329            .success
330            .iter()
331            .chain(&self.validation_results.informational)
332            .chain(&self.validation_results.failure)
333            .filter(|status| status.code.starts_with("cawg."))
334            .collect()
335    }
336}
337
338#[derive(Debug, thiserror::Error)]
339pub enum Error {
340    #[error("unsupported MIME type: {0}")]
341    UnsupportedMime(String),
342    #[error("invalid trust material: {0}")]
343    InvalidTrust(String),
344    #[error("invalid validation time: {0}")]
345    InvalidValidationTime(String),
346    #[error("verification failed: {0}")]
347    Verification(String),
348    #[error("could not read asset: {0}")]
349    Io(#[from] std::io::Error),
350    #[error(transparent)]
351    TelemetryPreference(#[from] TelemetryPreferenceError),
352    #[error(transparent)]
353    OnlinePreference(#[from] OnlinePreferenceError),
354    #[error("could not serialize report: {0}")]
355    Serialize(serde_json::Error),
356}
357
358impl Error {
359    pub fn code(&self) -> &'static str {
360        match self {
361            Self::UnsupportedMime(_) => "unsupported_mime",
362            Self::InvalidTrust(_) => "invalid_trust_material",
363            Self::InvalidValidationTime(_) => "invalid_validation_time",
364            Self::Verification(_) => "verification_error",
365            Self::Io(_) => "io_error",
366            Self::OnlinePreference(_) => "online_preference_error",
367            Self::TelemetryPreference(_) => "telemetry_preference_error",
368            Self::Serialize(_) => "serialization_error",
369        }
370    }
371}
372
373/// Verify asset bytes with default options. Parsing and validation stay offline;
374/// saved failure-telemetry consent may emit one bounded request. For guaranteed
375/// no egress, disable the `telemetry` feature or call [`verify_with_options`]
376/// with `telemetry.enabled` set to `Some(false)`.
377pub fn verify(data: &[u8], mime_type: &str) -> Result<VerificationReport, Error> {
378    verify_with_options(data, mime_type, &VerifyOptions::default())
379}
380
381/// Verify a fragmented ISO BMFF stream with default options.
382///
383/// `init_segment` carries the manifest. Each entry in `fragments` is one media
384/// segment (`.m4s`). Any contiguous subset may be supplied: each segment's
385/// Merkle-tree location is authenticated and adjacency is enforced.
386pub fn verify_fragmented(
387    init_segment: &[u8],
388    fragments: &[&[u8]],
389    mime_type: &str,
390) -> Result<VerificationReport, Error> {
391    verify_fragmented_with_options(
392        init_segment,
393        fragments,
394        mime_type,
395        &VerifyOptions::default(),
396    )
397}
398/// Extract the signed manifest store and its contiguous carrier for detached
399/// server validation.
400///
401/// This is read-only. It never performs network I/O and never constructs or
402/// writes a manifest. `Ok(None)` means the asset has no embedded manifest or
403/// its format cannot represent the manifest as one contiguous carrier.
404pub fn detached_manifest_evidence(
405    data: &[u8],
406    mime_type: &str,
407) -> Result<Option<DetachedManifestEvidence>, Error> {
408    let mime = canonicalize_mime(mime_type);
409    let format = crate::c2pa_formats::AssetFormat::from_mime(&mime)
410        .ok_or_else(|| Error::UnsupportedMime(mime.clone()))?;
411    if !crate::c2pa_formats::supports_hash_mode(&mime) {
412        return Ok(None);
413    }
414    let Some(manifest_store) = crate::c2pa_formats::extract_manifest(format, data)
415        .map_err(|error| Error::Verification(error.to_string()))?
416    else {
417        return Ok(None);
418    };
419    let spans = crate::c2pa_formats::compute_data_hash_exclusions(format, data)
420        .map_err(|error| Error::Verification(error.to_string()))?;
421    let [span] = spans.as_slice() else {
422        return Ok(None);
423    };
424    let end = span
425        .start
426        .checked_add(span.length)
427        .filter(|end| *end <= data.len())
428        .ok_or_else(|| Error::Verification("manifest carrier exceeds asset bounds".into()))?;
429    let manifest_store_sha256 = hex::encode(Sha256::digest(&manifest_store));
430    Ok(Some(DetachedManifestEvidence {
431        manifest_store,
432        manifest_store_sha256,
433        carrier: data[span.start..end].to_vec(),
434    }))
435}
436
437/// Verify one asset against a manifest store the caller supplies separately.
438///
439/// This is the entry point for provenance that does not travel inside the
440/// asset: a `.c2pa` sidecar, a manifest fetched from the URI an asset declares
441/// in its XMP `dcterms:provenance` key, or a store held in a repository. The
442/// SDK never fetches any of those. It reads what the caller hands it.
443///
444/// The manifest binds to `asset` through its ordinary hard binding, evaluated
445/// exactly as it would be for an embedded manifest, so a store paired with the
446/// wrong or altered asset fails on the hash rather than on a weaker check.
447/// Trust material, validation instant, CAWG identity configuration, and the
448/// conformance posture all come from `options`, as they do for
449/// [`verify_with_options`].
450///
451/// `mime_type` describes `asset`, not the store. `application/c2pa` is
452/// rejected: the content side must be a real asset.
453pub fn verify_with_manifest_store(
454    asset: &[u8],
455    manifest_store: &[u8],
456    mime_type: &str,
457    options: &VerifyOptions,
458) -> Result<VerificationReport, Error> {
459    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
460    let result = verify_with_manifest_store_inner(asset, manifest_store, mime_type, options);
461    if let Some(event) = telemetry::validation_failure_telemetry_with_enabled(
462        mime_type,
463        &result,
464        &options.telemetry,
465        telemetry_enabled,
466    ) {
467        telemetry::enqueue(options.telemetry.endpoint(), event);
468    }
469    result
470}
471
472fn verify_with_manifest_store_inner(
473    asset: &[u8],
474    manifest_store: &[u8],
475    mime_type: &str,
476    options: &VerifyOptions,
477) -> Result<VerificationReport, Error> {
478    let mime = resolve_mime(mime_type)?;
479    let (mut report, needs) = detached_pass(asset, manifest_store, &mime, options)?;
480    let (network, evidence) = online::gather(options, &needs);
481    if evidence.is_empty() {
482        report.network = network;
483        return Ok(report);
484    }
485    // The store the caller supplied is the one that binds this asset. Pass 2
486    // re-reads it with the fetched evidence; it never swaps in another store.
487    let next = online::apply_evidence(options, &evidence);
488    let (mut report, _) = detached_pass(asset, manifest_store, &mime, &next)?;
489    report.network = network;
490    Ok(report)
491}
492
493/// One offline verification of an asset against a separately held store, with
494/// the network needs it recorded.
495fn detached_pass(
496    asset: &[u8],
497    manifest_store: &[u8],
498    mime: &str,
499    options: &VerifyOptions,
500) -> Result<(VerificationReport, Vec<online::NetworkNeed>), Error> {
501    if manifest_store.is_empty() {
502        return Err(Error::Verification("manifest store is empty".into()));
503    }
504    if manifest_store.len() > MAX_MANIFEST_STORE_BYTES {
505        return Err(Error::Verification(format!(
506            "manifest store exceeds the {MAX_MANIFEST_STORE_BYTES} byte limit"
507        )));
508    }
509    let resolved = ResolvedOptions::resolve(options)?;
510    let input = resolved.input(asset, mime);
511    let output = crate::c2pa_validate::verify_detached_safe(
512        manifest_store,
513        asset,
514        mime,
515        &input,
516        resolved.cawg_trust(),
517        resolved.cawg_allowed_certs(),
518        true,
519        options.cawg_did_documents.as_ref(),
520        options.cawg_ica_trusted_issuers.as_deref(),
521        options.cawg_ica_trust_anchors.as_deref(),
522        options.cawg_ica_status_lists.as_ref(),
523    )
524    .map_err(map_validate_error)?;
525    let needs = online::network_needs(&output);
526    Ok((
527        report_from_output(output, mime.to_string(), &resolved),
528        needs,
529    ))
530}
531
532/// Verify asset bytes against the bundled trust snapshots plus any static
533/// trust material supplied by the caller. Failure telemetry follows the
534/// explicit override or saved per-user preference.
535pub fn verify_with_options(
536    data: &[u8],
537    mime_type: &str,
538    options: &VerifyOptions,
539) -> Result<VerificationReport, Error> {
540    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
541    let result = verify_with_options_inner(data, None, mime_type, options);
542    if let Some(event) = telemetry::validation_failure_telemetry_with_enabled(
543        mime_type,
544        &result,
545        &options.telemetry,
546        telemetry_enabled,
547    ) {
548        telemetry::enqueue(options.telemetry.endpoint(), event);
549    }
550    result
551}
552
553/// Verify a fragmented ISO BMFF stream with explicit trust, validation-time,
554/// CAWG, and telemetry options.
555///
556/// `init_segment` carries the C2PA manifest. `fragments` may be the complete
557/// stream or any contiguous available subset; missing trailing fragments are
558/// not a failure. Mark each intentional discontinuity by its zero-based index
559/// in [`VerifyOptions::expected_seek_positions`].
560pub fn verify_fragmented_with_options(
561    init_segment: &[u8],
562    fragments: &[&[u8]],
563    mime_type: &str,
564    options: &VerifyOptions,
565) -> Result<VerificationReport, Error> {
566    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
567    let mime = canonicalize_mime(mime_type);
568    let result = if crate::c2pa_formats::AssetFormat::from_mime(&mime)
569        == Some(crate::c2pa_formats::AssetFormat::Bmff)
570    {
571        verify_with_options_inner(init_segment, Some(fragments), &mime, options)
572    } else {
573        Err(Error::UnsupportedMime(mime))
574    };
575    if let Some(event) = telemetry::validation_failure_telemetry_with_enabled(
576        mime_type,
577        &result,
578        &options.telemetry,
579        telemetry_enabled,
580    ) {
581        telemetry::enqueue(options.telemetry.endpoint(), event);
582    }
583    result
584}
585
586/// Trust material, validation instant, and engine profile resolved once from
587/// [`VerifyOptions`].
588///
589/// Every entry point - asset, fragmented, and stream - resolves its inputs
590/// here, so a stream can never be verified against different trust material
591/// than a single asset would be.
592pub(crate) struct ResolvedOptions {
593    claim_trust: Option<ResolvedTrust>,
594    tsa_trust: Option<ResolvedTrust>,
595    allowed_certs: Option<ResolvedTrust>,
596    cawg_trust: Option<ResolvedTrust>,
597    cawg_allowed_certs: Option<ResolvedTrust>,
598    validation_time: OffsetDateTime,
599    validation_time_text: String,
600    trust_basis: &'static str,
601    profile: EngineProfile,
602    cawg_strict_encoding: bool,
603    /// Caller-supplied online evidence, base64-decoded once.
604    ocsp_responses: HashMap<String, Vec<u8>>,
605    ocsp_unreachable: Vec<String>,
606    external_data: HashMap<String, Vec<u8>>,
607}
608
609impl ResolvedOptions {
610    pub(crate) fn resolve(options: &VerifyOptions) -> Result<Self, Error> {
611        let use_defaults = !options.no_default_trust;
612        let validation_time = parse_validation_time(options.validation_time.as_deref())?;
613        let custom_claim_trust = options.trust_pem.is_some() || options.allowed_list_pem.is_some();
614        let bounds = (
615            parse_optional_instant(
616                options.trust_anchor_not_before.as_deref(),
617                "trust_anchor_not_before",
618            )?,
619            parse_optional_instant(
620                options.trust_anchor_not_after.as_deref(),
621                "trust_anchor_not_after",
622            )?,
623        );
624        Ok(Self {
625            claim_trust: resolve_trust(
626                options.trust_pem.as_deref(),
627                use_defaults.then(default_trust::claim_signing),
628                AnchorPurpose::ClaimSigning,
629                bounds,
630            )?,
631            tsa_trust: resolve_trust(
632                options.tsa_trust_pem.as_deref(),
633                use_defaults.then(default_trust::timestamp_authorities),
634                AnchorPurpose::TimeStamping,
635                bounds,
636            )?,
637            allowed_certs: resolve_trust(
638                options.allowed_list_pem.as_deref(),
639                use_defaults.then(default_trust::allowed_claim_signers),
640                AnchorPurpose::ClaimSigning,
641                bounds,
642            )?,
643            cawg_trust: resolve_trust(
644                options.cawg_trust_pem.as_deref(),
645                use_defaults.then(default_trust::cawg_identity),
646                AnchorPurpose::CawgIdentity,
647                bounds,
648            )?,
649            cawg_allowed_certs: resolve_trust(
650                options.cawg_allowed_certs_pem.as_deref(),
651                use_defaults.then(default_trust::cawg_allowed_identities),
652                AnchorPurpose::CawgIdentity,
653                bounds,
654            )?,
655            validation_time,
656            validation_time_text: validation_time
657                .format(&Rfc3339)
658                .map_err(|error| Error::InvalidValidationTime(error.to_string()))?,
659            trust_basis: match (use_defaults, custom_claim_trust) {
660                (true, true) => "bundled_and_caller_supplied_static_material",
661                (true, false) => "bundled_static_material",
662                (false, true) => "caller_supplied_static_material",
663                (false, false) => "none",
664            },
665            profile: if options.strict_conformance {
666                EngineProfile::strict(SpecVersion::V2_4)
667            } else {
668                EngineProfile::GENEROUS
669            },
670            cawg_strict_encoding: options.cawg_strict_encoding,
671            ocsp_responses: decode_base64_map(
672                options.ocsp_responses.as_ref(),
673                "ocsp_responses",
674                MAX_OCSP_EVIDENCE_BYTES,
675            )?,
676            ocsp_unreachable: options.ocsp_unreachable.clone().unwrap_or_default(),
677            external_data: decode_base64_map(
678                options.external_data.as_ref(),
679                "external_data",
680                MAX_EXTERNAL_DATA_EVIDENCE_BYTES,
681            )?,
682        })
683    }
684
685    pub(crate) fn input<'a>(&'a self, data: &'a [u8], mime: &'a str) -> VerifyInput<'a> {
686        VerifyInput {
687            data,
688            mime,
689            claim_signer_trust: self.claim_trust.as_ref().map(ResolvedTrust::get),
690            tsa_trust: self.tsa_trust.as_ref().map(ResolvedTrust::get),
691            allowed_certs: self.allowed_certs.as_ref().map(ResolvedTrust::get),
692            validation_time: Some(self.validation_time),
693            profile: self.profile,
694            cawg_strict_encoding: self.cawg_strict_encoding,
695            evidence: crate::c2pa_validate::OnlineEvidence {
696                ocsp_responses: (!self.ocsp_responses.is_empty()).then_some(&self.ocsp_responses),
697                ocsp_unreachable: (!self.ocsp_unreachable.is_empty())
698                    .then_some(self.ocsp_unreachable.as_slice()),
699                external_data: (!self.external_data.is_empty()).then_some(&self.external_data),
700            },
701        }
702    }
703
704    pub(crate) fn cawg_trust(&self) -> Option<&TrustList> {
705        self.cawg_trust.as_ref().map(ResolvedTrust::get)
706    }
707
708    pub(crate) fn cawg_allowed_certs(&self) -> Option<&TrustList> {
709        self.cawg_allowed_certs.as_ref().map(ResolvedTrust::get)
710    }
711}
712
713/// Decode a map of standard-base64 evidence values, rejecting anything
714/// malformed or over `max_bytes` rather than silently ignoring it.
715///
716/// A caller who hands the SDK evidence deserves to hear that it was unusable;
717/// dropping it would show up much later as an unexplained `ocsp.skipped`.
718fn decode_base64_map(
719    values: Option<&HashMap<String, String>>,
720    field: &str,
721    max_bytes: usize,
722) -> Result<HashMap<String, Vec<u8>>, Error> {
723    let Some(values) = values else {
724        return Ok(HashMap::new());
725    };
726    let mut decoded = HashMap::with_capacity(values.len());
727    for (key, encoded) in values {
728        let bytes = crate::c2pa_formats::util::base64_decode(encoded)
729            .ok_or_else(|| Error::Verification(format!("{field}[{key}] is not standard base64")))?;
730        if bytes.len() > max_bytes {
731            return Err(Error::Verification(format!(
732                "{field}[{key}] exceeds the {max_bytes} byte limit"
733            )));
734        }
735        decoded.insert(key.clone(), bytes);
736    }
737    Ok(decoded)
738}
739
740/// Canonicalize `mime_type` and reject one the C2PA 2.4 profile cannot read.
741pub(crate) fn resolve_mime(mime_type: &str) -> Result<String, Error> {
742    let mime = canonicalize_mime(mime_type);
743    if !mimes_for_version(SpecVersion::V2_4).contains(&mime.as_str())
744        || crate::c2pa_formats::AssetFormat::from_mime(&mime).is_none()
745    {
746        return Err(Error::UnsupportedMime(mime));
747    }
748    Ok(mime)
749}
750
751pub(crate) fn map_validate_error(error: crate::c2pa_validate::ValidateError) -> Error {
752    match error {
753        crate::c2pa_validate::ValidateError::UnsupportedMime(value) => {
754            Error::UnsupportedMime(value)
755        }
756        other => Error::Verification(other.to_string()),
757    }
758}
759
760/// Assemble the caller-facing report from one kernel verification.
761pub(crate) fn report_from_output(
762    output: crate::c2pa_validate::VerifyOutput,
763    mime: String,
764    resolved: &ResolvedOptions,
765) -> VerificationReport {
766    let present = output
767        .report_json
768        .pointer("/provenance_verdict/present")
769        .and_then(Value::as_bool)
770        .unwrap_or(false);
771    let integrity = output
772        .report_json
773        .pointer("/provenance_verdict/integrity")
774        .and_then(Value::as_str)
775        .unwrap_or(if present { "invalid" } else { "absent" })
776        .to_string();
777    let trust = trust_report(
778        &output.results,
779        present,
780        resolved.trust_basis,
781        resolved.validation_time_text.clone(),
782    );
783    VerificationReport {
784        schema_version: REPORT_SCHEMA_VERSION.to_string(),
785        profile: C2PA_PROFILE.to_string(),
786        mime_type: mime,
787        present,
788        integrity,
789        signature: signature_status(&output.results),
790        hard_binding: hard_binding_status(&output.results),
791        trust,
792        policy: None,
793        managed_receipt: None,
794        validation_state: output.validation_state.as_str().to_string(),
795        validation_results: copy_results(&output.results),
796        manifest_report: output.report_json,
797        content_credentials: output.crjson,
798        network: NetworkReport::default(),
799    }
800}
801
802fn verify_with_options_inner(
803    data: &[u8],
804    fragments: Option<&[&[u8]]>,
805    mime_type: &str,
806    options: &VerifyOptions,
807) -> Result<VerificationReport, Error> {
808    let mime = resolve_mime(mime_type)?;
809    let (mut report, needs) = embedded_pass(data, fragments, &mime, options)?;
810    let (mut network, evidence) = online::gather(options, &needs);
811    if evidence.is_empty() {
812        report.network = network;
813        return Ok(report);
814    }
815    let next = online::apply_evidence(options, &evidence);
816    let second = match (evidence.manifest_store.as_deref(), fragments) {
817        // The asset said where its manifest store lives and the store was
818        // fetched. Pass 2 verifies the asset against it exactly as it would
819        // against a sidecar handed in by the caller, hard binding and all.
820        (Some(store), None) => detached_pass(data, store, &mime, &next),
821        _ => embedded_pass(data, fragments, &mime, &next),
822    };
823    match second {
824        Ok((mut second, _)) => {
825            second.network = network;
826            Ok(second)
827        }
828        // What the server returned is not a manifest store this asset can be
829        // verified against. That is an answer about the file, not a failure of
830        // this SDK, so the offline verdict stands and the reason is recorded.
831        Err(error) => {
832            network.mark_unusable("remote_manifest", error.to_string());
833            report.network = network;
834            Ok(report)
835        }
836    }
837}
838
839/// One offline verification of an asset's own bytes, with the network needs it
840/// recorded.
841fn embedded_pass(
842    data: &[u8],
843    fragments: Option<&[&[u8]]>,
844    mime: &str,
845    options: &VerifyOptions,
846) -> Result<(VerificationReport, Vec<online::NetworkNeed>), Error> {
847    let resolved = ResolvedOptions::resolve(options)?;
848    let input = resolved.input(data, mime);
849    let output = match fragments {
850        Some(fragments) => verify_fragmented_safe(
851            &input,
852            fragments,
853            &options.expected_seek_positions,
854            resolved.cawg_trust(),
855            resolved.cawg_allowed_certs(),
856            true,
857            options.cawg_did_documents.as_ref(),
858            options.cawg_ica_trusted_issuers.as_deref(),
859            options.cawg_ica_trust_anchors.as_deref(),
860            options.cawg_ica_status_lists.as_ref(),
861        ),
862        None => verify_safe(
863            &input,
864            resolved.cawg_trust(),
865            resolved.cawg_allowed_certs(),
866            true,
867            options.cawg_did_documents.as_ref(),
868            options.cawg_ica_trusted_issuers.as_deref(),
869            options.cawg_ica_trust_anchors.as_deref(),
870            options.cawg_ica_status_lists.as_ref(),
871        ),
872    }
873    .map_err(map_validate_error)?;
874    let needs = online::network_needs(&output);
875    Ok((
876        report_from_output(output, mime.to_string(), &resolved),
877        needs,
878    ))
879}
880
881/// Read and verify one local asset.
882///
883/// Path-based verification accepts regular files up to 128 MiB. Byte-slice
884/// verification remains bounded only by caller memory.
885pub fn verify_file(
886    path: impl AsRef<Path>,
887    mime_type: Option<&str>,
888    options: &VerifyOptions,
889) -> Result<VerificationReport, Error> {
890    let path = path.as_ref();
891    let mime = match mime_type {
892        Some(value) => value.to_string(),
893        None => mime_from_path(path)
894            .ok_or_else(|| Error::UnsupportedMime(path.display().to_string()))?
895            .to_string(),
896    };
897    let data = read_path_asset(path, MAX_PATH_ASSET_BYTES)?;
898    verify_with_options(&data, &mime, options)
899}
900
901fn read_path_asset(path: &Path, limit: u64) -> io::Result<Vec<u8>> {
902    let mut options = OpenOptions::new();
903    options.read(true);
904    #[cfg(unix)]
905    options.custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC);
906
907    // Open once, then validate that exact handle. On Unix O_NONBLOCK prevents
908    // a FIFO substituted for the path from blocking before it can be rejected.
909    let mut file = options.open(path)?;
910    let opened_metadata = file.metadata()?;
911    validate_path_asset(path, &opened_metadata, limit)?;
912    read_bounded_file(&mut file, opened_metadata.len(), limit)
913}
914
915fn validate_path_asset(path: &Path, metadata: &fs::Metadata, limit: u64) -> io::Result<()> {
916    if !metadata.is_file() {
917        return Err(io::Error::new(
918            io::ErrorKind::InvalidInput,
919            format!("asset path is not a regular file: {}", path.display()),
920        ));
921    }
922    if metadata.len() > limit {
923        return Err(io::Error::new(
924            io::ErrorKind::InvalidData,
925            format!("asset exceeds the 128 MiB path limit: {}", path.display()),
926        ));
927    }
928    Ok(())
929}
930
931fn read_bounded_file<R: Read>(file: &mut R, expected_len: u64, limit: u64) -> io::Result<Vec<u8>> {
932    if expected_len > limit {
933        return Err(io::Error::new(
934            io::ErrorKind::InvalidData,
935            "asset exceeds the path size limit",
936        ));
937    }
938    let expected_len = usize::try_from(expected_len)
939        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "asset size is not addressable"))?;
940    let mut data = vec![0_u8; expected_len + 1];
941    let mut used = 0;
942    while used < expected_len {
943        let read = file.read(&mut data[used..expected_len])?;
944        if read == 0 {
945            break;
946        }
947        used += read;
948    }
949    if used == expected_len && file.read(&mut data[expected_len..expected_len + 1])? != 0 {
950        return Err(io::Error::new(
951            io::ErrorKind::InvalidData,
952            "asset grew while being read",
953        ));
954    }
955    data.truncate(used);
956    Ok(data)
957}
958
959/// Canonical MIME types covered by the C2PA 2.4 profile and readable by this build.
960pub fn supported_mime_types() -> Vec<&'static str> {
961    let mut mimes: Vec<_> = mimes_for_version(SpecVersion::V2_4)
962        .into_iter()
963        .filter(|mime| crate::c2pa_formats::AssetFormat::from_mime(mime).is_some())
964        .collect();
965    mimes.sort_unstable();
966    mimes.dedup();
967    mimes
968}
969
970/// Every filename extension the SDK maps to a MIME type, with its mapping.
971///
972/// This is the single source of truth for extension inference. It is public so
973/// that callers can discover it and so that the non-mutation contract tests
974/// iterate the real table rather than a hand-copied list: a reviewer hid a
975/// writer in the `.dng` branch precisely because the test's own copy had
976/// drifted and omitted it.
977pub const SUPPORTED_EXTENSIONS: &[(&str, &str)] = &[
978    ("jpg", "image/jpeg"),
979    ("jpeg", "image/jpeg"),
980    ("png", "image/png"),
981    ("webp", "image/webp"),
982    ("gif", "image/gif"),
983    ("tif", "image/tiff"),
984    ("tiff", "image/tiff"),
985    ("dng", "image/x-adobe-dng"),
986    ("heic", "image/heic"),
987    ("heics", "image/heic-sequence"),
988    ("heif", "image/heif"),
989    ("heifs", "image/heif-sequence"),
990    ("avif", "image/avif"),
991    ("jxl", "image/jxl"),
992    ("svg", "image/svg+xml"),
993    ("mp4", "video/mp4"),
994    ("m4v", "video/mp4"),
995    ("mov", "video/quicktime"),
996    ("avi", "video/x-msvideo"),
997    ("wav", "audio/wav"),
998    ("mp3", "audio/mpeg"),
999    ("m4a", "audio/mp4"),
1000    ("aac", "audio/aac"),
1001    ("flac", "audio/flac"),
1002    ("ogg", "audio/ogg"),
1003    ("oga", "audio/ogg"),
1004    ("pdf", "application/pdf"),
1005    ("epub", "application/epub+zip"),
1006    (
1007        "docx",
1008        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1009    ),
1010    (
1011        "xlsx",
1012        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1013    ),
1014    (
1015        "pptx",
1016        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1017    ),
1018    (
1019        "dotx",
1020        "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
1021    ),
1022    ("docm", "application/vnd.ms-word.document.macroenabled.12"),
1023    ("dotm", "application/vnd.ms-word.template.macroenabled.12"),
1024    (
1025        "xltx",
1026        "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
1027    ),
1028    ("xlsm", "application/vnd.ms-excel.sheet.macroenabled.12"),
1029    ("xltm", "application/vnd.ms-excel.template.macroenabled.12"),
1030    (
1031        "xlsb",
1032        "application/vnd.ms-excel.sheet.binary.macroenabled.12",
1033    ),
1034    (
1035        "ppsx",
1036        "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
1037    ),
1038    (
1039        "potx",
1040        "application/vnd.openxmlformats-officedocument.presentationml.template",
1041    ),
1042    (
1043        "pptm",
1044        "application/vnd.ms-powerpoint.presentation.macroenabled.12",
1045    ),
1046    (
1047        "ppsm",
1048        "application/vnd.ms-powerpoint.slideshow.macroenabled.12",
1049    ),
1050    (
1051        "potm",
1052        "application/vnd.ms-powerpoint.template.macroenabled.12",
1053    ),
1054    ("vsdx", "application/vnd.ms-visio.drawing"),
1055    ("vsdm", "application/vnd.ms-visio.drawing.macroenabled.12"),
1056    ("vssx", "application/vnd.ms-visio.stencil"),
1057    ("vssm", "application/vnd.ms-visio.stencil.macroenabled.12"),
1058    ("vstx", "application/vnd.ms-visio.template"),
1059    ("vstm", "application/vnd.ms-visio.template.macroenabled.12"),
1060    ("oxps", "application/oxps"),
1061    ("xps", "application/vnd.ms-xpsdocument"),
1062    ("odt", "application/vnd.oasis.opendocument.text"),
1063    ("ods", "application/vnd.oasis.opendocument.spreadsheet"),
1064    ("odp", "application/vnd.oasis.opendocument.presentation"),
1065    ("odg", "application/vnd.oasis.opendocument.graphics"),
1066    ("ttf", "font/ttf"),
1067    ("otf", "font/otf"),
1068    ("sfnt", "font/sfnt"),
1069    ("txt", "text/plain"),
1070    ("tsv", "text/tab-separated-values"),
1071    ("csv", "text/csv"),
1072    ("md", "text/markdown"),
1073    ("markdown", "text/markdown"),
1074    ("html", "text/html"),
1075    ("htm", "text/html"),
1076    ("xhtml", "application/xhtml+xml"),
1077    ("xml", "application/xml"),
1078    ("css", "text/css"),
1079    ("js", "application/javascript"),
1080    ("mjs", "application/javascript"),
1081    ("json", "application/json"),
1082    ("yaml", "application/yaml"),
1083    ("yml", "application/yaml"),
1084    ("toml", "application/toml"),
1085    ("py", "text/x-python"),
1086];
1087
1088/// Infer a MIME type from a filename extension, case-insensitively.
1089pub fn mime_from_path(path: &Path) -> Option<&'static str> {
1090    let extension = path.extension()?.to_str()?.to_ascii_lowercase();
1091    SUPPORTED_EXTENSIONS
1092        .iter()
1093        .find(|(candidate, _)| *candidate == extension)
1094        .map(|(_, mime)| *mime)
1095}
1096
1097enum ResolvedTrust {
1098    Bundled(&'static TrustList),
1099    Owned(TrustList),
1100}
1101
1102impl ResolvedTrust {
1103    fn get(&self) -> &TrustList {
1104        match self {
1105            Self::Bundled(trust) => trust,
1106            Self::Owned(trust) => trust,
1107        }
1108    }
1109}
1110
1111/// Resolve one purpose's trust store: the bundled snapshot, the caller's PEM
1112/// bundle, or the snapshot extended by it.
1113///
1114/// Caller-supplied anchors take the configured `bounds`; bundled snapshots stay
1115/// unbounded, because their trust window is the snapshot itself.
1116///
1117/// CAWG identity material a caller supplies is an entry that caller configured
1118/// as the validator, so it is accepted under the CAWG Identity 1.3 base trust
1119/// model rather than under the interim S/MIME additions, which belong to the
1120/// two root stores that section names.
1121fn resolve_trust(
1122    custom_pem: Option<&str>,
1123    bundled: Option<&'static TrustList>,
1124    purpose: AnchorPurpose,
1125    bounds: (Option<OffsetDateTime>, Option<OffsetDateTime>),
1126) -> Result<Option<ResolvedTrust>, Error> {
1127    let custom = custom_pem
1128        .map(|pem| TrustList::from_pem_for(purpose, pem))
1129        .transpose()
1130        .map_err(|error| Error::InvalidTrust(error.to_string()))?
1131        .map(|trust| trust.with_bounds(bounds.0, bounds.1))
1132        .map(|trust| match purpose {
1133            AnchorPurpose::CawgIdentity => trust.with_cawg_source(CawgTrustSource::CallerSupplied),
1134            _ => trust,
1135        });
1136    match (bundled, custom) {
1137        (None, None) => Ok(None),
1138        (Some(trust), None) => Ok(Some(ResolvedTrust::Bundled(trust))),
1139        (None, Some(trust)) => Ok(Some(ResolvedTrust::Owned(trust))),
1140        (Some(bundled), Some(custom)) => {
1141            let mut merged = bundled.clone();
1142            merged.anchors.extend(custom.anchors);
1143            Ok(Some(ResolvedTrust::Owned(merged)))
1144        }
1145    }
1146}
1147
1148fn parse_validation_time(value: Option<&str>) -> Result<OffsetDateTime, Error> {
1149    match value {
1150        Some(raw) => OffsetDateTime::parse(raw, &Rfc3339)
1151            .map_err(|error| Error::InvalidValidationTime(error.to_string())),
1152        None => Ok(OffsetDateTime::now_utc()),
1153    }
1154}
1155
1156/// Parse an optional RFC 3339 trust-anchor bound, naming the field in the error
1157/// so a caller can tell which of the two bounds it mistyped.
1158fn parse_optional_instant(
1159    value: Option<&str>,
1160    field: &str,
1161) -> Result<Option<OffsetDateTime>, Error> {
1162    value
1163        .map(|raw| {
1164            OffsetDateTime::parse(raw, &Rfc3339)
1165                .map_err(|error| Error::InvalidValidationTime(format!("{field}: {error}")))
1166        })
1167        .transpose()
1168}
1169
1170fn copy_status(status: &CoreStatus) -> VerificationStatus {
1171    VerificationStatus {
1172        code: status.code.clone(),
1173        url: status.url.clone(),
1174        explanation: status.explanation.clone(),
1175        details: status.details.clone(),
1176    }
1177}
1178
1179fn copy_results(results: &CoreResults) -> ValidationResults {
1180    ValidationResults {
1181        success: results.success.iter().map(copy_status).collect(),
1182        informational: results.informational.iter().map(copy_status).collect(),
1183        failure: results.failure.iter().map(copy_status).collect(),
1184    }
1185}
1186
1187fn signature_status(results: &CoreResults) -> String {
1188    if results.has_success(CLAIM_SIGNATURE_VALIDATED) {
1189        "valid"
1190    } else if results.has_failure(CLAIM_SIGNATURE_MISMATCH) {
1191        "invalid"
1192    } else if results.has_failure(CLAIM_SIGNATURE_MISSING) {
1193        "missing"
1194    } else {
1195        "unknown"
1196    }
1197    .to_string()
1198}
1199
1200fn hard_binding_status(results: &CoreResults) -> String {
1201    const MATCHES: &[&str] = &[
1202        ASSERTION_DATA_HASH_MATCH,
1203        ASSERTION_BMFF_HASH_MATCH,
1204        ASSERTION_BOXES_HASH_MATCH,
1205        ASSERTION_COLLECTION_HASH_MATCH,
1206        ASSERTION_MULTI_ASSET_HASH_MATCH,
1207    ];
1208    const FAILURES: &[&str] = &[
1209        ASSERTION_DATA_HASH_MISMATCH,
1210        ASSERTION_BMFF_HASH_MISMATCH,
1211        ASSERTION_BMFF_HASH_MALFORMED,
1212        ASSERTION_BOXES_HASH_MISMATCH,
1213        ASSERTION_BOXES_HASH_MALFORMED,
1214        ASSERTION_COLLECTION_HASH_MISMATCH,
1215        ASSERTION_COLLECTION_HASH_MALFORMED,
1216        ASSERTION_MULTI_ASSET_HASH_MISMATCH,
1217        ASSERTION_MULTI_ASSET_HASH_MALFORMED,
1218    ];
1219    // Checked before the match set, and it has to be: a manifest that binds
1220    // only its init segment DOES produce `assertion.bmffHash.match` over those
1221    // bytes, so a match-first read would answer "match" for a stream whose
1222    // media segments nothing covered. The axis answers "is every byte the
1223    // caller presented bound?", so an unbound or unauthenticated segment is a
1224    // mismatch no matter what else matched (PRD 1.1.1).
1225    if results.has_failure(crate::c2pa_validate::live_video::LIVEVIDEO_SEGMENT_INVALID) {
1226        "mismatch"
1227    } else if MATCHES.iter().any(|code| results.has_success(code)) {
1228        "match"
1229    } else if FAILURES.iter().any(|code| results.has_failure(code)) {
1230        "mismatch"
1231    } else if results.has_failure(CLAIM_HARD_BINDINGS_MISSING) {
1232        "missing"
1233    } else {
1234        "unknown"
1235    }
1236    .to_string()
1237}
1238
1239fn trust_report(
1240    results: &CoreResults,
1241    present: bool,
1242    basis: &str,
1243    validation_time: String,
1244) -> TrustReport {
1245    let supplied = basis != "none";
1246    let trusted = results.has_success(SIGNING_CREDENTIAL_TRUSTED);
1247    let rejected = results.has_failure(SIGNING_CREDENTIAL_UNTRUSTED)
1248        || results.has_failure(SIGNING_CREDENTIAL_INVALID);
1249    let revoked = results.has_failure(SIGNING_CREDENTIAL_OCSP_REVOKED);
1250    let not_revoked = results.has_success(SIGNING_CREDENTIAL_OCSP_NOT_REVOKED);
1251    TrustReport {
1252        status: if trusted && !revoked {
1253            "valid_for_supplied_material"
1254        } else if present && supplied && (rejected || revoked) {
1255            "not_valid_for_supplied_material"
1256        } else {
1257            "not_evaluated"
1258        }
1259        .to_string(),
1260        basis: basis.to_string(),
1261        validation_time,
1262        revocation: RevocationReport {
1263            status: if revoked {
1264                "revoked"
1265            } else if not_revoked {
1266                "not_revoked"
1267            } else {
1268                "not_checked"
1269            }
1270            .to_string(),
1271            source: if !(revoked || not_revoked) {
1272                "none"
1273            } else if results
1274                .success
1275                .iter()
1276                .chain(&results.failure)
1277                .filter(|status| {
1278                    status.code == SIGNING_CREDENTIAL_OCSP_REVOKED
1279                        || status.code == SIGNING_CREDENTIAL_OCSP_NOT_REVOKED
1280                })
1281                .any(|status| {
1282                    status
1283                        .details
1284                        .as_ref()
1285                        .and_then(|details| details.get("source"))
1286                        .and_then(Value::as_str)
1287                        == Some("online_ocsp")
1288                })
1289            {
1290                "online_ocsp"
1291            } else {
1292                "embedded_ocsp"
1293            }
1294            .to_string(),
1295            responder_signature: if revoked || not_revoked {
1296                "valid"
1297            } else {
1298                "not_applicable"
1299            }
1300            .to_string(),
1301        },
1302        freshness: FreshnessReport {
1303            status: "unknown".to_string(),
1304            as_of: None,
1305        },
1306    }
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311    use super::{
1312        mime_from_path, read_bounded_file, supported_mime_types, verify, verify_fragmented,
1313        verify_with_options, Error, VerifyOptions,
1314    };
1315    use std::{io::Cursor, path::Path};
1316
1317    #[test]
1318    fn known_filename_maps_to_mime() {
1319        assert_eq!(
1320            mime_from_path(Path::new("composition.MP4")),
1321            Some("video/mp4")
1322        );
1323
1324        assert_eq!(
1325            mime_from_path(Path::new("drawing.odg")),
1326            Some("application/vnd.oasis.opendocument.graphics")
1327        );
1328        assert_eq!(
1329            mime_from_path(Path::new("data.tsv")),
1330            Some("text/tab-separated-values")
1331        );
1332    }
1333
1334    /// A supported format with no extension can only be verified by passing
1335    /// its MIME type by hand, so a file named `report.oxps` failed on the
1336    /// command line as unsupported. Only alias names of a mapped type are exempt.
1337    #[test]
1338    fn every_supported_format_is_reachable_from_an_extension() {
1339        const ALIASES: &[&str] = &[
1340            "application/font-sfnt",
1341            "application/x-font-ttf",
1342            "application/mp4",
1343            "video/x-m4v",
1344            "text/xml",
1345        ];
1346        let unreachable: Vec<_> = supported_mime_types()
1347            .into_iter()
1348            .filter(|mime| !ALIASES.contains(mime))
1349            .filter(|mime| !super::SUPPORTED_EXTENSIONS.iter().any(|(_, m)| m == mime))
1350            .collect();
1351        assert!(
1352            unreachable.is_empty(),
1353            "no extension maps to {unreachable:?}"
1354        );
1355        assert_eq!(
1356            mime_from_path(Path::new("report.OXPS")),
1357            Some("application/oxps")
1358        );
1359    }
1360
1361    /// The trust block names where a revocation answer came from, so a reader
1362    /// can tell a stapled response from one fetched with the user's consent.
1363    #[test]
1364    fn revocation_source_distinguishes_online_from_embedded_ocsp() {
1365        use crate::c2pa_validate::{StatusCode, ValidationResults};
1366        let status = |source: Option<&str>| StatusCode {
1367            code: crate::c2pa_validate::SIGNING_CREDENTIAL_OCSP_NOT_REVOKED.into(),
1368            url: "self#jumbf=/c2pa/urn:c2pa:x/c2pa.signature".into(),
1369            explanation: "not revoked".into(),
1370            details: source.map(|source| serde_json::json!({ "source": source })),
1371        };
1372        let report = |source: Option<&str>| {
1373            let results = ValidationResults {
1374                success: vec![status(source)],
1375                ..ValidationResults::default()
1376            };
1377            super::trust_report(&results, true, "bundled_static_material", String::new())
1378                .revocation
1379                .source
1380        };
1381        assert_eq!(report(Some("online_ocsp")), "online_ocsp");
1382        assert_eq!(report(None), "embedded_ocsp");
1383    }
1384
1385    #[test]
1386    fn format_list_is_sorted_and_contains_composition_formats() {
1387        let formats = supported_mime_types();
1388        assert_eq!(formats.len(), 71);
1389        assert!(formats.windows(2).all(|pair| pair[0] < pair[1]));
1390        assert!(formats.contains(&"video/mp4"));
1391        assert!(formats.contains(&"image/jpeg"));
1392        assert!(formats.contains(&"text/tab-separated-values"));
1393        assert!(formats.contains(&"application/vnd.oasis.opendocument.graphics"));
1394    }
1395
1396    #[test]
1397    fn input_alias_is_reported_as_canonical_mime() {
1398        let asset = b"\x00\x00\x00\x10ftypisom\x00\x00\x00\x00";
1399        let report = verify(asset, "audio/aac; codecs=mp4a.40.2").unwrap();
1400        assert_eq!(report.mime_type, "audio/mp4");
1401        assert!(!report.present);
1402    }
1403
1404    #[test]
1405    fn strict_conformance_option_selects_the_program_profile() {
1406        let asset = include_bytes!("../../../tests/fixtures/signed_test.jpg");
1407        let report = verify_with_options(
1408            asset,
1409            "image/jpeg",
1410            &VerifyOptions {
1411                strict_conformance: true,
1412                ..VerifyOptions::default()
1413            },
1414        )
1415        .unwrap();
1416        assert_eq!(
1417            report.manifest_report["engine_profile"]["operating_mode"],
1418            "conformance"
1419        );
1420        assert_eq!(
1421            report.manifest_report["engine_profile"]["compliance_level"],
1422            "conformance-program"
1423        );
1424    }
1425
1426    #[test]
1427    fn fragmented_entry_point_rejects_non_bmff_mime() {
1428        let error = verify_fragmented(
1429            include_bytes!("../../../tests/fixtures/signed_test.jpg"),
1430            &[b"ignored fragment"],
1431            "image/jpeg",
1432        )
1433        .unwrap_err();
1434        assert!(matches!(error, Error::UnsupportedMime(mime) if mime == "image/jpeg"));
1435    }
1436
1437    #[test]
1438    fn unratified_hostless_store_is_not_in_public_profile() {
1439        let error = verify(b"jumb", "application/c2pa").unwrap_err();
1440        assert!(matches!(error, Error::UnsupportedMime(_)));
1441    }
1442
1443    #[test]
1444    fn bounded_reader_accepts_exact_limit_without_large_allocation() {
1445        let mut input = Cursor::new(b"1234");
1446        assert_eq!(read_bounded_file(&mut input, 4, 4).unwrap(), b"1234");
1447    }
1448
1449    #[test]
1450    fn bounded_reader_detects_growth_at_limit_plus_one() {
1451        let mut input = Cursor::new(b"12345");
1452        let error = read_bounded_file(&mut input, 4, 4).unwrap_err();
1453        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
1454        assert!(error.to_string().contains("grew while being read"));
1455    }
1456
1457    #[test]
1458    fn unsupported_mime_has_stable_error_code() {
1459        let error = verify(b"not an asset", "application/x-unknown").unwrap_err();
1460        assert!(matches!(error, Error::UnsupportedMime(_)));
1461        assert_eq!(error.code(), "unsupported_mime");
1462    }
1463}