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#[derive(Debug, Clone, Default, Deserialize)]
14#[serde(default, deny_unknown_fields)]
15pub struct TelemetryOptions {
16 pub enabled: Option<bool>,
19 pub endpoint: Option<String>,
21 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#[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}