Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
native_image.rs

1//! Native exact-ID image facts; no tag resolution, pull or absence authority.
2use 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    /// Exact image labels plus explicit operator overrides, following CompleteSpec.
111    /// This is not a persisted reservation or permission to send the resulting plan.
112    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}