Skip to main content

secunit_capture/
canonical.rs

1//! Canonical envelope shared by every capturer.
2//!
3//! Every capturer writes JSON shaped:
4//!
5//! ```text
6//! {
7//!   "capturer": "github.dependabot-alerts",
8//!   "version":  "1",
9//!   "captured_at": "2026-05-01T12:00:00Z",
10//!   "args":   { ...invocation arguments, sorted... },
11//!   "result": { ...subsystem-specific payload... }
12//! }
13//! ```
14//!
15//! Determinism requirements (Phase 4 exit criteria):
16//!
17//! - Map keys are emitted in lexicographic order regardless of whether
18//!   `serde_json` was compiled with the `preserve_order` feature.
19//! - Arrays of records are sorted by the caller using a stable id field;
20//!   non-record arrays preserve upstream order.
21//! - Timestamps are ISO-8601 UTC with whole-second precision.
22//! - Ephemeral fields (request ids, pagination cursors, `*_url`,
23//!   `node_id`, etag-likes) are stripped *before* the value reaches
24//!   here. This module does not know which keys are ephemeral; that's
25//!   the capturer's job.
26
27use std::fs;
28use std::io::Write;
29use std::path::Path;
30
31use anyhow::{Context, Result};
32use serde::Serialize;
33use serde_json::{Map, Value};
34
35use crate::time::now_iso8601;
36
37/// The canonical wrapper written to `--out` by every capturer.
38#[derive(Debug, Clone, Serialize)]
39pub struct Envelope {
40    pub capturer: String,
41    pub version: String,
42    pub captured_at: String,
43    pub args: Value,
44    pub result: Value,
45}
46
47impl Envelope {
48    /// Build an envelope. `captured_at` is filled from
49    /// [`crate::time::now_iso8601`] and `args`/`result` are
50    /// canonicalized so the final JSON is byte-stable.
51    pub fn new(capturer: &str, version: &str, args: Value, result: Value) -> Self {
52        Self {
53            capturer: capturer.to_string(),
54            version: version.to_string(),
55            captured_at: now_iso8601(),
56            args: canonicalize_value(args),
57            result: canonicalize_value(result),
58        }
59    }
60
61    /// Serialize to a canonical pretty-printed JSON string.
62    pub fn to_canonical_json(&self) -> Result<String> {
63        let v = serde_json::to_value(self).context("serialize envelope")?;
64        let v = canonicalize_value(v);
65        serde_json::to_string_pretty(&v).context("pretty-print envelope")
66    }
67
68    /// Atomically write this envelope to `path` (write-temp + rename so
69    /// readers never observe a partial file).
70    pub fn write_to(&self, path: &Path) -> Result<()> {
71        let body = self.to_canonical_json()?;
72        if let Some(parent) = path.parent() {
73            fs::create_dir_all(parent)
74                .with_context(|| format!("create dir {}", parent.display()))?;
75        }
76        let tmp = path.with_extension("json.tmp");
77        {
78            let mut f =
79                fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
80            f.write_all(body.as_bytes())
81                .with_context(|| format!("write {}", tmp.display()))?;
82            f.write_all(b"\n").ok();
83            f.sync_all().ok();
84        }
85        fs::rename(&tmp, path)
86            .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
87        Ok(())
88    }
89}
90
91/// Recursively rebuild every JSON object so its keys are emitted in
92/// lexicographic order. This is independent of the `serde_json`
93/// `preserve_order` feature, so output is stable even if a downstream
94/// crate flips it on through feature unification.
95pub fn canonicalize_value(v: Value) -> Value {
96    match v {
97        Value::Object(map) => {
98            let mut entries: Vec<(String, Value)> = map.into_iter().collect();
99            entries.sort_by(|a, b| a.0.cmp(&b.0));
100            let mut out = Map::with_capacity(entries.len());
101            for (k, v) in entries {
102                out.insert(k, canonicalize_value(v));
103            }
104            Value::Object(out)
105        }
106        Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize_value).collect()),
107        other => other,
108    }
109}
110
111/// Sort an array of objects in place by the given top-level key.
112/// Non-objects, or objects missing the key, sort to the end in stable
113/// upstream order. Tie-broken by the JSON serialization of the entry,
114/// so equal-key entries are still deterministic.
115pub fn sort_array_by_key(arr: &mut [Value], key: &str) {
116    arr.sort_by(|a, b| {
117        let ak = extract_sort_key(a, key);
118        let bk = extract_sort_key(b, key);
119        ak.cmp(&bk).then_with(|| {
120            let ja = serde_json::to_string(a).unwrap_or_default();
121            let jb = serde_json::to_string(b).unwrap_or_default();
122            ja.cmp(&jb)
123        })
124    });
125}
126
127fn extract_sort_key(v: &Value, key: &str) -> SortKey {
128    match v.get(key) {
129        Some(Value::String(s)) => SortKey::Present(s.clone()),
130        Some(Value::Number(n)) => {
131            // Pad integers so "10" sorts after "2"; for floats fall
132            // back to the JSON form.
133            if let Some(i) = n.as_i64() {
134                SortKey::Present(format!("{:020}", i))
135            } else {
136                SortKey::Present(n.to_string())
137            }
138        }
139        Some(Value::Bool(b)) => SortKey::Present(b.to_string()),
140        Some(Value::Null) | None => SortKey::Missing,
141        Some(other) => SortKey::Present(other.to_string()),
142    }
143}
144
145#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
146enum SortKey {
147    Present(String),
148    Missing,
149}
150
151/// Strip a fixed list of keys (recursively) from any object encountered
152/// in `value`. Used to drop ephemeral / volatile upstream fields like
153/// `*_url`, `node_id`, `etag` before canonicalization.
154pub fn strip_keys(value: &mut Value, keys: &[&str]) {
155    match value {
156        Value::Object(map) => {
157            for k in keys {
158                map.remove(*k);
159            }
160            for (_, v) in map.iter_mut() {
161                strip_keys(v, keys);
162            }
163        }
164        Value::Array(arr) => {
165            for v in arr.iter_mut() {
166                strip_keys(v, keys);
167            }
168        }
169        _ => {}
170    }
171}
172
173/// Strip every key matching `predicate` (recursively).
174pub fn strip_keys_matching(value: &mut Value, predicate: impl Fn(&str) -> bool + Copy) {
175    match value {
176        Value::Object(map) => {
177            let to_remove: Vec<String> = map
178                .keys()
179                .filter(|k| predicate(k.as_str()))
180                .cloned()
181                .collect();
182            for k in to_remove {
183                map.remove(&k);
184            }
185            for (_, v) in map.iter_mut() {
186                strip_keys_matching(v, predicate);
187            }
188        }
189        Value::Array(arr) => {
190            for v in arr.iter_mut() {
191                strip_keys_matching(v, predicate);
192            }
193        }
194        _ => {}
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use serde_json::json;
202
203    #[test]
204    fn canonicalize_sorts_keys() {
205        let v = json!({ "z": 1, "a": 2, "m": { "y": 3, "b": 4 } });
206        let s = serde_json::to_string(&canonicalize_value(v)).unwrap();
207        assert_eq!(s, r#"{"a":2,"m":{"b":4,"y":3},"z":1}"#);
208    }
209
210    #[test]
211    fn sort_array_by_key_sorts_by_id() {
212        let mut arr = vec![
213            json!({ "id": "c", "v": 1 }),
214            json!({ "id": "a", "v": 2 }),
215            json!({ "id": "b", "v": 3 }),
216        ];
217        sort_array_by_key(&mut arr, "id");
218        let ids: Vec<&str> = arr.iter().map(|v| v["id"].as_str().unwrap()).collect();
219        assert_eq!(ids, vec!["a", "b", "c"]);
220    }
221
222    #[test]
223    fn sort_array_by_numeric_key() {
224        let mut arr = vec![
225            json!({ "number": 10 }),
226            json!({ "number": 2 }),
227            json!({ "number": 100 }),
228        ];
229        sort_array_by_key(&mut arr, "number");
230        let nums: Vec<i64> = arr.iter().map(|v| v["number"].as_i64().unwrap()).collect();
231        assert_eq!(nums, vec![2, 10, 100]);
232    }
233
234    #[test]
235    fn strip_keys_removes_ephemeral() {
236        let mut v = json!({
237            "id": 1,
238            "url": "https://x",
239            "nested": { "node_id": "abc", "keep": true },
240        });
241        strip_keys(&mut v, &["url", "node_id"]);
242        assert_eq!(v, json!({ "id": 1, "nested": { "keep": true } }));
243    }
244
245    #[test]
246    fn strip_keys_matching_suffix() {
247        let mut v = json!({
248            "id": 1,
249            "html_url": "x",
250            "issues_url": "y",
251            "name": "ok",
252        });
253        strip_keys_matching(&mut v, |k| k.ends_with("_url"));
254        assert_eq!(v, json!({ "id": 1, "name": "ok" }));
255    }
256
257    #[test]
258    fn envelope_is_byte_stable() {
259        let _g = crate::time::set_fixed_time_for_tests("2026-05-01T00:00:00Z");
260        let e1 = Envelope::new(
261            "test.thing",
262            "1",
263            json!({ "z": 1, "a": 2 }),
264            json!({ "items": [{"id": "b"}, {"id": "a"}] }),
265        );
266        let e2 = Envelope::new(
267            "test.thing",
268            "1",
269            json!({ "a": 2, "z": 1 }),
270            json!({ "items": [{"id": "b"}, {"id": "a"}] }),
271        );
272        assert_eq!(
273            e1.to_canonical_json().unwrap(),
274            e2.to_canonical_json().unwrap()
275        );
276    }
277}