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;
67pub use c2pa_validate::external_discovery::{
68    locate_external_resources, locate_external_resources_with_manifest_store,
69    ExternalDidDocumentRequest, ExternalResourceKind, ExternalResourcePlan,
70    ExternalResourceRequest, ExternalResourceStopReason, GraphLimitKind,
71};
72pub use c2pa_validate::ingredient_graph::{GraphInputs, GraphLimits};
73pub use c2pa_validate::refs::{ExternalResource, ExternalResources};
74mod config_dir;
75mod default_trust;
76mod ertmp;
77mod ertmp_wire;
78mod native_online;
79mod online;
80mod online_consent;
81pub use ertmp::{
82    verify_ertmp, verify_ertmp_with_options, ErtmpEvent, ErtmpInput, ErtmpManifestReport,
83    ErtmpMediaType, ErtmpSegmentReport, ErtmpTrack, ErtmpVerificationReport,
84};
85pub use ertmp_wire::{
86    ertmp_input_from_flv, ertmp_input_from_rtmp_connections, ErtmpConnectionContext,
87    ErtmpWireError, ErtmpWireEventContext, ErtmpWireInput, ErtmpWireLimits, FlvWireOptions,
88    RtmpWireConnection,
89};
90mod soft_binding;
91pub use soft_binding::{
92    request_soft_binding_receipt_verification, resolve_soft_binding, RecoveredSoftBindingManifest,
93    SoftBindingAlgorithm, SoftBindingAlgorithms, SoftBindingCapabilities,
94    SoftBindingDisclosureConsent, SoftBindingDiscovery, SoftBindingFetchRequest,
95    SoftBindingHttpMethod, SoftBindingMatch, SoftBindingQuery, SoftBindingReceiptEvidence,
96    SoftBindingReceiptVerificationSession, SoftBindingResolution, SoftBindingResolverOptions,
97    SoftBindingResolverSession,
98};
99mod stream;
100pub use stream::{
101    verify_stream, verify_stream_with_options, SegmentReport, StreamEncapsulation, StreamMethod,
102    StreamVerificationReport,
103};
104#[cfg(feature = "caller-verified-binding")]
105mod caller_verified_binding;
106mod telemetry;
107mod telemetry_consent;
108pub use c2pa_trust::TrustSource;
109#[cfg(feature = "caller-verified-binding")]
110pub use caller_verified_binding::{
111    binding_target, verify_fragmented_with_cawg, verify_manifest_store_only_with_cawg,
112    verify_with_caller_verified_binding, verify_with_caller_verified_binding_and_cawg,
113    verify_with_cawg, verify_with_manifest_store_and_cawg, CallerBindingEvidence,
114    CallerBindingVerdict, CawgEvaluation, CawgEvaluator, CawgProfile, CawgStoreHost,
115    ManifestBindingTarget,
116};
117pub use config_dir::config_directory;
118pub use default_trust::SNAPSHOT_DATE as DEFAULT_TRUST_SNAPSHOT_DATE;
119
120pub use online::{NetworkReport, NetworkRequest};
121pub use online_consent::{
122    allow_interactive_online_consent, online_preference, set_online_preference, OnlinePreference,
123    OnlinePreferenceError,
124};
125pub use telemetry::{
126    validation_failure_telemetry, TelemetryOptions, ValidationFailureTelemetry,
127    DEFAULT_TELEMETRY_ENDPOINT,
128};
129pub use telemetry_consent::{
130    prompt_for_telemetry_consent, set_telemetry_enabled, telemetry_preference,
131    TelemetryPreferenceError,
132};
133
134use std::collections::HashMap;
135use std::fs::{self, OpenOptions};
136use std::io::{self, Read};
137#[cfg(unix)]
138use std::os::unix::fs::OpenOptionsExt;
139use std::path::Path;
140
141use crate::c2pa_core::{
142    spec::{canonicalize_mime, mimes_for_version},
143    ComplianceLevel, EngineProfile, OperatingMode, SpecVersion,
144};
145use crate::c2pa_trust::{AnchorPurpose, CawgTrustSource, TrustList};
146use crate::c2pa_validate::{
147    verify_fragmented_with_cawg_options_and_expected_seeks_safe as verify_fragmented_safe,
148    verify_with_cawg_options_and_graph_inputs_safe as verify_graph_safe,
149    verify_with_cawg_options_safe as verify_safe, StatusCode as CoreStatus,
150    ValidationResults as CoreResults, VerifyInput, ASSERTION_BMFF_HASH_MALFORMED,
151    ASSERTION_BMFF_HASH_MATCH, ASSERTION_BMFF_HASH_MISMATCH, ASSERTION_BOXES_HASH_MALFORMED,
152    ASSERTION_BOXES_HASH_MATCH, ASSERTION_BOXES_HASH_MISMATCH, ASSERTION_COLLECTION_HASH_MALFORMED,
153    ASSERTION_COLLECTION_HASH_MATCH, ASSERTION_COLLECTION_HASH_MISMATCH, ASSERTION_DATA_HASH_MATCH,
154    ASSERTION_DATA_HASH_MISMATCH, ASSERTION_MULTI_ASSET_HASH_MALFORMED,
155    ASSERTION_MULTI_ASSET_HASH_MATCH, ASSERTION_MULTI_ASSET_HASH_MISMATCH,
156    CLAIM_HARD_BINDINGS_MISSING, CLAIM_SIGNATURE_MISMATCH, CLAIM_SIGNATURE_MISSING,
157    CLAIM_SIGNATURE_VALIDATED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_OCSP_NOT_REVOKED,
158    SIGNING_CREDENTIAL_OCSP_REVOKED, SIGNING_CREDENTIAL_TRUSTED, SIGNING_CREDENTIAL_UNTRUSTED,
159};
160use serde::{Deserialize, Serialize};
161use serde_json::Value;
162use sha2::{Digest, Sha256};
163use time::{format_description::well_known::Rfc3339, OffsetDateTime};
164
165pub const REPORT_SCHEMA_VERSION: &str = "1.0";
166/// Report `profile` of the explicitly selected C2PA 2.2 policy.
167pub const C2PA_PROFILE_2_2: &str = "c2pa-2.2";
168/// Report `profile` of the default C2PA 2.4 validation policy.
169pub const C2PA_PROFILE: &str = "c2pa-2.4";
170/// Report `profile` of the explicitly selected C2PA 2.5 policy.
171pub const C2PA_PROFILE_2_5: &str = "c2pa-2.5";
172/// Every value [`VerifyOptions::spec_version`] accepts. `None` means `"2.4"`.
173pub const SPEC_VERSIONS: &[&str] = &SpecVersion::PUBLIC_POLICY_STRINGS;
174const MAX_MANIFEST_STORE_BYTES: usize = 64 * 1024 * 1024;
175const MAX_PATH_ASSET_BYTES: u64 = 128 * 1024 * 1024;
176/// Largest DER `OCSPResponse` accepted as caller-supplied evidence.
177const MAX_OCSP_EVIDENCE_BYTES: usize = 64 * 1024;
178/// Largest external-data payload accepted as caller-supplied evidence.
179const MAX_EXTERNAL_DATA_EVIDENCE_BYTES: usize = 64 * 1024 * 1024;
180
181#[derive(Debug, Clone, Default, Deserialize)]
182#[serde(default, deny_unknown_fields)]
183pub struct VerifyOptions {
184    /// PEM bundle of claim-signing trust anchors.
185    pub trust_pem: Option<String>,
186    /// Claim-signing trust lists with reliable source metadata for C2PA 2.5
187    /// crJSON. Each source remains separate until the matching anchor is known.
188    pub claim_trust_sources: Option<Vec<TrustListSource>>,
189    /// PEM bundle of timestamp-authority trust anchors.
190    pub tsa_trust_pem: Option<String>,
191    /// Timestamp-authority trust lists with reliable source metadata for C2PA
192    /// 2.5 crJSON. These certificates never enter the claim-signing store.
193    pub tsa_trust_sources: Option<Vec<TrustListSource>>,
194    /// PEM bundle of directly allowed end-entity certificates.
195    pub allowed_list_pem: Option<String>,
196    /// PEM bundle of trust anchors for CAWG named-actor (identity) X.509
197    /// credentials. `None` leaves identity signers untrusted (their
198    /// well-formedness is still validated).
199    pub cawg_trust_pem: Option<String>,
200    /// PEM bundle of directly allowed CAWG end-entity certificates.
201    pub cawg_allowed_certs_pem: Option<String>,
202    /// Require an `id-kp-documentSigning` CAWG credential to chain to a
203    /// configured anchor or appear on the allowed list. `None` keeps the
204    /// default, `true`.
205    pub cawg_document_signing_require_anchor: Option<bool>,
206    /// Typed CAWG trust configurations, one per source. Each carries its
207    /// profile and its own trust window; see [`CawgTrustConfiguration`].
208    /// Appended after `cawg_trust_pem`/`cawg_allowed_certs_pem`, in order.
209    pub cawg_trust_configurations: Option<Vec<CawgTrustConfiguration>>,
210    /// RFC 3339 start of trust for caller-supplied PEM anchors. Before this
211    /// instant those anchors do not validate a signature, whatever the anchor
212    /// certificate's own `notBefore` says. Bundled snapshots and typed
213    /// `cawg_trust_configurations` are unaffected.
214    pub trust_anchor_not_before: Option<String>,
215    /// RFC 3339 end of trust for caller-supplied PEM anchors. After this
216    /// instant those anchors no longer validate a signature. Bundled snapshots
217    /// and typed `cawg_trust_configurations` are unaffected.
218    pub trust_anchor_not_after: Option<String>,
219    /// Disable the bundled C2PA, IPTC, and Encypher trust snapshots. By
220    /// default, caller-supplied PEM bundles extend those snapshots; setting
221    /// this to `true` evaluates only caller-supplied trust material.
222    pub no_default_trust: bool,
223    /// Pinned offline DID-document store for CAWG ICA issuers, keyed by
224    /// primary DID (no fragment). Resolution never touches the network.
225    pub cawg_did_documents: Option<HashMap<String, Value>>,
226    /// ICA issuer DIDs trusted directly by the caller.
227    pub cawg_ica_trusted_issuers: Option<Vec<String>>,
228    /// Trusted DID controller anchors. Issuers may trace to these anchors
229    /// through `controller` links in caller-pinned DID documents.
230    pub cawg_ica_trust_anchors: Option<Vec<String>>,
231    /// Offline, decompressed revocation bitstrings keyed by
232    /// `credentialStatus.statusListCredential` URI and encoded as standard
233    /// base64. Missing lists fail closed without network access.
234    pub cawg_ica_status_lists: Option<HashMap<String, String>>,
235    /// OCSP responses a caller obtained online, keyed by the lowercase hex
236    /// SHA-256 of the certificate's DER and encoded as standard base64 of the
237    /// DER `OCSPResponse`. An empty decoded value is the sentinel for a
238    /// response body that arrived but was discarded under a size bound; it is
239    /// received, unusable evidence rather than an unreachable responder.
240    ///
241    /// The SDK never fetches these for you at this layer: supply them from
242    /// your own OCSP client, or let the SDK's consent-gated fetcher do it. A
243    /// response is trusted no further than any other input - it must still be
244    /// signed by an authorized responder for the certificate in question, and
245    /// it is evaluated under the C2PA 2.4 and CAWG 1.3 online rules.
246    pub ocsp_responses: Option<HashMap<String, String>>,
247    /// Certificates whose OCSP responder was queried but delivered no
248    /// response, by the same lowercase hex SHA-256 key. A response that
249    /// arrived but was unusable belongs in `ocsp_responses`.
250    ///
251    /// Each entry registers `signingCredential.ocsp.inaccessible` or, for a
252    /// CAWG identity, `cawg.x509.ocsp.inaccessible`, in place of the
253    /// `ocsp.skipped` code a purely offline run reports.
254    pub ocsp_unreachable: Option<Vec<String>>,
255    /// Content of cloud-data and hashed external-reference assertions, keyed
256    /// by the URI the assertion declares and encoded as standard base64.
257    ///
258    /// The bytes are hashed against the assertion's own `alg`/`hash` pair
259    /// before anything else is done with them.
260    pub external_data: Option<HashMap<String, String>>,
261    /// Media types observed while retrieving `external_data`, keyed by the
262    /// same URI. Linked-resource integrity uses this to evaluate `dc:format`;
263    /// bytes without an observed type cannot be reported as a format match.
264    pub external_data_media_types: Option<HashMap<String, String>>,
265    /// External-resource URIs for which retrieval was attempted but yielded no
266    /// usable body. Linked-resource integrity reports these as `inaccessible`
267    /// rather than as resources the validator chose to skip.
268    pub external_data_unreachable: Option<Vec<String>>,
269    /// Zero-based indexes into supplied fragments or stream segments where the
270    /// player expects a discontinuity. Values must be unique, strictly
271    /// increasing, and in bounds. Ignored by single-asset verification.
272    pub expected_seek_positions: Vec<usize>,
273    /// C2PA validation policy: `"2.2"`, `"2.4"`, or `"2.5"`. `None` is the
274    /// default and is identical to `"2.4"`, preserving current behavior and
275    /// report bytes. The caller selects this policy; a claim's declared
276    /// `specVersion` never selects or upgrades it.
277    ///
278    /// `"2.5"` validates under the published C2PA 2.5 standard (specs-core
279    /// tag `2.5`, `d538a737`): its text-carrier locating rules, padding admission,
280    /// action and assertion-metadata time checks, `relatedAssertions`, linked
281    /// resource integrity, `claim.actions.missing`,
282    /// `manifest.inaccessible.malformed`, BMFF hash rules, and the ESP claim
283    /// signature algorithms. Reports carry `profile: "c2pa-2.5"`. With
284    /// `strict_conformance`, 2.5 uses strict technical validation under the
285    /// core-spec compliance bar; this is not a certified or published
286    /// Conformance Program profile. It also reads the carriers 2.5 adds
287    /// (append-only text, WARC, MIDI, TTML, ONNX, SafeTensors, Parquet,
288    /// BlockFact, WebVTT and other structured text) and validates
289    /// `c2pa.hash.segment` bindings; see [`supported_mime_types_for`].
290    ///
291    /// Any other value fails with [`Error::UnsupportedSpecVersion`] before
292    /// asset I/O. The policy is never inferred from what a manifest declares.
293    pub spec_version: Option<String>,
294    /// Apply strict technical validation. Under the default 2.4 policy this is
295    /// the C2PA 2.4 Conformance Program posture, including its upgraded SHOULD
296    /// requirements. Under the 2.5 policy it selects conformance mode with the
297    /// core-spec compliance bar and strict diagnostics; it does not claim
298    /// certified scope or an unpublished 2.5 Conformance Program. The flag
299    /// also requires CAWG Identity 1.3 deterministic `signer_payload` CBOR; the
300    /// default accepts the CAWG 1.1 field order with
301    /// `com.encypher.cawg.legacyProfile`.
302    pub strict_conformance: bool,
303    /// Refuse the CAWG field-order `signer_payload` encoding that c2pa-rs
304    /// writes, without applying the rest of the conformance posture. When
305    /// unset, the default accepts it with the informational
306    /// `com.encypher.cawg.legacyProfile`; `strict_conformance` refuses it
307    /// either way.
308    pub cawg_strict_encoding: bool,
309    /// RFC 3339 validation instant. Current UTC time is used when omitted.
310    pub validation_time: Option<String>,
311    /// Trusted presentation-assessment instants for stream segments, RFC 3339,
312    /// exactly one per supplied segment and in the same order. Entry `i` is
313    /// the instant at which segment `i`'s C2PA 2.4 session-key validity window
314    /// is judged. `validation_time` still governs certificate and trust-list
315    /// evaluation of the init manifest; these instants never move that clock.
316    ///
317    /// The caller vouches for every entry. Supply each from your own trusted
318    /// record of when that segment was presented, such as an ingest log or a
319    /// packager clock you operate. The verifier never derives them from a
320    /// segment's own signed `iat`: that self-asserted time remains an
321    /// additional check inside the key window and never substitutes for the
322    /// caller's instant. `None` assesses every segment at `validation_time`,
323    /// which itself defaults to now. A time stamp on the init manifest proves
324    /// when the init segment was signed; it does not time stamp later
325    /// segments, so a historical archive needs these instants to verify
326    /// segments whose keys have since expired.
327    ///
328    /// Sequence continuity is still enforced across the whole supplied run, so
329    /// segments signed under different session keys, each valid at its own
330    /// instant, verify as one unbroken stream. Applies only to session-key
331    /// live streams verified through [`verify_stream_with_options`]; a
332    /// Merkle-bound or per-segment stream refuses it, as does
333    /// [`verify_fragmented_with_options`]. Single-asset verification ignores
334    /// it. A malformed entry, or a count that differs from the number of
335    /// segments supplied, fails the call before any segment is judged.
336    pub stream_presentation_times: Option<Vec<String>>,
337    /// Failure telemetry override. `None` uses the saved per-user preference.
338    pub telemetry: TelemetryOptions,
339    /// Allow this verification to fetch what the asset references: a manifest
340    /// store held elsewhere, certificate revocation status, a `did:web`
341    /// document, externally stored assertion content. `None` reads the
342    /// `ENCYPHER_C2PA_ONLINE` environment variable and otherwise stays
343    /// offline.
344    ///
345    /// A library call never consults the saved per-user choice and never
346    /// prompts: the caller decides here, or the operator decides through the
347    /// environment. Whatever is fetched is used as evidence only. The verdict
348    /// is still reached by the same offline kernel.
349    pub online: Option<bool>,
350    /// Intranet mode: let online checks contact loopback, private, and
351    /// link-local addresses, and accept plaintext `http` for every purpose
352    /// rather than for OCSP alone.
353    ///
354    /// Off by default: a file that names an internal host would otherwise turn
355    /// this verifier into a probe of the network it runs on. Turn it on for a
356    /// deployment whose manifest repository or OCSP responder lives on a
357    /// private address, and for tests that serve from `127.0.0.1`. Setting it
358    /// declares that this machine's own network is trusted; do not set it on a
359    /// host that verifies files sent in by strangers.
360    pub online_allow_private_networks: bool,
361}
362
363/// Caller-supplied external resource evidence decoded while validating all
364/// verification options.
365///
366/// Network adapters that retain an owned [`ExternalResources`] provider use
367/// this to avoid decoding large base64 bodies again before final verification.
368#[derive(Debug, Default)]
369pub struct ValidatedExternalData {
370    /// Decoded bodies keyed by the URI declared in signed manifest content.
371    pub data: HashMap<String, Vec<u8>>,
372    /// Observed media types keyed by the same URI.
373    pub media_types: HashMap<String, String>,
374    /// URIs the caller attempted but could not retrieve.
375    pub unreachable: Vec<String>,
376}
377
378impl VerifyOptions {
379    /// Resolve and validate every option without reading or parsing an asset.
380    ///
381    /// Path-based adapters call this before opening asset files so malformed
382    /// trust material and invalid validation instants fail deterministically.
383    /// The per-segment count of `stream_presentation_times` needs the segments
384    /// and is checked by the stream entry point itself.
385    pub fn validate(&self) -> Result<(), Error> {
386        self.resolve_and_validate()?;
387        Ok(())
388    }
389
390    /// Validate every option and retain decoded external-data evidence.
391    ///
392    /// This has the same error ordering and checks as [`Self::validate`].
393    pub fn validate_with_external_data(&self) -> Result<ValidatedExternalData, Error> {
394        let resolved = self.resolve_and_validate()?;
395        Ok(ValidatedExternalData {
396            data: resolved.external_data,
397            media_types: resolved.external_data_media_types,
398            unreachable: resolved.external_data_unreachable,
399        })
400    }
401
402    fn resolve_and_validate(&self) -> Result<ResolvedOptions, Error> {
403        let resolved = ResolvedOptions::resolve(self)?;
404        for (index, raw) in self.stream_presentation_times.iter().flatten().enumerate() {
405            parse_stream_presentation_time(raw, index)?;
406        }
407        Ok(resolved)
408    }
409}
410
411/// One caller-supplied trust list with provenance retained for C2PA 2.5 crJSON.
412///
413/// `uri` identifies the list and is never fetched. `source_type` is emitted as
414/// the required `type` member. Only a source whose certificate actually
415/// accepted the claim signer or timestamp signer may be reported.
416#[derive(Debug, Clone, Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct TrustListSource {
419    /// URI identifying this trust list. It need not be resolvable.
420    pub uri: String,
421    /// Standard trust-list type, read from the `type` JSON member.
422    #[serde(rename = "type")]
423    pub source_type: String,
424    /// Optional list-specific identifier.
425    #[serde(default)]
426    pub identifier: Option<String>,
427    /// PEM certificates supplied by this source.
428    pub certificates_pem: String,
429    /// Inclusive RFC 3339 start of this source's trust window.
430    #[serde(default)]
431    pub not_before: Option<String>,
432    /// Inclusive RFC 3339 end of this source's trust window.
433    #[serde(default)]
434    pub not_after: Option<String>,
435}
436
437/// One entry of the CAWG Identity 1.3 trust configuration a caller supplies.
438///
439/// Certificates are placed by what they are, not by the list they came from:
440/// a CA certificate, or a self-issued X.509 v1 certificate without
441/// BasicConstraints, anchors chains; any other certificate is accepted only
442/// by direct match.
443#[derive(Debug, Clone, Deserialize)]
444#[serde(deny_unknown_fields)]
445pub struct CawgTrustConfiguration {
446    /// Which trust-model rules this entry is accepted under.
447    pub profile: CawgTrustProfile,
448    /// PEM bundle of the entry's certificates. CA and end-entity certificates
449    /// may be mixed. Must hold at least one certificate.
450    pub certificates_pem: String,
451    /// RFC 3339 start of this entry's trust. A signature whose time stamp, or
452    /// without one the validation time, precedes it is not validated by it.
453    #[serde(default)]
454    pub not_before: Option<String>,
455    /// RFC 3339 end of this entry's trust. A signature whose time stamp, or
456    /// without one the validation time, follows it is not validated by it.
457    #[serde(default)]
458    pub not_after: Option<String>,
459}
460
461/// The CAWG Identity 1.3 rules a [`CawgTrustConfiguration`] is accepted under.
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
463#[serde(rename_all = "snake_case")]
464pub enum CawgTrustProfile {
465    /// An entry the validator configured itself. Accepts
466    /// `id-kp-documentSigning`, and, as local validator policy,
467    /// `id-kp-emailProtection` with one of the six CA/Browser Forum S/MIME
468    /// policies, without the interim time conditions. A direct certificate
469    /// match reports `trust_source: allowed_list`; an anchored
470    /// `id-kp-documentSigning` match reports `document_signing`; an anchored
471    /// `id-kp-emailProtection` match reports `caller_supplied`.
472    Base,
473    /// One of the two sources the interim trust model additions name: the
474    /// Mozilla root store with the email trust bit, or the IPTC Verified News
475    /// Publishers lists. Accepts only `id-kp-emailProtection` with one of the
476    /// six policies, and only while the validation time, or a trusted time
477    /// stamp, is on or before 31 March 2027. A direct certificate match reports
478    /// `trust_source: allowed_list`; an anchored match reports
479    /// `smime_interim`.
480    SmimeInterim,
481}
482
483#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
484pub struct VerificationStatus {
485    pub code: String,
486    #[serde(default)]
487    pub url: String,
488    /// Linked resource URI for a per-entry LRI status, serialized as `resourceUrl`.
489    #[serde(default, rename = "resourceUrl")]
490    pub resource_url: Option<String>,
491    pub explanation: String,
492    /// Machine-readable evidence for extension status codes (e.g. the CAWG
493    /// `payload_encoding` detail).
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub details: Option<Value>,
496}
497
498impl Serialize for VerificationStatus {
499    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
500        crate::c2pa_validate::serialize_status(
501            serializer,
502            &self.code,
503            &self.url,
504            self.resource_url.as_deref(),
505            &self.explanation,
506            self.details.as_ref(),
507        )
508    }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
512pub struct ValidationResults {
513    pub success: Vec<VerificationStatus>,
514    pub informational: Vec<VerificationStatus>,
515    pub failure: Vec<VerificationStatus>,
516    /// Trust lists whose anchors accepted the active claim or timestamp.
517    #[serde(default, skip_serializing_if = "Vec::is_empty")]
518    pub trust_list_sources: Vec<TrustSource>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
522pub struct RevocationReport {
523    pub status: String,
524    pub source: String,
525    pub responder_signature: String,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
529pub struct FreshnessReport {
530    pub status: String,
531    pub as_of: Option<String>,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
535pub struct TrustReport {
536    pub status: String,
537    pub basis: String,
538    pub validation_time: String,
539    pub revocation: RevocationReport,
540    pub freshness: FreshnessReport,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct VerificationReport {
545    pub schema_version: String,
546    pub profile: String,
547    pub mime_type: String,
548    pub present: bool,
549    pub integrity: String,
550    pub signature: String,
551    pub hard_binding: String,
552    pub trust: TrustReport,
553    pub policy: Option<Value>,
554    pub managed_receipt: Option<Value>,
555    pub validation_state: String,
556    pub validation_results: ValidationResults,
557    pub manifest_report: Value,
558    pub content_credentials: Option<Value>,
559    /// What this verification did, or could have done, on the network.
560    #[serde(default)]
561    pub network: NetworkReport,
562}
563/// Raw, read-only evidence needed to validate an embedded manifest remotely
564/// without uploading the host asset.
565///
566/// `carrier` is the single contiguous format carrier that contains
567/// `manifest_store`. Formats whose manifest spans multiple disjoint carriers
568/// return no detached evidence; local verification still covers them.
569#[derive(Debug, Clone, PartialEq, Eq)]
570pub struct DetachedManifestEvidence {
571    pub manifest_store: Vec<u8>,
572    pub manifest_store_sha256: String,
573    pub carrier: Vec<u8>,
574}
575
576/// One local verification plus what a service needs to re-verify the same
577/// manifest store without receiving the asset.
578///
579/// Every field comes from a single verification pass. `manifest_store` is the
580/// exact embedded store that pass read, so a remote verifier checks the bytes
581/// the local verdict was computed over, not a second extraction.
582#[derive(Debug, Clone, Serialize)]
583pub struct LocalEvidence {
584    /// Exactly what [`verify_with_options`] returns for the same inputs.
585    pub report: VerificationReport,
586    /// The embedded manifest-store bytes that were verified. `None` when the
587    /// asset carries no embedded store, or when the verdict came from a store
588    /// fetched from a remote reference the asset declares.
589    #[serde(serialize_with = "serialize_optional_bytes")]
590    pub manifest_store: Option<Vec<u8>>,
591    /// Lowercase hex SHA-256 of `manifest_store`.
592    pub manifest_store_sha256: Option<String>,
593    /// Lowercase hex SHA-256 of the whole asset.
594    pub asset_sha256: String,
595    /// The active manifest's hard binding and this pass's verdict on it.
596    pub hard_binding: LocalHardBinding,
597    /// Label of the store's active manifest, when `manifest_store` parses.
598    pub active_manifest_label: Option<String>,
599    /// Every manifest of `manifest_store` with the expanded-domain SHA-256 an
600    /// ingredient reference to it uses, in store order. Empty without a
601    /// readable store.
602    pub store_manifests: Vec<ManifestDigest>,
603}
604
605/// One manifest of a store and its expanded-domain SHA-256, the digest an
606/// ingredient's `activeManifest` reference to it carries.
607#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
608pub struct ManifestDigest {
609    /// The manifest's label.
610    pub label: String,
611    /// Lowercase hex SHA-256 over the manifest's expanded JUMBF content.
612    pub sha256: String,
613}
614
615/// The active label and manifest digests of `manifest_store`; empty when it
616/// does not parse (the report then already carries the reason).
617fn manifest_identity(manifest_store: Option<&[u8]>) -> (Option<String>, Vec<ManifestDigest>) {
618    let Some(store) = manifest_store else {
619        return (None, Vec::new());
620    };
621    match std::panic::catch_unwind(|| crate::c2pa_validate::store_manifest_identity(store)) {
622        Ok(Ok((active, digests))) => (
623            active,
624            digests
625                .into_iter()
626                .map(|digest| ManifestDigest {
627                    label: digest.label,
628                    sha256: hex::encode(digest.sha256),
629                })
630                .collect(),
631        ),
632        _ => (None, Vec::new()),
633    }
634}
635
636/// The hard binding a [`LocalEvidence`] pass checked.
637#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
638pub struct LocalHardBinding {
639    /// Label of the active manifest's sole hard-binding assertion, such as
640    /// `c2pa.hash.boxes`. An update manifest reports the binding it inherits
641    /// from its parent standard manifest. `None` when there is no store, the
642    /// claim cannot be decoded, or it does not declare exactly one.
643    pub algorithm: Option<String>,
644    /// `match`, `mismatch`, or `unknown`, from the same pass's status codes.
645    pub status: String,
646}
647
648/// What one manifest store proves with no asset: claim, assertion,
649/// ingredient, signature, time-stamp, revocation, trust and identity
650/// validation, and nothing about content.
651///
652/// This is the evidence a writer records for a source or ingredient it holds
653/// only as a store. `report.hard_binding` is `not_evaluated`, and
654/// `report.integrity` is `unbound` when no failure invalidates the claim
655/// (`invalid` otherwise): no asset bytes were compared, so the result never
656/// says the asset is intact.
657#[derive(Debug, Clone, Serialize)]
658pub struct StoreOnlyValidation {
659    /// The report of the store-level pass.
660    pub report: VerificationReport,
661    /// Label of the store's active manifest.
662    pub active_manifest_label: Option<String>,
663    /// Every manifest of the store with its expanded-domain SHA-256.
664    pub store_manifests: Vec<ManifestDigest>,
665}
666
667/// Bytes go to `serialize_bytes`, so a JavaScript binding receives a
668/// `Uint8Array` rather than an array of numbers.
669fn serialize_optional_bytes<S: serde::Serializer>(
670    value: &Option<Vec<u8>>,
671    serializer: S,
672) -> Result<S::Ok, S::Error> {
673    match value {
674        Some(bytes) => serializer.serialize_bytes(bytes),
675        None => serializer.serialize_none(),
676    }
677}
678
679/// What one verification pass read besides its report.
680#[derive(Default)]
681struct PassCapture {
682    manifest_store: Option<Vec<u8>>,
683    hard_binding_label: Option<String>,
684}
685
686impl PassCapture {
687    fn take(output: &mut crate::c2pa_validate::VerifyOutput) -> Self {
688        Self {
689            manifest_store: output.manifest_store.take(),
690            hard_binding_label: output.hard_binding_label.take(),
691        }
692    }
693}
694
695impl VerificationReport {
696    pub fn to_json(&self) -> Result<String, Error> {
697        serde_json::to_string(self).map_err(Error::Serialize)
698    }
699
700    pub fn to_pretty_json(&self) -> Result<String, Error> {
701        serde_json::to_string_pretty(self).map_err(Error::Serialize)
702    }
703
704    /// All CAWG identity / ICA credential statuses (`cawg.*` codes), across
705    /// the success, informational, and failure sections. CAWG failures are
706    /// assertion-scoped: they never invalidate C2PA manifest integrity.
707    pub fn cawg_statuses(&self) -> Vec<&VerificationStatus> {
708        self.validation_results
709            .success
710            .iter()
711            .chain(&self.validation_results.informational)
712            .chain(&self.validation_results.failure)
713            .filter(|status| status.code.starts_with("cawg."))
714            .collect()
715    }
716}
717
718#[derive(Debug, thiserror::Error)]
719pub enum Error {
720    #[error("unsupported MIME type: {0}")]
721    UnsupportedMime(String),
722    #[error("invalid trust material: {0}")]
723    InvalidTrust(String),
724    #[error("invalid validation time: {0}")]
725    InvalidValidationTime(String),
726    #[error("unsupported spec version: {0}")]
727    UnsupportedSpecVersion(String),
728    #[error("verification failed: {0}")]
729    Verification(String),
730    #[error("could not read asset: {0}")]
731    Io(#[from] std::io::Error),
732    #[error(transparent)]
733    TelemetryPreference(#[from] TelemetryPreferenceError),
734    #[error(transparent)]
735    OnlinePreference(#[from] OnlinePreferenceError),
736    #[error("could not serialize report: {0}")]
737    Serialize(serde_json::Error),
738}
739
740impl Error {
741    pub fn code(&self) -> &'static str {
742        match self {
743            Self::UnsupportedMime(_) => "unsupported_mime",
744            Self::InvalidTrust(_) => "invalid_trust_material",
745            Self::InvalidValidationTime(_) => "invalid_validation_time",
746            Self::UnsupportedSpecVersion(_) => "unsupported_spec_version",
747            Self::Verification(_) => "verification_error",
748            Self::Io(_) => "io_error",
749            Self::OnlinePreference(_) => "online_preference_error",
750            Self::TelemetryPreference(_) => "telemetry_preference_error",
751            Self::Serialize(_) => "serialization_error",
752        }
753    }
754}
755
756/// Verify asset bytes with default options. Parsing and validation stay offline;
757/// saved failure-telemetry consent may emit one bounded request. For guaranteed
758/// no egress, disable the `telemetry` feature or call [`verify_with_options`]
759/// with `telemetry.enabled` set to `Some(false)`.
760pub fn verify(data: &[u8], mime_type: &str) -> Result<VerificationReport, Error> {
761    verify_with_options(data, mime_type, &VerifyOptions::default())
762}
763
764/// Verify a fragmented ISO BMFF stream with default options.
765///
766/// `init_segment` carries the manifest. Each entry in `fragments` is one media
767/// segment (`.m4s`). Any contiguous subset may be supplied: each segment's
768/// Merkle-tree location is authenticated and adjacency is enforced.
769pub fn verify_fragmented(
770    init_segment: &[u8],
771    fragments: &[&[u8]],
772    mime_type: &str,
773) -> Result<VerificationReport, Error> {
774    verify_fragmented_with_options(
775        init_segment,
776        fragments,
777        mime_type,
778        &VerifyOptions::default(),
779    )
780}
781/// Extract the signed manifest store and its contiguous carrier for detached
782/// server validation, locating it under the default C2PA 2.4 policy.
783///
784/// This is read-only. It never performs network I/O and never constructs or
785/// writes a manifest. `Ok(None)` means the asset has no embedded manifest or
786/// its format cannot represent the manifest as one contiguous carrier.
787pub fn detached_manifest_evidence(
788    data: &[u8],
789    mime_type: &str,
790) -> Result<Option<DetachedManifestEvidence>, Error> {
791    detached_manifest_evidence_with_options(data, mime_type, &VerifyOptions::default())
792}
793
794/// [`detached_manifest_evidence`] under the policy `options.spec_version`
795/// selects. A 2.5 extraction therefore returns exactly the store and carrier
796/// span that a 2.5 verification reads.
797///
798/// Only `spec_version` and `strict_conformance` are read; an unknown policy
799/// fails with [`Error::UnsupportedSpecVersion`] before the asset is parsed.
800pub fn detached_manifest_evidence_with_options(
801    data: &[u8],
802    mime_type: &str,
803    options: &VerifyOptions,
804) -> Result<Option<DetachedManifestEvidence>, Error> {
805    let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
806    let mime = canonicalize_mime(mime_type);
807    require_embedded_mime(&mime, profile)?;
808    let format = crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime)
809        .ok_or_else(|| Error::UnsupportedMime(mime.clone()))?;
810    if !crate::c2pa_formats::supports_hash_mode(&mime) {
811        return Ok(None);
812    }
813    let Some(manifest_store) =
814        crate::c2pa_formats::extract_manifest_for_spec(format, data, profile.version)
815            .map_err(|error| Error::Verification(error.to_string()))?
816    else {
817        return Ok(None);
818    };
819    let spans =
820        crate::c2pa_formats::compute_data_hash_exclusions_for_spec(format, data, profile.version)
821            .map_err(|error| Error::Verification(error.to_string()))?;
822    let [span] = spans.as_slice() else {
823        return Ok(None);
824    };
825    let end = span
826        .start
827        .checked_add(span.length)
828        .filter(|end| *end <= data.len())
829        .ok_or_else(|| Error::Verification("manifest carrier exceeds asset bounds".into()))?;
830    let manifest_store_sha256 = hex::encode(Sha256::digest(&manifest_store));
831    Ok(Some(DetachedManifestEvidence {
832        manifest_store,
833        manifest_store_sha256,
834        carrier: data[span.start..end].to_vec(),
835    }))
836}
837
838/// Verify one asset against a manifest store the caller supplies separately.
839///
840/// This is the entry point for provenance that does not travel inside the
841/// asset: a `.c2pa` sidecar, a manifest fetched from the URI an asset declares
842/// in its XMP `dcterms:provenance` key, or a store held in a repository. The
843/// SDK never fetches any of those. It reads what the caller hands it.
844///
845/// The manifest binds to `asset` through its ordinary hard binding, evaluated
846/// exactly as it would be for an embedded manifest, so a store paired with the
847/// wrong or altered asset fails on the hash rather than on a weaker check.
848/// Trust material, validation instant, CAWG identity configuration, and the
849/// conformance posture all come from `options`, as they do for
850/// [`verify_with_options`].
851///
852/// `mime_type` describes `asset`, not the store. `application/c2pa` is
853/// rejected: the content side must be a real asset.
854/// WebM, ICO, BMP, raw AAC, and WOFF media are supported only on this explicit
855/// sidecar path, using a whole-file data hash without exclusions.
856pub fn verify_with_manifest_store(
857    asset: &[u8],
858    manifest_store: &[u8],
859    mime_type: &str,
860    options: &VerifyOptions,
861) -> Result<VerificationReport, Error> {
862    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
863    let result = verify_with_manifest_store_inner(asset, manifest_store, mime_type, options);
864    enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
865    result
866}
867
868/// [`verify_with_manifest_store`] with caller-controlled external resource
869/// resolution and lowered graph ceilings. The manifest store and external
870/// resources are supplied by the caller; this function performs no network
871/// I/O and completes the graph and CAWG checks in one verification pass.
872pub fn verify_with_manifest_store_and_graph_inputs(
873    asset: &[u8],
874    manifest_store: &[u8],
875    mime_type: &str,
876    options: &VerifyOptions,
877    graph_inputs: GraphInputs<'_>,
878) -> Result<VerificationReport, Error> {
879    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
880    let result = (|| {
881        let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
882        let mime = resolve_detached_mime(mime_type, profile)?;
883        detached_pass(asset, manifest_store, &mime, options, graph_inputs)
884            .map(|(report, _, _)| report)
885    })();
886    enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
887    result
888}
889
890fn verify_with_manifest_store_inner(
891    asset: &[u8],
892    manifest_store: &[u8],
893    mime_type: &str,
894    options: &VerifyOptions,
895) -> Result<VerificationReport, Error> {
896    let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
897    let mime = resolve_detached_mime(mime_type, profile)?;
898    if profile.version == SpecVersion::V2_5 {
899        let resolved = ResolvedOptions::resolve(options)?;
900        return verify_detached_online_v25(asset, manifest_store, &mime, options, resolved);
901    }
902    let (mut report, needs, _) = detached_pass(
903        asset,
904        manifest_store,
905        &mime,
906        options,
907        GraphInputs::default(),
908    )?;
909    let (network, evidence) = online::gather(options, &needs);
910    if evidence.is_empty() {
911        report.network = network;
912        return Ok(report);
913    }
914    // The store the caller supplied is the one that binds this asset. Pass 2
915    // re-reads it with the fetched evidence; it never swaps in another store.
916    let next = online::apply_evidence(options, &evidence);
917    let (mut report, _, _) =
918        detached_pass(asset, manifest_store, &mime, &next, GraphInputs::default())?;
919    report.network = network;
920    Ok(report)
921}
922
923/// One offline verification of an asset against a separately held store, with
924/// the network needs and capture it recorded.
925fn detached_pass(
926    asset: &[u8],
927    manifest_store: &[u8],
928    mime: &str,
929    options: &VerifyOptions,
930    graph_inputs: GraphInputs<'_>,
931) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
932    let resolved = ResolvedOptions::resolve(options)?;
933    detached_pass_resolved(
934        asset,
935        manifest_store,
936        mime,
937        options,
938        &resolved,
939        options.cawg_did_documents.as_ref(),
940        graph_inputs,
941    )
942}
943
944fn detached_pass_resolved(
945    asset: &[u8],
946    manifest_store: &[u8],
947    mime: &str,
948    options: &VerifyOptions,
949    resolved: &ResolvedOptions,
950    did_documents: Option<&HashMap<String, Value>>,
951    graph_inputs: GraphInputs<'_>,
952) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
953    if manifest_store.is_empty() {
954        return Err(Error::Verification("manifest store is empty".into()));
955    }
956    if manifest_store.len() > MAX_MANIFEST_STORE_BYTES {
957        return Err(Error::Verification(format!(
958            "manifest store exceeds the {MAX_MANIFEST_STORE_BYTES} byte limit"
959        )));
960    }
961    let input = resolved.input(asset, mime);
962    let mut output = crate::c2pa_validate::verify_detached_safe(
963        manifest_store,
964        asset,
965        mime,
966        &input,
967        resolved.cawg_trust(),
968        resolved.cawg_allowed_certs(),
969        resolved.cawg_document_signing_require_anchor,
970        did_documents,
971        options.cawg_ica_trusted_issuers.as_deref(),
972        options.cawg_ica_trust_anchors.as_deref(),
973        options.cawg_ica_status_lists.as_ref(),
974        graph_inputs,
975    )
976    .map_err(map_validate_error)?;
977    let needs = online::network_needs(&output);
978    let capture = PassCapture::take(&mut output);
979    Ok((
980        report_from_output(output, mime.to_string(), resolved),
981        needs,
982        capture,
983    ))
984}
985
986/// Validate a manifest store on its own, with no asset.
987///
988/// For a writer recording an ingredient or source it holds only as a store:
989/// everything about the store is checked - claim structure, the selected
990/// policy's assertion, action, ingredient and redaction rules, hashed-URI
991/// bindings, the claim signature, time stamps, revocation, trust and CAWG
992/// identity - and nothing about content. The report says so:
993/// `hard_binding` is `not_evaluated` with the informational
994/// `com.encypher.hardBinding.notEvaluated`, and `integrity` is `unbound`
995/// rather than `valid` when no failure invalidates the claim.
996///
997/// Options are read as for [`verify_with_options`]. This entry point never
998/// fetches (`online: Some(true)` is refused) and sends no telemetry.
999pub fn verify_manifest_store_only(
1000    manifest_store: &[u8],
1001    options: &VerifyOptions,
1002) -> Result<StoreOnlyValidation, Error> {
1003    verify_manifest_store_only_with_graph_inputs(manifest_store, options, GraphInputs::default())
1004}
1005
1006/// [`verify_manifest_store_only`] with caller-controlled external resource
1007/// resolution and lowered graph ceilings. Content remains deliberately
1008/// unevaluated, so a valid result stays unbound.
1009pub fn verify_manifest_store_only_with_graph_inputs(
1010    manifest_store: &[u8],
1011    options: &VerifyOptions,
1012    graph_inputs: GraphInputs<'_>,
1013) -> Result<StoreOnlyValidation, Error> {
1014    if options.online == Some(true) {
1015        return Err(Error::Verification(
1016            "online must not be Some(true): store-only validation never fetches".into(),
1017        ));
1018    }
1019    let resolved = ResolvedOptions::resolve(options)?;
1020    if manifest_store.is_empty() {
1021        return Err(Error::Verification("manifest store is empty".into()));
1022    }
1023    if manifest_store.len() > MAX_MANIFEST_STORE_BYTES {
1024        return Err(Error::Verification(format!(
1025            "manifest store exceeds the {MAX_MANIFEST_STORE_BYTES} byte limit"
1026        )));
1027    }
1028    let input = resolved.input(&[], "application/c2pa");
1029    let cawg_inputs = crate::c2pa_validate::CawgTrustInputs::new(
1030        resolved.cawg_trust(),
1031        resolved.cawg_allowed_certs(),
1032        resolved.cawg_document_signing_require_anchor,
1033        options.cawg_did_documents.as_ref(),
1034        options.cawg_ica_trusted_issuers.as_deref(),
1035        options.cawg_ica_trust_anchors.as_deref(),
1036        options.cawg_ica_status_lists.as_ref(),
1037        graph_inputs,
1038    );
1039    let output = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1040        crate::c2pa_validate::verify_store_binding(
1041            manifest_store,
1042            crate::c2pa_validate::StoreBinding::StoreOnly,
1043            &input,
1044            cawg_inputs,
1045        )
1046    }))
1047    .map_err(|_| Error::Verification("verification panicked".into()))?
1048    .map_err(map_validate_error)?;
1049    Ok(finish_store_only_validation(
1050        manifest_store,
1051        output,
1052        &resolved,
1053    ))
1054}
1055
1056fn finish_store_only_validation(
1057    manifest_store: &[u8],
1058    output: crate::c2pa_validate::VerifyOutput,
1059    resolved: &ResolvedOptions,
1060) -> StoreOnlyValidation {
1061    let mut report = report_from_output(output, "application/c2pa".to_string(), resolved);
1062    report.hard_binding = "not_evaluated".to_string();
1063    if report.integrity == "valid" {
1064        report.integrity = "unbound".to_string();
1065        if let Some(verdict) = report
1066            .manifest_report
1067            .pointer_mut("/provenance_verdict")
1068            .and_then(Value::as_object_mut)
1069        {
1070            verdict.insert("integrity".to_string(), Value::from("unbound"));
1071        }
1072    }
1073    let (active_manifest_label, store_manifests) = manifest_identity(Some(manifest_store));
1074    StoreOnlyValidation {
1075        report,
1076        active_manifest_label,
1077        store_manifests,
1078    }
1079}
1080
1081/// Verify with caller-controlled external resource resolution and lowered graph
1082/// ceilings. Policy selection, trust defaults and all verdict logic are the
1083/// same as [`verify_with_options`]; this function performs no network I/O.
1084pub fn verify_with_graph_inputs(
1085    data: &[u8],
1086    mime_type: &str,
1087    options: &VerifyOptions,
1088    graph_inputs: GraphInputs<'_>,
1089) -> Result<VerificationReport, Error> {
1090    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
1091    let result = (|| {
1092        let resolved = ResolvedOptions::resolve(options)?;
1093        embedded_graph_pass(data, None, mime_type, options, &resolved, graph_inputs)
1094    })();
1095    enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
1096    result
1097}
1098
1099/// Verify asset bytes against the bundled trust snapshots plus any static
1100/// trust material supplied by the caller. Failure telemetry follows the
1101/// explicit override or saved per-user preference.
1102pub fn verify_with_options(
1103    data: &[u8],
1104    mime_type: &str,
1105    options: &VerifyOptions,
1106) -> Result<VerificationReport, Error> {
1107    let resolved = ResolvedOptions::resolve(options)?;
1108    verify_with_resolved_options(data, None, mime_type, options, resolved)
1109}
1110
1111/// Verify asset bytes exactly as [`verify_with_options`] does and return the
1112/// evidence a remote service needs to re-verify the same manifest store.
1113///
1114/// The report, the store bytes, and the hard-binding verdict all come from
1115/// one verification pass. A remote verifier that checks `manifest_store`
1116/// against `hard_binding` therefore evaluates the store the local verdict
1117/// was computed over. Failure telemetry follows the same rules as
1118/// [`verify_with_options`].
1119pub fn local_evidence_with_options(
1120    data: &[u8],
1121    mime_type: &str,
1122    options: &VerifyOptions,
1123) -> Result<LocalEvidence, Error> {
1124    let resolved = ResolvedOptions::resolve(options)?;
1125    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
1126    let (result, capture) =
1127        match verify_with_options_inner(data, None, mime_type, options, resolved) {
1128            Ok((report, capture)) => (Ok(report), capture),
1129            Err(error) => (Err(error), PassCapture::default()),
1130        };
1131    enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
1132    let report = result?;
1133    let status = match report.hard_binding.as_str() {
1134        "match" => "match",
1135        "mismatch" => "mismatch",
1136        _ => "unknown",
1137    };
1138    let (active_manifest_label, store_manifests) =
1139        manifest_identity(capture.manifest_store.as_deref());
1140    Ok(LocalEvidence {
1141        manifest_store_sha256: capture
1142            .manifest_store
1143            .as_deref()
1144            .map(|store| hex::encode(Sha256::digest(store))),
1145        manifest_store: capture.manifest_store,
1146        asset_sha256: hex::encode(Sha256::digest(data)),
1147        hard_binding: LocalHardBinding {
1148            algorithm: capture.hard_binding_label,
1149            status: status.to_string(),
1150        },
1151        active_manifest_label,
1152        store_manifests,
1153        report,
1154    })
1155}
1156
1157fn verify_with_resolved_options(
1158    data: &[u8],
1159    fragments: Option<&[&[u8]]>,
1160    mime_type: &str,
1161    options: &VerifyOptions,
1162    resolved: ResolvedOptions,
1163) -> Result<VerificationReport, Error> {
1164    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
1165    let result = verify_with_options_inner(data, fragments, mime_type, options, resolved)
1166        .map(|(report, _)| report);
1167    enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
1168    result
1169}
1170
1171fn enqueue_failure_telemetry(
1172    mime_type: &str,
1173    result: &Result<VerificationReport, Error>,
1174    options: &VerifyOptions,
1175    enabled: bool,
1176) {
1177    if let Some(event) = telemetry::validation_failure_telemetry_with_enabled(
1178        mime_type,
1179        result,
1180        &options.telemetry,
1181        enabled,
1182    ) {
1183        telemetry::enqueue(options.telemetry.endpoint(), event);
1184    }
1185}
1186
1187/// Verify a fragmented ISO BMFF stream with explicit trust, validation-time,
1188/// CAWG, and telemetry options.
1189///
1190/// `init_segment` carries the C2PA manifest. `fragments` may be the complete
1191/// stream or any contiguous available subset; missing trailing fragments are
1192/// not a failure. Mark each intentional discontinuity by its zero-based index
1193/// in [`VerifyOptions::expected_seek_positions`].
1194pub fn verify_fragmented_with_options(
1195    init_segment: &[u8],
1196    fragments: &[&[u8]],
1197    mime_type: &str,
1198    options: &VerifyOptions,
1199) -> Result<VerificationReport, Error> {
1200    if options.stream_presentation_times.is_some() {
1201        return Err(Error::InvalidValidationTime(
1202            "stream_presentation_times: per-segment presentation times apply only to \
1203             session-key live streams verified with verify_stream_with_options; \
1204             Merkle-bound fragments are assessed at validation_time"
1205                .into(),
1206        ));
1207    }
1208    let resolved = ResolvedOptions::resolve(options)?;
1209    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
1210    let mime = canonicalize_mime(mime_type);
1211    let result = if crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime)
1212        == Some(crate::c2pa_formats::AssetFormat::Bmff)
1213    {
1214        verify_with_options_inner(init_segment, Some(fragments), &mime, options, resolved)
1215            .map(|(report, _)| report)
1216    } else {
1217        Err(Error::UnsupportedMime(mime))
1218    };
1219    enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
1220    result
1221}
1222
1223/// [`verify_fragmented_with_options`] with caller-controlled external resource
1224/// resolution and lowered graph ceilings. The fragments, graph walk, and CAWG
1225/// identity checks run in one verification pass without network I/O.
1226pub fn verify_fragmented_with_graph_inputs(
1227    init_segment: &[u8],
1228    fragments: &[&[u8]],
1229    mime_type: &str,
1230    options: &VerifyOptions,
1231    graph_inputs: GraphInputs<'_>,
1232) -> Result<VerificationReport, Error> {
1233    if options.stream_presentation_times.is_some() {
1234        return Err(Error::InvalidValidationTime(
1235            "stream_presentation_times: per-segment presentation times apply only to \
1236             session-key live streams verified with verify_stream_with_options; \
1237             Merkle-bound fragments are assessed at validation_time"
1238                .into(),
1239        ));
1240    }
1241    let resolved = ResolvedOptions::resolve(options)?;
1242    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
1243    let mime = canonicalize_mime(mime_type);
1244    let result = if crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime)
1245        == Some(crate::c2pa_formats::AssetFormat::Bmff)
1246    {
1247        embedded_graph_pass(
1248            init_segment,
1249            Some(fragments),
1250            &mime,
1251            options,
1252            &resolved,
1253            graph_inputs,
1254        )
1255    } else {
1256        Err(Error::UnsupportedMime(mime))
1257    };
1258    enqueue_failure_telemetry(mime_type, &result, options, telemetry_enabled);
1259    result
1260}
1261
1262/// Trust material, validation instant, and engine profile resolved once from
1263/// [`VerifyOptions`].
1264///
1265/// Every entry point - asset, fragmented, and stream - resolves its inputs
1266/// here, so a stream can never be verified against different trust material
1267/// than a single asset would be.
1268pub(crate) struct ResolvedOptions {
1269    claim_trust: Option<ResolvedTrust>,
1270    tsa_trust: Option<ResolvedTrust>,
1271    allowed_certs: Option<ResolvedTrust>,
1272    cawg_trust: Option<ResolvedTrust>,
1273    cawg_allowed_certs: Option<ResolvedTrust>,
1274    validation_time: OffsetDateTime,
1275    validation_time_text: String,
1276    trust_basis: &'static str,
1277    profile: EngineProfile,
1278    cawg_strict_encoding: bool,
1279    cawg_document_signing_require_anchor: bool,
1280    /// Caller-supplied online evidence, base64-decoded once.
1281    ocsp_responses: HashMap<String, Vec<u8>>,
1282    ocsp_unreachable: Vec<String>,
1283    external_data: HashMap<String, Vec<u8>>,
1284    external_data_media_types: HashMap<String, String>,
1285    external_data_unreachable: Vec<String>,
1286}
1287
1288impl ResolvedOptions {
1289    pub(crate) fn resolve(options: &VerifyOptions) -> Result<Self, Error> {
1290        // The policy selector is resolved first, so an unknown value fails
1291        // before any trust material is parsed or asset read.
1292        let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
1293        let use_defaults = !options.no_default_trust;
1294        let validation_time = parse_validation_time(options.validation_time.as_deref())?;
1295        let custom_claim_trust = options.trust_pem.is_some()
1296            || options
1297                .claim_trust_sources
1298                .as_ref()
1299                .is_some_and(|sources| !sources.is_empty())
1300            || options.allowed_list_pem.is_some();
1301        let bounds = (
1302            parse_optional_instant(
1303                options.trust_anchor_not_before.as_deref(),
1304                "trust_anchor_not_before",
1305            )?,
1306            parse_optional_instant(
1307                options.trust_anchor_not_after.as_deref(),
1308                "trust_anchor_not_after",
1309            )?,
1310        );
1311        let (cawg_anchors, cawg_direct) =
1312            resolve_cawg_configurations(options.cawg_trust_configurations.as_deref())?;
1313        Ok(Self {
1314            claim_trust: resolve_trust(
1315                options.trust_pem.as_deref(),
1316                options.claim_trust_sources.as_deref(),
1317                use_defaults.then(default_trust::claim_signing),
1318                AnchorPurpose::ClaimSigning,
1319                bounds,
1320            )?,
1321            tsa_trust: resolve_trust(
1322                options.tsa_trust_pem.as_deref(),
1323                options.tsa_trust_sources.as_deref(),
1324                use_defaults.then(default_trust::timestamp_authorities),
1325                AnchorPurpose::TimeStamping,
1326                bounds,
1327            )?,
1328            allowed_certs: resolve_trust(
1329                options.allowed_list_pem.as_deref(),
1330                None,
1331                use_defaults.then(default_trust::allowed_claim_signers),
1332                AnchorPurpose::ClaimSigning,
1333                bounds,
1334            )?,
1335            cawg_trust: extend_trust(
1336                resolve_trust(
1337                    options.cawg_trust_pem.as_deref(),
1338                    None,
1339                    use_defaults.then(default_trust::cawg_identity),
1340                    AnchorPurpose::CawgIdentity,
1341                    bounds,
1342                )?,
1343                cawg_anchors,
1344            ),
1345            cawg_allowed_certs: extend_trust(
1346                resolve_trust(
1347                    options.cawg_allowed_certs_pem.as_deref(),
1348                    None,
1349                    use_defaults.then(default_trust::cawg_allowed_identities),
1350                    AnchorPurpose::CawgIdentity,
1351                    bounds,
1352                )?,
1353                cawg_direct,
1354            ),
1355            validation_time,
1356            validation_time_text: validation_time
1357                .format(&Rfc3339)
1358                .map_err(|error| Error::InvalidValidationTime(error.to_string()))?,
1359            trust_basis: match (use_defaults, custom_claim_trust) {
1360                (true, true) => "bundled_and_caller_supplied_static_material",
1361                (true, false) => "bundled_static_material",
1362                (false, true) => "caller_supplied_static_material",
1363                (false, false) => "none",
1364            },
1365            profile,
1366            cawg_strict_encoding: options.cawg_strict_encoding,
1367            cawg_document_signing_require_anchor: options
1368                .cawg_document_signing_require_anchor
1369                .unwrap_or(true),
1370            ocsp_responses: decode_base64_map(
1371                options.ocsp_responses.as_ref(),
1372                "ocsp_responses",
1373                MAX_OCSP_EVIDENCE_BYTES,
1374            )?,
1375            ocsp_unreachable: options.ocsp_unreachable.clone().unwrap_or_default(),
1376            external_data: decode_base64_map(
1377                options.external_data.as_ref(),
1378                "external_data",
1379                MAX_EXTERNAL_DATA_EVIDENCE_BYTES,
1380            )?,
1381            external_data_media_types: options
1382                .external_data_media_types
1383                .clone()
1384                .unwrap_or_default(),
1385            external_data_unreachable: options
1386                .external_data_unreachable
1387                .clone()
1388                .unwrap_or_default(),
1389        })
1390    }
1391
1392    fn take_external_data(&mut self) -> ValidatedExternalData {
1393        ValidatedExternalData {
1394            data: std::mem::take(&mut self.external_data),
1395            media_types: std::mem::take(&mut self.external_data_media_types),
1396            unreachable: std::mem::take(&mut self.external_data_unreachable),
1397        }
1398    }
1399
1400    fn apply_ocsp_update(&mut self, update: native_online::OcspUpdate) {
1401        for (certificate, response) in update.responses {
1402            self.ocsp_responses.entry(certificate).or_insert(response);
1403        }
1404        for certificate in update.unreachable {
1405            if !self.ocsp_unreachable.contains(&certificate) {
1406                self.ocsp_unreachable.push(certificate);
1407            }
1408        }
1409    }
1410
1411    pub(crate) fn input<'a>(&'a self, data: &'a [u8], mime: &'a str) -> VerifyInput<'a> {
1412        VerifyInput {
1413            data,
1414            mime,
1415            claim_signer_trust: self.claim_trust.as_ref().map(ResolvedTrust::get),
1416            tsa_trust: self.tsa_trust.as_ref().map(ResolvedTrust::get),
1417            allowed_certs: self.allowed_certs.as_ref().map(ResolvedTrust::get),
1418            validation_time: Some(self.validation_time),
1419            profile: self.profile,
1420            cawg_strict_encoding: self.cawg_strict_encoding,
1421            evidence: crate::c2pa_validate::OnlineEvidence {
1422                ocsp_responses: (!self.ocsp_responses.is_empty()).then_some(&self.ocsp_responses),
1423                ocsp_unreachable: (!self.ocsp_unreachable.is_empty())
1424                    .then_some(self.ocsp_unreachable.as_slice()),
1425                external_data: (!self.external_data.is_empty()).then_some(&self.external_data),
1426                external_data_media_types: (!self.external_data_media_types.is_empty())
1427                    .then_some(&self.external_data_media_types),
1428                external_data_unreachable: (!self.external_data_unreachable.is_empty())
1429                    .then_some(self.external_data_unreachable.as_slice()),
1430            },
1431        }
1432    }
1433
1434    pub(crate) fn cawg_trust(&self) -> Option<&TrustList> {
1435        self.cawg_trust.as_ref().map(ResolvedTrust::get)
1436    }
1437
1438    pub(crate) fn cawg_allowed_certs(&self) -> Option<&TrustList> {
1439        self.cawg_allowed_certs.as_ref().map(ResolvedTrust::get)
1440    }
1441}
1442
1443/// The engine profile for the caller's policy selection.
1444///
1445/// `None` and `"2.4"` are the same policy and resolve to the same profile, so
1446/// the two produce byte-identical reports. `"2.2"` is an explicit older policy.
1447/// Strict 2.2 and 2.4 select their published program postures. Strict 2.5 is a
1448/// technical core-spec posture with strict diagnostics; it never claims a
1449/// certified or Conformance Program scope.
1450fn resolve_profile(spec_version: Option<&str>, strict: bool) -> Result<EngineProfile, Error> {
1451    let version = match spec_version {
1452        None => SpecVersion::V2_4,
1453        Some(value) => SpecVersion::public_policy_from_str(value).ok_or_else(|| {
1454            Error::UnsupportedSpecVersion(format!(
1455                "spec_version {value:?} is not one of {}",
1456                SpecVersion::PUBLIC_POLICIES
1457                    .iter()
1458                    .map(|version| format!("{:?}", version.version_str()))
1459                    .collect::<Vec<_>>()
1460                    .join(", ")
1461            ))
1462        })?,
1463    };
1464    match (version, strict) {
1465        (SpecVersion::V2_2, true) => Ok(EngineProfile::strict(SpecVersion::V2_2)),
1466        (SpecVersion::V2_2, false) => Ok(EngineProfile {
1467            version,
1468            ..EngineProfile::GENEROUS
1469        }),
1470        (SpecVersion::V2_4, true) => Ok(EngineProfile::strict(SpecVersion::V2_4)),
1471        (SpecVersion::V2_4, false) => Ok(EngineProfile::GENEROUS),
1472        (SpecVersion::V2_5, true) => Ok(EngineProfile::new(
1473            SpecVersion::V2_5,
1474            OperatingMode::Conformance,
1475        )
1476        .with_compliance(ComplianceLevel::CoreSpec)
1477        .with_debug(true)),
1478        (SpecVersion::V2_5, false) => Ok(EngineProfile {
1479            version,
1480            ..EngineProfile::GENEROUS
1481        }),
1482        _ => Err(Error::UnsupportedSpecVersion(format!(
1483            "spec_version {:?} is registered but has no public verification policy",
1484            version.version_str()
1485        ))),
1486    }
1487}
1488
1489/// The report `profile` for an engine profile's validation policy.
1490fn report_profile(profile: EngineProfile) -> &'static str {
1491    match profile.version {
1492        SpecVersion::V2_5 => C2PA_PROFILE_2_5,
1493        SpecVersion::V2_2 => C2PA_PROFILE_2_2,
1494        _ => C2PA_PROFILE,
1495    }
1496}
1497
1498/// Decode a map of standard-base64 evidence values, rejecting anything
1499/// malformed or over `max_bytes` rather than silently ignoring it.
1500///
1501/// A caller who hands the SDK evidence deserves to hear that it was unusable;
1502/// dropping it would show up much later as an unexplained `ocsp.skipped`.
1503fn decode_base64_map(
1504    values: Option<&HashMap<String, String>>,
1505    field: &str,
1506    max_bytes: usize,
1507) -> Result<HashMap<String, Vec<u8>>, Error> {
1508    let Some(values) = values else {
1509        return Ok(HashMap::new());
1510    };
1511    let mut decoded = HashMap::with_capacity(values.len());
1512    for (key, encoded) in values {
1513        let bytes = crate::c2pa_formats::util::base64_decode(encoded)
1514            .ok_or_else(|| Error::Verification(format!("{field}[{key}] is not standard base64")))?;
1515        if bytes.len() > max_bytes {
1516            return Err(Error::Verification(format!(
1517                "{field}[{key}] exceeds the {max_bytes} byte limit"
1518            )));
1519        }
1520        decoded.insert(key.clone(), bytes);
1521    }
1522    Ok(decoded)
1523}
1524
1525/// Canonicalize `mime_type` and reject one the selected policy has no
1526/// embedded-manifest reader for. The 2.4 policy reads exactly the 2.4
1527/// registry; the 2.5 policy adds the carriers and text families 2.5 defines
1528/// ([`crate::c2pa_core::spec::reader_permits`]).
1529pub(crate) fn resolve_mime(mime_type: &str, profile: EngineProfile) -> Result<String, Error> {
1530    let mime = canonicalize_mime(mime_type);
1531    if !crate::c2pa_core::spec::reader_permits(&mime, profile.version)
1532        || crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime).is_none()
1533    {
1534        return Err(Error::UnsupportedMime(mime));
1535    }
1536    Ok(mime)
1537}
1538
1539/// Canonicalize the media type of an asset bound by a separately supplied
1540/// store.
1541///
1542/// Retained profiles admit ordinary readers and designated detached media.
1543/// C2PA 2.5 also admits syntactically valid unknown types as opaque bytes.
1544/// Byte checks happen in the shared detached validation pipeline.
1545fn resolve_detached_mime(mime_type: &str, profile: EngineProfile) -> Result<String, Error> {
1546    let mime = crate::c2pa_formats::canonicalize_detached_mime(mime_type);
1547    if !crate::c2pa_formats::detached_mime_permitted(&mime, profile.version) {
1548        return Err(Error::UnsupportedMime(mime));
1549    }
1550    Ok(mime)
1551}
1552
1553/// Whether `mime` would be read with the generic XML comment armor: an
1554/// XML-derived type without a dedicated carrier.
1555fn is_xml_comment_carrier(mime: &str) -> bool {
1556    (matches!(mime, "application/xml" | "text/xml") || mime.ends_with("+xml"))
1557        && matches!(
1558            crate::c2pa_formats::AssetFormat::from_canonical_mime(mime),
1559            Some(crate::c2pa_formats::AssetFormat::TextStructured {
1560                comment_prefix: "<!--",
1561                ..
1562            })
1563        )
1564}
1565
1566fn require_embedded_mime(mime: &str, profile: EngineProfile) -> Result<(), Error> {
1567    // The draft's generic XML comment armor contains "--", forbidden inside
1568    // XML comments (upstream issue 2590), so no well-formed XML document can
1569    // carry it. This covers every XML-derived type without a dedicated
1570    // carrier (XML, XHTML, RSS, Atom, MathML, ...). Dedicated SVG/TTML
1571    // carriers and separately supplied manifest stores are not this
1572    // embedding method and stay readable.
1573    if profile.version == SpecVersion::V2_5 && is_xml_comment_carrier(mime) {
1574        return Err(Error::UnsupportedMime(mime.to_owned()));
1575    }
1576    Ok(())
1577}
1578
1579pub(crate) fn map_validate_error(error: crate::c2pa_validate::ValidateError) -> Error {
1580    match error {
1581        crate::c2pa_validate::ValidateError::UnsupportedMime(value) => {
1582            Error::UnsupportedMime(value)
1583        }
1584        other => Error::Verification(other.to_string()),
1585    }
1586}
1587
1588/// Assemble the caller-facing report from one kernel verification.
1589pub(crate) fn report_from_output(
1590    output: crate::c2pa_validate::VerifyOutput,
1591    mime: String,
1592    resolved: &ResolvedOptions,
1593) -> VerificationReport {
1594    let present = output
1595        .report_json
1596        .pointer("/provenance_verdict/present")
1597        .and_then(Value::as_bool)
1598        .unwrap_or(false);
1599    let integrity = output
1600        .report_json
1601        .pointer("/provenance_verdict/integrity")
1602        .and_then(Value::as_str)
1603        .unwrap_or(if present { "invalid" } else { "absent" })
1604        .to_string();
1605    let trust = trust_report(
1606        &output.results,
1607        present,
1608        resolved.trust_basis,
1609        resolved.validation_time_text.clone(),
1610    );
1611    VerificationReport {
1612        schema_version: REPORT_SCHEMA_VERSION.to_string(),
1613        profile: report_profile(resolved.profile).to_string(),
1614        mime_type: mime,
1615        present,
1616        integrity,
1617        signature: signature_status(&output.results),
1618        hard_binding: hard_binding_status(&output.results),
1619        trust,
1620        policy: None,
1621        managed_receipt: None,
1622        validation_state: output.validation_state.as_str().to_string(),
1623        validation_results: copy_results(&output.results),
1624        manifest_report: output.report_json,
1625        content_credentials: output.crjson,
1626        network: NetworkReport::default(),
1627    }
1628}
1629
1630/// Verify with the online pass when evidence was gathered, returning the
1631/// capture of the pass whose report is returned.
1632fn verify_with_options_inner(
1633    data: &[u8],
1634    fragments: Option<&[&[u8]]>,
1635    mime_type: &str,
1636    options: &VerifyOptions,
1637    resolved: ResolvedOptions,
1638) -> Result<(VerificationReport, PassCapture), Error> {
1639    let mime = resolve_mime(mime_type, resolved.profile)?;
1640    require_embedded_mime(&mime, resolved.profile)?;
1641    if resolved.profile.version == SpecVersion::V2_5 {
1642        return verify_embedded_online_v25(data, fragments, &mime, options, resolved);
1643    }
1644
1645    let (mut report, needs, capture) =
1646        embedded_pass_resolved(data, fragments, &mime, options, &resolved)?;
1647    let (mut network, evidence) = online::gather(options, &needs);
1648    if evidence.is_empty() {
1649        report.network = network;
1650        return Ok((report, capture));
1651    }
1652    let next = online::apply_evidence(options, &evidence);
1653    let second = match (evidence.manifest_store.as_deref(), fragments) {
1654        // The asset said where its manifest store lives and the store was
1655        // fetched. Pass 2 verifies the asset against it exactly as it would
1656        // against a sidecar handed in by the caller, hard binding and all.
1657        (Some(store), None) => detached_pass(data, store, &mime, &next, GraphInputs::default()),
1658        _ => embedded_pass(data, fragments, &mime, &next),
1659    };
1660    match second {
1661        Ok((mut second, _, second_capture)) => {
1662            second.network = network;
1663            Ok((second, second_capture))
1664        }
1665        // Legacy policies preserve their established fallback when a remote
1666        // body is not a usable store. Exact 2.5 never takes this route.
1667        Err(error) => {
1668            network.mark_unusable("remote_manifest", error.to_string());
1669            report.network = network;
1670            Ok((report, capture))
1671        }
1672    }
1673}
1674
1675fn merged_did_documents(
1676    pinned: Option<&HashMap<String, Value>>,
1677    fetched: HashMap<String, Value>,
1678) -> Option<HashMap<String, Value>> {
1679    if fetched.is_empty() {
1680        return None;
1681    }
1682    let mut merged = pinned.cloned().unwrap_or_default();
1683    for (did, document) in fetched {
1684        merged.entry(did).or_insert(document);
1685    }
1686    Some(merged)
1687}
1688
1689fn verify_embedded_online_v25(
1690    data: &[u8],
1691    fragments: Option<&[&[u8]]>,
1692    mime: &str,
1693    options: &VerifyOptions,
1694    mut resolved: ResolvedOptions,
1695) -> Result<(VerificationReport, PassCapture), Error> {
1696    let mut plan = locate_external_resources(data, mime, options, GraphLimits::default())?;
1697    let terminal_uri = (!plan.manifest_store_present)
1698        .then(|| {
1699            plan.resources
1700                .iter()
1701                .find(|request| request.kind == ExternalResourceKind::SegmentManifestStore)
1702                .map(|request| request.uri.clone())
1703        })
1704        .flatten();
1705    let mut online = native_online::NativeOnline::new(resolved.take_external_data(), options);
1706
1707    while !plan.resources.is_empty() {
1708        let frontier = plan.resources.clone();
1709        for request in &frontier {
1710            online.ensure_graph_resource(
1711                options,
1712                request,
1713                terminal_uri.as_deref() == Some(request.uri.as_str()),
1714            );
1715        }
1716        plan.continue_with(online.resources())?;
1717    }
1718    online.mark_discovery_stop(plan.stop_reason.as_ref());
1719
1720    let fetched_dids = online.resolve_did_documents(
1721        options,
1722        plan.did_documents(),
1723        options.cawg_did_documents.as_ref(),
1724    );
1725    let merged_dids = merged_did_documents(options.cawg_did_documents.as_ref(), fetched_dids);
1726    let did_documents = merged_dids.as_ref().or(options.cawg_did_documents.as_ref());
1727    let graph_inputs = GraphInputs {
1728        external: Some(online.resources()),
1729        limits: GraphLimits::default(),
1730    };
1731    let (mut report, mut needs, mut capture) = embedded_graph_pass_resolved(
1732        data,
1733        fragments,
1734        mime,
1735        options,
1736        &resolved,
1737        did_documents,
1738        graph_inputs,
1739    )?;
1740
1741    let ocsp = online.fetch_ocsp(options, &needs);
1742    if ocsp.has_evidence() {
1743        resolved.apply_ocsp_update(ocsp);
1744        let graph_inputs = GraphInputs {
1745            external: Some(online.resources()),
1746            limits: GraphLimits::default(),
1747        };
1748        (report, needs, capture) = embedded_graph_pass_resolved(
1749            data,
1750            fragments,
1751            mime,
1752            options,
1753            &resolved,
1754            did_documents,
1755            graph_inputs,
1756        )?;
1757    }
1758    report.network = online.finish(&needs);
1759    Ok((report, capture))
1760}
1761
1762fn verify_detached_online_v25(
1763    asset: &[u8],
1764    manifest_store: &[u8],
1765    mime: &str,
1766    options: &VerifyOptions,
1767    mut resolved: ResolvedOptions,
1768) -> Result<VerificationReport, Error> {
1769    let mut plan =
1770        c2pa_validate::external_discovery::locate_external_resources_with_canonical_manifest_store(
1771            asset,
1772            manifest_store,
1773            mime,
1774            resolved.profile,
1775            GraphLimits::default(),
1776        )?;
1777    let mut online = native_online::NativeOnline::new(resolved.take_external_data(), options);
1778    while !plan.resources.is_empty() {
1779        let frontier = plan.resources.clone();
1780        for request in &frontier {
1781            online.ensure_graph_resource(options, request, false);
1782        }
1783        plan.continue_with(online.resources())?;
1784    }
1785    online.mark_discovery_stop(plan.stop_reason.as_ref());
1786
1787    let fetched_dids = online.resolve_did_documents(
1788        options,
1789        plan.did_documents(),
1790        options.cawg_did_documents.as_ref(),
1791    );
1792    let merged_dids = merged_did_documents(options.cawg_did_documents.as_ref(), fetched_dids);
1793    let did_documents = merged_dids.as_ref().or(options.cawg_did_documents.as_ref());
1794    let graph_inputs = GraphInputs {
1795        external: Some(online.resources()),
1796        limits: GraphLimits::default(),
1797    };
1798    let (mut report, mut needs, _) = detached_pass_resolved(
1799        asset,
1800        manifest_store,
1801        mime,
1802        options,
1803        &resolved,
1804        did_documents,
1805        graph_inputs,
1806    )?;
1807
1808    let ocsp = online.fetch_ocsp(options, &needs);
1809    if ocsp.has_evidence() {
1810        resolved.apply_ocsp_update(ocsp);
1811        let graph_inputs = GraphInputs {
1812            external: Some(online.resources()),
1813            limits: GraphLimits::default(),
1814        };
1815        (report, needs, _) = detached_pass_resolved(
1816            asset,
1817            manifest_store,
1818            mime,
1819            options,
1820            &resolved,
1821            did_documents,
1822            graph_inputs,
1823        )?;
1824    }
1825    report.network = online.finish(&needs);
1826    Ok(report)
1827}
1828
1829/// One offline verification of an asset's own bytes, with the network needs it
1830/// recorded.
1831fn embedded_pass(
1832    data: &[u8],
1833    fragments: Option<&[&[u8]]>,
1834    mime: &str,
1835    options: &VerifyOptions,
1836) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
1837    let resolved = ResolvedOptions::resolve(options)?;
1838    embedded_pass_resolved(data, fragments, mime, options, &resolved)
1839}
1840
1841fn embedded_pass_resolved(
1842    data: &[u8],
1843    fragments: Option<&[&[u8]]>,
1844    mime: &str,
1845    options: &VerifyOptions,
1846    resolved: &ResolvedOptions,
1847) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
1848    let input = resolved.input(data, mime);
1849    let mut output = match fragments {
1850        Some(fragments) => verify_fragmented_safe(
1851            &input,
1852            fragments,
1853            &options.expected_seek_positions,
1854            resolved.cawg_trust(),
1855            resolved.cawg_allowed_certs(),
1856            resolved.cawg_document_signing_require_anchor,
1857            options.cawg_did_documents.as_ref(),
1858            options.cawg_ica_trusted_issuers.as_deref(),
1859            options.cawg_ica_trust_anchors.as_deref(),
1860            options.cawg_ica_status_lists.as_ref(),
1861            GraphInputs::default(),
1862        ),
1863        None => verify_safe(
1864            &input,
1865            resolved.cawg_trust(),
1866            resolved.cawg_allowed_certs(),
1867            resolved.cawg_document_signing_require_anchor,
1868            options.cawg_did_documents.as_ref(),
1869            options.cawg_ica_trusted_issuers.as_deref(),
1870            options.cawg_ica_trust_anchors.as_deref(),
1871            options.cawg_ica_status_lists.as_ref(),
1872        ),
1873    }
1874    .map_err(map_validate_error)?;
1875    let needs = online::network_needs(&output);
1876    let capture = PassCapture::take(&mut output);
1877    Ok((
1878        report_from_output(output, mime.to_string(), resolved),
1879        needs,
1880        capture,
1881    ))
1882}
1883
1884fn embedded_graph_pass(
1885    data: &[u8],
1886    fragments: Option<&[&[u8]]>,
1887    mime_type: &str,
1888    options: &VerifyOptions,
1889    resolved: &ResolvedOptions,
1890    graph_inputs: GraphInputs<'_>,
1891) -> Result<VerificationReport, Error> {
1892    let mime = resolve_mime(mime_type, resolved.profile)?;
1893    require_embedded_mime(&mime, resolved.profile)?;
1894    embedded_graph_pass_resolved(
1895        data,
1896        fragments,
1897        &mime,
1898        options,
1899        resolved,
1900        options.cawg_did_documents.as_ref(),
1901        graph_inputs,
1902    )
1903    .map(|(report, _, _)| report)
1904}
1905
1906#[allow(clippy::too_many_arguments)]
1907fn embedded_graph_pass_resolved(
1908    data: &[u8],
1909    fragments: Option<&[&[u8]]>,
1910    mime: &str,
1911    options: &VerifyOptions,
1912    resolved: &ResolvedOptions,
1913    did_documents: Option<&HashMap<String, Value>>,
1914    graph_inputs: GraphInputs<'_>,
1915) -> Result<(VerificationReport, Vec<online::NetworkNeed>, PassCapture), Error> {
1916    let input = resolved.input(data, mime);
1917    let mut output = match fragments {
1918        Some(fragments) => verify_fragmented_safe(
1919            &input,
1920            fragments,
1921            &options.expected_seek_positions,
1922            resolved.cawg_trust(),
1923            resolved.cawg_allowed_certs(),
1924            resolved.cawg_document_signing_require_anchor,
1925            did_documents,
1926            options.cawg_ica_trusted_issuers.as_deref(),
1927            options.cawg_ica_trust_anchors.as_deref(),
1928            options.cawg_ica_status_lists.as_ref(),
1929            graph_inputs,
1930        ),
1931        None => verify_graph_safe(
1932            &input,
1933            resolved.cawg_trust(),
1934            resolved.cawg_allowed_certs(),
1935            resolved.cawg_document_signing_require_anchor,
1936            did_documents,
1937            options.cawg_ica_trusted_issuers.as_deref(),
1938            options.cawg_ica_trust_anchors.as_deref(),
1939            options.cawg_ica_status_lists.as_ref(),
1940            graph_inputs,
1941        ),
1942    }
1943    .map_err(map_validate_error)?;
1944    let needs = online::network_needs(&output);
1945    let capture = PassCapture::take(&mut output);
1946    Ok((
1947        report_from_output(output, mime.to_string(), resolved),
1948        needs,
1949        capture,
1950    ))
1951}
1952
1953/// Read and verify one local asset.
1954///
1955/// Path-based verification accepts regular files up to 128 MiB. Byte-slice
1956/// verification remains bounded only by caller memory.
1957pub fn verify_file(
1958    path: impl AsRef<Path>,
1959    mime_type: Option<&str>,
1960    options: &VerifyOptions,
1961) -> Result<VerificationReport, Error> {
1962    let resolved = ResolvedOptions::resolve(options)?;
1963    let path = path.as_ref();
1964    let mime = match mime_type {
1965        Some(value) => value.to_string(),
1966        None => mime_from_path(path)
1967            .ok_or_else(|| Error::UnsupportedMime(path.display().to_string()))?
1968            .to_string(),
1969    };
1970    let data = read_path_asset(path, MAX_PATH_ASSET_BYTES)?;
1971    verify_with_resolved_options(&data, None, &mime, options, resolved)
1972}
1973
1974fn read_path_asset(path: &Path, limit: u64) -> io::Result<Vec<u8>> {
1975    let mut options = OpenOptions::new();
1976    options.read(true);
1977    #[cfg(unix)]
1978    options.custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC);
1979
1980    // Open once, then validate that exact handle. On Unix O_NONBLOCK prevents
1981    // a FIFO substituted for the path from blocking before it can be rejected.
1982    let mut file = options.open(path)?;
1983    let opened_metadata = file.metadata()?;
1984    validate_path_asset(path, &opened_metadata, limit)?;
1985    read_bounded_file(&mut file, opened_metadata.len(), limit)
1986}
1987
1988fn validate_path_asset(path: &Path, metadata: &fs::Metadata, limit: u64) -> io::Result<()> {
1989    if !metadata.is_file() {
1990        return Err(io::Error::new(
1991            io::ErrorKind::InvalidInput,
1992            format!("asset path is not a regular file: {}", path.display()),
1993        ));
1994    }
1995    if metadata.len() > limit {
1996        return Err(io::Error::new(
1997            io::ErrorKind::InvalidData,
1998            format!("asset exceeds the 128 MiB path limit: {}", path.display()),
1999        ));
2000    }
2001    Ok(())
2002}
2003
2004fn read_bounded_file<R: Read>(file: &mut R, expected_len: u64, limit: u64) -> io::Result<Vec<u8>> {
2005    if expected_len > limit {
2006        return Err(io::Error::new(
2007            io::ErrorKind::InvalidData,
2008            "asset exceeds the path size limit",
2009        ));
2010    }
2011    let expected_len = usize::try_from(expected_len)
2012        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "asset size is not addressable"))?;
2013    let mut data = vec![0_u8; expected_len + 1];
2014    let mut used = 0;
2015    while used < expected_len {
2016        let read = file.read(&mut data[used..expected_len])?;
2017        if read == 0 {
2018            break;
2019        }
2020        used += read;
2021    }
2022    if used == expected_len && file.read(&mut data[expected_len..expected_len + 1])? != 0 {
2023        return Err(io::Error::new(
2024            io::ErrorKind::InvalidData,
2025            "asset grew while being read",
2026        ));
2027    }
2028    data.truncate(used);
2029    Ok(data)
2030}
2031
2032/// Canonical MIME types covered by the C2PA 2.4 profile and readable by this build.
2033pub fn supported_mime_types() -> Vec<&'static str> {
2034    let mut mimes: Vec<_> = mimes_for_version(SpecVersion::V2_4)
2035        .into_iter()
2036        .filter(|mime| crate::c2pa_formats::AssetFormat::from_mime(mime).is_some())
2037        .collect();
2038    mimes.sort_unstable();
2039    mimes.dedup();
2040    mimes
2041}
2042
2043/// Canonical MIME types the caller-selected policy reads with an embedded
2044/// manifest carrier. `None` is exactly `"2.4"`. The finite 2.2 and 2.4 lists
2045/// come from the shared format registry. `"2.5"` adds the standard ZIP package
2046/// and generic-container names used by generator products, plus WARC, MIDI,
2047/// TTML, ONNX, SafeTensors, Parquet, BlockFact, append-only JSON Lines, WebVTT
2048/// and textual comment-syntax types. It also reads the OpenDocument family,
2049/// other `text/*` types and `+xml` types by family, which no finite list can
2050/// enumerate. Generic XML-comment embedding stays unreadable under 2.5
2051/// (upstream issue 2590), so XML types without a dedicated carrier are omitted.
2052/// Any well-formed type can still be verified against a separately supplied
2053/// store with [`verify_with_manifest_store`].
2054pub fn supported_mime_types_for(spec_version: Option<&str>) -> Result<Vec<&'static str>, Error> {
2055    let profile = resolve_profile(spec_version, false)?;
2056    let registry_version = match profile.version {
2057        SpecVersion::V2_5 => SpecVersion::V2_4,
2058        version => version,
2059    };
2060    let mut mimes: Vec<_> = mimes_for_version(registry_version)
2061        .into_iter()
2062        .chain(
2063            (profile.version == SpecVersion::V2_5)
2064                .then_some(crate::c2pa_core::spec::V2_5_READER_MIMES)
2065                .into_iter()
2066                .flatten()
2067                .copied(),
2068        )
2069        .filter(|mime| crate::c2pa_formats::AssetFormat::from_mime(mime).is_some())
2070        .filter(|mime| require_embedded_mime(mime, profile).is_ok())
2071        .collect();
2072    mimes.sort_unstable();
2073    mimes.dedup();
2074    Ok(mimes)
2075}
2076
2077/// Every filename extension the SDK maps to a MIME type, with its mapping.
2078///
2079/// This is the single source of truth for extension inference. It is public so
2080/// that callers can discover it and so that the non-mutation contract tests
2081/// iterate the real table rather than a hand-copied list: a reviewer hid a
2082/// writer in the `.dng` branch precisely because the test's own copy had
2083/// drifted and omitted it.
2084pub const SUPPORTED_EXTENSIONS: &[(&str, &str)] = &[
2085    ("jpg", "image/jpeg"),
2086    ("jpeg", "image/jpeg"),
2087    ("png", "image/png"),
2088    ("webp", "image/webp"),
2089    ("gif", "image/gif"),
2090    ("tif", "image/tiff"),
2091    ("tiff", "image/tiff"),
2092    ("dng", "image/x-adobe-dng"),
2093    ("heic", "image/heic"),
2094    ("heics", "image/heic-sequence"),
2095    ("heif", "image/heif"),
2096    ("heifs", "image/heif-sequence"),
2097    ("avif", "image/avif"),
2098    ("jxl", "image/jxl"),
2099    ("svg", "image/svg+xml"),
2100    ("mp4", "video/mp4"),
2101    ("m4v", "video/mp4"),
2102    ("mov", "video/quicktime"),
2103    ("avi", "video/x-msvideo"),
2104    ("wav", "audio/wav"),
2105    ("mp3", "audio/mpeg"),
2106    ("m4a", "audio/mp4"),
2107    ("aac", "audio/aac"),
2108    ("flac", "audio/flac"),
2109    ("ogg", "audio/ogg"),
2110    ("oga", "audio/ogg"),
2111    ("pdf", "application/pdf"),
2112    ("zip", "application/zip"),
2113    ("sketch", "application/zip"),
2114    ("epub", "application/epub+zip"),
2115    (
2116        "docx",
2117        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
2118    ),
2119    (
2120        "xlsx",
2121        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2122    ),
2123    (
2124        "pptx",
2125        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
2126    ),
2127    (
2128        "dotx",
2129        "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
2130    ),
2131    ("docm", "application/vnd.ms-word.document.macroenabled.12"),
2132    ("dotm", "application/vnd.ms-word.template.macroenabled.12"),
2133    (
2134        "xltx",
2135        "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
2136    ),
2137    ("xlsm", "application/vnd.ms-excel.sheet.macroenabled.12"),
2138    ("xltm", "application/vnd.ms-excel.template.macroenabled.12"),
2139    (
2140        "xlsb",
2141        "application/vnd.ms-excel.sheet.binary.macroenabled.12",
2142    ),
2143    (
2144        "ppsx",
2145        "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
2146    ),
2147    (
2148        "potx",
2149        "application/vnd.openxmlformats-officedocument.presentationml.template",
2150    ),
2151    (
2152        "pptm",
2153        "application/vnd.ms-powerpoint.presentation.macroenabled.12",
2154    ),
2155    (
2156        "ppsm",
2157        "application/vnd.ms-powerpoint.slideshow.macroenabled.12",
2158    ),
2159    (
2160        "potm",
2161        "application/vnd.ms-powerpoint.template.macroenabled.12",
2162    ),
2163    ("vsdx", "application/vnd.ms-visio.drawing"),
2164    ("vsdm", "application/vnd.ms-visio.drawing.macroenabled.12"),
2165    ("vssx", "application/vnd.ms-visio.stencil"),
2166    ("vssm", "application/vnd.ms-visio.stencil.macroenabled.12"),
2167    ("vstx", "application/vnd.ms-visio.template"),
2168    ("vstm", "application/vnd.ms-visio.template.macroenabled.12"),
2169    ("oxps", "application/oxps"),
2170    ("xps", "application/vnd.ms-xpsdocument"),
2171    ("odt", "application/vnd.oasis.opendocument.text"),
2172    ("ods", "application/vnd.oasis.opendocument.spreadsheet"),
2173    ("odp", "application/vnd.oasis.opendocument.presentation"),
2174    ("odg", "application/vnd.oasis.opendocument.graphics"),
2175    ("ttf", "font/ttf"),
2176    ("otf", "font/otf"),
2177    ("sfnt", "font/sfnt"),
2178    ("txt", "text/plain"),
2179    ("tsv", "text/tab-separated-values"),
2180    ("csv", "text/csv"),
2181    ("md", "text/markdown"),
2182    ("markdown", "text/markdown"),
2183    ("html", "text/html"),
2184    ("htm", "text/html"),
2185    ("xhtml", "application/xhtml+xml"),
2186    ("xml", "application/xml"),
2187    ("css", "text/css"),
2188    ("js", "application/javascript"),
2189    ("mjs", "application/javascript"),
2190    ("json", "application/json"),
2191    ("yaml", "application/yaml"),
2192    ("yml", "application/yaml"),
2193    ("toml", "application/toml"),
2194    ("py", "text/x-python"),
2195    // Carriers C2PA 2.5 adds. Their media types are read only under the 2.5
2196    // policy; the default 2.4 policy reports them as unsupported.
2197    ("warc", "application/warc"),
2198    ("mid", "audio/midi"),
2199    ("midi", "audio/midi"),
2200    ("ttml", "application/ttml+xml"),
2201    ("onnx", "application/onnx"),
2202    ("safetensors", "application/safetensors"),
2203    ("parquet", "application/vnd.apache.parquet"),
2204    ("facti", "image/vnd.blockfact.facti"),
2205    ("facta", "audio/vnd.blockfact.facta"),
2206    ("factv", "video/vnd.blockfact.factv"),
2207    ("jsonl", "application/jsonl"),
2208    ("ndjson", "application/jsonl"),
2209    ("vtt", "text/vtt"),
2210];
2211
2212/// Infer a MIME type from a filename extension, case-insensitively.
2213pub fn mime_from_path(path: &Path) -> Option<&'static str> {
2214    let extension = path.extension()?.to_str()?.to_ascii_lowercase();
2215    SUPPORTED_EXTENSIONS
2216        .iter()
2217        .find(|(candidate, _)| *candidate == extension)
2218        .map(|(_, mime)| *mime)
2219}
2220
2221enum ResolvedTrust {
2222    Bundled(&'static TrustList),
2223    Owned(TrustList),
2224}
2225
2226impl ResolvedTrust {
2227    fn get(&self) -> &TrustList {
2228        match self {
2229            Self::Bundled(trust) => trust,
2230            Self::Owned(trust) => trust,
2231        }
2232    }
2233}
2234
2235/// Resolve one purpose's trust store: the bundled snapshot, the caller's PEM
2236/// bundle, or the snapshot extended by it.
2237///
2238/// Caller-supplied anchors take the configured `bounds`; bundled snapshots stay
2239/// unbounded, because their trust window is the snapshot itself.
2240///
2241/// CAWG identity material a caller supplies is an entry that caller configured
2242/// as the validator, so it is accepted under the CAWG Identity 1.3 base trust
2243/// model rather than under the interim S/MIME additions, which belong to the
2244/// two root stores that section names.
2245fn resolve_trust(
2246    custom_pem: Option<&str>,
2247    sources: Option<&[TrustListSource]>,
2248    bundled: Option<&'static TrustList>,
2249    purpose: AnchorPurpose,
2250    bounds: (Option<OffsetDateTime>, Option<OffsetDateTime>),
2251) -> Result<Option<ResolvedTrust>, Error> {
2252    let mut custom = custom_pem
2253        .map(|pem| TrustList::from_pem_for(purpose, pem))
2254        .transpose()
2255        .map_err(|error| Error::InvalidTrust(error.to_string()))?
2256        .map(|trust| trust.with_bounds(bounds.0, bounds.1))
2257        .map(|trust| match purpose {
2258            AnchorPurpose::CawgIdentity => trust.with_cawg_source(CawgTrustSource::CallerSupplied),
2259            _ => trust,
2260        });
2261    let mut windowed = TrustList::default();
2262    for (index, source) in sources.unwrap_or_default().iter().enumerate() {
2263        if source.uri.trim().is_empty() || url::Url::parse(&source.uri).is_err() {
2264            return Err(Error::InvalidTrust(format!(
2265                "trust source {index} has an invalid uri"
2266            )));
2267        }
2268        if source.source_type.trim().is_empty() {
2269            return Err(Error::InvalidTrust(format!(
2270                "trust source {index} has an empty type"
2271            )));
2272        }
2273        if source
2274            .identifier
2275            .as_ref()
2276            .is_some_and(|value| value.trim().is_empty())
2277        {
2278            return Err(Error::InvalidTrust(format!(
2279                "trust source {index} has an empty identifier"
2280            )));
2281        }
2282        let instant = |value: Option<&str>, field: &str| {
2283            value
2284                .map(|raw| {
2285                    OffsetDateTime::parse(raw, &Rfc3339).map_err(|error| {
2286                        Error::InvalidTrust(format!("trust source {index}.{field}: {error}"))
2287                    })
2288                })
2289                .transpose()
2290        };
2291        let not_before = instant(source.not_before.as_deref(), "not_before")?;
2292        let not_after = instant(source.not_after.as_deref(), "not_after")?;
2293        if let (Some(start), Some(end)) = (not_before, not_after) {
2294            if start > end {
2295                return Err(Error::InvalidTrust(format!(
2296                    "trust source {index}: not_before is later than not_after"
2297                )));
2298            }
2299        }
2300        // A source-specific window may narrow, but never relax, the caller's
2301        // bounds on all supplied anchors.
2302        let not_before = not_before.into_iter().chain(bounds.0).max();
2303        let not_after = not_after.into_iter().chain(bounds.1).min();
2304        let mut metadata =
2305            crate::c2pa_trust::TrustSource::new(source.uri.clone(), source.source_type.clone());
2306        if let Some(identifier) = &source.identifier {
2307            metadata = metadata.with_identifier(identifier.clone());
2308        }
2309        let sourced = TrustList::from_pem_for(purpose, &source.certificates_pem)
2310            .map_err(|error| Error::InvalidTrust(format!("trust source {index}: {error}")))?
2311            .with_bounds(not_before, not_after)
2312            .with_source(metadata);
2313        if source.not_before.is_some() || source.not_after.is_some() {
2314            for anchor in sourced.anchors {
2315                if let Some(existing) = windowed
2316                    .anchors
2317                    .iter_mut()
2318                    .find(|existing| existing.certificate == anchor.certificate)
2319                {
2320                    existing.not_before = existing
2321                        .not_before
2322                        .into_iter()
2323                        .chain(anchor.not_before)
2324                        .max();
2325                    existing.not_after =
2326                        existing.not_after.into_iter().chain(anchor.not_after).min();
2327                } else {
2328                    windowed.anchors.push(anchor);
2329                }
2330            }
2331        } else {
2332            match &mut custom {
2333                Some(custom) => custom.anchors.extend(sourced.anchors),
2334                None => custom = Some(sourced),
2335            }
2336        }
2337    }
2338    if !windowed.anchors.is_empty() {
2339        let mut merged = bundled.cloned().unwrap_or_default();
2340        if let Some(custom) = custom {
2341            merged.anchors.extend(custom.anchors);
2342        }
2343        // A window is a restriction on the certificate, not just on one
2344        // occurrence of it. Remove every unbounded duplicate, including a
2345        // bundled copy, before installing the intersected sourced entry.
2346        merged.anchors.retain(|anchor| {
2347            !windowed
2348                .anchors
2349                .iter()
2350                .any(|bounded| bounded.certificate == anchor.certificate)
2351        });
2352        merged.anchors.extend(windowed.anchors);
2353        return Ok(Some(ResolvedTrust::Owned(merged)));
2354    }
2355    match (bundled, custom) {
2356        (None, None) => Ok(None),
2357        (Some(trust), None) => Ok(Some(ResolvedTrust::Bundled(trust))),
2358        (None, Some(trust)) => Ok(Some(ResolvedTrust::Owned(trust))),
2359        (Some(bundled), Some(custom)) => {
2360            let mut merged = bundled.clone();
2361            merged.anchors.extend(custom.anchors);
2362            Ok(Some(ResolvedTrust::Owned(merged)))
2363        }
2364    }
2365}
2366
2367/// Build the CAWG anchors and direct matches the caller's typed trust
2368/// configurations contribute, each certificate tagged with its entry's
2369/// profile and window and placed by what it is.
2370///
2371/// A malformed entry fails the call and names itself: silently dropping a
2372/// source or a bound would change which signatures the configuration
2373/// validates.
2374fn resolve_cawg_configurations(
2375    configurations: Option<&[CawgTrustConfiguration]>,
2376) -> Result<(TrustList, TrustList), Error> {
2377    let mut anchors = TrustList::default();
2378    let mut direct = TrustList::default();
2379    for (index, configuration) in configurations.unwrap_or_default().iter().enumerate() {
2380        let invalid = |field: &str, detail: &dyn std::fmt::Display| {
2381            Error::InvalidTrust(format!(
2382                "cawg_trust_configurations[{index}]{field}: {detail}"
2383            ))
2384        };
2385        let instant = |value: Option<&str>, field| {
2386            value
2387                .map(|raw| {
2388                    OffsetDateTime::parse(raw, &Rfc3339).map_err(|error| invalid(field, &error))
2389                })
2390                .transpose()
2391        };
2392        let not_before = instant(configuration.not_before.as_deref(), ".not_before")?;
2393        let not_after = instant(configuration.not_after.as_deref(), ".not_after")?;
2394        if let (Some(start), Some(end)) = (not_before, not_after) {
2395            if start > end {
2396                return Err(invalid("", &"not_before is later than not_after"));
2397            }
2398        }
2399        let source = match configuration.profile {
2400            CawgTrustProfile::Base => CawgTrustSource::CallerSupplied,
2401            CawgTrustProfile::SmimeInterim => CawgTrustSource::SmimeInterim,
2402        };
2403        let (entry_anchors, entry_direct) =
2404            TrustList::from_pem_for(AnchorPurpose::CawgIdentity, &configuration.certificates_pem)
2405                .map_err(|error| invalid(".certificates_pem", &error))?
2406                .with_bounds(not_before, not_after)
2407                .with_cawg_source(source)
2408                .split_certificate_authorities();
2409        anchors.anchors.extend(entry_anchors.anchors);
2410        direct.anchors.extend(entry_direct.anchors);
2411    }
2412    Ok((anchors, direct))
2413}
2414
2415/// Append `extra` to one resolved trust store.
2416fn extend_trust(resolved: Option<ResolvedTrust>, extra: TrustList) -> Option<ResolvedTrust> {
2417    if extra.anchors.is_empty() {
2418        return resolved;
2419    }
2420    let mut merged = match resolved {
2421        None => return Some(ResolvedTrust::Owned(extra)),
2422        Some(ResolvedTrust::Bundled(bundled)) => bundled.clone(),
2423        Some(ResolvedTrust::Owned(owned)) => owned,
2424    };
2425    merged.anchors.extend(extra.anchors);
2426    Some(ResolvedTrust::Owned(merged))
2427}
2428
2429fn parse_validation_time(value: Option<&str>) -> Result<OffsetDateTime, Error> {
2430    match value {
2431        Some(raw) => OffsetDateTime::parse(raw, &Rfc3339)
2432            .map_err(|error| Error::InvalidValidationTime(error.to_string())),
2433        None => Ok(OffsetDateTime::now_utc()),
2434    }
2435}
2436
2437/// Parse an optional RFC 3339 trust-anchor bound, naming the field in the error
2438/// so a caller can tell which of the two bounds it mistyped.
2439fn parse_optional_instant(
2440    value: Option<&str>,
2441    field: &str,
2442) -> Result<Option<OffsetDateTime>, Error> {
2443    value
2444        .map(|raw| {
2445            OffsetDateTime::parse(raw, &Rfc3339)
2446                .map_err(|error| Error::InvalidValidationTime(format!("{field}: {error}")))
2447        })
2448        .transpose()
2449}
2450
2451fn parse_stream_presentation_time(raw: &str, index: usize) -> Result<OffsetDateTime, Error> {
2452    OffsetDateTime::parse(raw, &Rfc3339).map_err(|error| {
2453        Error::InvalidValidationTime(format!("stream_presentation_times[{index}]: {error}"))
2454    })
2455}
2456
2457/// Parse caller-supplied per-segment presentation instants, naming the first
2458/// malformed entry by index. `None` stays `None`: the stream is then assessed
2459/// at `validation_time`, never at a time read from the segments themselves.
2460pub(crate) fn parse_stream_presentation_times(
2461    values: Option<&[String]>,
2462) -> Result<Option<Vec<OffsetDateTime>>, Error> {
2463    values
2464        .map(|values| {
2465            values
2466                .iter()
2467                .enumerate()
2468                .map(|(index, raw)| parse_stream_presentation_time(raw, index))
2469                .collect()
2470        })
2471        .transpose()
2472}
2473
2474fn copy_status(status: &CoreStatus) -> VerificationStatus {
2475    VerificationStatus {
2476        code: status.code.clone(),
2477        url: status.url.clone(),
2478        resource_url: status.resource_url.clone(),
2479        explanation: status.explanation.clone(),
2480        details: status.details.clone(),
2481    }
2482}
2483
2484fn copy_results(results: &CoreResults) -> ValidationResults {
2485    ValidationResults {
2486        success: results.success.iter().map(copy_status).collect(),
2487        informational: results.informational.iter().map(copy_status).collect(),
2488        failure: results.failure.iter().map(copy_status).collect(),
2489        trust_list_sources: results.trust_list_sources.clone(),
2490    }
2491}
2492
2493fn signature_status(results: &CoreResults) -> String {
2494    if results.has_success(CLAIM_SIGNATURE_VALIDATED) {
2495        "valid"
2496    } else if results.has_failure(CLAIM_SIGNATURE_MISMATCH) {
2497        "invalid"
2498    } else if results.has_failure(CLAIM_SIGNATURE_MISSING) {
2499        "missing"
2500    } else {
2501        "unknown"
2502    }
2503    .to_string()
2504}
2505
2506fn hard_binding_status(results: &CoreResults) -> String {
2507    const MATCHES: &[&str] = &[
2508        ASSERTION_DATA_HASH_MATCH,
2509        ASSERTION_BMFF_HASH_MATCH,
2510        ASSERTION_BOXES_HASH_MATCH,
2511        ASSERTION_COLLECTION_HASH_MATCH,
2512        ASSERTION_MULTI_ASSET_HASH_MATCH,
2513        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MATCH,
2514    ];
2515    const FAILURES: &[&str] = &[
2516        ASSERTION_DATA_HASH_MISMATCH,
2517        ASSERTION_BMFF_HASH_MISMATCH,
2518        ASSERTION_BMFF_HASH_MALFORMED,
2519        ASSERTION_BOXES_HASH_MISMATCH,
2520        ASSERTION_BOXES_HASH_MALFORMED,
2521        ASSERTION_COLLECTION_HASH_MISMATCH,
2522        ASSERTION_COLLECTION_HASH_MALFORMED,
2523        ASSERTION_MULTI_ASSET_HASH_MISMATCH,
2524        ASSERTION_MULTI_ASSET_HASH_MALFORMED,
2525        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MISMATCH,
2526        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MALFORMED,
2527        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PARTIAL_COVERAGE,
2528        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PREDECESSOR_MISMATCH,
2529    ];
2530    // Checked before the match set, and it has to be: a manifest that binds
2531    // only its init segment DOES produce `assertion.bmffHash.match` over those
2532    // bytes, so a match-first read would answer "match" for a stream whose
2533    // media segments nothing covered. The axis answers "is every byte the
2534    // caller presented bound?", so an unbound or unauthenticated segment is a
2535    // mismatch no matter what else matched (PRD 1.1.1). The same holds for an
2536    // append-only file: the active checkpoint can match while an earlier
2537    // segment was rewritten, and a refused caller verdict binds nothing.
2538    const UNBOUND: &[&str] = &[
2539        crate::c2pa_validate::live_video::LIVEVIDEO_SEGMENT_INVALID,
2540        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MISMATCH,
2541        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_MALFORMED,
2542        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PARTIAL_COVERAGE,
2543        crate::c2pa_validate::ASSERTION_SEGMENT_HASH_PREDECESSOR_MISMATCH,
2544        crate::c2pa_validate::HARD_BINDING_CALLER_EVIDENCE_MISMATCH,
2545    ];
2546    if UNBOUND.iter().any(|code| results.has_failure(code)) {
2547        "mismatch"
2548    } else if MATCHES.iter().any(|code| results.has_success(code)) {
2549        "match"
2550    } else if FAILURES.iter().any(|code| results.has_failure(code)) {
2551        "mismatch"
2552    } else if results.has_failure(CLAIM_HARD_BINDINGS_MISSING) {
2553        "missing"
2554    } else {
2555        "unknown"
2556    }
2557    .to_string()
2558}
2559
2560fn trust_report(
2561    results: &CoreResults,
2562    present: bool,
2563    basis: &str,
2564    validation_time: String,
2565) -> TrustReport {
2566    let supplied = basis != "none";
2567    let trusted = results.has_success(SIGNING_CREDENTIAL_TRUSTED);
2568    let rejected = results.has_failure(SIGNING_CREDENTIAL_UNTRUSTED)
2569        || results.has_failure(SIGNING_CREDENTIAL_INVALID);
2570    let revoked = results.has_failure(SIGNING_CREDENTIAL_OCSP_REVOKED);
2571    let not_revoked = results.has_success(SIGNING_CREDENTIAL_OCSP_NOT_REVOKED);
2572    TrustReport {
2573        status: if trusted && !revoked {
2574            "valid_for_supplied_material"
2575        } else if present && supplied && (rejected || revoked) {
2576            "not_valid_for_supplied_material"
2577        } else {
2578            "not_evaluated"
2579        }
2580        .to_string(),
2581        basis: basis.to_string(),
2582        validation_time,
2583        revocation: RevocationReport {
2584            status: if revoked {
2585                "revoked"
2586            } else if not_revoked {
2587                "not_revoked"
2588            } else {
2589                "not_checked"
2590            }
2591            .to_string(),
2592            source: if !(revoked || not_revoked) {
2593                "none"
2594            } else if results
2595                .success
2596                .iter()
2597                .chain(&results.failure)
2598                .filter(|status| {
2599                    status.code == SIGNING_CREDENTIAL_OCSP_REVOKED
2600                        || status.code == SIGNING_CREDENTIAL_OCSP_NOT_REVOKED
2601                })
2602                .any(|status| {
2603                    status
2604                        .details
2605                        .as_ref()
2606                        .and_then(|details| details.get("source"))
2607                        .and_then(Value::as_str)
2608                        == Some("online_ocsp")
2609                })
2610            {
2611                "online_ocsp"
2612            } else {
2613                "embedded_ocsp"
2614            }
2615            .to_string(),
2616            responder_signature: if revoked || not_revoked {
2617                "valid"
2618            } else {
2619                "not_applicable"
2620            }
2621            .to_string(),
2622        },
2623        freshness: FreshnessReport {
2624            status: "unknown".to_string(),
2625            as_of: None,
2626        },
2627    }
2628}
2629
2630#[cfg(test)]
2631mod tests {
2632    use super::{
2633        detached_manifest_evidence, mime_from_path, read_bounded_file, supported_mime_types,
2634        verify, verify_file, verify_fragmented, verify_fragmented_with_graph_inputs,
2635        verify_manifest_store_only_with_graph_inputs, verify_with_manifest_store_and_graph_inputs,
2636        verify_with_options, CawgTrustConfiguration, CawgTrustProfile, Error, GraphInputs,
2637        GraphLimits, VerificationReport, VerifyOptions,
2638    };
2639    use std::{io::Cursor, path::Path};
2640
2641    #[test]
2642    fn windowed_claim_sources_override_pem_duplicates_and_intersect() {
2643        use crate::c2pa_crypto::extract_x5chain;
2644        use crate::c2pa_trust::{validate_chain_with, AnchorPurpose, CertificateProfile};
2645        use crate::c2pa_validate::signature_conformance_tests::Signer;
2646        use time::macros::datetime;
2647
2648        let signer = Signer::conformant();
2649        let other = Signer::conformant();
2650        let pem = format!("{}{}", signer.root_pem(), other.root_pem());
2651        let chain = extract_x5chain(&signer.sign(b"claim")).unwrap();
2652        let other_chain = extract_x5chain(&other.sign(b"claim")).unwrap();
2653        let source = |uri: &str, start: Option<&str>, end: Option<&str>| {
2654            serde_json::from_value::<super::TrustListSource>(serde_json::json!({
2655                "uri": uri, "type": "c2pa.trustList", "certificates_pem": signer.root_pem(),
2656                "not_before": start, "not_after": end,
2657            }))
2658            .unwrap()
2659        };
2660        for reverse in [false, true] {
2661            let mut sources = vec![
2662                source("urn:fixture:start", Some("2026-06-01T00:00:00Z"), None),
2663                source("urn:fixture:end", None, Some("2026-06-30T00:00:00Z")),
2664                source("urn:fixture:unbounded", None, None),
2665            ];
2666            if reverse {
2667                sources.reverse();
2668            }
2669            let trust = super::resolve_trust(
2670                Some(&pem),
2671                Some(&sources),
2672                None,
2673                AnchorPurpose::ClaimSigning,
2674                (None, None),
2675            )
2676            .unwrap()
2677            .unwrap();
2678            assert_eq!(trust.get().anchors.len(), 2);
2679            let bounded = trust
2680                .get()
2681                .anchors
2682                .iter()
2683                .find(|anchor| anchor.certificate == *chain.last().unwrap())
2684                .unwrap();
2685            assert_eq!(bounded.not_before, Some(datetime!(2026-06-01 0:00 UTC)));
2686            assert_eq!(bounded.not_after, Some(datetime!(2026-06-30 0:00 UTC)));
2687            assert!(bounded.source.as_ref().unwrap().uri != "urn:fixture:unbounded");
2688            for profile in [
2689                CertificateProfile::V2_2,
2690                CertificateProfile::V2_4,
2691                CertificateProfile::V2_5,
2692            ] {
2693                for (at, expected) in [
2694                    (datetime!(2026-05-31 23:59:59 UTC), false),
2695                    (datetime!(2026-06-01 0:00 UTC), true),
2696                    (datetime!(2026-06-30 0:00 UTC), true),
2697                    (datetime!(2026-06-30 0:00:01 UTC), false),
2698                ] {
2699                    let result = validate_chain_with(
2700                        &chain[0],
2701                        &chain[1..],
2702                        trust.get(),
2703                        AnchorPurpose::ClaimSigning,
2704                        Some(at),
2705                        profile,
2706                    );
2707                    assert_eq!(result.trusted, expected, "{at} {profile:?}: {result:?}");
2708                    let unaffected = validate_chain_with(
2709                        &other_chain[0],
2710                        &other_chain[1..],
2711                        trust.get(),
2712                        AnchorPurpose::ClaimSigning,
2713                        Some(at),
2714                        profile,
2715                    );
2716                    assert!(
2717                        unaffected.trusted,
2718                        "PEM-only anchor must stay trusted: {unaffected:?}"
2719                    );
2720                }
2721            }
2722        }
2723    }
2724
2725    #[test]
2726    fn source_windows_are_validated_before_asset_io() {
2727        for field in ["not_before", "not_after"] {
2728            for list in ["claim_trust_sources", "tsa_trust_sources"] {
2729                let mut source = serde_json::json!({
2730                    "uri": "urn:fixture:list",
2731                    "type": "c2pa.tsaTrustList",
2732                    "certificates_pem": include_str!("c2pa-trust/tests/fixtures/rfc3161_root.pem")
2733                });
2734                source[field] = serde_json::json!("not-an-rfc3339-instant");
2735                let mut value = serde_json::json!({"no_default_trust": true});
2736                value[list] = serde_json::json!([source]);
2737                let options: VerifyOptions = serde_json::from_value(value).unwrap();
2738                let error = options.validate().unwrap_err();
2739                assert_eq!(error.code(), "invalid_trust_material");
2740                assert!(error
2741                    .to_string()
2742                    .contains(&format!("trust source 0.{field}")));
2743            }
2744        }
2745    }
2746
2747    #[test]
2748    fn source_windows_preserve_global_bounds_and_refuse_reversed_dates() {
2749        let source = |start: &str, end: &str| {
2750            serde_json::from_value::<super::TrustListSource>(serde_json::json!({
2751                "uri": "urn:fixture:list", "type": "c2pa.tsaTrustList",
2752                "certificates_pem": include_str!("c2pa-trust/tests/fixtures/rfc3161_root.pem"),
2753                "not_before": start, "not_after": end
2754            }))
2755            .unwrap()
2756        };
2757        let global_start = time::macros::datetime!(2026-01-01 0:00 UTC);
2758        let global_end = time::macros::datetime!(2027-01-01 0:00 UTC);
2759        let sources = [source("2025-01-01T00:00:00Z", "2028-01-01T00:00:00Z")];
2760        let trust = super::resolve_trust(
2761            None,
2762            Some(&sources),
2763            None,
2764            super::AnchorPurpose::TimeStamping,
2765            (Some(global_start), Some(global_end)),
2766        )
2767        .unwrap()
2768        .unwrap();
2769        assert_eq!(trust.get().anchors[0].not_before, Some(global_start));
2770        assert_eq!(trust.get().anchors[0].not_after, Some(global_end));
2771        let options = VerifyOptions {
2772            tsa_trust_sources: Some(vec![source("2028-01-01T00:00:00Z", "2025-01-01T00:00:00Z")]),
2773            ..Default::default()
2774        };
2775        assert!(options
2776            .validate()
2777            .unwrap_err()
2778            .to_string()
2779            .contains("not_before is later than not_after"));
2780    }
2781
2782    #[test]
2783    fn known_filename_maps_to_mime() {
2784        assert_eq!(
2785            mime_from_path(Path::new("composition.MP4")),
2786            Some("video/mp4")
2787        );
2788
2789        assert_eq!(
2790            mime_from_path(Path::new("drawing.odg")),
2791            Some("application/vnd.oasis.opendocument.graphics")
2792        );
2793        assert_eq!(
2794            mime_from_path(Path::new("data.tsv")),
2795            Some("text/tab-separated-values")
2796        );
2797        assert_eq!(
2798            mime_from_path(Path::new("archive.ZIP")),
2799            Some("application/zip")
2800        );
2801        assert_eq!(
2802            mime_from_path(Path::new("design.sketch")),
2803            Some("application/zip")
2804        );
2805    }
2806
2807    /// A supported format with no extension can only be verified by passing
2808    /// its MIME type by hand, so a file named `report.oxps` failed on the
2809    /// command line as unsupported. Only alias names of a mapped type are exempt.
2810    #[test]
2811    fn every_supported_format_is_reachable_from_an_extension() {
2812        const ALIASES: &[&str] = &[
2813            "application/font-sfnt",
2814            "application/x-font-ttf",
2815            "application/mp4",
2816            "video/x-m4v",
2817            "text/xml",
2818            "application/x-zip-based",
2819        ];
2820        let unreachable: Vec<_> = supported_mime_types()
2821            .into_iter()
2822            .filter(|mime| !ALIASES.contains(mime))
2823            .filter(|mime| !super::SUPPORTED_EXTENSIONS.iter().any(|(_, m)| m == mime))
2824            .collect();
2825        assert!(
2826            unreachable.is_empty(),
2827            "no extension maps to {unreachable:?}"
2828        );
2829        assert_eq!(
2830            mime_from_path(Path::new("report.OXPS")),
2831            Some("application/oxps")
2832        );
2833    }
2834
2835    /// The trust block names where a revocation answer came from, so a reader
2836    /// can tell a stapled response from one fetched with the user's consent.
2837    #[test]
2838    fn revocation_source_distinguishes_online_from_embedded_ocsp() {
2839        use crate::c2pa_validate::{StatusCode, ValidationResults};
2840        let status = |source: Option<&str>| StatusCode {
2841            code: crate::c2pa_validate::SIGNING_CREDENTIAL_OCSP_NOT_REVOKED.into(),
2842            url: "self#jumbf=/c2pa/urn:c2pa:x/c2pa.signature".into(),
2843            resource_url: None,
2844            explanation: "not revoked".into(),
2845            details: source.map(|source| serde_json::json!({ "source": source })),
2846        };
2847        let report = |source: Option<&str>| {
2848            let results = ValidationResults {
2849                success: vec![status(source)],
2850                ..ValidationResults::default()
2851            };
2852            super::trust_report(&results, true, "bundled_static_material", String::new())
2853                .revocation
2854                .source
2855        };
2856        assert_eq!(report(Some("online_ocsp")), "online_ocsp");
2857        assert_eq!(report(None), "embedded_ocsp");
2858    }
2859
2860    #[test]
2861    fn format_list_is_sorted_and_contains_composition_formats() {
2862        let formats = supported_mime_types();
2863        assert_eq!(formats.len(), 73);
2864        assert!(formats.windows(2).all(|pair| pair[0] < pair[1]));
2865        assert!(formats.contains(&"video/mp4"));
2866        assert!(formats.contains(&"image/jpeg"));
2867        assert!(formats.contains(&"text/tab-separated-values"));
2868        assert!(formats.contains(&"application/vnd.oasis.opendocument.graphics"));
2869        assert!(formats.contains(&"application/zip"));
2870        assert!(formats.contains(&"application/x-zip-based"));
2871    }
2872
2873    #[test]
2874    fn input_alias_is_reported_as_canonical_mime() {
2875        let asset = b"\x00\x00\x00\x10ftypisom\x00\x00\x00\x00";
2876        let report = verify(asset, "audio/aac; codecs=mp4a.40.2").unwrap();
2877        assert_eq!(report.mime_type, "audio/mp4");
2878        assert!(!report.present);
2879    }
2880
2881    #[test]
2882    fn strict_conformance_option_selects_the_program_profile() {
2883        let asset = include_bytes!("../../../tests/fixtures/signed_test.jpg");
2884        let report = verify_with_options(
2885            asset,
2886            "image/jpeg",
2887            &VerifyOptions {
2888                strict_conformance: true,
2889                ..VerifyOptions::default()
2890            },
2891        )
2892        .unwrap();
2893        assert_eq!(
2894            report.manifest_report["engine_profile"]["operating_mode"],
2895            "conformance"
2896        );
2897        assert_eq!(
2898            report.manifest_report["engine_profile"]["compliance_level"],
2899            "conformance-program"
2900        );
2901    }
2902
2903    #[test]
2904    fn fragmented_entry_point_rejects_non_bmff_mime() {
2905        let error = verify_fragmented(
2906            include_bytes!("../../../tests/fixtures/signed_test.jpg"),
2907            &[b"ignored fragment"],
2908            "image/jpeg",
2909        )
2910        .unwrap_err();
2911        assert!(matches!(error, Error::UnsupportedMime(mime) if mime == "image/jpeg"));
2912    }
2913
2914    #[test]
2915    fn detached_fragmented_and_store_only_routes_honor_one_graph_budget() {
2916        let limited = || GraphInputs {
2917            external: None,
2918            limits: GraphLimits::lowered(32, 0, 16_384, 64 * 1024 * 1024),
2919        };
2920        let has_node_limit = |report: &VerificationReport| {
2921            report
2922                .validation_results
2923                .informational
2924                .iter()
2925                .any(|status| {
2926                    status.code == "com.encypher.resource.graphLimitExceeded"
2927                        && status
2928                            .details
2929                            .as_ref()
2930                            .and_then(|details| details.get("limit"))
2931                            .and_then(serde_json::Value::as_str)
2932                            == Some("nodes")
2933                })
2934        };
2935
2936        let jpeg = include_bytes!("../../../tests/fixtures/signed_test.jpg");
2937        let store = detached_manifest_evidence(jpeg, "image/jpeg")
2938            .expect("extract detached evidence")
2939            .expect("signed JPEG manifest")
2940            .manifest_store;
2941        let detached = verify_with_manifest_store_and_graph_inputs(
2942            jpeg,
2943            &store,
2944            "image/jpeg",
2945            &VerifyOptions::default(),
2946            limited(),
2947        )
2948        .expect("detached graph verification");
2949        assert!(
2950            has_node_limit(&detached),
2951            "{:?}",
2952            detached.validation_results
2953        );
2954
2955        let store_only = verify_manifest_store_only_with_graph_inputs(
2956            &store,
2957            &VerifyOptions::default(),
2958            limited(),
2959        )
2960        .expect("store-only graph verification");
2961        assert_eq!(store_only.report.hard_binding, "not_evaluated");
2962        assert!(has_node_limit(&store_only.report));
2963
2964        let mp4 = include_bytes!("../../../tests/fixtures/signed_test.mp4");
2965        let fragmented = verify_fragmented_with_graph_inputs(
2966            mp4,
2967            &[],
2968            "video/mp4",
2969            &VerifyOptions::default(),
2970            limited(),
2971        )
2972        .expect("fragmented graph verification");
2973        assert!(
2974            has_node_limit(&fragmented),
2975            "{:?}",
2976            fragmented.validation_results
2977        );
2978    }
2979
2980    #[test]
2981    fn unratified_hostless_store_is_not_in_public_profile() {
2982        let error = verify(b"jumb", "application/c2pa").unwrap_err();
2983        assert!(matches!(error, Error::UnsupportedMime(_)));
2984    }
2985
2986    #[test]
2987    fn bounded_reader_accepts_exact_limit_without_large_allocation() {
2988        let mut input = Cursor::new(b"1234");
2989        assert_eq!(read_bounded_file(&mut input, 4, 4).unwrap(), b"1234");
2990    }
2991
2992    #[test]
2993    fn bounded_reader_detects_growth_at_limit_plus_one() {
2994        let mut input = Cursor::new(b"12345");
2995        let error = read_bounded_file(&mut input, 4, 4).unwrap_err();
2996        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
2997        assert!(error.to_string().contains("grew while being read"));
2998    }
2999
3000    #[test]
3001    fn unsupported_mime_has_stable_error_code() {
3002        let error = verify(b"not an asset", "application/x-unknown").unwrap_err();
3003        assert!(matches!(error, Error::UnsupportedMime(_)));
3004        assert_eq!(error.code(), "unsupported_mime");
3005    }
3006
3007    #[test]
3008    fn verify_file_resolves_options_before_opening_the_asset() {
3009        let path = std::env::temp_dir().join(format!(
3010            "encypher-c2pa-invalid-options-missing-{}.jpg",
3011            std::process::id()
3012        ));
3013        let options = VerifyOptions {
3014            no_default_trust: true,
3015            cawg_trust_configurations: Some(vec![CawgTrustConfiguration {
3016                profile: CawgTrustProfile::Base,
3017                certificates_pem: String::new(),
3018                not_before: None,
3019                not_after: None,
3020            }]),
3021            ..VerifyOptions::default()
3022        };
3023
3024        let error = verify_file(path, Some("image/jpeg"), &options)
3025            .expect_err("invalid trust material must win over a missing asset");
3026        assert_eq!(error.code(), "invalid_trust_material");
3027        assert!(
3028            error
3029                .to_string()
3030                .contains("cawg_trust_configurations[0].certificates_pem"),
3031            "{error}"
3032        );
3033    }
3034
3035    #[test]
3036    fn zip64_is_admitted_only_by_the_caller_selected_v2_5_policy() {
3037        let (archive, _) = crate::c2pa_formats::zip64_test_asset();
3038        for spec_version in [None, Some("2.4")] {
3039            let error = verify_with_options(
3040                &archive,
3041                "application/zip",
3042                &VerifyOptions {
3043                    spec_version: spec_version.map(str::to_string),
3044                    ..VerifyOptions::default()
3045                },
3046            )
3047            .expect_err("legacy policy must refuse ZIP64");
3048            assert!(
3049                matches!(error, Error::Verification(_)),
3050                "{spec_version:?} returned {error:?}"
3051            );
3052        }
3053
3054        let error = verify_with_options(
3055            &archive,
3056            "application/zip",
3057            &VerifyOptions {
3058                spec_version: Some("2.2".into()),
3059                ..VerifyOptions::default()
3060            },
3061        )
3062        .expect_err("2.2 must refuse a ZIP MIME outside its format set");
3063        assert!(
3064            matches!(&error, Error::UnsupportedMime(mime) if mime == "application/zip"),
3065            "2.2 returned {error:?}"
3066        );
3067
3068        let report = verify_with_options(
3069            &archive,
3070            "application/zip",
3071            &VerifyOptions {
3072                spec_version: Some("2.5".into()),
3073                ..VerifyOptions::default()
3074            },
3075        )
3076        .expect("2.5 ZIP64 verification returns a report");
3077        assert!(report.present, "2.5 did not reach the ZIP64 manifest");
3078    }
3079
3080    /// CAWG-ID13-DELTA-009: a trust configuration's certificates and window
3081    /// are what the validator enforces, so a malformed one fails the call
3082    /// before any asset is read, naming the entry, rather than silently
3083    /// dropping a source or a bound. An unknown profile is a malformed option.
3084    #[test]
3085    fn a_malformed_cawg_trust_configuration_fails_the_call_naming_its_entry() {
3086        let root = include_str!("default_trust/encypher-identity-root.pem");
3087        let resolve = |entry: serde_json::Value| {
3088            let options: VerifyOptions = serde_json::from_value(serde_json::json!({
3089                "no_default_trust": true,
3090                "cawg_trust_configurations": [
3091                    { "profile": "base", "certificates_pem": root },
3092                    entry,
3093                ],
3094            }))
3095            .map_err(|error| error.to_string())?;
3096            super::ResolvedOptions::resolve(&options)
3097                .map(|_| ())
3098                .map_err(|error| format!("{}: {error}", error.code()))
3099        };
3100
3101        for (entry, field) in [
3102            (
3103                serde_json::json!({ "profile": "smime_interim", "certificates_pem": "" }),
3104                "cawg_trust_configurations[1].certificates_pem",
3105            ),
3106            (
3107                serde_json::json!({ "profile": "smime_interim", "certificates_pem": root, "not_after": "next spring" }),
3108                "cawg_trust_configurations[1].not_after",
3109            ),
3110            (
3111                serde_json::json!({
3112                    "profile": "smime_interim",
3113                    "certificates_pem": root,
3114                    "not_before": "2027-01-02T00:00:00Z",
3115                    "not_after": "2027-01-01T00:00:00Z",
3116                }),
3117                "cawg_trust_configurations[1]",
3118            ),
3119        ] {
3120            let error = resolve(entry).expect_err("malformed configuration");
3121            assert!(error.starts_with("invalid_trust_material: "), "{error}");
3122            assert!(error.contains(field), "{error}");
3123        }
3124
3125        let unknown =
3126            resolve(serde_json::json!({ "profile": "trusted", "certificates_pem": root }))
3127                .expect_err("unknown profile");
3128        assert!(unknown.contains("unknown variant `trusted`"), "{unknown}");
3129        assert_eq!(
3130            resolve(serde_json::json!({ "profile": "smime_interim", "certificates_pem": root })),
3131            Ok(())
3132        );
3133    }
3134}