Skip to main content

rusty_stack/telemetry/
payload.rs

1//! Anonymous telemetry payload construction.
2//!
3//! Builds a `TelemetryPayload` from benchmark results and hardware data.
4//! The payload is strictly hardware data only — no personal information.
5//!
6//! # Privacy (VAL-TELE-005, VAL-TELE-006)
7//!
8//! - No username, hostname, home directory paths, email, IP addresses
9//! - All payload keys are from a predefined allow-list of hardware/benchmark fields
10//! - PII validation runs before any payload is finalized
11//!
12//! # Schema (VAL-TELE-007)
13//!
14//! - Payload conforms to JSON schema
15//! - Serialized payload does not exceed 64 KiB
16
17use crate::core::telemetry_types::{scan_json_for_personal_data, TelemetryPayload};
18use crate::telemetry::benchmark::{BenchmarkOutcome, MAX_PAYLOAD_SIZE_BYTES};
19use anyhow::{Context, Result};
20
21/// Allow-list of top-level keys permitted in the telemetry payload.
22/// No other keys are allowed — this prevents accidental inclusion of PII.
23pub 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
36/// Allow-list of keys permitted in the gpu_metrics sub-object.
37const 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
46/// Allow-list of keys permitted in the benchmark sub-object.
47const BENCHMARK_ALLOW_LIST: &[&str] = &["duration_seconds", "completed", "sample_count"];
48
49/// Build a telemetry payload from hardware info and benchmark outcome.
50///
51/// The payload contains only hardware/benchmark data from the allow-lists.
52/// Personal data validation is performed before returning.
53pub 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    // Validate no personal data
77    payload
78        .validate_no_personal_data()
79        .map_err(|errors| anyhow::anyhow!("Personal data detected in payload: {:?}", errors))?;
80
81    Ok(payload)
82}
83
84/// Validate that a serialized payload conforms to all constraints:
85///
86/// 1. All top-level keys are from the allow-list (VAL-TELE-006)
87/// 2. No personal data patterns in serialized JSON (VAL-TELE-005)
88/// 3. Payload size ≤ 64 KiB (VAL-TELE-007)
89/// 4. Valid JSON structure (VAL-TELE-007)
90pub fn validate_payload(payload: &TelemetryPayload) -> Result<Vec<String>> {
91    let mut warnings = Vec::new();
92
93    // Serialize to JSON
94    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    // Check top-level keys against allow-list
99    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    // Check gpu_metrics keys
111    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    // Check benchmark keys
123    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    // Check for personal data patterns
135    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    // Check size constraint
141    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
152/// Serialize a payload to JSON, ensuring it meets all constraints.
153///
154/// Returns the JSON string if valid, or an error describing the issues.
155pub fn serialize_payload(payload: &TelemetryPayload) -> Result<String> {
156    // Validate personal data
157    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    // Validate allow-list
164    let warnings = validate_payload(payload)?;
165    if !warnings.is_empty() {
166        anyhow::bail!("Payload validation warnings: {:?}", warnings);
167    }
168
169    // Validate size
170    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// ===========================================================================
182// Tests
183// ===========================================================================
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::core::telemetry_types::{BenchmarkResult, GpuMetrics};
189    use crate::telemetry::benchmark::{BenchmarkOutcome, GpuSample};
190
191    // =======================================================================
192    // VAL-TELE-005: Anonymization — No Personal Data in Payload
193    // =======================================================================
194
195    #[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    // =======================================================================
238    // VAL-TELE-006: Anonymization — Hardware Data Only (Allow-list)
239    // =======================================================================
240
241    #[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    // =======================================================================
289    // VAL-TELE-007: Payload Schema — Structured and Bounded
290    // =======================================================================
291
292    #[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        // A typical payload should be well under 1 KiB
322        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        // Required fields
361        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    // =======================================================================
394    // Full flow test
395    // =======================================================================
396
397    #[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        // Verify it's valid JSON
414        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
415        assert!(parsed.is_object());
416
417        // Verify no PII
418        let pii = scan_json_for_personal_data(&json);
419        assert!(pii.is_empty(), "No PII expected: {:?}", pii);
420
421        // Verify allow-list compliance
422        let warnings = validate_payload(&payload).unwrap();
423        assert!(warnings.is_empty(), "No warnings expected: {:?}", warnings);
424
425        // Verify size
426        assert!(json.len() <= MAX_PAYLOAD_SIZE_BYTES);
427    }
428
429    // =======================================================================
430    // Helpers
431    // =======================================================================
432
433    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}