Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
encoding.rs

1//! Bounded canonical identity envelope; not a journal or filesystem verifier.
2use super::profile::SlotIdentity;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6pub const SCHEMA: &str = "gate-runtime/podman-slot-identity-v1";
7const LIMIT: usize = 4096;
8
9#[derive(Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11struct Envelope {
12    schema: String,
13    identity: SlotIdentity,
14}
15
16/// UTF-8 JSON with lexicographically ordered object keys, no whitespace/newline.
17/// This envelope is distinct from every historical lifecycle slot format.
18pub fn encode(identity: &SlotIdentity) -> Result<Vec<u8>, &'static str> {
19    identity.validate()?;
20    // Struct fields below explicitly fix lexicographic order at every level.
21    // No serde_json::Value/Map is involved, including under preserve_order.
22    #[derive(Serialize)]
23    struct CanonicalProfile<'a> {
24        engine: &'a str,
25        product: &'a str,
26        protocol: &'a str,
27        version: &'a str,
28    }
29    #[derive(Serialize)]
30    struct CanonicalIdentity<'a> {
31        config_sha256: &'a str,
32        executable_sha256: &'a str,
33        installation_sha256: &'a str,
34        profile: CanonicalProfile<'a>,
35    }
36    #[derive(Serialize)]
37    struct CanonicalEnvelope<'a> {
38        identity: CanonicalIdentity<'a>,
39        schema: &'a str,
40    }
41    let value = CanonicalEnvelope {
42        identity: CanonicalIdentity {
43            config_sha256: &identity.config_sha256,
44            executable_sha256: &identity.executable_sha256,
45            installation_sha256: &identity.installation_sha256,
46            profile: CanonicalProfile {
47                engine: &identity.profile.engine,
48                product: &identity.profile.product,
49                protocol: &identity.profile.protocol,
50                version: &identity.profile.version,
51            },
52        },
53        schema: SCHEMA,
54    };
55    let bytes = serde_json::to_vec(&value).map_err(|_| "invalid Podman slot encoding")?;
56    if bytes.len() > LIMIT {
57        return Err("Podman slot identity exceeds limit");
58    }
59    Ok(bytes)
60}
61
62/// Deserialize directly into structs before canonical comparison: duplicate and
63/// unknown fields must fail rather than being collapsed into a JSON map.
64pub fn decode(bytes: &[u8]) -> Result<SlotIdentity, &'static str> {
65    if bytes.len() > LIMIT {
66        return Err("Podman slot identity exceeds limit");
67    }
68    let value: Envelope =
69        serde_json::from_slice(bytes).map_err(|_| "invalid Podman slot envelope")?;
70    if value.schema != SCHEMA || encode(&value.identity)? != bytes {
71        return Err("noncanonical or wrong-schema Podman slot identity");
72    }
73    Ok(value.identity)
74}
75
76/// Match exact snapshots supplied by the caller; never hash reserialized JSON.
77/// This does NOT validate descriptor schemas, provenance, permissions, stable
78/// file handles or engine identity. Core must establish those before use.
79pub fn match_snapshots(
80    identity: &SlotIdentity,
81    installation: &[u8],
82    config: &[u8],
83    executable: &[u8],
84) -> Result<(), &'static str> {
85    identity.validate()?;
86    for (bytes, expected) in [
87        (installation, &identity.installation_sha256),
88        (config, &identity.config_sha256),
89        (executable, &identity.executable_sha256),
90    ] {
91        if format!("{:x}", Sha256::digest(bytes)) != *expected {
92            return Err("Podman slot snapshot mismatch");
93        }
94    }
95    Ok(())
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::lifecycle_v2::profile::{Profile, PRODUCT, PROTOCOL, VERSION};
102
103    fn identity() -> SlotIdentity {
104        SlotIdentity {
105            profile: Profile {
106                protocol: PROTOCOL.into(),
107                product: PRODUCT.into(),
108                version: VERSION.into(),
109                engine: "podman".into(),
110            },
111            installation_sha256: format!("{:x}", Sha256::digest(b"installation")),
112            config_sha256: format!("{:x}", Sha256::digest(b"config")),
113            executable_sha256: format!("{:x}", Sha256::digest(b"executable")),
114        }
115    }
116
117    #[test]
118    fn canonical_envelope_has_stable_order_and_schema() {
119        let original = identity();
120        let bytes = encode(&original).unwrap();
121        assert_eq!(decode(&bytes).unwrap(), original);
122        let text = String::from_utf8(bytes.clone()).unwrap();
123        assert!(text.starts_with("{\"identity\":{\"config_sha256\":"));
124        assert!(text.ends_with(&format!(",\"schema\":\"{SCHEMA}\"}}")));
125        assert_eq!(encode(&decode(&bytes).unwrap()).unwrap(), bytes);
126    }
127
128    #[test]
129    fn whitespace_trailing_and_reordered_encoding_are_rejected() {
130        let bytes = encode(&identity()).unwrap();
131        let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
132        let reordered = format!(
133            "{{\"schema\":\"{SCHEMA}\",\"identity\":{}}}",
134            value["identity"]
135        );
136        for invalid in [
137            serde_json::to_vec_pretty(&value).unwrap(),
138            [bytes.clone(), b"\n".to_vec()].concat(),
139            [bytes, b"{}".to_vec()].concat(),
140            reordered.into_bytes(),
141        ] {
142            assert!(decode(&invalid).is_err());
143        }
144    }
145
146    #[test]
147    fn duplicate_fields_at_each_level_are_rejected() {
148        let text = String::from_utf8(encode(&identity()).unwrap()).unwrap();
149        for invalid in [
150            text.replacen("{", &format!("{{\"schema\":\"{SCHEMA}\","), 1),
151            text.replace(
152                "\"identity\":{",
153                "\"identity\":{\"config_sha256\":\"duplicate\",",
154            ),
155            text.replace("\"profile\":{", "\"profile\":{\"engine\":\"podman\","),
156        ] {
157            assert!(decode(invalid.as_bytes()).is_err());
158        }
159    }
160
161    #[test]
162    fn every_required_field_is_required() {
163        let value: serde_json::Value =
164            serde_json::from_slice(&encode(&identity()).unwrap()).unwrap();
165        for path in [vec![], vec!["identity"], vec!["identity", "profile"]] {
166            let mut object = &value;
167            for key in &path {
168                object = &object[*key];
169            }
170            for field in object.as_object().unwrap().keys() {
171                let mut changed = value.clone();
172                let mut target = &mut changed;
173                for key in &path {
174                    target = &mut target[*key];
175                }
176                target.as_object_mut().unwrap().remove(field);
177                assert!(decode(&serde_json::to_vec(&changed).unwrap()).is_err());
178            }
179        }
180    }
181
182    #[test]
183    fn nested_extra_field_and_old_schema_are_rejected() {
184        let value: serde_json::Value =
185            serde_json::from_slice(&encode(&identity()).unwrap()).unwrap();
186        let mut extra = value.clone();
187        extra["identity"]["profile"]["daemon_id"] = "docker-id".into();
188        let mut old = value;
189        old["schema"] = "gate-runtime/lifecycle-slot-v1".into();
190        for invalid in [extra, old] {
191            assert!(decode(&serde_json::to_vec(&invalid).unwrap()).is_err());
192        }
193    }
194
195    #[test]
196    fn extra_envelope_and_identity_fields_are_rejected() {
197        let value: serde_json::Value =
198            serde_json::from_slice(&encode(&identity()).unwrap()).unwrap();
199        let mut envelope = value.clone();
200        envelope["authority"] = "adopt".into();
201        let mut identity = value;
202        identity["identity"]["authority"] = "adopt".into();
203        for invalid in [envelope, identity] {
204            assert!(decode(&serde_json::to_vec(&invalid).unwrap()).is_err());
205        }
206    }
207
208    #[test]
209    fn full_canonical_bytes_are_feature_independent() {
210        let id = identity();
211        let expected = format!(
212            "{{\"identity\":{{\"config_sha256\":\"{}\",\"executable_sha256\":\"{}\",\"installation_sha256\":\"{}\",\"profile\":{{\"engine\":\"podman\",\"product\":\"podman-lifecycle\",\"protocol\":\"gate-runtime/lifecycle-v2\",\"version\":\"0.1.0\"}}}},\"schema\":\"gate-runtime/podman-slot-identity-v1\"}}",
213            id.config_sha256, id.executable_sha256, id.installation_sha256
214        );
215        assert_eq!(encode(&id).unwrap(), expected.as_bytes());
216    }
217
218    #[test]
219    fn oversized_and_invalid_utf8_are_rejected() {
220        assert!(decode(&vec![b' '; LIMIT + 1]).is_err());
221        assert!(decode(&[0xff]).is_err());
222    }
223
224    #[test]
225    fn exact_bytes_match_but_each_replaced_snapshot_fails() {
226        let id = identity();
227        match_snapshots(&id, b"installation", b"config", b"executable").unwrap();
228        for values in [
229            [b"installation ".as_slice(), b"config", b"executable"],
230            [b"installation", b"config ", b"executable"],
231            [b"installation", b"config", b"executable "],
232        ] {
233            assert!(match_snapshots(&id, values[0], values[1], values[2]).is_err());
234        }
235    }
236}