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 READ_ONLY_API_5_7: &str = "5.7.0";
7pub const INFO_ROUTE: &str = "/v6.1.4/libpod/info";
8
9pub fn read_only_api(value: &str) -> bool {
10    value == API || value == READ_ONLY_API_5_7
11}
12
13pub fn info_route(api: &str) -> Result<String, &'static str> {
14    if read_only_api(api) {
15        Ok(format!("/v{api}/libpod/info"))
16    } else {
17        Err("unsupported native Podman API")
18    }
19}
20
21/// Only an unambiguous local unix:///absolute path may be normalized.
22/// No authority, query, fragment, percent/escape, backslash or traversal.
23pub fn normalize_local_socket_path(value: &str) -> Result<String, &'static str> {
24    let path = value.strip_prefix("unix://").unwrap_or(value);
25    if !path.starts_with('/')
26        || path == "/"
27        || path.len() > 4096
28        || path.ends_with('/')
29        || path.contains("//")
30        || path
31            .chars()
32            .any(|c| c.is_control() || matches!(c, '?' | '#' | '%' | '\\') || c == '\0')
33        || path
34            .split('/')
35            .skip(1)
36            .any(|part| part.is_empty() || part == "." || part == "..")
37    {
38        return Err("local Podman socket path rejected");
39    }
40    Ok(path.to_string())
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct SocketObservation {
46    pub path: String,
47    pub device: u64,
48    pub inode: u64,
49    pub uid: u32,
50    /// Full Unix st_mode, including socket type; never inferred from a URI.
51    pub mode: u32,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct RemoteSocketObservation {
57    pub path: String,
58    pub exists: bool,
59}
60
61/// Normalized selected native-info fields plus local socket metadata.
62/// These are not the HTTP response schema. No compat ID or daemon_id is accepted.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Observation {
66    pub engine: String,
67    pub api_surface: String,
68    pub selected_api: String,
69    pub server_version: String,
70    pub hostname: String,
71    pub rootless: bool,
72    pub graph_root: String,
73    pub run_root: String,
74    pub remote_socket: Option<RemoteSocketObservation>,
75    pub socket: SocketObservation,
76}
77
78/// Operator assertion from protected configuration, never an engine observation.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ExpectedContext {
82    pub operator_epoch: String,
83    pub principal_uid: u32,
84    pub observation: Observation,
85}
86
87fn absolute_path(value: &str) -> bool {
88    value.starts_with('/')
89        && value.len() <= 4096
90        && value
91            .split('/')
92            .skip(1)
93            .all(|part| !part.is_empty() && part != "." && part != "..")
94        && !value.chars().any(char::is_control)
95}
96
97impl Observation {
98    fn validate_shape(&self) -> Result<(), &'static str> {
99        if self.engine != "podman" || self.api_surface != "libpod" {
100            return Err("unsupported native Podman observation profile");
101        }
102        if self.hostname.is_empty()
103            || self.hostname.len() > 255
104            || self
105                .hostname
106                .chars()
107                .any(|c| c.is_control() || c.is_whitespace())
108            || !absolute_path(&self.graph_root)
109            || !absolute_path(&self.run_root)
110            || !absolute_path(&self.socket.path)
111            || self.socket.inode == 0
112            || self.socket.mode & 0o170000 != 0o140000
113            || self.socket.mode & 0o7000 != 0
114            || self.socket.mode & 0o007 != 0
115            || self.socket.mode & !0o177777 != 0
116        {
117            return Err("invalid native Podman observation");
118        }
119        if let Some(remote) = &self.remote_socket {
120            if !remote.exists || remote.path != self.socket.path {
121                return Err("Podman advertised socket differs from selected socket");
122            }
123        }
124        Ok(())
125    }
126
127    /// Mutation/lease-compatible profile. This remains 6.1.4-only.
128    pub fn validate(&self) -> Result<(), &'static str> {
129        self.validate_shape()?;
130        if self.selected_api != API || self.server_version != API {
131            return Err("unsupported native Podman observation profile");
132        }
133        Ok(())
134    }
135
136    /// Read-only capture profile. 5.7.0 is deliberately not mutation-capable.
137    pub fn validate_read_only(&self) -> Result<(), &'static str> {
138        self.validate_shape()?;
139        if !read_only_api(&self.selected_api) || self.server_version != self.selected_api {
140            return Err("unsupported read-only Podman observation profile");
141        }
142        Ok(())
143    }
144}
145
146impl ExpectedContext {
147    pub fn validate(&self) -> Result<(), &'static str> {
148        self.validate_epoch_shape()?;
149        self.observation.validate()
150    }
151
152    pub fn validate_read_only(&self) -> Result<(), &'static str> {
153        self.validate_epoch_shape()?;
154        self.observation.validate_read_only()
155    }
156
157    fn validate_epoch_shape(&self) -> Result<(), &'static str> {
158        if self.operator_epoch.len() != 32
159            || !self
160                .operator_epoch
161                .bytes()
162                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
163        {
164            return Err("explicit operator epoch required");
165        }
166        Ok(())
167    }
168
169    /// Supported local deployment policy, not an authentication claim.
170    /// Rootful sockets must belong to root; rootless sockets to the non-root
171    /// configured principal. Other deployments require an explicit future profile.
172    pub fn validate_local_socket_policy(&self) -> Result<(), &'static str> {
173        self.validate()?;
174        self.validate_socket_owner()
175    }
176
177    pub fn validate_local_socket_policy_read_only(&self) -> Result<(), &'static str> {
178        self.validate_read_only()?;
179        self.validate_socket_owner()
180    }
181
182    fn validate_socket_owner(&self) -> Result<(), &'static str> {
183        let uid = self.observation.socket.uid;
184        if self.observation.rootless {
185            if self.principal_uid == 0 || uid != self.principal_uid {
186                return Err("rootless socket must belong to non-root principal");
187            }
188        } else if uid != 0 {
189            return Err("rootful socket must belong to root");
190        }
191        Ok(())
192    }
193
194    /// Integration entry on Unix: compare the configured principal to actual
195    /// process euid, not another caller-supplied identity. Config protection is
196    /// still the host's responsibility; this does not open any file.
197    #[cfg(unix)]
198    pub fn match_current_operation(
199        &self,
200        bound: &Self,
201        before: &Observation,
202        after: &Observation,
203    ) -> Result<(), &'static str> {
204        if self.principal_uid != unsafe { libc::geteuid() } {
205            return Err("Podman configured principal differs from process euid");
206        }
207        self.match_operation(bound, before, after)
208    }
209
210    #[cfg(unix)]
211    pub fn match_current_operation_read_only(
212        &self,
213        bound: &Self,
214        before: &Observation,
215        after: &Observation,
216    ) -> Result<(), &'static str> {
217        if self.principal_uid != unsafe { libc::geteuid() } {
218            return Err("Podman configured principal differs from process euid");
219        }
220        self.match_operation_read_only(bound, before, after)
221    }
222
223    /// Compare the exact protected caller context and both observations.
224    /// Equal observations can occur across socket activation or service restart.
225    /// Success therefore says nothing about daemon lifetime or authentication.
226    pub fn match_operation(
227        &self,
228        bound: &Self,
229        before: &Observation,
230        after: &Observation,
231    ) -> Result<(), &'static str> {
232        self.validate()?;
233        bound.validate()?;
234        before.validate()?;
235        after.validate()?;
236        if self != bound || &self.observation != before || before != after {
237            return Err("Podman operation context or observations changed");
238        }
239        Ok(())
240    }
241
242    pub fn match_operation_read_only(
243        &self,
244        bound: &Self,
245        before: &Observation,
246        after: &Observation,
247    ) -> Result<(), &'static str> {
248        self.validate_read_only()?;
249        bound.validate_read_only()?;
250        before.validate_read_only()?;
251        after.validate_read_only()?;
252        if self != bound || &self.observation != before || before != after {
253            return Err("Podman read-only operation context or observations changed");
254        }
255        Ok(())
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    fn context() -> ExpectedContext {
263        ExpectedContext {
264            operator_epoch: "a".repeat(32),
265            principal_uid: 1000,
266            observation: Observation {
267                engine: "podman".into(),
268                api_surface: "libpod".into(),
269                selected_api: API.into(),
270                server_version: API.into(),
271                hostname: "fixture".into(),
272                rootless: false,
273                graph_root: "/var/lib/containers/storage".into(),
274                run_root: "/run/containers/storage".into(),
275                remote_socket: None,
276                socket: SocketObservation {
277                    path: "/run/podman/podman.sock".into(),
278                    device: 1,
279                    inode: 2,
280                    uid: 0,
281                    mode: 0o140660,
282                },
283            },
284        }
285    }
286
287    #[test]
288    fn explicit_rootful_and_rootless_contexts_match() {
289        for rootless in [false, true] {
290            let mut c = context();
291            c.observation.rootless = rootless;
292            if rootless {
293                c.observation.socket.uid = 1000;
294            }
295            c.match_operation(&c, &c.observation, &c.observation)
296                .unwrap();
297        }
298    }
299
300    #[test]
301    fn epoch_and_principal_changes_are_rejected() {
302        let c = context();
303        let mut epoch = c.clone();
304        epoch.operator_epoch = "b".repeat(32);
305        let mut principal = c.clone();
306        principal.principal_uid += 1;
307        for changed in [epoch, principal] {
308            assert!(c
309                .match_operation(&changed, &c.observation, &c.observation)
310                .is_err());
311        }
312        for value in ["".into(), "A".repeat(32), "a".repeat(31)] {
313            let mut changed = c.clone();
314            changed.operator_epoch = value;
315            assert!(changed.validate().is_err());
316        }
317    }
318
319    #[test]
320    fn every_observation_dimension_is_bound() {
321        let c = context();
322        let original = serde_json::to_value(&c.observation).unwrap();
323        for (field, value) in [
324            ("engine", serde_json::json!("docker")),
325            ("api_surface", serde_json::json!("compat")),
326            ("selected_api", serde_json::json!("1.44.0")),
327            ("server_version", serde_json::json!("6.1.5")),
328            ("hostname", serde_json::json!("other")),
329            ("rootless", serde_json::json!(true)),
330            ("graph_root", serde_json::json!("/other")),
331            ("run_root", serde_json::json!("/other")),
332        ] {
333            let mut v = original.clone();
334            v[field] = value;
335            let changed: Observation = serde_json::from_value(v).unwrap();
336            assert!(
337                c.match_operation(&c, &c.observation, &changed).is_err(),
338                "{field}"
339            );
340            assert!(
341                c.match_operation(&c, &changed, &changed).is_err(),
342                "{field}"
343            );
344        }
345        for (field, value) in [
346            ("device", 3),
347            ("inode", 3),
348            ("uid", 1000),
349            ("mode", 0o140600),
350        ] {
351            let mut v = original.clone();
352            v["socket"][field] = serde_json::json!(value);
353            let changed: Observation = serde_json::from_value(v).unwrap();
354            assert!(
355                c.match_operation(&c, &c.observation, &changed).is_err(),
356                "{field}"
357            );
358        }
359    }
360
361    #[test]
362    fn optional_remote_socket_is_exact_when_present() {
363        let mut c = context();
364        c.observation.remote_socket = Some(RemoteSocketObservation {
365            path: c.observation.socket.path.clone(),
366            exists: true,
367        });
368        c.validate().unwrap();
369        let mut changed = c.observation.clone();
370        changed.remote_socket = None;
371        assert!(c.match_operation(&c, &c.observation, &changed).is_err());
372        c.observation.remote_socket.as_mut().unwrap().exists = false;
373        assert!(c.validate().is_err());
374    }
375
376    #[test]
377    fn daemon_id_epoch_in_response_and_duplicate_fields_are_rejected() {
378        let o = context().observation;
379        for key in ["daemon_id", "ID", "operator_epoch"] {
380            let mut v = serde_json::to_value(&o).unwrap();
381            v[key] = "fabricated".into();
382            assert!(serde_json::from_value::<Observation>(v).is_err());
383        }
384        let text = serde_json::to_string(&o).unwrap();
385        let duplicate = text.replacen('{', "{\"engine\":\"podman\",", 1);
386        assert!(serde_json::from_str::<Observation>(&duplicate).is_err());
387    }
388
389    #[cfg(unix)]
390    #[test]
391    fn actual_principal_cannot_be_replaced_by_self_consistent_caller_values() {
392        let mut c = context();
393        c.principal_uid = unsafe { libc::geteuid() };
394        c.match_current_operation(&c, &c.observation, &c.observation)
395            .unwrap();
396        c.principal_uid = c.principal_uid.wrapping_add(1);
397        assert!(c
398            .match_current_operation(&c, &c.observation, &c.observation)
399            .is_err());
400    }
401
402    #[test]
403    fn different_valid_socket_path_is_not_the_same_context() {
404        let c = context();
405        let mut changed = c.observation.clone();
406        changed.socket.path = "/run/podman/other.sock".into();
407        changed.validate().unwrap();
408        assert!(c.match_operation(&c, &changed, &changed).is_err());
409    }
410
411    #[test]
412    fn unsafe_paths_and_socket_types_are_rejected() {
413        for path in [
414            "relative",
415            "/run/../socket",
416            "/run//socket",
417            "/run/socket/",
418            "/run/so\0cket",
419        ] {
420            let mut c = context();
421            c.observation.socket.path = path.into();
422            assert!(c.validate().is_err());
423        }
424        for mode in [0o100600, 0o140666, 0o144600] {
425            let mut c = context();
426            c.observation.socket.mode = mode;
427            assert!(c.validate().is_err());
428        }
429    }
430    #[test]
431    fn five_seven_is_read_only_only() {
432        let mut c = context();
433        c.observation.selected_api = READ_ONLY_API_5_7.into();
434        c.observation.server_version = READ_ONLY_API_5_7.into();
435        assert!(c.validate().is_err());
436        assert!(c
437            .match_operation(&c, &c.observation, &c.observation)
438            .is_err());
439        c.validate_read_only().unwrap();
440        c.match_operation_read_only(&c, &c.observation, &c.observation)
441            .unwrap();
442        let mut mismatch = c.clone();
443        mismatch.observation.server_version = API.into();
444        assert!(mismatch.validate_read_only().is_err());
445        let mut unknown = c.clone();
446        unknown.observation.selected_api = "5.8.0".into();
447        unknown.observation.server_version = "5.8.0".into();
448        assert!(unknown.validate_read_only().is_err());
449    }
450
451    #[test]
452    fn local_socket_uri_normalization_is_unambiguous() {
453        assert_eq!(
454            normalize_local_socket_path("unix:///run/user/1000/podman/podman.sock").unwrap(),
455            "/run/user/1000/podman/podman.sock"
456        );
457        assert_eq!(
458            normalize_local_socket_path("/run/user/1000/podman/podman.sock").unwrap(),
459            "/run/user/1000/podman/podman.sock"
460        );
461        for value in [
462            "unix://host/run/podman.sock",
463            "unix:///run/../podman.sock",
464            "unix:///run/%2e%2e/podman.sock",
465            "unix:///run/podman.sock?x=1",
466            "unix:///run/podman.sock#frag",
467            "unix:///run//podman.sock",
468            "unix:///run/podman.sock/",
469            "relative.sock",
470        ] {
471            assert!(normalize_local_socket_path(value).is_err(), "{value}");
472        }
473    }
474
475    #[test]
476    fn local_socket_owner_policy_is_explicit() {
477        let mut c = context();
478        c.principal_uid = 1000;
479        c.observation.rootless = false;
480        c.observation.socket.uid = 0;
481        assert!(c.validate_local_socket_policy().is_ok());
482        c.observation.socket.uid = 1000;
483        assert!(c.validate_local_socket_policy().is_err());
484        c.observation.rootless = true;
485        assert!(c.validate_local_socket_policy().is_ok());
486        c.observation.socket.uid = 1001;
487        assert!(c.validate_local_socket_policy().is_err());
488        c.principal_uid = 0;
489        c.observation.socket.uid = 0;
490        assert!(c.validate_local_socket_policy().is_err());
491    }
492}