Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
profile.rs

1//! Explicit profile selection and durable slot identity, independent of engine I/O.
2use serde::{Deserialize, Serialize};
3
4pub const PROTOCOL: &str = "gate-runtime/lifecycle-v2";
5pub const PRODUCT: &str = "podman-lifecycle";
6pub const VERSION: &str = "0.1.0";
7
8/// Exact operator-selected product tuple. No inference from executable names,
9/// environment, endpoint responses, or another protocol's engine field.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct Profile {
13    pub protocol: String,
14    pub product: String,
15    pub version: String,
16    pub engine: String,
17}
18
19impl Profile {
20    pub fn validate(&self) -> Result<(), &'static str> {
21        if self.protocol != PROTOCOL
22            || self.product != PRODUCT
23            || self.version != VERSION
24            || self.engine != "podman"
25        {
26            return Err("unsupported Podman lifecycle profile");
27        }
28        Ok(())
29    }
30}
31
32/// Proposed durable identity for new slots. This is not a Docker journal reader.
33/// Missing identity, including historical slots, must not become a default.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct SlotIdentity {
37    pub profile: Profile,
38    pub installation_sha256: String,
39    pub config_sha256: String,
40    pub executable_sha256: String,
41}
42
43impl SlotIdentity {
44    pub fn validate(&self) -> Result<(), &'static str> {
45        self.profile.validate()?;
46        for hash in [
47            &self.installation_sha256,
48            &self.config_sha256,
49            &self.executable_sha256,
50        ] {
51            if hash.len() != 64
52                || !hash
53                    .bytes()
54                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
55            {
56                return Err("invalid Podman slot identity digest");
57            }
58        }
59        Ok(())
60    }
61
62    /// A mismatch requires original-profile recovery, never adoption or rewriting.
63    /// Matching bytes alone do not grant lease or operator cleanup authority.
64    pub fn require_same(&self, recorded: &Self) -> Result<(), &'static str> {
65        self.validate()?;
66        recorded.validate()?;
67        if self != recorded {
68            return Err("Podman slot profile or installation changed");
69        }
70        Ok(())
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn identity() -> SlotIdentity {
79        SlotIdentity {
80            profile: Profile {
81                protocol: PROTOCOL.into(),
82                product: PRODUCT.into(),
83                version: VERSION.into(),
84                engine: "podman".into(),
85            },
86            installation_sha256: "a".repeat(64),
87            config_sha256: "b".repeat(64),
88            executable_sha256: "c".repeat(64),
89        }
90    }
91
92    #[test]
93    fn exact_profile_and_identity_roundtrip() {
94        let original = identity();
95        let decoded: SlotIdentity =
96            serde_json::from_slice(&serde_json::to_vec(&original).unwrap()).unwrap();
97        original.require_same(&decoded).unwrap();
98    }
99
100    #[test]
101    fn docker_and_mixed_profiles_are_rejected() {
102        for field in ["protocol", "product", "version", "engine"] {
103            let mut value = serde_json::to_value(identity()).unwrap();
104            value["profile"][field] = serde_json::json!(match field {
105                "protocol" => "gate-runtime/lifecycle-v1",
106                "product" => "docker-lifecycle",
107                "version" => "0.2.0",
108                _ => "docker",
109            });
110            let changed: SlotIdentity = serde_json::from_value(value).unwrap();
111            assert!(changed.validate().is_err(), "{field}");
112        }
113    }
114
115    #[test]
116    fn changed_installation_config_or_executable_cannot_adopt_slot() {
117        for field in ["installation_sha256", "config_sha256", "executable_sha256"] {
118            let original = identity();
119            let mut value = serde_json::to_value(&original).unwrap();
120            value[field] = serde_json::json!("d".repeat(64));
121            let changed: SlotIdentity = serde_json::from_value(value).unwrap();
122            assert!(original.require_same(&changed).is_err());
123        }
124    }
125
126    #[test]
127    fn missing_profile_and_unknown_authority_fields_are_rejected() {
128        for value in [serde_json::json!({"released":true}), {
129            let mut value = serde_json::to_value(identity()).unwrap();
130            value["authority"] = serde_json::json!("adopt-docker");
131            value
132        }] {
133            assert!(serde_json::from_value::<SlotIdentity>(value).is_err());
134        }
135    }
136
137    #[test]
138    fn noncanonical_hashes_are_rejected() {
139        for hash in ["A".repeat(64), "a".repeat(63), "g".repeat(64)] {
140            let mut value = identity();
141            value.config_sha256 = hash;
142            assert!(value.validate().is_err());
143        }
144    }
145}