gatehouse_runtime_protocol/lifecycle_v2/
native_image.rs1use super::{
3 native_create::CreatePlan,
4 native_json,
5 native_status::inspect_route,
6 observation::API,
7 ownership::{validate_exposed, FixedPort},
8};
9use serde::Deserialize;
10use sha2::{Digest, Sha256};
11use std::collections::{BTreeMap, BTreeSet};
12type Result<T> = std::result::Result<T, &'static str>;
13#[derive(Debug)]
14pub struct ImageFacts {
15 id: String,
16 labels: BTreeMap<String, String>,
17 exposed: BTreeSet<String>,
18 response_sha256: String,
19}
20#[derive(Deserialize)]
21#[serde(rename_all = "PascalCase")]
22struct Image {
23 id: String,
24 labels: Option<BTreeMap<String, String>>,
25 config: Config,
26}
27#[derive(Deserialize)]
28#[serde(rename_all = "PascalCase")]
29struct Config {
30 labels: Option<BTreeMap<String, String>>,
31 #[serde(default)]
32 exposed_ports: BTreeMap<String, serde_json::Value>,
33}
34pub fn image_route(id: &str) -> Result<String> {
35 inspect_route(id)?;
36 Ok(format!("/v{API}/libpod/images/{id}/json"))
37}
38pub fn decode_image(
39 id: &str,
40 method: &str,
41 route: &str,
42 status: u16,
43 body: &[u8],
44) -> Result<ImageFacts> {
45 if method != "GET" || route != image_route(id)? || status != 200 {
46 return Err("native image inspect unavailable");
47 }
48 let value = native_json::decode(body)?;
49 if value.get("ExposedPorts").is_some() {
50 return Err("image exposed ports must come from Config");
51 }
52 let image: Image = serde_json::from_value(value).map_err(|_| "native image fields rejected")?;
53 if image.id != id {
54 return Err("native image ID mismatch");
55 }
56 let labels = image.labels.unwrap_or_default();
57 if labels != image.config.labels.unwrap_or_default() {
58 return Err("native image label views disagree");
59 }
60 if labels.len() > 64
61 || labels.iter().any(|(k, v)| {
62 k.is_empty()
63 || k.len() > 256
64 || v.len() > 4096
65 || k.chars().any(char::is_control)
66 || v.chars().any(char::is_control)
67 })
68 {
69 return Err("bounded image labels required");
70 }
71 let ports = image.config.exposed_ports;
72 if ports
73 .values()
74 .any(|v| !v.as_object().is_some_and(|m| m.is_empty()))
75 {
76 return Err("image ExposedPorts values must be empty objects");
77 }
78 let exposed = ports.into_keys().collect();
79 validate_exposed(&exposed)?;
80 Ok(ImageFacts {
81 id: id.into(),
82 labels,
83 exposed,
84 response_sha256: format!("{:x}", Sha256::digest(body)),
85 })
86}
87impl ImageFacts {
88 pub fn id(&self) -> &str {
89 &self.id
90 }
91 pub fn response_sha256(&self) -> &str {
92 &self.response_sha256
93 }
94 pub fn create_plan(
97 &self,
98 name: String,
99 operator_labels: BTreeMap<String, String>,
100 port: Option<FixedPort>,
101 ) -> Result<CreatePlan> {
102 let mut labels = self.labels.clone();
103 labels.extend(operator_labels);
104 CreatePlan::new(name, self.id.clone(), labels, port)?
105 .with_image_exposed_ports(self.exposed.clone())
106 }
107}
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use serde_json::{json, Value};
112 fn id() -> String {
113 "b".repeat(64)
114 }
115 fn fixture() -> Value {
116 json!({"Id":id(),"Labels":{"image":"value","override":"old"},"Config":{"Labels":{"image":"value","override":"old"},"ExposedPorts":{"80/tcp":{}}},"Digest":"","RepoTags":["mutable:tag"]})
117 }
118 fn parse(v: &Value) -> Result<ImageFacts> {
119 decode_image(
120 &id(),
121 "GET",
122 &image_route(&id()).unwrap(),
123 200,
124 &serde_json::to_vec(v).unwrap(),
125 )
126 }
127 #[test]
128 fn image_metadata_derives_exact_plan_without_publishing_expose() {
129 let facts = parse(&fixture()).unwrap();
130 let plan = facts
131 .create_plan(
132 "fixture".into(),
133 BTreeMap::from([
134 ("override".into(), "new".into()),
135 ("lease".into(), "owned".into()),
136 ]),
137 None,
138 )
139 .unwrap();
140 let body: Value = serde_json::from_slice(&plan.body().unwrap()).unwrap();
141 assert_eq!(body["image"], id());
142 assert_eq!(
143 body["labels"],
144 json!({"image":"value","override":"new","lease":"owned"})
145 );
146 assert_eq!(body["portmappings"], json!([]));
147 let e = plan
148 .expected_from_response(
149 201,
150 &serde_json::to_vec(&json!({"Id":"a".repeat(64),"Warnings":[]})).unwrap(),
151 )
152 .unwrap();
153 assert_eq!(e.image_exposed_ports, BTreeSet::from(["80/tcp".into()]));
154 assert_eq!(facts.id(), id());
155 assert_eq!(
156 facts.response_sha256(),
157 format!(
158 "{:x}",
159 Sha256::digest(serde_json::to_vec(&fixture()).unwrap())
160 )
161 );
162 }
163 #[test]
164 fn labels_null_missing_empty_normalize_but_mismatch_refused() {
165 for v in [
166 json!({"Id":id(),"Config":{}}),
167 json!({"Id":id(),"Labels":null,"Config":{"Labels":{},"ExposedPorts":{}}}),
168 ] {
169 assert!(parse(&v).is_ok());
170 }
171 let mut v = fixture();
172 v["Labels"]["image"] = "other".into();
173 assert!(parse(&v).is_err());
174 for key in ["Id", "Config"] {
175 let mut v = fixture();
176 v.as_object_mut().unwrap().remove(key);
177 assert!(parse(&v).is_err());
178 }
179 let mut v = fixture();
180 v["Config"] = Value::Null;
181 assert!(parse(&v).is_err());
182 }
183 #[test]
184 fn id_route_status_and_no_digest_substitution() {
185 for bad in ["tag:latest".into(), "b".repeat(63), "B".repeat(64)] {
186 assert!(image_route(&bad).is_err());
187 }
188 let bytes = serde_json::to_vec(&fixture()).unwrap();
189 let route = image_route(&id()).unwrap();
190 for r in [
191 route.replace("/libpod", ""),
192 format!("{route}?size=true"),
193 route.replace(&id(), &"c".repeat(64)),
194 ] {
195 assert!(decode_image(&id(), "GET", &r, 200, &bytes).is_err());
196 }
197 for status in [201, 204, 404, 500] {
198 assert!(decode_image(&id(), "GET", &route, status, &bytes).is_err());
199 }
200 assert!(decode_image(&id(), "POST", &route, 200, &bytes).is_err());
201 let mut v = fixture();
202 v["Id"] = "c".repeat(64).into();
203 v["Digest"] = format!("sha256:{}", id()).into();
204 assert!(parse(&v).is_err());
205 }
206 #[test]
207 fn expose_shape_keys_and_duplicate_labels_refused() {
208 for exposed in [
209 json!({"080/tcp":{}}),
210 json!({"80/tcp":null}),
211 json!({"80/tcp":{"x":true}}),
212 json!([]),
213 ] {
214 let mut v = fixture();
215 v["Config"]["ExposedPorts"] = exposed;
216 assert!(parse(&v).is_err());
217 }
218 let mut v = fixture();
219 v["ExposedPorts"] = json!({});
220 assert!(parse(&v).is_err());
221 let raw = serde_json::to_string(&fixture()).unwrap().replace(
222 "\"image\":\"value\"",
223 "\"image\":\"value\",\"image\":\"value\"",
224 );
225 assert!(decode_image(
226 &id(),
227 "GET",
228 &image_route(&id()).unwrap(),
229 200,
230 raw.as_bytes()
231 )
232 .is_err());
233 }
234}