1use crate::core::telemetry_types::{scan_json_for_personal_data, TelemetryPayload};
18use crate::telemetry::benchmark::{BenchmarkOutcome, MAX_PAYLOAD_SIZE_BYTES};
19use anyhow::{Context, Result};
20
21pub const PAYLOAD_ALLOW_LIST: &[&str] = &[
24 "schema_version",
25 "gpu_architecture",
26 "gpu_model",
27 "rocm_version",
28 "gpu_count",
29 "os_family",
30 "cpu_cores",
31 "ram_gb",
32 "gpu_metrics",
33 "benchmark",
34];
35
36const GPU_METRICS_ALLOW_LIST: &[&str] = &[
38 "gpu_utilization_pct",
39 "vram_used_mb",
40 "vram_total_mb",
41 "gpu_temp_c",
42 "gpu_throttling",
43 "gpu_clock_mhz",
44];
45
46const BENCHMARK_ALLOW_LIST: &[&str] = &["duration_seconds", "completed", "sample_count"];
48
49pub fn build_payload(
54 gpu_architecture: impl Into<String>,
55 gpu_model: impl Into<String>,
56 rocm_version: impl Into<String>,
57 gpu_count: usize,
58 cpu_cores: usize,
59 ram_gb: f32,
60 benchmark_outcome: Option<&BenchmarkOutcome>,
61) -> Result<TelemetryPayload> {
62 let mut payload = TelemetryPayload::new(
63 gpu_architecture,
64 gpu_model,
65 rocm_version,
66 gpu_count,
67 cpu_cores,
68 ram_gb,
69 );
70
71 if let Some(outcome) = benchmark_outcome {
72 payload.gpu_metrics = outcome.aggregated_metrics.clone();
73 payload.benchmark = Some(outcome.benchmark_result.clone());
74 }
75
76 payload
78 .validate_no_personal_data()
79 .map_err(|errors| anyhow::anyhow!("Personal data detected in payload: {:?}", errors))?;
80
81 Ok(payload)
82}
83
84pub fn validate_payload(payload: &TelemetryPayload) -> Result<Vec<String>> {
91 let mut warnings = Vec::new();
92
93 let json = serde_json::to_string(payload).context("Failed to serialize payload")?;
95 let parsed: serde_json::Value =
96 serde_json::from_str(&json).context("Payload is not valid JSON")?;
97
98 if let Some(obj) = parsed.as_object() {
100 for key in obj.keys() {
101 if !PAYLOAD_ALLOW_LIST.contains(&key.as_str()) {
102 warnings.push(format!(
103 "Unexpected key '{}' in payload (not in allow-list)",
104 key
105 ));
106 }
107 }
108 }
109
110 if let Some(metrics) = parsed.get("gpu_metrics").and_then(|m| m.as_object()) {
112 for key in metrics.keys() {
113 if !GPU_METRICS_ALLOW_LIST.contains(&key.as_str()) {
114 warnings.push(format!(
115 "Unexpected key 'gpu_metrics.{}' (not in allow-list)",
116 key
117 ));
118 }
119 }
120 }
121
122 if let Some(bench) = parsed.get("benchmark").and_then(|b| b.as_object()) {
124 for key in bench.keys() {
125 if !BENCHMARK_ALLOW_LIST.contains(&key.as_str()) {
126 warnings.push(format!(
127 "Unexpected key 'benchmark.{}' (not in allow-list)",
128 key
129 ));
130 }
131 }
132 }
133
134 let pii_errors = scan_json_for_personal_data(&json);
136 for err in pii_errors {
137 warnings.push(format!("PII detected: {}", err));
138 }
139
140 let size = json.len();
142 if size > MAX_PAYLOAD_SIZE_BYTES {
143 warnings.push(format!(
144 "Payload size {} bytes exceeds maximum {} bytes",
145 size, MAX_PAYLOAD_SIZE_BYTES
146 ));
147 }
148
149 Ok(warnings)
150}
151
152pub fn serialize_payload(payload: &TelemetryPayload) -> Result<String> {
156 payload
158 .validate_no_personal_data()
159 .map_err(|errors| anyhow::anyhow!("Personal data in payload: {:?}", errors))?;
160
161 let json = serde_json::to_string(payload).context("Failed to serialize payload")?;
162
163 let warnings = validate_payload(payload)?;
165 if !warnings.is_empty() {
166 anyhow::bail!("Payload validation warnings: {:?}", warnings);
167 }
168
169 if json.len() > MAX_PAYLOAD_SIZE_BYTES {
171 anyhow::bail!(
172 "Payload size {} exceeds maximum {} bytes",
173 json.len(),
174 MAX_PAYLOAD_SIZE_BYTES
175 );
176 }
177
178 Ok(json)
179}
180
181#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::core::telemetry_types::{BenchmarkResult, GpuMetrics};
189 use crate::telemetry::benchmark::{BenchmarkOutcome, GpuSample};
190
191 #[test]
196 fn test_payload_no_personal_data() {
197 let payload = build_payload(
198 "gfx1100",
199 "AMD Radeon RX 7900 XTX",
200 "7.2.1",
201 1,
202 16,
203 54.0,
204 None,
205 )
206 .expect("Should build clean payload");
207
208 let json = serde_json::to_string(&payload).unwrap();
209 let pii_errors = scan_json_for_personal_data(&json);
210 assert!(
211 pii_errors.is_empty(),
212 "Payload should contain no PII, found: {:?}",
213 pii_errors
214 );
215 }
216
217 #[test]
218 fn test_payload_rejects_home_path_in_gpu_model() {
219 let result = build_payload(
220 "gfx1100",
221 "/home/user/gpu-model",
222 "7.2.1",
223 1,
224 16,
225 54.0,
226 None,
227 );
228 assert!(result.is_err(), "Should reject home path in gpu_model");
229 }
230
231 #[test]
232 fn test_payload_rejects_ip_in_gpu_arch() {
233 let result = build_payload("192.168.1.1", "AMD GPU", "7.2.1", 1, 16, 54.0, None);
234 assert!(result.is_err(), "Should reject IP address");
235 }
236
237 #[test]
242 fn test_all_payload_keys_in_allow_list() {
243 let payload = build_payload("gfx1100", "AMD GPU", "7.2.1", 1, 16, 54.0, None).unwrap();
244
245 let warnings = validate_payload(&payload).unwrap();
246 assert!(
247 warnings.is_empty(),
248 "All keys should be in allow-list, warnings: {:?}",
249 warnings
250 );
251 }
252
253 #[test]
254 fn test_payload_with_benchmark_keys_in_allow_list() {
255 let outcome = create_test_outcome(180, true, 19);
256 let payload =
257 build_payload("gfx1100", "AMD GPU", "7.2.1", 1, 16, 54.0, Some(&outcome)).unwrap();
258
259 let warnings = validate_payload(&payload).unwrap();
260 assert!(
261 warnings.is_empty(),
262 "All keys including benchmark should be in allow-list, warnings: {:?}",
263 warnings
264 );
265 }
266
267 #[test]
268 fn test_gpu_metrics_keys_in_allow_list() {
269 let mut payload = build_payload("gfx1100", "AMD GPU", "7.2.1", 1, 16, 54.0, None).unwrap();
270
271 payload.gpu_metrics = Some(GpuMetrics {
272 gpu_utilization_pct: 95.0,
273 vram_used_mb: 8192.0,
274 vram_total_mb: 24576.0,
275 gpu_temp_c: 72.0,
276 gpu_throttling: false,
277 gpu_clock_mhz: 2500.0,
278 });
279
280 let warnings = validate_payload(&payload).unwrap();
281 assert!(
282 warnings.is_empty(),
283 "All gpu_metrics keys should be in allow-list, warnings: {:?}",
284 warnings
285 );
286 }
287
288 #[test]
293 fn test_payload_is_valid_json() {
294 let payload = build_payload("gfx1100", "AMD GPU", "7.2.1", 1, 16, 54.0, None).unwrap();
295
296 let json = serialize_payload(&payload).unwrap();
297 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
298 assert!(parsed.is_object(), "Payload should be a JSON object");
299 }
300
301 #[test]
302 fn test_payload_size_within_64kib() {
303 let payload = build_payload(
304 "gfx1100",
305 "AMD Radeon RX 7900 XTX",
306 "7.2.1",
307 1,
308 16,
309 54.0,
310 None,
311 )
312 .unwrap();
313
314 let json = serialize_payload(&payload).unwrap();
315 assert!(
316 json.len() <= MAX_PAYLOAD_SIZE_BYTES,
317 "Payload size {} should be ≤ {} bytes",
318 json.len(),
319 MAX_PAYLOAD_SIZE_BYTES
320 );
321 assert!(
323 json.len() < 1024,
324 "Typical payload should be under 1 KiB, got {} bytes",
325 json.len()
326 );
327 }
328
329 #[test]
330 fn test_payload_size_with_benchmark_within_64kib() {
331 let outcome = create_test_outcome(180, true, 19);
332 let payload = build_payload(
333 "gfx1100",
334 "AMD Radeon RX 7900 XTX",
335 "7.2.1",
336 1,
337 16,
338 54.0,
339 Some(&outcome),
340 )
341 .unwrap();
342
343 let json = serialize_payload(&payload).unwrap();
344 assert!(
345 json.len() <= MAX_PAYLOAD_SIZE_BYTES,
346 "Payload with benchmark should be ≤ {} bytes, got {}",
347 MAX_PAYLOAD_SIZE_BYTES,
348 json.len()
349 );
350 }
351
352 #[test]
353 fn test_payload_schema_has_required_fields() {
354 let payload = build_payload("gfx1100", "AMD GPU", "7.2.1", 1, 16, 54.0, None).unwrap();
355
356 let json = serde_json::to_string(&payload).unwrap();
357 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
358 let obj = parsed.as_object().unwrap();
359
360 assert!(obj.contains_key("schema_version"), "Missing schema_version");
362 assert!(
363 obj.contains_key("gpu_architecture"),
364 "Missing gpu_architecture"
365 );
366 assert!(obj.contains_key("gpu_model"), "Missing gpu_model");
367 assert!(obj.contains_key("rocm_version"), "Missing rocm_version");
368 assert!(obj.contains_key("gpu_count"), "Missing gpu_count");
369 assert!(obj.contains_key("os_family"), "Missing os_family");
370 assert!(obj.contains_key("cpu_cores"), "Missing cpu_cores");
371 assert!(obj.contains_key("ram_gb"), "Missing ram_gb");
372 }
373
374 #[test]
375 fn test_benchmark_incomplete_flagged_in_payload() {
376 let outcome = create_test_outcome(90, false, 10);
377 let payload =
378 build_payload("gfx1100", "AMD GPU", "7.2.1", 1, 16, 54.0, Some(&outcome)).unwrap();
379
380 assert!(
381 payload.benchmark.is_some(),
382 "Benchmark should be present in payload"
383 );
384 let bench = payload.benchmark.unwrap();
385 assert!(
386 !bench.completed,
387 "Incomplete benchmark should have completed=false"
388 );
389 assert_eq!(bench.duration_seconds, 90);
390 assert_eq!(bench.sample_count, 10);
391 }
392
393 #[test]
398 fn test_full_payload_build_and_validate() {
399 let outcome = create_test_outcome(180, true, 19);
400 let payload = build_payload(
401 "gfx1100",
402 "AMD Radeon RX 7900 XTX",
403 "7.2.1",
404 1,
405 16,
406 54.0,
407 Some(&outcome),
408 )
409 .expect("Should build payload with benchmark");
410
411 let json = serialize_payload(&payload).expect("Should serialize valid payload");
412
413 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
415 assert!(parsed.is_object());
416
417 let pii = scan_json_for_personal_data(&json);
419 assert!(pii.is_empty(), "No PII expected: {:?}", pii);
420
421 let warnings = validate_payload(&payload).unwrap();
423 assert!(warnings.is_empty(), "No warnings expected: {:?}", warnings);
424
425 assert!(json.len() <= MAX_PAYLOAD_SIZE_BYTES);
427 }
428
429 fn create_test_outcome(duration: u64, complete: bool, sample_count: usize) -> BenchmarkOutcome {
434 let samples: Vec<GpuSample> = (0..sample_count)
435 .map(|i| GpuSample {
436 utilization_pct: 50.0,
437 vram_used_mb: 8192.0,
438 vram_total_mb: 24576.0,
439 temp_c: 65.0,
440 throttling: false,
441 clock_mhz: 2000.0,
442 timestamp_secs: i as f64 * 10.0,
443 })
444 .collect();
445
446 let aggregated = if samples.is_empty() {
447 None
448 } else {
449 Some(GpuMetrics {
450 gpu_utilization_pct: 50.0,
451 vram_used_mb: 8192.0,
452 vram_total_mb: 24576.0,
453 gpu_temp_c: 65.0,
454 gpu_throttling: false,
455 gpu_clock_mhz: 2000.0,
456 })
457 };
458
459 let benchmark_result = BenchmarkResult {
460 duration_seconds: duration,
461 completed: complete,
462 sample_count: sample_count as u32,
463 };
464
465 BenchmarkOutcome {
466 samples,
467 duration_secs: duration,
468 is_complete: complete,
469 aggregated_metrics: aggregated,
470 benchmark_result,
471 }
472 }
473}