1use serde_json::{Map, Value};
4
5pub fn normalize(value: &Value, volatile_fields: &[&str]) -> Value {
12 normalize_value(value, volatile_fields)
13}
14
15fn normalize_value(value: &Value, volatile_fields: &[&str]) -> Value {
16 match value {
17 Value::Object(map) => {
18 let filtered: Map<String, Value> = map
20 .iter()
21 .filter(|(k, _)| !volatile_fields.contains(&k.as_str()))
22 .map(|(k, v)| (k.clone(), normalize_value(v, volatile_fields)))
23 .collect();
24
25 Value::Object(filtered)
26 }
27 Value::Array(arr) => {
28 let normalized: Vec<Value> = arr
29 .iter()
30 .map(|v| normalize_value(v, volatile_fields))
31 .collect();
32
33 Value::Array(normalized)
34 }
35 _ => value.clone(),
36 }
37}
38
39pub fn format_json(value: &Value) -> String {
41 let mut output = serde_json::to_string_pretty(value).unwrap_or_default();
42 if !output.ends_with('\n') {
43 output.push('\n');
44 }
45 output
46}
47
48pub fn redact_credentials(value: &mut Value) {
50 if let Some(obj) = value.as_object_mut() {
51 if let Some(creds) = obj.get_mut("credentials") {
53 if let Some(creds_obj) = creds.as_object_mut() {
54 if creds_obj.contains_key("connectionString") {
55 creds_obj.insert(
56 "connectionString".to_string(),
57 Value::String("<REDACTED>".to_string()),
58 );
59 }
60 }
61 }
62
63 if obj.contains_key("storageConnectionStringSecret") {
65 obj.insert(
66 "storageConnectionStringSecret".to_string(),
67 Value::String("<REDACTED>".to_string()),
68 );
69 }
70
71 for (_, v) in obj.iter_mut() {
73 redact_credentials(v);
74 }
75 } else if let Some(arr) = value.as_array_mut() {
76 for item in arr {
77 redact_credentials(item);
78 }
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use serde_json::json;
86
87 #[test]
88 fn test_strips_volatile_fields() {
89 let input = json!({
90 "@odata.etag": "abc123",
91 "@odata.context": "https://...",
92 "name": "test",
93 "fields": []
94 });
95
96 let result = normalize(&input, &["@odata.etag", "@odata.context"]);
97
98 assert!(result.get("@odata.etag").is_none());
99 assert!(result.get("@odata.context").is_none());
100 assert_eq!(result.get("name"), Some(&json!("test")));
101 }
102
103 #[test]
104 fn test_preserves_key_order() {
105 let mut map = serde_json::Map::new();
107 map.insert("zebra".to_string(), json!(1));
108 map.insert("apple".to_string(), json!(2));
109 map.insert("mango".to_string(), json!(3));
110 let input = Value::Object(map);
111
112 let result = normalize(&input, &[]);
113 let formatted = serde_json::to_string(&result).unwrap();
114
115 let zebra_pos = formatted.find("zebra").unwrap();
117 let apple_pos = formatted.find("apple").unwrap();
118 let mango_pos = formatted.find("mango").unwrap();
119
120 assert!(zebra_pos < apple_pos);
121 assert!(apple_pos < mango_pos);
122 }
123
124 #[test]
125 fn test_preserves_array_order() {
126 let input = json!({
127 "items": [
128 {"name": "charlie", "value": 3},
129 {"name": "alice", "value": 1},
130 {"name": "bob", "value": 2}
131 ]
132 });
133
134 let result = normalize(&input, &[]);
135 let items = result.get("items").unwrap().as_array().unwrap();
136
137 assert_eq!(items[0].get("name").unwrap(), "charlie");
139 assert_eq!(items[1].get("name").unwrap(), "alice");
140 assert_eq!(items[2].get("name").unwrap(), "bob");
141 }
142
143 #[test]
144 fn test_redact_credentials() {
145 let mut input = json!({
146 "name": "test",
147 "credentials": {
148 "connectionString": "secret-connection-string"
149 }
150 });
151
152 redact_credentials(&mut input);
153
154 assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
155 }
156
157 #[test]
158 fn test_deeply_nested_volatile_fields() {
159 let input = json!({
160 "name": "top",
161 "@odata.etag": "top-etag",
162 "nested": {
163 "@odata.etag": "nested-etag",
164 "value": 1,
165 "deeper": {
166 "@odata.context": "ctx",
167 "keep": true
168 }
169 }
170 });
171
172 let result = normalize(&input, &["@odata.etag", "@odata.context"]);
173
174 assert!(result.get("@odata.etag").is_none());
175 let nested = result.get("nested").unwrap();
176 assert!(nested.get("@odata.etag").is_none());
177 assert_eq!(nested.get("value"), Some(&json!(1)));
178 let deeper = nested.get("deeper").unwrap();
179 assert!(deeper.get("@odata.context").is_none());
180 assert_eq!(deeper.get("keep"), Some(&json!(true)));
181 }
182
183 #[test]
184 fn test_primitive_array_order_preserved() {
185 let input = json!({
186 "values": [3, 1, 2]
187 });
188
189 let result = normalize(&input, &[]);
190 let values = result.get("values").unwrap().as_array().unwrap();
191
192 assert_eq!(values[0], json!(3));
193 assert_eq!(values[1], json!(1));
194 assert_eq!(values[2], json!(2));
195 }
196
197 #[test]
198 fn test_empty_object_preserved() {
199 let input = json!({});
200 let result = normalize(&input, &[]);
201 assert_eq!(result, json!({}));
202 }
203
204 #[test]
205 fn test_empty_array_preserved() {
206 let input = json!({
207 "items": []
208 });
209
210 let result = normalize(&input, &[]);
211 let items = result.get("items").unwrap().as_array().unwrap();
212 assert!(items.is_empty());
213 }
214
215 #[test]
216 fn test_redact_nested_credentials() {
217 let mut input = json!({
218 "name": "test",
219 "outer": {
220 "credentials": {
221 "connectionString": "nested-secret"
222 }
223 }
224 });
225
226 redact_credentials(&mut input);
227
228 assert_eq!(
229 input["outer"]["credentials"]["connectionString"],
230 "<REDACTED>"
231 );
232 }
233
234 #[test]
235 fn test_redact_storage_connection_string() {
236 let mut input = json!({
237 "name": "test",
238 "storageConnectionStringSecret": "my-storage-secret"
239 });
240
241 redact_credentials(&mut input);
242
243 assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
244 }
245
246 #[test]
247 fn test_redact_multiple_targets() {
248 let mut input = json!({
249 "name": "test",
250 "credentials": {
251 "connectionString": "secret-conn"
252 },
253 "storageConnectionStringSecret": "secret-storage"
254 });
255
256 redact_credentials(&mut input);
257
258 assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
259 assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
260 }
261
262 #[test]
263 fn test_redact_credentials_in_array() {
264 let mut input = json!({
265 "dataSources": [
266 {
267 "name": "ds1",
268 "credentials": {
269 "connectionString": "secret1"
270 }
271 },
272 {
273 "name": "ds2",
274 "credentials": {
275 "connectionString": "secret2"
276 }
277 }
278 ]
279 });
280
281 redact_credentials(&mut input);
282
283 assert_eq!(
284 input["dataSources"][0]["credentials"]["connectionString"],
285 "<REDACTED>"
286 );
287 assert_eq!(
288 input["dataSources"][1]["credentials"]["connectionString"],
289 "<REDACTED>"
290 );
291 }
292
293 #[test]
294 fn test_format_json_trailing_newline() {
295 let input = json!({"key": "value"});
296 let output = format_json(&input);
297 assert!(output.ends_with('\n'));
298 }
299
300 #[test]
301 fn test_format_json_empty_object() {
302 let input = json!({});
303 let output = format_json(&input);
304 assert_eq!(output, "{}\n");
305 }
306}