Skip to main content

spectra_core/
classification.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4/// Replacement shown when a field is classified as PII (or unsafe for console).
5pub const PII_MASK: &str = "***";
6
7/// Per-field GDPR-oriented metadata for Spectra event schemas.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct FieldClassification {
10    /// Whether the field contains personally identifiable information.
11    pub pii: bool,
12    /// Whether the field may be logged to developer consoles.
13    pub safe_for_console: bool,
14    /// Optional retention period in days for stored values.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub retention_days: Option<u32>,
17    /// Optional human-readable purpose for collecting this field.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub purpose: Option<String>,
20}
21
22/// Mask a field value for display or console when classification requires it.
23///
24/// Returns [`PII_MASK`] when `classification.pii` is true, or when
25/// `safe_for_console` is false. Otherwise returns a display string for `value`
26/// (strings cloned; other JSON types via `to_string()`).
27///
28/// Hosts and UI layers should call this before rendering classified columns.
29/// Query authorization (`spectra.query.*`) remains a host/Gauge concern.
30///
31/// # Examples
32///
33/// ```
34/// use serde_json::json;
35/// use spectra_core::{mask_field_value, FieldClassification, PII_MASK};
36///
37/// let pii = FieldClassification {
38///     pii: true,
39///     safe_for_console: false,
40///     retention_days: None,
41///     purpose: None,
42/// };
43/// assert_eq!(mask_field_value(&pii, &json!("alice@example.com")), PII_MASK);
44///
45/// let safe = FieldClassification {
46///     pii: false,
47///     safe_for_console: true,
48///     retention_days: None,
49///     purpose: None,
50/// };
51/// assert_eq!(mask_field_value(&safe, &json!("us-west")), "us-west");
52/// ```
53#[must_use]
54pub fn mask_field_value(classification: &FieldClassification, value: &Value) -> String {
55    if classification.pii || !classification.safe_for_console {
56        return PII_MASK.to_string();
57    }
58    match value {
59        Value::String(s) => s.clone(),
60        other => other.to_string(),
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use serde_json::json;
68
69    #[test]
70    fn classification_roundtrip() {
71        let c = FieldClassification {
72            pii: true,
73            safe_for_console: false,
74            retention_days: Some(30),
75            purpose: Some("debug".to_string()),
76        };
77        let json = serde_json::to_string(&c).expect("serialize");
78        let back: FieldClassification = serde_json::from_str(&json).expect("deserialize");
79        assert_eq!(back, c);
80    }
81
82    #[test]
83    fn mask_pii_hides_value() {
84        let c = FieldClassification {
85            pii: true,
86            safe_for_console: true,
87            retention_days: None,
88            purpose: None,
89        };
90        assert_eq!(mask_field_value(&c, &json!("secret")), PII_MASK);
91    }
92
93    #[test]
94    fn mask_unsafe_console_hides_value() {
95        let c = FieldClassification {
96            pii: false,
97            safe_for_console: false,
98            retention_days: None,
99            purpose: None,
100        };
101        assert_eq!(mask_field_value(&c, &json!(42)), PII_MASK);
102    }
103
104    #[test]
105    fn mask_safe_passthrough() {
106        let c = FieldClassification {
107            pii: false,
108            safe_for_console: true,
109            retention_days: None,
110            purpose: None,
111        };
112        assert_eq!(mask_field_value(&c, &json!("ok")), "ok");
113        assert_eq!(mask_field_value(&c, &json!(1)), "1");
114    }
115}