use serde_json::{Map, Value};
pub fn normalize(value: &Value, volatile_fields: &[&str]) -> Value {
normalize_value(value, volatile_fields)
}
fn normalize_value(value: &Value, volatile_fields: &[&str]) -> Value {
match value {
Value::Object(map) => {
let filtered: Map<String, Value> = map
.iter()
.filter(|(k, _)| !volatile_fields.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), normalize_value(v, volatile_fields)))
.collect();
Value::Object(filtered)
}
Value::Array(arr) => {
let normalized: Vec<Value> = arr
.iter()
.map(|v| normalize_value(v, volatile_fields))
.collect();
Value::Array(normalized)
}
_ => value.clone(),
}
}
pub fn format_json(value: &Value) -> String {
let mut output = serde_json::to_string_pretty(value).unwrap_or_default();
if !output.ends_with('\n') {
output.push('\n');
}
output
}
pub fn redact_credentials(value: &mut Value) {
if let Some(obj) = value.as_object_mut() {
if let Some(creds) = obj.get_mut("credentials") {
if let Some(creds_obj) = creds.as_object_mut() {
if creds_obj.contains_key("connectionString") {
creds_obj.insert(
"connectionString".to_string(),
Value::String("<REDACTED>".to_string()),
);
}
}
}
if obj.contains_key("storageConnectionStringSecret") {
obj.insert(
"storageConnectionStringSecret".to_string(),
Value::String("<REDACTED>".to_string()),
);
}
for (_, v) in obj.iter_mut() {
redact_credentials(v);
}
} else if let Some(arr) = value.as_array_mut() {
for item in arr {
redact_credentials(item);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strips_volatile_fields() {
let input = json!({
"@odata.etag": "abc123",
"@odata.context": "https://...",
"name": "test",
"fields": []
});
let result = normalize(&input, &["@odata.etag", "@odata.context"]);
assert!(result.get("@odata.etag").is_none());
assert!(result.get("@odata.context").is_none());
assert_eq!(result.get("name"), Some(&json!("test")));
}
#[test]
fn test_preserves_key_order() {
let mut map = serde_json::Map::new();
map.insert("zebra".to_string(), json!(1));
map.insert("apple".to_string(), json!(2));
map.insert("mango".to_string(), json!(3));
let input = Value::Object(map);
let result = normalize(&input, &[]);
let formatted = serde_json::to_string(&result).unwrap();
let zebra_pos = formatted.find("zebra").unwrap();
let apple_pos = formatted.find("apple").unwrap();
let mango_pos = formatted.find("mango").unwrap();
assert!(zebra_pos < apple_pos);
assert!(apple_pos < mango_pos);
}
#[test]
fn test_preserves_array_order() {
let input = json!({
"items": [
{"name": "charlie", "value": 3},
{"name": "alice", "value": 1},
{"name": "bob", "value": 2}
]
});
let result = normalize(&input, &[]);
let items = result.get("items").unwrap().as_array().unwrap();
assert_eq!(items[0].get("name").unwrap(), "charlie");
assert_eq!(items[1].get("name").unwrap(), "alice");
assert_eq!(items[2].get("name").unwrap(), "bob");
}
#[test]
fn test_redact_credentials() {
let mut input = json!({
"name": "test",
"credentials": {
"connectionString": "secret-connection-string"
}
});
redact_credentials(&mut input);
assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
}
#[test]
fn test_deeply_nested_volatile_fields() {
let input = json!({
"name": "top",
"@odata.etag": "top-etag",
"nested": {
"@odata.etag": "nested-etag",
"value": 1,
"deeper": {
"@odata.context": "ctx",
"keep": true
}
}
});
let result = normalize(&input, &["@odata.etag", "@odata.context"]);
assert!(result.get("@odata.etag").is_none());
let nested = result.get("nested").unwrap();
assert!(nested.get("@odata.etag").is_none());
assert_eq!(nested.get("value"), Some(&json!(1)));
let deeper = nested.get("deeper").unwrap();
assert!(deeper.get("@odata.context").is_none());
assert_eq!(deeper.get("keep"), Some(&json!(true)));
}
#[test]
fn test_primitive_array_order_preserved() {
let input = json!({
"values": [3, 1, 2]
});
let result = normalize(&input, &[]);
let values = result.get("values").unwrap().as_array().unwrap();
assert_eq!(values[0], json!(3));
assert_eq!(values[1], json!(1));
assert_eq!(values[2], json!(2));
}
#[test]
fn test_empty_object_preserved() {
let input = json!({});
let result = normalize(&input, &[]);
assert_eq!(result, json!({}));
}
#[test]
fn test_empty_array_preserved() {
let input = json!({
"items": []
});
let result = normalize(&input, &[]);
let items = result.get("items").unwrap().as_array().unwrap();
assert!(items.is_empty());
}
#[test]
fn test_redact_nested_credentials() {
let mut input = json!({
"name": "test",
"outer": {
"credentials": {
"connectionString": "nested-secret"
}
}
});
redact_credentials(&mut input);
assert_eq!(
input["outer"]["credentials"]["connectionString"],
"<REDACTED>"
);
}
#[test]
fn test_redact_storage_connection_string() {
let mut input = json!({
"name": "test",
"storageConnectionStringSecret": "my-storage-secret"
});
redact_credentials(&mut input);
assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
}
#[test]
fn test_redact_multiple_targets() {
let mut input = json!({
"name": "test",
"credentials": {
"connectionString": "secret-conn"
},
"storageConnectionStringSecret": "secret-storage"
});
redact_credentials(&mut input);
assert_eq!(input["credentials"]["connectionString"], "<REDACTED>");
assert_eq!(input["storageConnectionStringSecret"], "<REDACTED>");
}
#[test]
fn test_redact_credentials_in_array() {
let mut input = json!({
"dataSources": [
{
"name": "ds1",
"credentials": {
"connectionString": "secret1"
}
},
{
"name": "ds2",
"credentials": {
"connectionString": "secret2"
}
}
]
});
redact_credentials(&mut input);
assert_eq!(
input["dataSources"][0]["credentials"]["connectionString"],
"<REDACTED>"
);
assert_eq!(
input["dataSources"][1]["credentials"]["connectionString"],
"<REDACTED>"
);
}
#[test]
fn test_format_json_trailing_newline() {
let input = json!({"key": "value"});
let output = format_json(&input);
assert!(output.ends_with('\n'));
}
#[test]
fn test_format_json_empty_object() {
let input = json!({});
let output = format_json(&input);
assert_eq!(output, "{}\n");
}
#[test]
fn test_normalize_preserves_tool_permission_fields() {
use crate::resources::agent::agent_volatile_fields;
let input = json!({
"kind": "prompt",
"model": "gpt-4o",
"id": "should-be-stripped",
"created_at": 1234567890,
"tools": [
{
"type": "mcp",
"server_label": "kb_test",
"require_approval": "never",
"allowed_tools": ["tool_a", "tool_b"]
},
{
"type": "mcp",
"server_label": "kb_other",
"require_approval": {
"never": {"tool_names": ["safe"]},
"always": {"tool_names": ["risky"]}
}
}
]
});
let result = normalize(&input, agent_volatile_fields());
assert!(result.get("id").is_none());
assert!(result.get("created_at").is_none());
assert_eq!(result["kind"], "prompt");
assert_eq!(result["model"], "gpt-4o");
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools[0]["require_approval"], "never");
assert_eq!(tools[0]["allowed_tools"][0], "tool_a");
assert_eq!(tools[0]["allowed_tools"][1], "tool_b");
let ra = &tools[1]["require_approval"];
assert_eq!(ra["never"]["tool_names"][0], "safe");
assert_eq!(ra["always"]["tool_names"][0], "risky");
}
}