Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
observation.rs

1//! Native Libpod observation comparison, not daemon authentication or restart proof.
2//! Adapter-side HTTP parsing and Core-held socket/config verification remain required.
3use serde::{Deserialize, Serialize};
4
5pub const API: &str = "6.1.4";
6pub const INFO_ROUTE: &str = "/v6.1.4/libpod/info";
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct SocketObservation {
11    pub path: String,
12    pub device: u64,
13    pub inode: u64,
14    pub uid: u32,
15    /// Full Unix st_mode, including socket type; never inferred from a URI.
16    pub mode: u32,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct RemoteSocketObservation {
22    pub path: String,
23    pub exists: bool,
24}
25
26/// Normalized selected native-info fields plus local socket metadata.
27/// These are not the HTTP response schema. No compat ID or daemon_id is accepted.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct Observation {
31    pub engine: String,
32    pub api_surface: String,
33    pub selected_api: String,
34    pub server_version: String,
35    pub hostname: String,
36    pub rootless: bool,
37    pub graph_root: String,
38    pub run_root: String,
39    pub remote_socket: Option<RemoteSocketObservation>,
40    pub socket: SocketObservation,
41}
42
43/// Operator assertion from protected configuration, never an engine observation.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct ExpectedContext {
47    pub operator_epoch: String,
48    pub principal_uid: u32,
49    pub observation: Observation,
50}
51
52fn absolute_path(value: &str) -> bool {
53    value.starts_with('/')
54        && value.len() <= 4096
55        && value
56            .split('/')
57            .skip(1)
58            .all(|part| !part.is_empty() && part != "." && part != "..")
59        && !value.chars().any(char::is_control)
60}
61
62impl Observation {
63    pub fn validate(&self) -> Result<(), &'static str> {
64        if self.engine != "podman"
65            || self.api_surface != "libpod"
66            || self.selected_api != API
67            || self.server_version != API
68        {
69            return Err("unsupported native Podman observation profile");
70        }
71        if self.hostname.is_empty()
72            || self.hostname.len() > 255
73            || self
74                .hostname
75                .chars()
76                .any(|c| c.is_control() || c.is_whitespace())
77            || !absolute_path(&self.graph_root)
78            || !absolute_path(&self.run_root)
79            || !absolute_path(&self.socket.path)
80            || self.socket.inode == 0
81            || self.socket.mode & 0o170000 != 0o140000
82            || self.socket.mode & 0o7000 != 0
83            || self.socket.mode & 0o007 != 0
84            || self.socket.mode & !0o177777 != 0
85        {
86            return Err("invalid native Podman observation");
87        }
88        if let Some(remote) = &self.remote_socket {
89            if !remote.exists || remote.path != self.socket.path {
90                return Err("Podman advertised socket differs from selected socket");
91            }
92        }
93        Ok(())
94    }
95}
96
97impl ExpectedContext {
98    pub fn validate(&self) -> Result<(), &'static str> {
99        if self.operator_epoch.len() != 32
100            || !self
101                .operator_epoch
102                .bytes()
103                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
104        {
105            return Err("explicit operator epoch required");
106        }
107        self.observation.validate()
108    }
109
110    /// Supported local deployment policy, not an authentication claim.
111    /// Rootful sockets must belong to root; rootless sockets to the non-root
112    /// configured principal. Other deployments require an explicit future profile.
113    pub fn validate_local_socket_policy(&self) -> Result<(), &'static str> {
114        self.validate()?;
115        let uid = self.observation.socket.uid;
116        if self.observation.rootless {
117            if self.principal_uid == 0 || uid != self.principal_uid {
118                return Err("rootless socket must belong to non-root principal");
119            }
120        } else if uid != 0 {
121            return Err("rootful socket must belong to root");
122        }
123        Ok(())
124    }
125
126    /// Integration entry on Unix: compare the configured principal to actual
127    /// process euid, not another caller-supplied identity. Config protection is
128    /// still the host's responsibility; this does not open any file.
129    #[cfg(unix)]
130    pub fn match_current_operation(
131        &self,
132        bound: &Self,
133        before: &Observation,
134        after: &Observation,
135    ) -> Result<(), &'static str> {
136        if self.principal_uid != unsafe { libc::geteuid() } {
137            return Err("Podman configured principal differs from process euid");
138        }
139        self.match_operation(bound, before, after)
140    }
141
142    /// Compare the exact protected caller context and both observations.
143    /// Equal observations can occur across socket activation or service restart.
144    /// Success therefore says nothing about daemon lifetime or authentication.
145    pub fn match_operation(
146        &self,
147        bound: &Self,
148        before: &Observation,
149        after: &Observation,
150    ) -> Result<(), &'static str> {
151        self.validate()?;
152        bound.validate()?;
153        before.validate()?;
154        after.validate()?;
155        if self != bound || &self.observation != before || before != after {
156            return Err("Podman operation context or observations changed");
157        }
158        Ok(())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    fn context() -> ExpectedContext {
166        ExpectedContext {
167            operator_epoch: "a".repeat(32),
168            principal_uid: 1000,
169            observation: Observation {
170                engine: "podman".into(),
171                api_surface: "libpod".into(),
172                selected_api: API.into(),
173                server_version: API.into(),
174                hostname: "fixture".into(),
175                rootless: false,
176                graph_root: "/var/lib/containers/storage".into(),
177                run_root: "/run/containers/storage".into(),
178                remote_socket: None,
179                socket: SocketObservation {
180                    path: "/run/podman/podman.sock".into(),
181                    device: 1,
182                    inode: 2,
183                    uid: 0,
184                    mode: 0o140660,
185                },
186            },
187        }
188    }
189
190    #[test]
191    fn explicit_rootful_and_rootless_contexts_match() {
192        for rootless in [false, true] {
193            let mut c = context();
194            c.observation.rootless = rootless;
195            if rootless {
196                c.observation.socket.uid = 1000;
197            }
198            c.match_operation(&c, &c.observation, &c.observation)
199                .unwrap();
200        }
201    }
202
203    #[test]
204    fn epoch_and_principal_changes_are_rejected() {
205        let c = context();
206        let mut epoch = c.clone();
207        epoch.operator_epoch = "b".repeat(32);
208        let mut principal = c.clone();
209        principal.principal_uid += 1;
210        for changed in [epoch, principal] {
211            assert!(c
212                .match_operation(&changed, &c.observation, &c.observation)
213                .is_err());
214        }
215        for value in ["".into(), "A".repeat(32), "a".repeat(31)] {
216            let mut changed = c.clone();
217            changed.operator_epoch = value;
218            assert!(changed.validate().is_err());
219        }
220    }
221
222    #[test]
223    fn every_observation_dimension_is_bound() {
224        let c = context();
225        let original = serde_json::to_value(&c.observation).unwrap();
226        for (field, value) in [
227            ("engine", serde_json::json!("docker")),
228            ("api_surface", serde_json::json!("compat")),
229            ("selected_api", serde_json::json!("1.44.0")),
230            ("server_version", serde_json::json!("6.1.5")),
231            ("hostname", serde_json::json!("other")),
232            ("rootless", serde_json::json!(true)),
233            ("graph_root", serde_json::json!("/other")),
234            ("run_root", serde_json::json!("/other")),
235        ] {
236            let mut v = original.clone();
237            v[field] = value;
238            let changed: Observation = serde_json::from_value(v).unwrap();
239            assert!(
240                c.match_operation(&c, &c.observation, &changed).is_err(),
241                "{field}"
242            );
243            assert!(
244                c.match_operation(&c, &changed, &changed).is_err(),
245                "{field}"
246            );
247        }
248        for (field, value) in [
249            ("device", 3),
250            ("inode", 3),
251            ("uid", 1000),
252            ("mode", 0o140600),
253        ] {
254            let mut v = original.clone();
255            v["socket"][field] = serde_json::json!(value);
256            let changed: Observation = serde_json::from_value(v).unwrap();
257            assert!(
258                c.match_operation(&c, &c.observation, &changed).is_err(),
259                "{field}"
260            );
261        }
262    }
263
264    #[test]
265    fn optional_remote_socket_is_exact_when_present() {
266        let mut c = context();
267        c.observation.remote_socket = Some(RemoteSocketObservation {
268            path: c.observation.socket.path.clone(),
269            exists: true,
270        });
271        c.validate().unwrap();
272        let mut changed = c.observation.clone();
273        changed.remote_socket = None;
274        assert!(c.match_operation(&c, &c.observation, &changed).is_err());
275        c.observation.remote_socket.as_mut().unwrap().exists = false;
276        assert!(c.validate().is_err());
277    }
278
279    #[test]
280    fn daemon_id_epoch_in_response_and_duplicate_fields_are_rejected() {
281        let o = context().observation;
282        for key in ["daemon_id", "ID", "operator_epoch"] {
283            let mut v = serde_json::to_value(&o).unwrap();
284            v[key] = "fabricated".into();
285            assert!(serde_json::from_value::<Observation>(v).is_err());
286        }
287        let text = serde_json::to_string(&o).unwrap();
288        let duplicate = text.replacen('{', "{\"engine\":\"podman\",", 1);
289        assert!(serde_json::from_str::<Observation>(&duplicate).is_err());
290    }
291
292    #[cfg(unix)]
293    #[test]
294    fn actual_principal_cannot_be_replaced_by_self_consistent_caller_values() {
295        let mut c = context();
296        c.principal_uid = unsafe { libc::geteuid() };
297        c.match_current_operation(&c, &c.observation, &c.observation)
298            .unwrap();
299        c.principal_uid = c.principal_uid.wrapping_add(1);
300        assert!(c
301            .match_current_operation(&c, &c.observation, &c.observation)
302            .is_err());
303    }
304
305    #[test]
306    fn different_valid_socket_path_is_not_the_same_context() {
307        let c = context();
308        let mut changed = c.observation.clone();
309        changed.socket.path = "/run/podman/other.sock".into();
310        changed.validate().unwrap();
311        assert!(c.match_operation(&c, &changed, &changed).is_err());
312    }
313
314    #[test]
315    fn unsafe_paths_and_socket_types_are_rejected() {
316        for path in [
317            "relative",
318            "/run/../socket",
319            "/run//socket",
320            "/run/socket/",
321            "/run/so\0cket",
322        ] {
323            let mut c = context();
324            c.observation.socket.path = path.into();
325            assert!(c.validate().is_err());
326        }
327        for mode in [0o100600, 0o140666, 0o144600] {
328            let mut c = context();
329            c.observation.socket.mode = mode;
330            assert!(c.validate().is_err());
331        }
332    }
333    #[test]
334    fn local_socket_owner_policy_is_explicit() {
335        let mut c = context();
336        c.principal_uid = 1000;
337        c.observation.rootless = false;
338        c.observation.socket.uid = 0;
339        assert!(c.validate_local_socket_policy().is_ok());
340        c.observation.socket.uid = 1000;
341        assert!(c.validate_local_socket_policy().is_err());
342        c.observation.rootless = true;
343        assert!(c.validate_local_socket_policy().is_ok());
344        c.observation.socket.uid = 1001;
345        assert!(c.validate_local_socket_policy().is_err());
346        c.principal_uid = 0;
347        c.observation.socket.uid = 0;
348        assert!(c.validate_local_socket_policy().is_err());
349    }
350}