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