Skip to main content

fakecloud_cloudcontrol/
patch.rs

1//! Minimal RFC 6902 JSON Patch applied to Cloud Control desired state.
2//!
3//! Cloud Control's `UpdateResource` takes a `PatchDocument` (a JSON Patch array)
4//! describing the change from the resource's current properties to the desired
5//! ones. We apply it to the stored desired state, then hand the resulting
6//! properties to the CloudFormation update handler. Supports the full operation
7//! set (`add`, `remove`, `replace`, `move`, `copy`, `test`) over JSON Pointer
8//! paths (RFC 6901).
9
10use serde_json::Value;
11
12/// Apply a JSON Patch document (an array of operations) to `doc` in place.
13pub fn apply_json_patch(doc: &mut Value, patch: &Value) -> Result<(), String> {
14    let ops = patch
15        .as_array()
16        .ok_or_else(|| "patch document must be a JSON array".to_string())?;
17    for op in ops {
18        apply_one(doc, op)?;
19    }
20    Ok(())
21}
22
23fn apply_one(doc: &mut Value, op: &Value) -> Result<(), String> {
24    let kind = op
25        .get("op")
26        .and_then(|v| v.as_str())
27        .ok_or_else(|| "patch operation missing 'op'".to_string())?;
28    match kind {
29        "add" => {
30            let path = path_of(op)?;
31            let value = require_value(op)?;
32            add(doc, &path, value)
33        }
34        "remove" => {
35            let path = path_of(op)?;
36            remove(doc, &path).map(|_| ())
37        }
38        "replace" => {
39            let path = path_of(op)?;
40            let value = require_value(op)?;
41            // replace requires the target to exist.
42            get(doc, &path)
43                .ok_or_else(|| format!("replace target does not exist: /{}", path.join("/")))?;
44            remove(doc, &path)?;
45            add(doc, &path, value)
46        }
47        "move" => {
48            let from = pointer_of(op, "from")?;
49            let path = path_of(op)?;
50            let value = remove(doc, &from)?;
51            add(doc, &path, value)
52        }
53        "copy" => {
54            let from = pointer_of(op, "from")?;
55            let path = path_of(op)?;
56            let value = get(doc, &from)
57                .cloned()
58                .ok_or_else(|| format!("copy source does not exist: /{}", from.join("/")))?;
59            add(doc, &path, value)
60        }
61        "test" => {
62            let path = path_of(op)?;
63            let expected = require_value(op)?;
64            let actual = get(doc, &path)
65                .ok_or_else(|| format!("test target does not exist: /{}", path.join("/")))?;
66            if *actual != expected {
67                return Err(format!("test failed at /{}", path.join("/")));
68            }
69            Ok(())
70        }
71        other => Err(format!("unsupported patch op: {other}")),
72    }
73}
74
75/// Require the `value` member on `add`/`replace`/`test`. A missing member is a
76/// malformed patch, not an implicit `null` (RFC 6902 s4.1/4.3/4.6).
77fn require_value(op: &Value) -> Result<Value, String> {
78    op.get("value")
79        .cloned()
80        .ok_or_else(|| "patch operation missing 'value'".to_string())
81}
82
83fn path_of(op: &Value) -> Result<Vec<String>, String> {
84    pointer_of(op, "path")
85}
86
87fn pointer_of(op: &Value, field: &str) -> Result<Vec<String>, String> {
88    let raw = op
89        .get(field)
90        .and_then(|v| v.as_str())
91        .ok_or_else(|| format!("patch operation missing '{field}'"))?;
92    parse_pointer(raw)
93}
94
95/// Parse an RFC 6901 JSON Pointer into decoded reference tokens. A non-empty
96/// pointer that does not begin with `/` is malformed (RFC 6901 s3) and is
97/// rejected rather than silently targeting the whole document; so is any token
98/// containing a stray `~` not followed by `0` or `1`.
99fn parse_pointer(pointer: &str) -> Result<Vec<String>, String> {
100    if pointer.is_empty() {
101        return Ok(Vec::new());
102    }
103    if !pointer.starts_with('/') {
104        return Err(format!(
105            "invalid JSON Pointer '{pointer}': must be empty or begin with '/'"
106        ));
107    }
108    pointer
109        .split('/')
110        .skip(1) // leading empty segment before the first '/'
111        .map(unescape_token)
112        .collect()
113}
114
115/// Decode a single RFC 6901 reference token: `~1` -> `/`, `~0` -> `~`. A `~`
116/// followed by anything else (or trailing) is an invalid escape.
117fn unescape_token(token: &str) -> Result<String, String> {
118    let mut out = String::with_capacity(token.len());
119    let mut chars = token.chars();
120    while let Some(c) = chars.next() {
121        if c == '~' {
122            match chars.next() {
123                Some('0') => out.push('~'),
124                Some('1') => out.push('/'),
125                other => {
126                    return Err(format!(
127                        "invalid JSON Pointer escape '~{}'",
128                        other.map(String::from).unwrap_or_default()
129                    ));
130                }
131            }
132        } else {
133            out.push(c);
134        }
135    }
136    Ok(out)
137}
138
139fn get<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
140    let mut cur = doc;
141    for token in path {
142        cur = match cur {
143            Value::Object(map) => map.get(token)?,
144            Value::Array(arr) => arr.get(token.parse::<usize>().ok()?)?,
145            _ => return None,
146        };
147    }
148    Some(cur)
149}
150
151fn add(doc: &mut Value, path: &[String], value: Value) -> Result<(), String> {
152    if path.is_empty() {
153        *doc = value;
154        return Ok(());
155    }
156    let (last, parents) = path.split_last().unwrap();
157    let target = get_mut(doc, parents)
158        .ok_or_else(|| format!("add parent path does not exist: /{}", parents.join("/")))?;
159    match target {
160        Value::Object(map) => {
161            map.insert(last.clone(), value);
162            Ok(())
163        }
164        Value::Array(arr) => {
165            if last == "-" {
166                arr.push(value);
167                return Ok(());
168            }
169            let idx = last
170                .parse::<usize>()
171                .map_err(|_| format!("invalid array index: {last}"))?;
172            if idx > arr.len() {
173                return Err(format!("array index out of bounds: {idx}"));
174            }
175            arr.insert(idx, value);
176            Ok(())
177        }
178        _ => Err(format!(
179            "cannot add into non-container at /{}",
180            parents.join("/")
181        )),
182    }
183}
184
185fn remove(doc: &mut Value, path: &[String]) -> Result<Value, String> {
186    let (last, parents) = path
187        .split_last()
188        .ok_or_else(|| "cannot remove the whole document".to_string())?;
189    let target = get_mut(doc, parents)
190        .ok_or_else(|| format!("remove parent path does not exist: /{}", parents.join("/")))?;
191    match target {
192        Value::Object(map) => map
193            .remove(last)
194            .ok_or_else(|| format!("remove target does not exist: {last}")),
195        Value::Array(arr) => {
196            let idx = last
197                .parse::<usize>()
198                .map_err(|_| format!("invalid array index: {last}"))?;
199            if idx >= arr.len() {
200                return Err(format!("array index out of bounds: {idx}"));
201            }
202            Ok(arr.remove(idx))
203        }
204        _ => Err(format!(
205            "cannot remove from non-container at /{}",
206            parents.join("/")
207        )),
208    }
209}
210
211fn get_mut<'a>(doc: &'a mut Value, path: &[String]) -> Option<&'a mut Value> {
212    let mut cur = doc;
213    for token in path {
214        cur = match cur {
215            Value::Object(map) => map.get_mut(token)?,
216            Value::Array(arr) => arr.get_mut(token.parse::<usize>().ok()?)?,
217            _ => return None,
218        };
219    }
220    Some(cur)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use serde_json::json;
227
228    #[test]
229    fn replace_scalar() {
230        let mut doc = json!({"BucketName": "old", "Tags": []});
231        apply_json_patch(
232            &mut doc,
233            &json!([{"op":"replace","path":"/BucketName","value":"new"}]),
234        )
235        .unwrap();
236        assert_eq!(doc["BucketName"], "new");
237    }
238
239    #[test]
240    fn add_and_remove() {
241        let mut doc = json!({"A": 1});
242        apply_json_patch(&mut doc, &json!([{"op":"add","path":"/B","value":2}])).unwrap();
243        assert_eq!(doc["B"], 2);
244        apply_json_patch(&mut doc, &json!([{"op":"remove","path":"/A"}])).unwrap();
245        assert!(doc.get("A").is_none());
246    }
247
248    #[test]
249    fn array_append_and_index() {
250        let mut doc = json!({"L": [1, 2]});
251        apply_json_patch(&mut doc, &json!([{"op":"add","path":"/L/-","value":3}])).unwrap();
252        assert_eq!(doc["L"], json!([1, 2, 3]));
253        apply_json_patch(&mut doc, &json!([{"op":"remove","path":"/L/0"}])).unwrap();
254        assert_eq!(doc["L"], json!([2, 3]));
255    }
256
257    #[test]
258    fn move_and_copy() {
259        let mut doc = json!({"A": {"X": 1}, "B": {}});
260        apply_json_patch(
261            &mut doc,
262            &json!([{"op":"move","from":"/A/X","path":"/B/Y"}]),
263        )
264        .unwrap();
265        assert_eq!(doc["B"]["Y"], 1);
266        assert!(doc["A"].get("X").is_none());
267        apply_json_patch(
268            &mut doc,
269            &json!([{"op":"copy","from":"/B/Y","path":"/B/Z"}]),
270        )
271        .unwrap();
272        assert_eq!(doc["B"]["Z"], 1);
273    }
274
275    #[test]
276    fn test_op_mismatch_errors() {
277        let mut doc = json!({"A": 1});
278        assert!(apply_json_patch(&mut doc, &json!([{"op":"test","path":"/A","value":2}])).is_err());
279    }
280
281    #[test]
282    fn missing_value_is_rejected() {
283        // `add`/`replace`/`test` must carry a `value` member; a missing one is a
284        // malformed patch, not an implicit null that mutates the document.
285        let mut doc = json!({"A": 1});
286        assert!(apply_json_patch(&mut doc, &json!([{"op":"add","path":"/B"}])).is_err());
287        assert!(apply_json_patch(&mut doc, &json!([{"op":"replace","path":"/A"}])).is_err());
288        assert!(apply_json_patch(&mut doc, &json!([{"op":"test","path":"/A"}])).is_err());
289        // Document is untouched by the rejected patches.
290        assert_eq!(doc, json!({"A": 1}));
291    }
292
293    #[test]
294    fn malformed_pointer_is_rejected() {
295        // A non-empty pointer that does not start with '/' is invalid and must
296        // not silently target the whole document.
297        let mut doc = json!({"BucketName": "old"});
298        assert!(apply_json_patch(
299            &mut doc,
300            &json!([{"op":"replace","path":"BucketName","value":"new"}])
301        )
302        .is_err());
303        assert_eq!(doc, json!({"BucketName": "old"}));
304    }
305
306    #[test]
307    fn escaped_pointer_tokens() {
308        let mut doc = json!({"a/b": 1, "c~d": 2});
309        apply_json_patch(
310            &mut doc,
311            &json!([{"op":"replace","path":"/a~1b","value":9}]),
312        )
313        .unwrap();
314        assert_eq!(doc["a/b"], 9);
315        apply_json_patch(
316            &mut doc,
317            &json!([{"op":"replace","path":"/c~0d","value":8}]),
318        )
319        .unwrap();
320        assert_eq!(doc["c~d"], 8);
321    }
322
323    #[test]
324    fn invalid_pointer_escape_is_rejected() {
325        // `~` must be followed by `0` or `1`; `~2` (and a trailing `~`) are
326        // invalid escapes and must not mutate the document.
327        let mut doc = json!({"A": 1});
328        assert!(
329            apply_json_patch(&mut doc, &json!([{"op":"add","path":"/A~2B","value":9}])).is_err()
330        );
331        assert!(apply_json_patch(&mut doc, &json!([{"op":"add","path":"/A~","value":9}])).is_err());
332        assert_eq!(doc, json!({"A": 1}));
333    }
334}