Skip to main content

hoist_core/
normalize.rs

1//! JSON normalization for consistent Git diffs
2
3use serde_json::{Map, Value};
4
5/// Normalize a JSON value for consistent Git diffs
6///
7/// This performs:
8/// 1. Strips volatile fields (@odata.etag, @odata.context, credentials, etc.)
9/// 2. Preserves the property order from the Azure API response
10/// 3. Preserves array element order as returned by the API
11pub 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            // Preserve original key order, just filter out volatile fields
19            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
39/// Format JSON with consistent formatting (2-space indent, trailing newline, sorted keys)
40pub 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
48/// Strip sensitive fields from credentials objects
49pub fn redact_credentials(value: &mut Value) {
50    if let Some(obj) = value.as_object_mut() {
51        // Redact connection strings
52        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        // Redact storage connection strings
64        if obj.contains_key("storageConnectionStringSecret") {
65            obj.insert(
66                "storageConnectionStringSecret".to_string(),
67                Value::String("<REDACTED>".to_string()),
68            );
69        }
70
71        // Recursively process nested objects
72        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        // Build a map with explicit insertion order
106        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        // Keys should preserve insertion order (not alphabetical)
116        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        // Order should be preserved as-is, not sorted
138        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}