1use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{Error, VerificationReport, C2PA_PROFILE};
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#[derive(Debug, Clone, Default, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct TelemetryOptions {
19 pub enabled: Option<bool>,
22 pub endpoint: Option<String>,
24 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#[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) = 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 )
87 }
88 Err(Error::Verification(_)) => (
89 "verification_error",
90 vec!["verification_error".to_string()],
91 safe_mime(mime_type)?,
92 ),
93 _ => return None,
94 };
95
96 Some(ValidationFailureTelemetry {
97 schema_version: TELEMETRY_SCHEMA_VERSION.to_string(),
98 sdk_name: safe_sdk_name(options.sdk_name.as_deref()),
99 sdk_version: env!("CARGO_PKG_VERSION").to_string(),
100 profile: C2PA_PROFILE.to_string(),
101 mime_type: canonical_mime,
102 failure_kind: failure_kind.to_string(),
103 status_codes,
104 })
105}
106
107fn bounded_codes<'a>(codes: impl Iterator<Item = &'a str>) -> Vec<String> {
108 let codes: Vec<_> = codes
109 .filter(|code| is_safe_token(code))
110 .collect::<BTreeSet<_>>()
111 .into_iter()
112 .take(MAX_STATUS_CODES)
113 .map(str::to_string)
114 .collect();
115 if codes.is_empty() {
116 vec!["invalid_provenance".to_string()]
117 } else {
118 codes
119 }
120}
121
122fn safe_mime(mime_type: &str) -> Option<String> {
123 let mime = crate::c2pa_core::spec::canonicalize_mime(mime_type);
124 crate::c2pa_core::spec::mimes_for_version(crate::c2pa_core::SpecVersion::V2_4)
125 .contains(&mime.as_str())
126 .then_some(mime)
127}
128
129fn safe_sdk_name(value: Option<&str>) -> String {
130 value
131 .filter(|name| !name.is_empty() && name.len() <= 24 && is_safe_token(name))
132 .unwrap_or("rust")
133 .to_string()
134}
135
136fn is_safe_token(value: &str) -> bool {
137 value
138 .bytes()
139 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
140}
141
142#[cfg(feature = "telemetry")]
143pub(crate) fn enqueue(endpoint: &str, event: ValidationFailureTelemetry) {
144 transport::enqueue(endpoint, event);
145}
146
147#[cfg(not(feature = "telemetry"))]
148pub(crate) fn enqueue(_endpoint: &str, _event: ValidationFailureTelemetry) {}
149
150#[cfg(feature = "telemetry")]
151mod transport {
152 use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
153 use std::sync::LazyLock;
154 use std::time::Duration;
155
156 use super::ValidationFailureTelemetry;
157
158 const QUEUE_CAPACITY: usize = 64;
159 const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
160 static SENDER: LazyLock<SyncSender<(String, ValidationFailureTelemetry)>> =
161 LazyLock::new(|| {
162 let (sender, receiver) =
163 sync_channel::<(String, ValidationFailureTelemetry)>(QUEUE_CAPACITY);
164 std::thread::Builder::new()
165 .name("encypher-c2pa-telemetry".to_string())
166 .spawn(move || {
167 let agent = ureq::AgentBuilder::new().timeout(REQUEST_TIMEOUT).build();
168 while let Ok((endpoint, event)) = receiver.recv() {
169 let _ = agent.post(&endpoint).send_json(&event);
170 }
171 })
172 .ok();
173 sender
174 });
175
176 pub(super) fn enqueue(endpoint: &str, event: ValidationFailureTelemetry) {
177 match SENDER.try_send((endpoint.to_string(), event)) {
178 Ok(()) | Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {}
179 }
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::{validation_failure_telemetry, TelemetryOptions};
186 use crate::{
187 FreshnessReport, RevocationReport, TrustReport, ValidationResults, VerificationReport,
188 };
189 use serde_json::Value;
190
191 fn invalid_report() -> VerificationReport {
192 VerificationReport {
193 schema_version: "1.0".to_string(),
194 profile: "c2pa-2.4".to_string(),
195 mime_type: "video/mp4".to_string(),
196 present: true,
197 integrity: "invalid".to_string(),
198 signature: "invalid".to_string(),
199 hard_binding: "mismatch".to_string(),
200 trust: TrustReport {
201 status: "not_evaluated".to_string(),
202 basis: "none".to_string(),
203 validation_time: "2026-08-03T00:00:00Z".to_string(),
204 revocation: RevocationReport {
205 status: "not_checked".to_string(),
206 source: "none".to_string(),
207 responder_signature: "not_applicable".to_string(),
208 },
209 freshness: FreshnessReport {
210 status: "unknown".to_string(),
211 as_of: None,
212 },
213 },
214 policy: None,
215 managed_receipt: None,
216 validation_state: "invalid".to_string(),
217 validation_results: ValidationResults {
218 success: vec![],
219 informational: vec![],
220 failure: vec![
221 crate::VerificationStatus {
222 code: "claimSignature.mismatch".to_string(),
223 url: String::new(),
224 explanation: "must not leave the device".to_string(),
225 details: None,
226 },
227 crate::VerificationStatus {
228 code: "assertion.dataHash.mismatch".to_string(),
229 url: String::new(),
230 explanation: "must not leave the device".to_string(),
231 details: None,
232 },
233 ],
234 },
235 manifest_report: Value::Null,
236 content_credentials: None,
237 network: crate::NetworkReport::default(),
238 }
239 }
240
241 #[test]
242 fn emits_only_bounded_codes_for_invalid_provenance() {
243 let options = TelemetryOptions {
244 enabled: Some(true),
245 endpoint: None,
246 sdk_name: Some("python".to_string()),
247 };
248 let event =
249 validation_failure_telemetry("video/mp4", &Ok(invalid_report()), &options).unwrap();
250 assert_eq!(event.sdk_name, "python");
251 assert_eq!(event.mime_type, "video/mp4");
252 assert_eq!(event.failure_kind, "invalid_provenance");
253 assert_eq!(
254 event.status_codes,
255 ["assertion.dataHash.mismatch", "claimSignature.mismatch"]
256 );
257 let json = serde_json::to_string(&event).unwrap();
258 assert!(!json.contains("must not leave"));
259 }
260
261 #[test]
262 fn disabled_telemetry_emits_nothing() {
263 assert!(validation_failure_telemetry(
264 "video/mp4",
265 &Ok(invalid_report()),
266 &TelemetryOptions {
267 enabled: Some(false),
268 ..TelemetryOptions::default()
269 }
270 )
271 .is_none());
272 }
273}