Skip to main content

encypher_c2pa/
telemetry.rs

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