majordomus_cli/execution/
redact.rs1use serde_json::{Map, Value};
28
29pub const REDACTED: &str = "«redacted»";
32
33const MAX_DEPTH: usize = 32;
36
37pub fn redact(schema: &Value, value: &Value) -> Value {
54 walk(schema, schema, value, 0)
55}
56
57fn sensitive(schema: &Value) -> bool {
59 schema.get("format").and_then(Value::as_str) == Some("password")
60 || schema.get("writeOnly") == Some(&Value::Bool(true))
61 || schema.get("x-majordomus-sensitive") == Some(&Value::Bool(true))
62}
63
64fn resolve<'a>(root: &'a Value, schema: &'a Value) -> &'a Value {
66 let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
67 return schema;
68 };
69 reference
70 .strip_prefix("#/$defs/")
71 .and_then(|name| root.get("$defs").and_then(|d| d.get(name)))
72 .unwrap_or(schema)
73}
74
75fn walk(root: &Value, schema: &Value, value: &Value, depth: usize) -> Value {
76 if depth >= MAX_DEPTH {
77 return value.clone();
78 }
79 let schema = resolve(root, schema);
80 if sensitive(schema) {
81 return Value::String(REDACTED.to_string());
82 }
83 match value {
84 Value::Object(members) => {
85 let properties = schema.get("properties").and_then(Value::as_object);
86 let additional = schema.get("additionalProperties");
87 let mut out = Map::with_capacity(members.len());
88 for (key, member) in members {
89 let property = properties
90 .and_then(|p| p.get(key))
91 .or(additional.filter(|a| a.is_object()));
92 match property {
93 Some(p) => out.insert(key.clone(), walk(root, p, member, depth + 1)),
94 None => out.insert(key.clone(), member.clone()),
95 };
96 }
97 Value::Object(out)
98 }
99 Value::Array(items) => match schema.get("items") {
100 Some(item_schema) => Value::Array(
101 items
102 .iter()
103 .map(|i| walk(root, item_schema, i, depth + 1))
104 .collect(),
105 ),
106 None => value.clone(),
107 },
108 other => other.clone(),
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use serde_json::json;
116
117 #[test]
118 fn every_way_a_schema_can_say_sensitive_is_honoured() {
119 for marker in [
120 json!({ "type": "string", "format": "password" }),
121 json!({ "type": "string", "writeOnly": true }),
122 json!({ "type": "string", "x-majordomus-sensitive": true }),
123 ] {
124 let schema = json!({ "type": "object", "properties": { "s": marker } });
125 assert_eq!(redact(&schema, &json!({ "s": "secret" }))["s"], REDACTED);
126 }
127 let plain = json!({ "type": "object", "properties": { "s": { "type": "string" } } });
128 assert_eq!(redact(&plain, &json!({ "s": "public" }))["s"], "public");
129 }
130
131 #[test]
132 fn a_name_that_looks_like_a_secret_is_not_one() {
133 let schema = json!({
136 "type": "object",
137 "properties": { "password_policy": { "type": "string" } }
138 });
139 let safe = redact(&schema, &json!({ "password_policy": "docs/PASSWORDS.md" }));
140 assert_eq!(safe["password_policy"], "docs/PASSWORDS.md");
141 }
142
143 #[test]
144 fn nesting_refs_arrays_and_maps_are_all_reached() {
145 let schema = json!({
146 "type": "object",
147 "properties": {
148 "creds": { "$ref": "#/$defs/Credentials" },
149 "many": { "type": "array", "items": { "$ref": "#/$defs/Credentials" } },
150 "map": { "type": "object", "additionalProperties": { "$ref": "#/$defs/Credentials" } }
151 },
152 "$defs": {
153 "Credentials": {
154 "type": "object",
155 "properties": {
156 "user": { "type": "string" },
157 "token": { "type": "string", "format": "password" }
158 }
159 }
160 }
161 });
162 let value = json!({
163 "creds": { "user": "a", "token": "t1" },
164 "many": [{ "user": "b", "token": "t2" }],
165 "map": { "k": { "user": "c", "token": "t3" } },
166 "unknown": "kept"
167 });
168 let safe = redact(&schema, &value);
169 assert_eq!(safe["creds"]["token"], REDACTED);
170 assert_eq!(safe["creds"]["user"], "a");
171 assert_eq!(safe["many"][0]["token"], REDACTED);
172 assert_eq!(safe["map"]["k"]["token"], REDACTED);
173 assert_eq!(
174 safe["unknown"], "kept",
175 "a value the schema does not describe is kept"
176 );
177 assert!(!serde_json::to_string(&safe).unwrap().contains("t1"));
178 assert!(!serde_json::to_string(&safe).unwrap().contains("t3"));
179 }
180
181 #[test]
182 fn a_value_that_is_not_an_object_and_a_schema_that_says_nothing_survive() {
183 assert_eq!(redact(&json!({}), &json!(5)), json!(5));
184 assert_eq!(redact(&json!(true), &json!("x")), json!("x"));
185 assert_eq!(redact(&json!({}), &json!([1, 2])), json!([1, 2]));
186 let schema =
188 json!({ "type": "object", "properties": { "a": { "$ref": "#/$defs/Missing" } } });
189 assert_eq!(redact(&schema, &json!({ "a": "x" }))["a"], "x");
190 }
191
192 #[test]
193 fn deep_recursion_terminates() {
194 let mut schema =
195 json!({ "type": "object", "properties": { "next": { "$ref": "#/$defs/Node" } } });
196 schema["$defs"] = json!({ "Node": { "type": "object", "properties": { "next": { "$ref": "#/$defs/Node" } } } });
197 let mut value = json!({ "leaf": true });
198 for _ in 0..64 {
199 value = json!({ "next": value });
200 }
201 let safe = redact(&schema, &value);
202 assert!(safe.is_object());
203 }
204}