Skip to main content

encypher_c2pa/
lib.rs

1//! Local-first, verification-only C2PA SDK.
2//!
3//! The public facade reads caller-provided bytes and reports integrity separately
4//! from trust. Network access occurs only after saved failure telemetry consent
5//! or an explicit per-call override.
6
7#![forbid(unsafe_code)]
8// The six modules below are the verification kernel. They derive from the
9// engine that runs in Encypher's production signing service, and are kept as
10// six crate-named directories so the two trees stay readable side by side.
11//
12// They are a derivative, not a copy, and the difference is the point of this
13// repository: the production tree carries manifest construction and container
14// writing, and this one does not. The line counts differ accordingly and by
15// design - production `c2pa-formats` is roughly twice this one.
16//
17// No automated comparison exists between the two trees. An earlier version of
18// this comment said a private drift gate compared them; there is no such gate
19// in the monorepo, and saying otherwise credited this code with a control
20// nobody had written. Divergence is currently caught by review alone. If that
21// is not good enough - and for a security boundary it probably is not - the
22// thing to build is a projection that maps production paths onto this layout
23// and diffs the shared functions, which would have to model the intended
24// removals rather than expect equality.
25//
26// They were separate published crates until this change. That made 22,890 lines
27// of implementation into semver-bound public API with no consumer - 81% of the
28// reviewed public surface - and it is why manifest construction needed a Cargo
29// feature to hide it. As private modules they are unreachable by construction,
30// so the writers are simply `cfg(test)` and no feature can expose them.
31//
32// Suppressing `dead_code`/`unused_imports` here is deliberate and narrow to
33// these mirrors.
34// The public verifier exercises a subset of the kernel: the rest is reached by
35// the production signer (`c2pa-sign`, `c2pa-cli` on the private side). Deleting
36// what this crate happens not to call would fork the shared source and destroy
37// the property the mirror exists for.
38//
39// `allow` rather than `expect`, which would be preferable: the dead set differs
40// between the two compilations of this crate. Under `cargo build` the writers
41// are absent and much of the kernel is unused; under `cargo test` the
42// `cfg(test)` code exercises them. A single `expect` cannot be fulfilled in
43// both, so it fails the `--all-targets` lint run. `allow` is scoped to these
44// six mirrors and to these two lints only; nothing else in the crate is
45// exempted, and the surface gate is what actually holds the boundary.
46#[path = "c2pa-cbor/lib.rs"]
47#[allow(dead_code, reason = "production kernel mirror")]
48mod c2pa_cbor;
49#[path = "c2pa-core/lib.rs"]
50#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
51mod c2pa_core;
52#[path = "c2pa-crypto/lib.rs"]
53#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
54mod c2pa_crypto;
55#[path = "c2pa-formats/lib.rs"]
56#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
57mod c2pa_formats;
58#[path = "c2pa-trust/lib.rs"]
59#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
60mod c2pa_trust;
61#[path = "c2pa-validate/lib.rs"]
62#[allow(dead_code, unused_imports, reason = "production kernel mirror")]
63mod c2pa_validate;
64mod default_trust;
65mod telemetry;
66mod telemetry_consent;
67pub use default_trust::SNAPSHOT_DATE as DEFAULT_TRUST_SNAPSHOT_DATE;
68
69pub use telemetry::{
70    validation_failure_telemetry, TelemetryOptions, ValidationFailureTelemetry,
71    DEFAULT_TELEMETRY_ENDPOINT,
72};
73pub use telemetry_consent::{
74    prompt_for_telemetry_consent, set_telemetry_enabled, telemetry_preference,
75    TelemetryPreferenceError,
76};
77
78use std::collections::HashMap;
79use std::fs::{self, OpenOptions};
80use std::io::{self, Read};
81#[cfg(unix)]
82use std::os::unix::fs::OpenOptionsExt;
83use std::path::Path;
84
85use crate::c2pa_core::{
86    spec::{canonicalize_mime, mimes_for_version},
87    EngineProfile, SpecVersion,
88};
89use crate::c2pa_trust::TrustList;
90use crate::c2pa_validate::{
91    verify_fragmented_with_cawg_trust_policy_did_documents_and_strict_encoding_safe as verify_fragmented_safe,
92    verify_with_cawg_trust_policy_did_documents_and_strict_encoding_safe as verify_safe,
93    StatusCode as CoreStatus, ValidationResults as CoreResults, VerifyInput,
94    ASSERTION_BMFF_HASH_MALFORMED, ASSERTION_BMFF_HASH_MATCH, ASSERTION_BMFF_HASH_MISMATCH,
95    ASSERTION_BOXES_HASH_MALFORMED, ASSERTION_BOXES_HASH_MATCH, ASSERTION_BOXES_HASH_MISMATCH,
96    ASSERTION_COLLECTION_HASH_MALFORMED, ASSERTION_COLLECTION_HASH_MATCH,
97    ASSERTION_COLLECTION_HASH_MISMATCH, ASSERTION_DATA_HASH_MATCH, ASSERTION_DATA_HASH_MISMATCH,
98    ASSERTION_MULTI_ASSET_HASH_MALFORMED, ASSERTION_MULTI_ASSET_HASH_MATCH,
99    ASSERTION_MULTI_ASSET_HASH_MISMATCH, CLAIM_HARD_BINDINGS_MISSING, CLAIM_SIGNATURE_MISMATCH,
100    CLAIM_SIGNATURE_MISSING, CLAIM_SIGNATURE_VALIDATED, SIGNING_CREDENTIAL_INVALID,
101    SIGNING_CREDENTIAL_OCSP_NOT_REVOKED, SIGNING_CREDENTIAL_OCSP_REVOKED,
102    SIGNING_CREDENTIAL_TRUSTED, SIGNING_CREDENTIAL_UNTRUSTED,
103};
104use serde::{Deserialize, Serialize};
105use serde_json::Value;
106use sha2::{Digest, Sha256};
107use time::{format_description::well_known::Rfc3339, OffsetDateTime};
108
109pub const REPORT_SCHEMA_VERSION: &str = "1.0";
110pub const C2PA_PROFILE: &str = "c2pa-2.4";
111const MAX_MANIFEST_STORE_BYTES: usize = 64 * 1024 * 1024;
112const MAX_PATH_ASSET_BYTES: u64 = 128 * 1024 * 1024;
113
114#[derive(Debug, Clone, Default, Deserialize)]
115#[serde(default, deny_unknown_fields)]
116pub struct VerifyOptions {
117    /// PEM bundle of claim-signing trust anchors.
118    pub trust_pem: Option<String>,
119    /// PEM bundle of timestamp-authority trust anchors.
120    pub tsa_trust_pem: Option<String>,
121    /// PEM bundle of directly allowed end-entity certificates.
122    pub allowed_list_pem: Option<String>,
123    /// PEM bundle of trust anchors for CAWG named-actor (identity) X.509
124    /// credentials. `None` leaves identity signers untrusted (their
125    /// well-formedness is still validated).
126    pub cawg_trust_pem: Option<String>,
127    /// PEM bundle of directly allowed CAWG end-entity certificates.
128    pub cawg_allowed_certs_pem: Option<String>,
129    /// Disable the bundled C2PA, IPTC, and Encypher trust snapshots. By
130    /// default, caller-supplied PEM bundles extend those snapshots; setting
131    /// this to `true` evaluates only caller-supplied trust material.
132    pub no_default_trust: bool,
133    /// Pinned offline `did:web` DID-document store for CAWG ICA issuers,
134    /// keyed by primary DID (no fragment). Resolution never touches the
135    /// network: an issuer absent from the store fails closed with
136    /// `cawg.ica.did_unavailable`.
137    pub cawg_did_documents: Option<HashMap<String, Value>>,
138    /// Refuse CAWG 1.1-era legacy encodings (field-order `signer_payload`,
139    /// 1.1 ICA context, byte-array `c2paAsset` hashes): only the CAWG 1.2
140    /// canonical shapes are attempted. When unset, legacy shapes verify and
141    /// are surfaced via the informational `com.encypher.cawg.legacyProfile`
142    /// status.
143    pub cawg_strict_encoding: bool,
144    /// Apply the C2PA 2.4 Conformance Program posture instead of the default
145    /// generous core-spec read. This raises applicable SHOULD requirements to
146    /// the conformance bar and emits strict diagnostics in the reader report.
147    pub strict_conformance: bool,
148    /// RFC 3339 validation instant. Current UTC time is used when omitted.
149    pub validation_time: Option<String>,
150    /// Failure telemetry override. `None` uses the saved per-user preference.
151    pub telemetry: TelemetryOptions,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct VerificationStatus {
156    pub code: String,
157    pub url: String,
158    pub explanation: String,
159    /// Machine-readable evidence for extension status codes (e.g. the CAWG
160    /// `payload_encoding` detail).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub details: Option<Value>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166pub struct ValidationResults {
167    pub success: Vec<VerificationStatus>,
168    pub informational: Vec<VerificationStatus>,
169    pub failure: Vec<VerificationStatus>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
173pub struct RevocationReport {
174    pub status: String,
175    pub source: String,
176    pub responder_signature: String,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180pub struct FreshnessReport {
181    pub status: String,
182    pub as_of: Option<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct TrustReport {
187    pub status: String,
188    pub basis: String,
189    pub validation_time: String,
190    pub revocation: RevocationReport,
191    pub freshness: FreshnessReport,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct VerificationReport {
196    pub schema_version: String,
197    pub profile: String,
198    pub mime_type: String,
199    pub present: bool,
200    pub integrity: String,
201    pub signature: String,
202    pub hard_binding: String,
203    pub trust: TrustReport,
204    pub policy: Option<Value>,
205    pub managed_receipt: Option<Value>,
206    pub validation_state: String,
207    pub validation_results: ValidationResults,
208    pub manifest_report: Value,
209    pub content_credentials: Option<Value>,
210}
211/// Raw, read-only evidence needed to validate an embedded manifest remotely
212/// without uploading the host asset.
213///
214/// `carrier` is the single contiguous format carrier that contains
215/// `manifest_store`. Formats whose manifest spans multiple disjoint carriers
216/// return no detached evidence; local verification still covers them.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct DetachedManifestEvidence {
219    pub manifest_store: Vec<u8>,
220    pub manifest_store_sha256: String,
221    pub carrier: Vec<u8>,
222}
223
224impl VerificationReport {
225    pub fn to_json(&self) -> Result<String, Error> {
226        serde_json::to_string(self).map_err(Error::Serialize)
227    }
228
229    pub fn to_pretty_json(&self) -> Result<String, Error> {
230        serde_json::to_string_pretty(self).map_err(Error::Serialize)
231    }
232
233    /// All CAWG identity / ICA credential statuses (`cawg.*` codes), across
234    /// the success, informational, and failure sections. CAWG failures are
235    /// assertion-scoped: they never invalidate C2PA manifest integrity.
236    pub fn cawg_statuses(&self) -> Vec<&VerificationStatus> {
237        self.validation_results
238            .success
239            .iter()
240            .chain(&self.validation_results.informational)
241            .chain(&self.validation_results.failure)
242            .filter(|status| status.code.starts_with("cawg."))
243            .collect()
244    }
245}
246
247#[derive(Debug, thiserror::Error)]
248pub enum Error {
249    #[error("unsupported MIME type: {0}")]
250    UnsupportedMime(String),
251    #[error("invalid trust material: {0}")]
252    InvalidTrust(String),
253    #[error("invalid validation time: {0}")]
254    InvalidValidationTime(String),
255    #[error("verification failed: {0}")]
256    Verification(String),
257    #[error("could not read asset: {0}")]
258    Io(#[from] std::io::Error),
259    #[error(transparent)]
260    TelemetryPreference(#[from] TelemetryPreferenceError),
261    #[error("could not serialize report: {0}")]
262    Serialize(serde_json::Error),
263}
264
265impl Error {
266    pub fn code(&self) -> &'static str {
267        match self {
268            Self::UnsupportedMime(_) => "unsupported_mime",
269            Self::InvalidTrust(_) => "invalid_trust_material",
270            Self::InvalidValidationTime(_) => "invalid_validation_time",
271            Self::Verification(_) => "verification_error",
272            Self::Io(_) => "io_error",
273            Self::TelemetryPreference(_) => "telemetry_preference_error",
274            Self::Serialize(_) => "serialization_error",
275        }
276    }
277}
278
279/// Verify asset bytes with default options. Parsing and validation stay offline;
280/// saved failure-telemetry consent may emit one bounded request. For guaranteed
281/// no egress, disable the `telemetry` feature or call [`verify_with_options`]
282/// with `telemetry.enabled` set to `Some(false)`.
283pub fn verify(data: &[u8], mime_type: &str) -> Result<VerificationReport, Error> {
284    verify_with_options(data, mime_type, &VerifyOptions::default())
285}
286
287/// Verify a fragmented ISO BMFF stream with default options.
288///
289/// `init_segment` carries the manifest. Each entry in `fragments` is one media
290/// segment (`.m4s`). A subset may be supplied: each segment carries its own
291/// Merkle-tree location and is checked independently.
292pub fn verify_fragmented(
293    init_segment: &[u8],
294    fragments: &[&[u8]],
295    mime_type: &str,
296) -> Result<VerificationReport, Error> {
297    verify_fragmented_with_options(
298        init_segment,
299        fragments,
300        mime_type,
301        &VerifyOptions::default(),
302    )
303}
304/// Extract the signed manifest store and its contiguous carrier for detached
305/// server validation.
306///
307/// This is read-only. It never performs network I/O and never constructs or
308/// writes a manifest. `Ok(None)` means the asset has no embedded manifest or
309/// its format cannot represent the manifest as one contiguous carrier.
310pub fn detached_manifest_evidence(
311    data: &[u8],
312    mime_type: &str,
313) -> Result<Option<DetachedManifestEvidence>, Error> {
314    let mime = canonicalize_mime(mime_type);
315    let format = crate::c2pa_formats::AssetFormat::from_mime(&mime)
316        .ok_or_else(|| Error::UnsupportedMime(mime.clone()))?;
317    if !crate::c2pa_formats::supports_hash_mode(&mime) {
318        return Ok(None);
319    }
320    let Some(manifest_store) = crate::c2pa_formats::extract_manifest(format, data)
321        .map_err(|error| Error::Verification(error.to_string()))?
322    else {
323        return Ok(None);
324    };
325    let spans = crate::c2pa_formats::compute_data_hash_exclusions(format, data)
326        .map_err(|error| Error::Verification(error.to_string()))?;
327    let [span] = spans.as_slice() else {
328        return Ok(None);
329    };
330    let end = span
331        .start
332        .checked_add(span.length)
333        .filter(|end| *end <= data.len())
334        .ok_or_else(|| Error::Verification("manifest carrier exceeds asset bounds".into()))?;
335    let manifest_store_sha256 = hex::encode(Sha256::digest(&manifest_store));
336    Ok(Some(DetachedManifestEvidence {
337        manifest_store,
338        manifest_store_sha256,
339        carrier: data[span.start..end].to_vec(),
340    }))
341}
342
343/// Verify asset bytes against the bundled trust snapshots plus any static
344/// trust material supplied by the caller. Failure telemetry follows the
345/// explicit override or saved per-user preference.
346pub fn verify_with_options(
347    data: &[u8],
348    mime_type: &str,
349    options: &VerifyOptions,
350) -> Result<VerificationReport, Error> {
351    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
352    let result = verify_with_options_inner(data, None, mime_type, options);
353    if let Some(event) = telemetry::validation_failure_telemetry_with_enabled(
354        mime_type,
355        &result,
356        &options.telemetry,
357        telemetry_enabled,
358    ) {
359        telemetry::enqueue(options.telemetry.endpoint(), event);
360    }
361    result
362}
363
364/// Verify a fragmented ISO BMFF stream with explicit trust, validation-time,
365/// CAWG, and telemetry options.
366///
367/// `init_segment` carries the C2PA manifest. `fragments` may be the complete
368/// stream or any available subset; validation covers every supplied fragment.
369pub fn verify_fragmented_with_options(
370    init_segment: &[u8],
371    fragments: &[&[u8]],
372    mime_type: &str,
373    options: &VerifyOptions,
374) -> Result<VerificationReport, Error> {
375    let telemetry_enabled = telemetry_consent::resolve_telemetry_enabled(options.telemetry.enabled);
376    let mime = canonicalize_mime(mime_type);
377    let result = if crate::c2pa_formats::AssetFormat::from_mime(&mime)
378        == Some(crate::c2pa_formats::AssetFormat::Bmff)
379    {
380        verify_with_options_inner(init_segment, Some(fragments), &mime, options)
381    } else {
382        Err(Error::UnsupportedMime(mime))
383    };
384    if let Some(event) = telemetry::validation_failure_telemetry_with_enabled(
385        mime_type,
386        &result,
387        &options.telemetry,
388        telemetry_enabled,
389    ) {
390        telemetry::enqueue(options.telemetry.endpoint(), event);
391    }
392    result
393}
394
395fn verify_with_options_inner(
396    data: &[u8],
397    fragments: Option<&[&[u8]]>,
398    mime_type: &str,
399    options: &VerifyOptions,
400) -> Result<VerificationReport, Error> {
401    let mime = canonicalize_mime(mime_type);
402    if !mimes_for_version(SpecVersion::V2_4).contains(&mime.as_str())
403        || crate::c2pa_formats::AssetFormat::from_mime(&mime).is_none()
404    {
405        return Err(Error::UnsupportedMime(mime));
406    }
407
408    let use_defaults = !options.no_default_trust;
409    let claim_trust = resolve_trust(
410        options.trust_pem.as_deref(),
411        use_defaults.then(default_trust::claim_signing),
412    )?;
413    let tsa_trust = resolve_trust(
414        options.tsa_trust_pem.as_deref(),
415        use_defaults.then(default_trust::timestamp_authorities),
416    )?;
417    let allowed_certs = resolve_trust(
418        options.allowed_list_pem.as_deref(),
419        use_defaults.then(default_trust::allowed_claim_signers),
420    )?;
421    let cawg_trust = resolve_trust(
422        options.cawg_trust_pem.as_deref(),
423        use_defaults.then(default_trust::cawg_identity),
424    )?;
425    let cawg_allowed_certs = resolve_trust(
426        options.cawg_allowed_certs_pem.as_deref(),
427        use_defaults.then(default_trust::cawg_allowed_identities),
428    )?;
429    let validation_time = parse_validation_time(options.validation_time.as_deref())?;
430    let validation_time_text = validation_time
431        .format(&Rfc3339)
432        .map_err(|error| Error::InvalidValidationTime(error.to_string()))?;
433
434    let input = VerifyInput {
435        data,
436        mime: &mime,
437        claim_signer_trust: claim_trust.as_ref().map(ResolvedTrust::get),
438        tsa_trust: tsa_trust.as_ref().map(ResolvedTrust::get),
439        allowed_certs: allowed_certs.as_ref().map(ResolvedTrust::get),
440        validation_time: Some(validation_time),
441        profile: if options.strict_conformance {
442            EngineProfile::strict(SpecVersion::V2_4)
443        } else {
444            EngineProfile::GENEROUS
445        },
446    };
447    let cawg_trust = cawg_trust.as_ref().map(ResolvedTrust::get);
448    let cawg_allowed_certs = cawg_allowed_certs.as_ref().map(ResolvedTrust::get);
449    let output = match fragments {
450        Some(fragments) => verify_fragmented_safe(
451            &input,
452            fragments,
453            cawg_trust,
454            cawg_allowed_certs,
455            true,
456            options.cawg_did_documents.as_ref(),
457            options.cawg_strict_encoding,
458        ),
459        None => verify_safe(
460            &input,
461            cawg_trust,
462            cawg_allowed_certs,
463            true,
464            options.cawg_did_documents.as_ref(),
465            options.cawg_strict_encoding,
466        ),
467    }
468    .map_err(|error| match error {
469        crate::c2pa_validate::ValidateError::UnsupportedMime(value) => {
470            Error::UnsupportedMime(value)
471        }
472        other => Error::Verification(other.to_string()),
473    })?;
474
475    let present = output
476        .report_json
477        .pointer("/provenance_verdict/present")
478        .and_then(Value::as_bool)
479        .unwrap_or(false);
480    let integrity = output
481        .report_json
482        .pointer("/provenance_verdict/integrity")
483        .and_then(Value::as_str)
484        .unwrap_or(if present { "invalid" } else { "absent" })
485        .to_string();
486    let signature = signature_status(&output.results);
487    let hard_binding = hard_binding_status(&output.results);
488    let custom_claim_trust = options.trust_pem.is_some() || options.allowed_list_pem.is_some();
489    let trust_basis = match (use_defaults, custom_claim_trust) {
490        (true, true) => "bundled_and_caller_supplied_static_material",
491        (true, false) => "bundled_static_material",
492        (false, true) => "caller_supplied_static_material",
493        (false, false) => "none",
494    };
495    let trust = trust_report(&output.results, present, trust_basis, validation_time_text);
496
497    Ok(VerificationReport {
498        schema_version: REPORT_SCHEMA_VERSION.to_string(),
499        profile: C2PA_PROFILE.to_string(),
500        mime_type: mime,
501        present,
502        integrity,
503        signature,
504        hard_binding,
505        trust,
506        policy: None,
507        managed_receipt: None,
508        validation_state: output.validation_state.as_str().to_string(),
509        validation_results: copy_results(&output.results),
510        manifest_report: output.report_json,
511        content_credentials: output.crjson,
512    })
513}
514
515/// Read and verify one local asset.
516///
517/// Path-based verification accepts regular files up to 128 MiB. Byte-slice
518/// verification remains bounded only by caller memory.
519pub fn verify_file(
520    path: impl AsRef<Path>,
521    mime_type: Option<&str>,
522    options: &VerifyOptions,
523) -> Result<VerificationReport, Error> {
524    let path = path.as_ref();
525    let mime = match mime_type {
526        Some(value) => value.to_string(),
527        None => mime_from_path(path)
528            .ok_or_else(|| Error::UnsupportedMime(path.display().to_string()))?
529            .to_string(),
530    };
531    let data = read_path_asset(path, MAX_PATH_ASSET_BYTES)?;
532    verify_with_options(&data, &mime, options)
533}
534
535fn read_path_asset(path: &Path, limit: u64) -> io::Result<Vec<u8>> {
536    let mut options = OpenOptions::new();
537    options.read(true);
538    #[cfg(unix)]
539    options.custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC);
540
541    // Open once, then validate that exact handle. On Unix O_NONBLOCK prevents
542    // a FIFO substituted for the path from blocking before it can be rejected.
543    let mut file = options.open(path)?;
544    let opened_metadata = file.metadata()?;
545    validate_path_asset(path, &opened_metadata, limit)?;
546    read_bounded_file(&mut file, opened_metadata.len(), limit)
547}
548
549fn validate_path_asset(path: &Path, metadata: &fs::Metadata, limit: u64) -> io::Result<()> {
550    if !metadata.is_file() {
551        return Err(io::Error::new(
552            io::ErrorKind::InvalidInput,
553            format!("asset path is not a regular file: {}", path.display()),
554        ));
555    }
556    if metadata.len() > limit {
557        return Err(io::Error::new(
558            io::ErrorKind::InvalidData,
559            format!("asset exceeds the 128 MiB path limit: {}", path.display()),
560        ));
561    }
562    Ok(())
563}
564
565fn read_bounded_file<R: Read>(file: &mut R, expected_len: u64, limit: u64) -> io::Result<Vec<u8>> {
566    if expected_len > limit {
567        return Err(io::Error::new(
568            io::ErrorKind::InvalidData,
569            "asset exceeds the path size limit",
570        ));
571    }
572    let expected_len = usize::try_from(expected_len)
573        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "asset size is not addressable"))?;
574    let mut data = vec![0_u8; expected_len + 1];
575    let mut used = 0;
576    while used < expected_len {
577        let read = file.read(&mut data[used..expected_len])?;
578        if read == 0 {
579            break;
580        }
581        used += read;
582    }
583    if used == expected_len && file.read(&mut data[expected_len..expected_len + 1])? != 0 {
584        return Err(io::Error::new(
585            io::ErrorKind::InvalidData,
586            "asset grew while being read",
587        ));
588    }
589    data.truncate(used);
590    Ok(data)
591}
592
593/// Canonical MIME types covered by the C2PA 2.4 profile and readable by this build.
594pub fn supported_mime_types() -> Vec<&'static str> {
595    let mut mimes: Vec<_> = mimes_for_version(SpecVersion::V2_4)
596        .into_iter()
597        .filter(|mime| crate::c2pa_formats::AssetFormat::from_mime(mime).is_some())
598        .collect();
599    mimes.sort_unstable();
600    mimes.dedup();
601    mimes
602}
603
604/// Every filename extension the SDK maps to a MIME type, with its mapping.
605///
606/// This is the single source of truth for extension inference. It is public so
607/// that callers can discover it and so that the non-mutation contract tests
608/// iterate the real table rather than a hand-copied list: a reviewer hid a
609/// writer in the `.dng` branch precisely because the test's own copy had
610/// drifted and omitted it.
611pub const SUPPORTED_EXTENSIONS: &[(&str, &str)] = &[
612    ("jpg", "image/jpeg"),
613    ("jpeg", "image/jpeg"),
614    ("png", "image/png"),
615    ("webp", "image/webp"),
616    ("gif", "image/gif"),
617    ("tif", "image/tiff"),
618    ("tiff", "image/tiff"),
619    ("dng", "image/x-adobe-dng"),
620    ("heic", "image/heic"),
621    ("heif", "image/heif"),
622    ("avif", "image/avif"),
623    ("jxl", "image/jxl"),
624    ("svg", "image/svg+xml"),
625    ("mp4", "video/mp4"),
626    ("m4v", "video/mp4"),
627    ("mov", "video/quicktime"),
628    ("avi", "video/x-msvideo"),
629    ("wav", "audio/wav"),
630    ("mp3", "audio/mpeg"),
631    ("m4a", "audio/mp4"),
632    ("aac", "audio/aac"),
633    ("flac", "audio/flac"),
634    ("ogg", "audio/ogg"),
635    ("oga", "audio/ogg"),
636    ("pdf", "application/pdf"),
637    ("epub", "application/epub+zip"),
638    (
639        "docx",
640        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
641    ),
642    (
643        "xlsx",
644        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
645    ),
646    (
647        "pptx",
648        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
649    ),
650    ("odt", "application/vnd.oasis.opendocument.text"),
651    ("odg", "application/vnd.oasis.opendocument.graphics"),
652    ("ttf", "font/ttf"),
653    ("otf", "font/otf"),
654    ("txt", "text/plain"),
655    ("tsv", "text/tab-separated-values"),
656];
657
658/// Infer a MIME type from a filename extension, case-insensitively.
659pub fn mime_from_path(path: &Path) -> Option<&'static str> {
660    let extension = path.extension()?.to_str()?.to_ascii_lowercase();
661    SUPPORTED_EXTENSIONS
662        .iter()
663        .find(|(candidate, _)| *candidate == extension)
664        .map(|(_, mime)| *mime)
665}
666
667enum ResolvedTrust {
668    Bundled(&'static TrustList),
669    Owned(TrustList),
670}
671
672impl ResolvedTrust {
673    fn get(&self) -> &TrustList {
674        match self {
675            Self::Bundled(trust) => trust,
676            Self::Owned(trust) => trust,
677        }
678    }
679}
680
681fn resolve_trust(
682    custom_pem: Option<&str>,
683    bundled: Option<&'static TrustList>,
684) -> Result<Option<ResolvedTrust>, Error> {
685    let custom = custom_pem
686        .map(TrustList::from_pem)
687        .transpose()
688        .map_err(|error| Error::InvalidTrust(error.to_string()))?;
689    match (bundled, custom) {
690        (None, None) => Ok(None),
691        (Some(trust), None) => Ok(Some(ResolvedTrust::Bundled(trust))),
692        (None, Some(trust)) => Ok(Some(ResolvedTrust::Owned(trust))),
693        (Some(bundled), Some(custom)) => {
694            let mut merged = bundled.clone();
695            merged.anchors.extend(custom.anchors);
696            Ok(Some(ResolvedTrust::Owned(merged)))
697        }
698    }
699}
700
701fn parse_validation_time(value: Option<&str>) -> Result<OffsetDateTime, Error> {
702    match value {
703        Some(raw) => OffsetDateTime::parse(raw, &Rfc3339)
704            .map_err(|error| Error::InvalidValidationTime(error.to_string())),
705        None => Ok(OffsetDateTime::now_utc()),
706    }
707}
708
709fn copy_status(status: &CoreStatus) -> VerificationStatus {
710    VerificationStatus {
711        code: status.code.clone(),
712        url: status.url.clone(),
713        explanation: status.explanation.clone(),
714        details: status.details.clone(),
715    }
716}
717
718fn copy_results(results: &CoreResults) -> ValidationResults {
719    ValidationResults {
720        success: results.success.iter().map(copy_status).collect(),
721        informational: results.informational.iter().map(copy_status).collect(),
722        failure: results.failure.iter().map(copy_status).collect(),
723    }
724}
725
726fn signature_status(results: &CoreResults) -> String {
727    if results.has_success(CLAIM_SIGNATURE_VALIDATED) {
728        "valid"
729    } else if results.has_failure(CLAIM_SIGNATURE_MISMATCH) {
730        "invalid"
731    } else if results.has_failure(CLAIM_SIGNATURE_MISSING) {
732        "missing"
733    } else {
734        "unknown"
735    }
736    .to_string()
737}
738
739fn hard_binding_status(results: &CoreResults) -> String {
740    const MATCHES: &[&str] = &[
741        ASSERTION_DATA_HASH_MATCH,
742        ASSERTION_BMFF_HASH_MATCH,
743        ASSERTION_BOXES_HASH_MATCH,
744        ASSERTION_COLLECTION_HASH_MATCH,
745        ASSERTION_MULTI_ASSET_HASH_MATCH,
746    ];
747    const FAILURES: &[&str] = &[
748        ASSERTION_DATA_HASH_MISMATCH,
749        ASSERTION_BMFF_HASH_MISMATCH,
750        ASSERTION_BMFF_HASH_MALFORMED,
751        ASSERTION_BOXES_HASH_MISMATCH,
752        ASSERTION_BOXES_HASH_MALFORMED,
753        ASSERTION_COLLECTION_HASH_MISMATCH,
754        ASSERTION_COLLECTION_HASH_MALFORMED,
755        ASSERTION_MULTI_ASSET_HASH_MISMATCH,
756        ASSERTION_MULTI_ASSET_HASH_MALFORMED,
757    ];
758    if MATCHES.iter().any(|code| results.has_success(code)) {
759        "match"
760    } else if FAILURES.iter().any(|code| results.has_failure(code)) {
761        "mismatch"
762    } else if results.has_failure(CLAIM_HARD_BINDINGS_MISSING) {
763        "missing"
764    } else {
765        "unknown"
766    }
767    .to_string()
768}
769
770fn trust_report(
771    results: &CoreResults,
772    present: bool,
773    basis: &str,
774    validation_time: String,
775) -> TrustReport {
776    let supplied = basis != "none";
777    let trusted = results.has_success(SIGNING_CREDENTIAL_TRUSTED);
778    let rejected = results.has_failure(SIGNING_CREDENTIAL_UNTRUSTED)
779        || results.has_failure(SIGNING_CREDENTIAL_INVALID);
780    let revoked = results.has_failure(SIGNING_CREDENTIAL_OCSP_REVOKED);
781    let not_revoked = results.has_success(SIGNING_CREDENTIAL_OCSP_NOT_REVOKED);
782    TrustReport {
783        status: if trusted && !revoked {
784            "valid_for_supplied_material"
785        } else if present && supplied && (rejected || revoked) {
786            "not_valid_for_supplied_material"
787        } else {
788            "not_evaluated"
789        }
790        .to_string(),
791        basis: basis.to_string(),
792        validation_time,
793        revocation: RevocationReport {
794            status: if revoked {
795                "revoked"
796            } else if not_revoked {
797                "not_revoked"
798            } else {
799                "not_checked"
800            }
801            .to_string(),
802            source: if revoked || not_revoked {
803                "embedded_ocsp"
804            } else {
805                "none"
806            }
807            .to_string(),
808            responder_signature: if revoked || not_revoked {
809                "valid"
810            } else {
811                "not_applicable"
812            }
813            .to_string(),
814        },
815        freshness: FreshnessReport {
816            status: "unknown".to_string(),
817            as_of: None,
818        },
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use super::{
825        mime_from_path, read_bounded_file, supported_mime_types, verify, verify_fragmented,
826        verify_with_options, Error, VerifyOptions,
827    };
828    use std::{io::Cursor, path::Path};
829
830    #[test]
831    fn known_filename_maps_to_mime() {
832        assert_eq!(
833            mime_from_path(Path::new("composition.MP4")),
834            Some("video/mp4")
835        );
836
837        assert_eq!(
838            mime_from_path(Path::new("drawing.odg")),
839            Some("application/vnd.oasis.opendocument.graphics")
840        );
841        assert_eq!(
842            mime_from_path(Path::new("data.tsv")),
843            Some("text/tab-separated-values")
844        );
845    }
846
847    #[test]
848    fn format_list_is_sorted_and_contains_composition_formats() {
849        let formats = supported_mime_types();
850        assert_eq!(formats.len(), 71);
851        assert!(formats.windows(2).all(|pair| pair[0] < pair[1]));
852        assert!(formats.contains(&"video/mp4"));
853        assert!(formats.contains(&"image/jpeg"));
854        assert!(formats.contains(&"text/tab-separated-values"));
855        assert!(formats.contains(&"application/vnd.oasis.opendocument.graphics"));
856    }
857
858    #[test]
859    fn input_alias_is_reported_as_canonical_mime() {
860        let asset = b"\x00\x00\x00\x10ftypisom\x00\x00\x00\x00";
861        let report = verify(asset, "audio/aac; codecs=mp4a.40.2").unwrap();
862        assert_eq!(report.mime_type, "audio/mp4");
863        assert!(!report.present);
864    }
865
866    #[test]
867    fn strict_conformance_option_selects_the_program_profile() {
868        let asset = include_bytes!("../../../tests/fixtures/signed_test.jpg");
869        let report = verify_with_options(
870            asset,
871            "image/jpeg",
872            &VerifyOptions {
873                strict_conformance: true,
874                ..VerifyOptions::default()
875            },
876        )
877        .unwrap();
878        assert_eq!(
879            report.manifest_report["engine_profile"]["operating_mode"],
880            "conformance"
881        );
882        assert_eq!(
883            report.manifest_report["engine_profile"]["compliance_level"],
884            "conformance-program"
885        );
886    }
887
888    #[test]
889    fn fragmented_entry_point_rejects_non_bmff_mime() {
890        let error = verify_fragmented(
891            include_bytes!("../../../tests/fixtures/signed_test.jpg"),
892            &[b"ignored fragment"],
893            "image/jpeg",
894        )
895        .unwrap_err();
896        assert!(matches!(error, Error::UnsupportedMime(mime) if mime == "image/jpeg"));
897    }
898
899    #[test]
900    fn unratified_hostless_store_is_not_in_public_profile() {
901        let error = verify(b"jumb", "application/c2pa").unwrap_err();
902        assert!(matches!(error, Error::UnsupportedMime(_)));
903    }
904
905    #[test]
906    fn bounded_reader_accepts_exact_limit_without_large_allocation() {
907        let mut input = Cursor::new(b"1234");
908        assert_eq!(read_bounded_file(&mut input, 4, 4).unwrap(), b"1234");
909    }
910
911    #[test]
912    fn bounded_reader_detects_growth_at_limit_plus_one() {
913        let mut input = Cursor::new(b"12345");
914        let error = read_bounded_file(&mut input, 4, 4).unwrap_err();
915        assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
916        assert!(error.to_string().contains("grew while being read"));
917    }
918
919    #[test]
920    fn unsupported_mime_has_stable_error_code() {
921        let error = verify(b"not an asset", "application/x-unknown").unwrap_err();
922        assert!(matches!(error, Error::UnsupportedMime(_)));
923        assert_eq!(error.code(), "unsupported_mime");
924    }
925}