cratestack_sqlx/audit/
redact.rs1pub fn redact_snapshot(
12 snapshot: &mut serde_json::Value,
13 pii_columns: &[&str],
14 sensitive_columns: &[&str],
15) {
16 let Some(map) = snapshot.as_object_mut() else {
17 return;
18 };
19 for col in pii_columns {
20 if let Some(slot) = map.get_mut(*col) {
21 *slot = serde_json::Value::String("[redacted-pii]".to_owned());
22 }
23 let camel = snake_to_camel(col);
24 if camel != *col {
25 if let Some(slot) = map.get_mut(&camel) {
26 *slot = serde_json::Value::String("[redacted-pii]".to_owned());
27 }
28 }
29 }
30 for col in sensitive_columns {
31 if let Some(slot) = map.get_mut(*col) {
32 *slot = serde_json::Value::String("[redacted-sensitive]".to_owned());
33 }
34 let camel = snake_to_camel(col);
35 if camel != *col {
36 if let Some(slot) = map.get_mut(&camel) {
37 *slot = serde_json::Value::String("[redacted-sensitive]".to_owned());
38 }
39 }
40 }
41}
42
43pub fn snapshot_model<T>(model: &T) -> Option<serde_json::Value>
47where
48 T: serde::Serialize,
49{
50 serde_json::to_value(model).ok()
51}
52
53pub fn primary_key_from_snapshot(
57 snapshot: &serde_json::Value,
58 primary_key_column: &str,
59) -> serde_json::Value {
60 if let Some(map) = snapshot.as_object() {
61 if let Some(value) = map.get(primary_key_column) {
62 return value.clone();
63 }
64 let camel = snake_to_camel(primary_key_column);
67 if let Some(value) = map.get(&camel) {
68 return value.clone();
69 }
70 }
71 serde_json::Value::Null
72}
73
74fn snake_to_camel(input: &str) -> String {
75 let mut out = String::with_capacity(input.len());
76 let mut upper = false;
77 for ch in input.chars() {
78 if ch == '_' {
79 upper = true;
80 } else if upper {
81 out.extend(ch.to_uppercase());
82 upper = false;
83 } else {
84 out.push(ch);
85 }
86 }
87 out
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use serde_json::json;
94
95 #[test]
96 fn extracts_primary_key_by_snake_case_column() {
97 let snapshot = json!({ "user_id": 42, "balance": "10.00" });
98 let pk = primary_key_from_snapshot(&snapshot, "user_id");
99 assert_eq!(pk, json!(42));
100 }
101
102 #[test]
103 fn extracts_primary_key_via_camel_case_fallback() {
104 let snapshot = json!({ "userId": 42, "balance": "10.00" });
105 let pk = primary_key_from_snapshot(&snapshot, "user_id");
106 assert_eq!(pk, json!(42));
107 }
108
109 #[test]
110 fn returns_null_when_primary_key_absent() {
111 let snapshot = json!({ "balance": "10.00" });
112 let pk = primary_key_from_snapshot(&snapshot, "user_id");
113 assert_eq!(pk, serde_json::Value::Null);
114 }
115
116 #[test]
117 fn snapshot_round_trip_preserves_strings_and_numbers() {
118 let snap =
119 snapshot_model(&json!({ "amount": "12.34", "currency": "USD" })).expect("serializable");
120 assert_eq!(snap["amount"], json!("12.34"));
121 assert_eq!(snap["currency"], json!("USD"));
122 }
123
124 #[test]
125 fn redacts_pii_columns_with_canned_marker() {
126 let mut snap = json!({
127 "id": 1,
128 "email": "alice@example.com",
129 "balance": "10.00",
130 });
131 redact_snapshot(&mut snap, &["email"], &[]);
132 assert_eq!(snap["email"], json!("[redacted-pii]"));
133 assert_eq!(snap["balance"], json!("10.00"));
134 }
135
136 #[test]
137 fn redacts_sensitive_columns_with_distinct_marker() {
138 let mut snap = json!({
139 "id": 1,
140 "risk_score": 87,
141 });
142 redact_snapshot(&mut snap, &[], &["risk_score"]);
143 assert_eq!(snap["risk_score"], json!("[redacted-sensitive]"));
144 }
145
146 #[test]
147 fn redaction_handles_camel_case_keys() {
148 let mut snap = json!({
149 "id": 1,
150 "primaryEmail": "x@y.com",
151 });
152 redact_snapshot(&mut snap, &["primary_email"], &[]);
153 assert_eq!(snap["primaryEmail"], json!("[redacted-pii]"));
154 }
155
156 #[test]
157 fn redaction_is_noop_for_absent_columns() {
158 let mut snap = json!({ "id": 1 });
159 redact_snapshot(&mut snap, &["email"], &["risk_score"]);
160 assert_eq!(snap, json!({ "id": 1 }));
161 }
162}