1use 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#[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, 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
109fn 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}