Skip to main content

faucet_cli/serve/
idempotency.rs

1//! Stable content fingerprint for idempotency replay-vs-conflict detection. A
2//! key replayed with the *same* merged config returns the existing run; reused
3//! with a *different* config is a 409. The hash is order-independent for object
4//! keys (canonical JSON) and stable across process restarts (sha256), so the
5//! Phase 5 SQL backends can store and compare it unchanged.
6
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10/// Fingerprint of a merged, resolved config plus its pipeline `name`.
11pub fn fingerprint(merged: &Value, name: Option<&str>) -> String {
12    let mut canon = String::new();
13    write_canonical(merged, &mut canon);
14    let mut hasher = Sha256::new();
15    hasher.update(name.unwrap_or("").as_bytes());
16    hasher.update([0u8]); // separator so name|config can't collide across the boundary
17    hasher.update(canon.as_bytes());
18    format!("{:x}", hasher.finalize())
19}
20
21/// Fold the run-affecting request fields into the config fingerprint.
22///
23/// The idempotency key identifies a *request*, not just a config: a key
24/// replayed with the same merged config but a different `clock`,
25/// `timeout_secs`, or `labels` is a genuinely different run and must be
26/// detected as a **conflict** (409), not silently replayed as the original
27/// (#146 M7). `clock` is the most important — it sets the `${now.*}` backfill
28/// window the run reads, so a retry that changes only the clock would otherwise
29/// return the original backfill's result.
30///
31/// `labels` is hashed in `BTreeMap` (sorted-key) order, so the result is stable
32/// regardless of insertion order.
33pub fn request_fingerprint(
34    config_fingerprint: &str,
35    clock: Option<&str>,
36    timeout_secs: Option<u64>,
37    labels: &std::collections::BTreeMap<String, String>,
38) -> String {
39    let mut hasher = Sha256::new();
40    hasher.update(config_fingerprint.as_bytes());
41    hasher.update([0u8]);
42    hasher.update(clock.unwrap_or("").as_bytes());
43    hasher.update([0u8]);
44    hasher.update(
45        timeout_secs
46            .map(|t| t.to_string())
47            .unwrap_or_default()
48            .as_bytes(),
49    );
50    hasher.update([0u8]);
51    for (k, v) in labels {
52        hasher.update(k.as_bytes());
53        hasher.update([0u8]);
54        hasher.update(v.as_bytes());
55        hasher.update([0u8]);
56    }
57    format!("{:x}", hasher.finalize())
58}
59
60/// Append a canonical (object keys sorted) JSON rendering of `v` to `out`.
61fn write_canonical(v: &Value, out: &mut String) {
62    match v {
63        Value::Object(map) => {
64            let mut keys: Vec<&String> = map.keys().collect();
65            keys.sort_unstable();
66            out.push('{');
67            for (i, k) in keys.iter().enumerate() {
68                if i > 0 {
69                    out.push(',');
70                }
71                out.push_str(&serde_json::to_string(k).expect("string key serializes"));
72                out.push(':');
73                write_canonical(&map[*k], out);
74            }
75            out.push('}');
76        }
77        Value::Array(items) => {
78            out.push('[');
79            for (i, item) in items.iter().enumerate() {
80                if i > 0 {
81                    out.push(',');
82                }
83                write_canonical(item, out);
84            }
85            out.push(']');
86        }
87        other => out.push_str(&serde_json::to_string(other).expect("scalar serializes")),
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use serde_json::json;
95
96    #[test]
97    fn stable_and_order_independent() {
98        let a = json!({ "b": 1, "a": [1, 2], "c": { "y": true, "x": null } });
99        let b = json!({ "c": { "x": null, "y": true }, "a": [1, 2], "b": 1 });
100        assert_eq!(fingerprint(&a, Some("p")), fingerprint(&b, Some("p")));
101    }
102
103    #[test]
104    fn differs_on_value_change() {
105        let a = json!({ "a": 1 });
106        let b = json!({ "a": 2 });
107        assert_ne!(fingerprint(&a, Some("p")), fingerprint(&b, Some("p")));
108    }
109
110    #[test]
111    fn differs_on_name() {
112        let v = json!({ "a": 1 });
113        assert_ne!(fingerprint(&v, Some("p1")), fingerprint(&v, Some("p2")));
114    }
115
116    #[test]
117    fn array_order_is_significant() {
118        assert_ne!(
119            fingerprint(&json!([1, 2]), None),
120            fingerprint(&json!([2, 1]), None)
121        );
122    }
123
124    #[test]
125    fn request_fingerprint_includes_run_affecting_fields() {
126        use std::collections::BTreeMap;
127        let cfg_fp = fingerprint(&json!({ "a": 1 }), Some("p"));
128        let empty = BTreeMap::new();
129        let base = request_fingerprint(&cfg_fp, None, None, &empty);
130
131        // Same inputs → same fingerprint.
132        assert_eq!(base, request_fingerprint(&cfg_fp, None, None, &empty));
133        // A different clock (backfill window) must change it (#146 M7).
134        assert_ne!(
135            base,
136            request_fingerprint(&cfg_fp, Some("2026-01-01T00:00:00Z"), None, &empty)
137        );
138        // timeout_secs is run-affecting.
139        assert_ne!(base, request_fingerprint(&cfg_fp, None, Some(30), &empty));
140        // labels are part of the request identity.
141        let mut labels = BTreeMap::new();
142        labels.insert("env".to_string(), "prod".to_string());
143        assert_ne!(base, request_fingerprint(&cfg_fp, None, None, &labels));
144        // A different config fingerprint still changes the result.
145        let other_cfg = fingerprint(&json!({ "a": 2 }), Some("p"));
146        assert_ne!(base, request_fingerprint(&other_cfg, None, None, &empty));
147    }
148
149    #[test]
150    fn request_fingerprint_label_value_is_significant() {
151        use std::collections::BTreeMap;
152        let cfg_fp = fingerprint(&json!({}), None);
153        let mut a = BTreeMap::new();
154        a.insert("k".to_string(), "v1".to_string());
155        let mut b = BTreeMap::new();
156        b.insert("k".to_string(), "v2".to_string());
157        assert_ne!(
158            request_fingerprint(&cfg_fp, None, None, &a),
159            request_fingerprint(&cfg_fp, None, None, &b)
160        );
161    }
162}