Skip to main content

encypher_c2pa/
telemetry.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{Error, VerificationReport, C2PA_PROFILE, C2PA_PROFILE_2_5};
9
10pub const DEFAULT_TELEMETRY_ENDPOINT: &str =
11    "https://api.encypher.com/api/v1/sdk-validation-failures";
12const TELEMETRY_SCHEMA_VERSION: &str = "1.0";
13const MAX_STATUS_CODES: usize = 8;
14
15/// Per-verification override for persisted failure telemetry consent.
16#[derive(Debug, Clone, Default, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct TelemetryOptions {
19    /// Override the saved preference for this verification. `None` uses the
20    /// environment or saved per-user setting and prompts on first interactive use.
21    pub enabled: Option<bool>,
22    /// Override the Encypher ingest URL, mainly for self-hosting and tests.
23    pub endpoint: Option<String>,
24    /// Binding name included in the event, such as `rust`, `python`, or `go`.
25    pub sdk_name: Option<String>,
26}
27
28impl TelemetryOptions {
29    pub fn endpoint(&self) -> &str {
30        self.endpoint
31            .as_deref()
32            .unwrap_or(DEFAULT_TELEMETRY_ENDPOINT)
33    }
34}
35
36/// Privacy-bounded validation failure report. It contains no asset bytes,
37/// manifest data, filenames, URLs, keys, trust material, or stable identifiers.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(deny_unknown_fields)]
40pub struct ValidationFailureTelemetry {
41    pub schema_version: String,
42    pub sdk_name: String,
43    pub sdk_version: String,
44    pub profile: String,
45    pub mime_type: String,
46    pub failure_kind: String,
47    pub status_codes: Vec<String>,
48}
49
50impl ValidationFailureTelemetry {
51    pub fn to_json(&self) -> Result<String, serde_json::Error> {
52        serde_json::to_string(self)
53    }
54}
55
56pub fn validation_failure_telemetry(
57    mime_type: &str,
58    result: &Result<VerificationReport, Error>,
59    options: &TelemetryOptions,
60) -> Option<ValidationFailureTelemetry> {
61    let enabled = crate::telemetry_consent::resolve_telemetry_enabled(options.enabled);
62    validation_failure_telemetry_with_enabled(mime_type, result, options, enabled)
63}
64
65pub(crate) fn validation_failure_telemetry_with_enabled(
66    mime_type: &str,
67    result: &Result<VerificationReport, Error>,
68    options: &TelemetryOptions,
69    enabled: bool,
70) -> Option<ValidationFailureTelemetry> {
71    if !enabled {
72        return None;
73    }
74
75    let (failure_kind, status_codes, canonical_mime, profile) = match result {
76        Ok(report) if report.integrity == "invalid" => {
77            let codes = report
78                .validation_results
79                .failure
80                .iter()
81                .map(|status| status.code.as_str());
82            (
83                "invalid_provenance",
84                bounded_codes(codes),
85                report.mime_type.clone(),
86                safe_profile(&report.profile),
87            )
88        }
89        Err(Error::Verification(_)) => (
90            "verification_error",
91            vec!["verification_error".to_string()],
92            safe_mime(mime_type)?,
93            C2PA_PROFILE,
94        ),
95        _ => return None,
96    };
97
98    Some(ValidationFailureTelemetry {
99        schema_version: TELEMETRY_SCHEMA_VERSION.to_string(),
100        sdk_name: safe_sdk_name(options.sdk_name.as_deref()),
101        sdk_version: env!("CARGO_PKG_VERSION").to_string(),
102        profile: profile.to_string(),
103        mime_type: canonical_mime,
104        failure_kind: failure_kind.to_string(),
105        status_codes,
106    })
107}
108
109/// The report's validation-policy profile, restricted to the fixed tokens this
110/// SDK emits so telemetry never carries free-form text.
111fn safe_profile(profile: &str) -> &'static str {
112    if profile == C2PA_PROFILE_2_5 {
113        C2PA_PROFILE_2_5
114    } else {
115        C2PA_PROFILE
116    }
117}
118
119fn bounded_codes<'a>(codes: impl Iterator<Item = &'a str>) -> Vec<String> {
120    let codes: Vec<_> = codes
121        .filter(|code| is_safe_token(code))
122        .collect::<BTreeSet<_>>()
123        .into_iter()
124        .take(MAX_STATUS_CODES)
125        .map(str::to_string)
126        .collect();
127    if codes.is_empty() {
128        vec!["invalid_provenance".to_string()]
129    } else {
130        codes
131    }
132}
133
134fn safe_mime(mime_type: &str) -> Option<String> {
135    let mime = crate::c2pa_core::spec::canonicalize_mime(mime_type);
136    crate::c2pa_core::spec::mimes_for_version(crate::c2pa_core::SpecVersion::V2_4)
137        .contains(&mime.as_str())
138        .then_some(mime)
139}
140
141fn safe_sdk_name(value: Option<&str>) -> String {
142    value
143        .filter(|name| !name.is_empty() && name.len() <= 24 && is_safe_token(name))
144        .unwrap_or("rust")
145        .to_string()
146}
147
148fn is_safe_token(value: &str) -> bool {
149    value
150        .bytes()
151        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
152}
153
154#[cfg(feature = "telemetry")]
155pub(crate) fn enqueue(endpoint: &str, event: ValidationFailureTelemetry) {
156    transport::enqueue(endpoint, event);
157}
158
159#[cfg(not(feature = "telemetry"))]
160pub(crate) fn enqueue(_endpoint: &str, _event: ValidationFailureTelemetry) {}
161
162#[cfg(feature = "telemetry")]
163mod transport {
164    use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
165    use std::sync::LazyLock;
166    use std::time::Duration;
167
168    use super::ValidationFailureTelemetry;
169
170    const QUEUE_CAPACITY: usize = 64;
171    const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
172    static SENDER: LazyLock<SyncSender<(String, ValidationFailureTelemetry)>> =
173        LazyLock::new(|| {
174            let (sender, receiver) =
175                sync_channel::<(String, ValidationFailureTelemetry)>(QUEUE_CAPACITY);
176            std::thread::Builder::new()
177                .name("encypher-c2pa-telemetry".to_string())
178                .spawn(move || {
179                    let agent = ureq::AgentBuilder::new().timeout(REQUEST_TIMEOUT).build();
180                    while let Ok((endpoint, event)) = receiver.recv() {
181                        let _ = agent.post(&endpoint).send_json(&event);
182                    }
183                })
184                .ok();
185            sender
186        });
187
188    pub(super) fn enqueue(endpoint: &str, event: ValidationFailureTelemetry) {
189        match SENDER.try_send((endpoint.to_string(), event)) {
190            Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {}
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::{validation_failure_telemetry, TelemetryOptions};
198    use crate::{
199        FreshnessReport, RevocationReport, TrustReport, ValidationResults, VerificationReport,
200    };
201    use serde_json::Value;
202
203    fn invalid_report() -> VerificationReport {
204        VerificationReport {
205            schema_version: "1.0".to_string(),
206            profile: "c2pa-2.4".to_string(),
207            mime_type: "video/mp4".to_string(),
208            present: true,
209            integrity: "invalid".to_string(),
210            signature: "invalid".to_string(),
211            hard_binding: "mismatch".to_string(),
212            trust: TrustReport {
213                status: "not_evaluated".to_string(),
214                basis: "none".to_string(),
215                validation_time: "2026-08-03T00:00:00Z".to_string(),
216                revocation: RevocationReport {
217                    status: "not_checked".to_string(),
218                    source: "none".to_string(),
219                    responder_signature: "not_applicable".to_string(),
220                },
221                freshness: FreshnessReport {
222                    status: "unknown".to_string(),
223                    as_of: None,
224                },
225            },
226            policy: None,
227            managed_receipt: None,
228            validation_state: "invalid".to_string(),
229            validation_results: ValidationResults {
230                success: vec![],
231                informational: vec![],
232                trust_list_sources: Vec::new(),
233                failure: vec![
234                    crate::VerificationStatus {
235                        code: "claimSignature.mismatch".to_string(),
236                        url: String::new(),
237                        resource_url: None,
238                        explanation: "must not leave the device".to_string(),
239                        details: None,
240                    },
241                    crate::VerificationStatus {
242                        code: "assertion.dataHash.mismatch".to_string(),
243                        url: String::new(),
244                        resource_url: None,
245                        explanation: "must not leave the device".to_string(),
246                        details: None,
247                    },
248                ],
249            },
250            manifest_report: Value::Null,
251            content_credentials: None,
252            network: crate::NetworkReport::default(),
253        }
254    }
255
256    #[test]
257    fn emits_only_bounded_codes_for_invalid_provenance() {
258        let options = TelemetryOptions {
259            enabled: Some(true),
260            endpoint: None,
261            sdk_name: Some("python".to_string()),
262        };
263        let event =
264            validation_failure_telemetry("video/mp4", &Ok(invalid_report()), &options).unwrap();
265        assert_eq!(event.sdk_name, "python");
266        assert_eq!(event.mime_type, "video/mp4");
267        assert_eq!(event.failure_kind, "invalid_provenance");
268        assert_eq!(
269            event.status_codes,
270            ["assertion.dataHash.mismatch", "claimSignature.mismatch"]
271        );
272        let json = serde_json::to_string(&event).unwrap();
273        assert!(!json.contains("must not leave"));
274    }
275
276    #[test]
277    fn disabled_telemetry_emits_nothing() {
278        assert!(validation_failure_telemetry(
279            "video/mp4",
280            &Ok(invalid_report()),
281            &TelemetryOptions {
282                enabled: Some(false),
283                ..TelemetryOptions::default()
284            }
285        )
286        .is_none());
287    }
288}