gatehouse_runtime_protocol/lifecycle_v2/
encoding.rs1use 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
16pub fn encode(identity: &SlotIdentity) -> Result<Vec<u8>, &'static str> {
19 identity.validate()?;
20 #[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
62pub 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
76pub 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.2.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}