Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
protected_context.rs

1//! Linux filesystem checkpoint only: protected config bytes and held socket inode.
2//! An O_PATH descriptor is not a connected transport or peer authentication.
3use super::{
4    native_json,
5    observation::{ExpectedContext, SocketObservation},
6};
7use sha2::{Digest, Sha256};
8use std::os::{
9    fd::{AsRawFd, FromRawFd},
10    unix::{
11        ffi::OsStrExt,
12        fs::{FileExt, MetadataExt, OpenOptionsExt},
13    },
14};
15use std::{
16    fs::{self, File, OpenOptions},
17    path::{Path, PathBuf},
18};
19
20type Result<T> = std::result::Result<T, &'static str>;
21fn io<T>(r: std::io::Result<T>) -> Result<T> {
22    r.map_err(|_| "protected Podman filesystem rejected")
23}
24#[derive(PartialEq, Eq)]
25pub(super) struct Identity {
26    dev: u64,
27    ino: u64,
28    mode: u32,
29    uid: u32,
30    gid: u32,
31    links: u64,
32    len: u64,
33    ctime: i64,
34    ctime_ns: i64,
35    mtime: i64,
36    mtime_ns: i64,
37}
38impl Identity {
39    pub(super) fn of(m: &fs::Metadata) -> Self {
40        Self {
41            dev: m.dev(),
42            ino: m.ino(),
43            mode: m.mode(),
44            uid: m.uid(),
45            gid: m.gid(),
46            links: m.nlink(),
47            len: m.len(),
48            ctime: m.ctime(),
49            ctime_ns: m.ctime_nsec(),
50            mtime: m.mtime(),
51            mtime_ns: m.mtime_nsec(),
52        }
53    }
54}
55pub(super) fn protected(path: &Path) -> Result<()> {
56    let text = path.to_str().ok_or("non-UTF8 path")?;
57    if !path.is_absolute()
58        || text.len() > 4096
59        || text.chars().any(char::is_control)
60        || text
61            .split('/')
62            .skip(1)
63            .any(|p| p.is_empty() || p == "." || p == "..")
64        || io(fs::canonicalize(path))? != path
65    {
66        return Err("noncanonical protected path");
67    }
68    for parent in path.ancestors().skip(1) {
69        let m = io(fs::symlink_metadata(parent))?;
70        if !m.is_dir()
71            || m.mode() & 0o022 != 0
72            || ![0, unsafe { libc::geteuid() }].contains(&m.uid())
73        {
74            return Err("unprotected ancestor");
75        }
76    }
77    Ok(())
78}
79struct Held {
80    path: PathBuf,
81    file: File,
82    identity: Identity,
83}
84impl Held {
85    fn open(path: &Path, socket: bool) -> Result<Self> {
86        protected(path)?;
87        let flags = libc::O_NOFOLLOW
88            | libc::O_CLOEXEC
89            | libc::O_NONBLOCK
90            | if socket { libc::O_PATH } else { 0 };
91        let file = if socket {
92            // musl O_ACCMODE includes O_PATH; std OpenOptions masks it out.
93            // Open the path-only handle directly, retaining atomic CLOEXEC/no-follow.
94            let path = std::ffi::CString::new(path.as_os_str().as_bytes())
95                .map_err(|_| "invalid Podman socket path")?;
96            let fd = unsafe { libc::open(path.as_ptr(), flags) };
97            if fd < 0 {
98                return Err("protected Podman filesystem rejected");
99            }
100            // SAFETY: open returned a fresh owned descriptor.
101            unsafe { File::from_raw_fd(fd) }
102        } else {
103            io(OpenOptions::new().read(true).custom_flags(flags).open(path))?
104        };
105        Self::from_file(path, file, socket)
106    }
107    fn from_file(path: &Path, file: File, socket: bool) -> Result<Self> {
108        protected(path)?;
109        if !socket {
110            let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) };
111            let fd_flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) };
112            if flags < 0
113                || flags & libc::O_ACCMODE != libc::O_RDONLY
114                || flags & libc::O_PATH != 0
115                || fd_flags < 0
116                || fd_flags & libc::FD_CLOEXEC == 0
117            {
118                return Err("read-only cloexec config descriptor required");
119            }
120        }
121        let m = io(file.metadata())?;
122        if socket {
123            if m.mode() & 0o170000 != 0o140000
124                || m.mode() & 0o7007 != 0
125                || ![0, unsafe { libc::geteuid() }].contains(&m.uid())
126            {
127                return Err("unprotected socket");
128            }
129        } else if !m.is_file()
130            || m.nlink() != 1
131            || m.uid() != unsafe { libc::geteuid() }
132            || !matches!(m.mode() & 0o7777, 0o400 | 0o600)
133            || m.len() > native_json::LIMIT as u64
134        {
135            return Err("unprotected config");
136        }
137        let held = Self {
138            path: path.into(),
139            file,
140            identity: Identity::of(&m),
141        };
142        held.check()?;
143        Ok(held)
144    }
145    fn check(&self) -> Result<()> {
146        protected(&self.path)?;
147        if Identity::of(&io(self.file.metadata())?) != self.identity
148            || Identity::of(&io(fs::symlink_metadata(&self.path))?) != self.identity
149        {
150            return Err("protected input replaced or changed");
151        }
152        Ok(())
153    }
154}
155/// Exact expected context bytes supplied by the operator. No installation or
156/// dispatch authority is minted here. No environment or old lease migration.
157#[derive(serde::Serialize, serde::Deserialize)]
158#[serde(deny_unknown_fields)]
159struct VersionedContext {
160    schema: String,
161    context: ExpectedContext,
162}
163pub const CONTEXT_SCHEMA: &str = "gate-runtime/podman-context-v1";
164pub struct ProtectedContext {
165    config: Held,
166    socket: Held,
167    bytes: Vec<u8>,
168    context: ExpectedContext,
169    versioned: bool,
170}
171impl ProtectedContext {
172    /// Legacy development raw context only; never inferred as versioned.
173    pub fn load(path: &Path) -> Result<Self> {
174        Self::load_format(path, false)
175    }
176    /// Canonical versioned context, held from the original open. Exact wrapper
177    /// bytes are the snapshot and config digest, not reserialized inner bytes.
178    pub fn load_versioned(path: &Path) -> Result<Self> {
179        Self::load_format(path, true)
180    }
181    /// Takes ownership of the original read-only CLOEXEC config descriptor.
182    /// Path is checked for identity only, never reopened for config bytes.
183    /// Reads at explicit offsets, independent of the caller's shared cursor.
184    pub fn from_held_versioned(path: &Path, file: File) -> Result<Self> {
185        Self::from_config(Held::from_file(path, file, false)?, true)
186    }
187    pub fn is_versioned(&self) -> bool {
188        self.versioned
189    }
190    fn load_format(path: &Path, versioned: bool) -> Result<Self> {
191        Self::from_config(Held::open(path, false)?, versioned)
192    }
193    fn from_config(config: Held, versioned: bool) -> Result<Self> {
194        let mut bytes = Vec::new();
195        let mut buffer = [0; 8192];
196        while bytes.len() <= native_json::LIMIT {
197            let count = io(config.file.read_at(&mut buffer, bytes.len() as u64))?;
198            if count == 0 {
199                break;
200            }
201            bytes.extend_from_slice(&buffer[..count]);
202        }
203        config.check()?;
204        let value = native_json::decode(&bytes)?;
205        let context: ExpectedContext = if versioned {
206            let wrapper: VersionedContext =
207                serde_json::from_value(value).map_err(|_| "versioned context fields rejected")?;
208            if wrapper.schema != CONTEXT_SCHEMA
209                || serde_json::to_vec(&wrapper).map_err(|_| "context encode")? != bytes
210            {
211                return Err("noncanonical versioned context");
212            }
213            wrapper.context
214        } else {
215            serde_json::from_value(value).map_err(|_| "protected context schema rejected")?
216        };
217        context.validate_read_only()?;
218        context.validate_local_socket_policy_read_only()?;
219        if context.principal_uid != unsafe { libc::geteuid() } {
220            return Err("configured principal differs from euid");
221        }
222        let socket = Held::open(Path::new(&context.observation.socket.path), true)?;
223        let held = Self {
224            config,
225            socket,
226            bytes,
227            context,
228            versioned,
229        };
230        held.revalidate()?;
231        Ok(held)
232    }
233    pub fn revalidate(&self) -> Result<()> {
234        if self.context.principal_uid != unsafe { libc::geteuid() } {
235            return Err("principal changed");
236        }
237        self.config.check()?;
238        self.socket.check()?;
239        let m = io(self.socket.file.metadata())?;
240        let observed = SocketObservation {
241            path: self
242                .socket
243                .path
244                .to_str()
245                .ok_or("socket path rejected")?
246                .into(),
247            device: m.dev(),
248            inode: m.ino(),
249            uid: m.uid(),
250            mode: m.mode(),
251        };
252        if observed != self.context.observation.socket {
253            return Err("configured socket differs from held inode");
254        }
255        Ok(())
256    }
257    /// Borrow the held Linux O_PATH socket handle after revalidation. A transport
258    /// may connect through /proc/self/fd/N to avoid reopening the ambient path.
259    /// This alone is not peer authentication or a mutation capability.
260    pub fn socket_file(&self) -> Result<&File> {
261        self.revalidate()?;
262        Ok(&self.socket.file)
263    }
264    pub fn snapshot(&self) -> Result<(&ExpectedContext, &[u8])> {
265        self.revalidate()?;
266        Ok((&self.context, &self.bytes))
267    }
268    /// API selected by the protected context; routes must use exactly this value.
269    pub fn read_only_api(&self) -> Result<String> {
270        let (context, _) = self.snapshot()?;
271        context.observation.validate_read_only()?;
272        Ok(context.observation.selected_api.clone())
273    }
274    pub fn config_sha256(&self) -> Result<String> {
275        self.revalidate()?;
276        Ok(format!("{:x}", Sha256::digest(&self.bytes)))
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::os::unix::{
284        fs::{symlink, PermissionsExt},
285        net::UnixListener,
286    };
287    struct Fixture {
288        dir: tempfile::TempDir,
289        _listener: UnixListener,
290        config: PathBuf,
291        socket: PathBuf,
292    }
293    impl Fixture {
294        fn new() -> Self {
295            Self::new_api("6.1.4")
296        }
297        fn new_api(api: &str) -> Self {
298            // HOME is only a test scratch location, never production identity.
299            // A protected short parent is required; assertions do not bypass checks.
300            let dir = tempfile::tempdir_in(
301                std::env::var_os("HOME").expect("protected test HOME required"),
302            )
303            .unwrap();
304            fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
305            let socket = dir.path().join("engine.sock");
306            let listener = UnixListener::bind(&socket).unwrap();
307            fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap();
308            let m = fs::metadata(&socket).unwrap();
309            let value = serde_json::json!({"operator_epoch":"a".repeat(32),"principal_uid":unsafe{libc::geteuid()},"observation":{"engine":"podman","api_surface":"libpod","selected_api":api,"server_version":api,"hostname":"fixture","rootless":unsafe{libc::geteuid()}!=0,"graph_root":"/var/lib/containers/storage","run_root":"/run/containers/storage","remote_socket":null,"socket":{"path":socket,"device":m.dev(),"inode":m.ino(),"uid":m.uid(),"mode":m.mode()}}});
310            let config = dir.path().join("context.json");
311            fs::write(&config, serde_json::to_vec(&value).unwrap()).unwrap();
312            fs::set_permissions(&config, fs::Permissions::from_mode(0o600)).unwrap();
313            Self {
314                dir,
315                _listener: listener,
316                config,
317                socket,
318            }
319        }
320    }
321    #[test]
322    fn socket_handle_preserves_path_only_and_cloexec() {
323        let f = Fixture::new();
324        let held = Held::open(&f.socket, true).unwrap();
325        let fd = held.file.as_raw_fd();
326        assert_ne!(unsafe { libc::fcntl(fd, libc::F_GETFL) } & libc::O_PATH, 0);
327        assert_ne!(
328            unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
329            0
330        );
331        let mut byte = 0u8;
332        assert_eq!(
333            unsafe { libc::read(fd, (&mut byte as *mut u8).cast(), 1) },
334            -1
335        );
336        assert_eq!(
337            std::io::Error::last_os_error().raw_os_error(),
338            Some(libc::EBADF)
339        );
340        assert_eq!(
341            held.file.metadata().unwrap().ino(),
342            fs::metadata(&f.socket).unwrap().ino()
343        );
344    }
345    #[test]
346    fn exact_bytes_and_socket_snapshot() {
347        let f = Fixture::new();
348        let held = ProtectedContext::load(&f.config).unwrap();
349        assert_eq!(held.snapshot().unwrap().1, fs::read(&f.config).unwrap());
350        assert_eq!(
351            held.config_sha256().unwrap(),
352            format!("{:x}", Sha256::digest(fs::read(&f.config).unwrap()))
353        );
354    }
355    #[test]
356    fn config_replacement_same_bytes_is_rejected() {
357        let f = Fixture::new();
358        let held = ProtectedContext::load(&f.config).unwrap();
359        let replacement = f.dir.path().join("new");
360        fs::write(&replacement, fs::read(&f.config).unwrap()).unwrap();
361        fs::set_permissions(&replacement, fs::Permissions::from_mode(0o600)).unwrap();
362        fs::rename(replacement, &f.config).unwrap();
363        assert!(held.snapshot().is_err());
364    }
365    #[test]
366    fn socket_replacement_is_rejected() {
367        let f = Fixture::new();
368        let held = ProtectedContext::load(&f.config).unwrap();
369        fs::remove_file(&f.socket).unwrap();
370        let _new = UnixListener::bind(&f.socket).unwrap();
371        fs::set_permissions(&f.socket, fs::Permissions::from_mode(0o600)).unwrap();
372        assert!(held.revalidate().is_err());
373    }
374    #[test]
375    fn config_socket_and_ancestor_permission_changes_rejected() {
376        for which in 0..3 {
377            let f = Fixture::new();
378            let held = ProtectedContext::load(&f.config).unwrap();
379            let path = match which {
380                0 => &f.config,
381                1 => &f.socket,
382                _ => f.dir.path(),
383            };
384            fs::set_permissions(path, fs::Permissions::from_mode(0o777)).unwrap();
385            assert!(held.revalidate().is_err());
386            assert!(ProtectedContext::load(&f.config).is_err());
387        }
388    }
389    #[test]
390    fn symlink_and_hardlink_config_refused() {
391        let f = Fixture::new();
392        let link = f.dir.path().join("link");
393        symlink(&f.config, &link).unwrap();
394        assert!(ProtectedContext::load(&link).is_err());
395        fs::remove_file(&link).unwrap();
396        fs::hard_link(&f.config, &link).unwrap();
397        assert!(ProtectedContext::load(&f.config).is_err());
398    }
399    #[test]
400    fn socket_symlink_and_regular_file_refused() {
401        for link in [false, true] {
402            let f = Fixture::new();
403            let held = ProtectedContext::load(&f.config).unwrap();
404            let old = f.dir.path().join("original.sock");
405            fs::rename(&f.socket, &old).unwrap();
406            if link {
407                symlink(&old, &f.socket).unwrap();
408            } else {
409                fs::write(&f.socket, b"not a socket").unwrap();
410            }
411            assert!(held.revalidate().is_err());
412            assert!(ProtectedContext::load(&f.config).is_err());
413        }
414    }
415    #[test]
416    fn changed_bytes_wrong_principal_metadata_and_duplicates_refused() {
417        for which in 0..4 {
418            let f = Fixture::new();
419            let held = ProtectedContext::load(&f.config).unwrap();
420            let mut v: serde_json::Value =
421                serde_json::from_slice(&fs::read(&f.config).unwrap()).unwrap();
422            let bytes = match which {
423                0 => {
424                    v["principal_uid"] = (u64::from(unsafe { libc::geteuid() }) + 1).into();
425                    serde_json::to_vec(&v).unwrap()
426                }
427                1 => {
428                    v["observation"]["socket"]["inode"] = 0.into();
429                    serde_json::to_vec(&v).unwrap()
430                }
431                2 => b"{}".to_vec(),
432                _ => serde_json::to_string(&v)
433                    .unwrap()
434                    .replacen(
435                        "\"operator_epoch\":",
436                        "\"operator_epoch\":\"bad\",\"operator_epoch\":",
437                        1,
438                    )
439                    .into_bytes(),
440            };
441            fs::write(&f.config, bytes).unwrap();
442            assert!(held.revalidate().is_err());
443            assert!(ProtectedContext::load(&f.config).is_err());
444        }
445    }
446    fn info(held: &ProtectedContext) -> Vec<u8> {
447        let (c, _) = held.snapshot().unwrap();
448        let o = &c.observation;
449        serde_json::to_vec(&serde_json::json!({"host":{"hostname":o.hostname,"security":{"rootless":o.rootless}},"store":{"graphRoot":o.graph_root,"runRoot":o.run_root},"version":{"APIVersion":o.selected_api,"Version":o.server_version}})).unwrap()
450    }
451    fn absence(id: &str) -> Vec<u8> {
452        serde_json::to_vec(&serde_json::json!({"cause":"no such container","message":format!("no container with name or ID \"{id}\" found: no such container"),"response":404})).unwrap()
453    }
454    #[test]
455    fn readonly_sequence_releases_result_only_after_final_info() {
456        let f = Fixture::new();
457        let held = ProtectedContext::load(&f.config).unwrap();
458        let body = info(&held);
459        let id = "a".repeat(64);
460        let mut routes = vec![];
461        let result = held
462            .inspect_with(&id, |route| {
463                routes.push(route.to_owned());
464                Ok(if routes.len() == 2 {
465                    (404, absence(&id))
466                } else {
467                    (200, body.clone())
468                })
469            })
470            .unwrap();
471        assert_eq!(result, super::super::native_status::InspectOutcome::Missing);
472        assert_eq!(
473            routes,
474            vec![
475                super::super::observation::INFO_ROUTE.to_string(),
476                super::super::native_status::inspect_route(&id).unwrap(),
477                super::super::observation::INFO_ROUTE.to_string()
478            ]
479        );
480    }
481    #[test]
482    fn readonly_info_failure_and_invalid_id_do_not_inspect() {
483        let f = Fixture::new();
484        let held = ProtectedContext::load(&f.config).unwrap();
485        let mut calls = 0;
486        assert!(held
487            .inspect_with("name", |_| {
488                calls += 1;
489                Ok((200, vec![]))
490            })
491            .is_err());
492        assert_eq!(calls, 0);
493        for status in [200, 401, 500] {
494            calls = 0;
495            assert!(held
496                .inspect_with(&"a".repeat(64), |_| {
497                    calls += 1;
498                    Ok((status, b"{}".to_vec()))
499                })
500                .is_err());
501            assert_eq!(calls, 1);
502        }
503    }
504    #[test]
505    fn readonly_final_failure_or_observation_change_withholds_absence() {
506        for change in 0..3 {
507            let f = Fixture::new();
508            let held = ProtectedContext::load(&f.config).unwrap();
509            let body = info(&held);
510            let id = "a".repeat(64);
511            let mut calls = 0;
512            assert!(held
513                .inspect_with(&id, |_| {
514                    calls += 1;
515                    match calls {
516                        1 => Ok((200, body.clone())),
517                        2 => Ok((404, absence(&id))),
518                        _ => match change {
519                            0 => Err("timeout"),
520                            1 => Ok((500, body.clone())),
521                            _ => {
522                                let mut v: serde_json::Value =
523                                    serde_json::from_slice(&body).unwrap();
524                                v["host"]["hostname"] = "other".into();
525                                Ok((200, serde_json::to_vec(&v).unwrap()))
526                            }
527                        },
528                    }
529                })
530                .is_err());
531            assert_eq!(calls, 3);
532        }
533    }
534    #[test]
535    fn readonly_revalidates_after_each_exchange_without_retry() {
536        for boundary in 1..=3 {
537            let f = Fixture::new();
538            let held = ProtectedContext::load(&f.config).unwrap();
539            let body = info(&held);
540            let id = "a".repeat(64);
541            let mut calls = 0;
542            assert!(held
543                .inspect_with(&id, |_| {
544                    calls += 1;
545                    if calls == boundary {
546                        fs::set_permissions(&f.config, fs::Permissions::from_mode(0o644)).unwrap();
547                    }
548                    Ok(if calls == 2 {
549                        (404, absence(&id))
550                    } else {
551                        (200, body.clone())
552                    })
553                })
554                .is_err());
555            assert_eq!(calls, boundary);
556        }
557    }
558    #[test]
559    fn readonly_initial_observation_mismatch_stops_before_inspect() {
560        let f = Fixture::new();
561        let held = ProtectedContext::load(&f.config).unwrap();
562        let mut v: serde_json::Value = serde_json::from_slice(&info(&held)).unwrap();
563        v["store"]["runRoot"] = "/run/other".into();
564        let mut calls = 0;
565        assert!(held
566            .inspect_with(&"a".repeat(64), |_| {
567                calls += 1;
568                Ok((200, serde_json::to_vec(&v).unwrap()))
569            })
570            .is_err());
571        assert_eq!(calls, 1);
572    }
573    #[test]
574    fn readonly_socket_change_at_each_boundary_rejects_result() {
575        for boundary in 1..=3 {
576            let f = Fixture::new();
577            let held = ProtectedContext::load(&f.config).unwrap();
578            let body = info(&held);
579            let id = "a".repeat(64);
580            let mut calls = 0;
581            assert!(held
582                .inspect_with(&id, |_| {
583                    calls += 1;
584                    if calls == boundary {
585                        fs::set_permissions(&f.socket, fs::Permissions::from_mode(0o660)).unwrap();
586                    }
587                    Ok(if calls == 2 {
588                        (404, absence(&id))
589                    } else {
590                        (200, body.clone())
591                    })
592                })
593                .is_err());
594            assert_eq!(calls, boundary);
595        }
596    }
597    #[test]
598    fn readonly_inspect_failure_does_not_retry() {
599        let f = Fixture::new();
600        let held = ProtectedContext::load(&f.config).unwrap();
601        let body = info(&held);
602        let mut calls = 0;
603        assert!(held
604            .inspect_with(&"a".repeat(64), |_| {
605                calls += 1;
606                if calls == 1 {
607                    Ok((200, body.clone()))
608                } else {
609                    Err("cancelled")
610                }
611            })
612            .is_err());
613        assert_eq!(calls, 2);
614    }
615    #[test]
616    fn readonly_present_is_also_finally_checked() {
617        let f = Fixture::new();
618        let held = ProtectedContext::load(&f.config).unwrap();
619        let body = info(&held);
620        let id = "a".repeat(64);
621        let mut calls = 0;
622        let result=held.inspect_with(&id, |_|{calls+=1;Ok((200,if calls==2{serde_json::to_vec(&serde_json::json!({"Id":id,"State":{"Status":"initialized","Running":false,"Paused":false,"Restarting":false,"Dead":false,"ExitCode":0}})).unwrap()}else{body.clone()}))}).unwrap();
623        assert_eq!(calls, 3);
624        assert!(
625            matches!(result,super::super::native_status::InspectOutcome::Present(state) if state.status=="initialized")
626        );
627    }
628    #[test]
629    fn ownership_composition_binds_body_and_final_observation() {
630        use super::super::ownership::ExpectedContainer;
631        use std::collections::BTreeMap;
632        for failure in 0..3 {
633            let f = Fixture::new();
634            let held = ProtectedContext::load(&f.config).unwrap();
635            let info_body = info(&held);
636            let owner = ExpectedContainer {
637                id: "a".repeat(64),
638                name: "fixture".into(),
639                image_id: "b".repeat(64),
640                labels: BTreeMap::from([("lease".into(), "owned".into())]),
641                port: None,
642                image_exposed_ports: Default::default(),
643            };
644            let mut object = serde_json::json!({"Id":owner.id,"Name":owner.name,"Image":owner.image_id,"Config":{"Labels":owner.labels},"HostConfig":{"PortBindings":{},"Privileged":false,"AutoRemove":false,"AutoRemoveImage":false,"RestartPolicy":{"Name":"no","MaximumRetryCount":0}},"NetworkSettings":{"Ports":{}},"State":{"Status":"running","Running":true,"Paused":false,"Restarting":false,"Dead":false,"ExitCode":0}});
645            if failure == 1 {
646                object["Config"]["Labels"]["lease"] = "foreign".into();
647            }
648            let mut calls = 0;
649            let result = held.inspect_owned_with(&owner, |_| {
650                calls += 1;
651                if failure == 2 && calls == 3 {
652                    return Err("timeout");
653                }
654                Ok((
655                    200,
656                    if calls == 2 {
657                        serde_json::to_vec(&object).unwrap()
658                    } else {
659                        info_body.clone()
660                    },
661                ))
662            });
663            if failure == 0 {
664                assert_eq!(result.unwrap().status, "running");
665                assert_eq!(calls, 3);
666            } else {
667                assert!(result.is_err());
668                assert_eq!(calls, if failure == 1 { 2 } else { 3 });
669            }
670        }
671    }
672    #[test]
673    fn five_seven_readonly_capture_routes_and_rejects_mutation_adjacent_reads() {
674        use super::super::{
675            ownership::ExpectedContainer,
676            plan_record::{PlanInputs, PlanRecord},
677            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
678        };
679        use std::collections::{BTreeMap, BTreeSet};
680        let f = Fixture::new_api("5.7.0");
681        let held = ProtectedContext::load(&f.config).unwrap();
682        let info_body = info(&held);
683        let id = "b".repeat(64);
684        let raw = serde_json::to_vec(&serde_json::json!({"Id":id,"Config":{}})).unwrap();
685        let mut routes = vec![];
686        let captured = held
687            .capture_image_for_plan(&id, |route| {
688                routes.push(route.to_owned());
689                Ok((
690                    200,
691                    if routes.len() == 2 {
692                        raw.clone()
693                    } else {
694                        info_body.clone()
695                    },
696                ))
697            })
698            .unwrap();
699        assert_eq!(
700            routes,
701            vec![
702                "/v5.7.0/libpod/info".to_string(),
703                format!("/v5.7.0/libpod/images/{id}/json"),
704                "/v5.7.0/libpod/info".to_string(),
705            ]
706        );
707        let identity = SlotIdentity {
708            profile: Profile {
709                protocol: PROTOCOL.into(),
710                product: PRODUCT.into(),
711                version: VERSION.into(),
712                engine: "podman".into(),
713            },
714            installation_sha256: "a".repeat(64),
715            executable_sha256: "c".repeat(64),
716            config_sha256: held.config_sha256().unwrap(),
717        };
718        let record = PlanRecord::capture(
719            &held,
720            &captured,
721            &identity,
722            PlanInputs {
723                name: "fixture".into(),
724                labels: [("owner".into(), "fixture".into())].into(),
725                port: None,
726            },
727        )
728        .unwrap();
729        let bytes = record.encode().unwrap();
730        PlanRecord::decode(&bytes, &held).unwrap();
731        let other = ProtectedContext::load(&Fixture::new().config).unwrap();
732        assert!(PlanRecord::decode(&bytes, &other).is_err());
733        let owner = ExpectedContainer {
734            id: "a".repeat(64),
735            name: "fixture".into(),
736            image_id: id.clone(),
737            labels: BTreeMap::from([("owner".into(), "fixture".into())]),
738            port: None,
739            image_exposed_ports: BTreeSet::new(),
740        };
741        let mut calls = 0;
742        assert!(held
743            .inspect_with(&"a".repeat(64), |_| {
744                calls += 1;
745                Ok((200, vec![]))
746            })
747            .is_err());
748        assert_eq!(calls, 0);
749        assert!(held
750            .inspect_owned_with(&owner, |_| {
751                calls += 1;
752                Ok((200, vec![]))
753            })
754            .is_err());
755        assert_eq!(calls, 0);
756    }
757
758    #[test]
759    fn image_metadata_is_withheld_until_final_protected_observation() {
760        for failure in 0..3 {
761            let f = Fixture::new();
762            let held = ProtectedContext::load(&f.config).unwrap();
763            let info_body = info(&held);
764            let id = "b".repeat(64);
765            let mut routes = vec![];
766            let image = serde_json::json!({"Id":id,"Labels":{"image":"value"},"Config":{"Labels":{"image":"value"},"ExposedPorts":{"80/tcp":{}}}});
767            let result = held.capture_image_for_plan(&id, |route| {
768                routes.push(route.to_string());
769                if failure == 1 && routes.len() == 2 {
770                    return Ok((404, b"{}".to_vec()));
771                }
772                if failure == 2 && routes.len() == 3 {
773                    return Err("cancelled");
774                }
775                Ok((
776                    200,
777                    if routes.len() == 2 {
778                        serde_json::to_vec(&image).unwrap()
779                    } else {
780                        info_body.clone()
781                    },
782                ))
783            });
784            if failure == 0 {
785                let facts = result.unwrap();
786                assert_eq!(facts.facts().id(), id);
787                assert_eq!(routes.len(), 3);
788            } else {
789                assert!(result.is_err());
790                assert_eq!(routes.len(), if failure == 1 { 2 } else { 3 });
791            }
792            assert_eq!(
793                routes[1],
794                super::super::native_image::image_route(&id).unwrap()
795            );
796        }
797    }
798    #[test]
799    fn capture_retains_exact_bytes_and_refuses_another_context() {
800        let f = Fixture::new();
801        let held = ProtectedContext::load(&f.config).unwrap();
802        let id = "b".repeat(64);
803        let raw = format!("{{ \"Id\":\"{id}\",\"Config\":{{}} }}").into_bytes();
804        let info = info(&held);
805        let mut calls = 0;
806        let captured = held
807            .capture_image_for_plan(&id, |_| {
808                calls += 1;
809                Ok((
810                    200,
811                    if calls == 2 {
812                        raw.clone()
813                    } else {
814                        info.clone()
815                    },
816                ))
817            })
818            .unwrap();
819        assert_eq!(calls, 3);
820        assert_eq!(captured.image_bytes(), raw);
821        assert_eq!(captured.config_bytes(), held.snapshot().unwrap().1);
822        assert_eq!(
823            captured.operator_epoch(),
824            held.snapshot().unwrap().0.operator_epoch
825        );
826        assert_eq!(captured.facts().id(), id);
827        captured.match_context(&held).unwrap();
828        use super::super::{
829            plan_record::{PlanInputs, PlanRecord},
830            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
831        };
832        use sha2::{Digest, Sha256};
833        let identity = SlotIdentity {
834            profile: Profile {
835                protocol: PROTOCOL.into(),
836                product: PRODUCT.into(),
837                version: VERSION.into(),
838                engine: "podman".into(),
839            },
840            installation_sha256: "a".repeat(64),
841            executable_sha256: "c".repeat(64),
842            config_sha256: format!("{:x}", Sha256::digest(captured.config_bytes())),
843        };
844        let inputs = PlanInputs {
845            name: "fixture".into(),
846            labels: [("owner".into(), "fixture".into())].into(),
847            port: None,
848        };
849        let record = PlanRecord::capture(&held, &captured, &identity, inputs.clone()).unwrap();
850        let bytes = record.encode().unwrap();
851        let reopened = PlanRecord::decode(&bytes, &held).unwrap();
852        let substituted = f.config.parent().unwrap().join("substituted-record");
853        let replacement = String::from_utf8(bytes.clone())
854            .unwrap()
855            .replace(&"a".repeat(64), &"f".repeat(64));
856        // This is independently valid evidence, so only exact intended-byte
857        // comparison rejects substitution, not ordinary record validation.
858        PlanRecord::decode(replacement.as_bytes(), &held).unwrap();
859        assert_eq!(
860            record
861                .create_checked(&substituted, &held, || {
862                    std::fs::write(&substituted, replacement.as_bytes()).unwrap();
863                    Ok(())
864                })
865                .unwrap_err(),
866            "created record differs from intended evidence"
867        );
868        assert_eq!(std::fs::read(&substituted).unwrap(), replacement.as_bytes());
869        let disk = f.config.parent().unwrap().join("plan-record");
870        record.create_new(&disk, &held).unwrap();
871        assert_eq!(
872            PlanRecord::open(&disk, &held).unwrap().encode().unwrap(),
873            bytes
874        );
875        assert!(record.create_new(&disk, &held).is_err());
876        std::fs::set_permissions(&disk, std::fs::Permissions::from_mode(0o644)).unwrap();
877        assert!(PlanRecord::open(&disk, &held).is_err());
878        std::fs::set_permissions(&disk, std::fs::Permissions::from_mode(0o600)).unwrap();
879        let linked = disk.with_extension("link");
880        std::fs::hard_link(&disk, &linked).unwrap();
881        assert!(PlanRecord::open(&disk, &held).is_err());
882        std::fs::remove_file(&linked).unwrap();
883        std::fs::write(&disk, b"{").unwrap();
884        assert!(PlanRecord::open(&disk, &held).is_err());
885
886        assert_eq!(reopened.plan_bytes(), record.plan_bytes());
887        assert!(!reopened.installation_verified());
888        assert_eq!(format!("{captured:?}"), "CapturedImage { .. }");
889        let original: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
890        for field in [
891            "schema",
892            "operator_epoch",
893            "config_json",
894            "config_sha256",
895            "image_id",
896            "image_json",
897            "image_sha256",
898            "plan_json",
899            "plan_sha256",
900        ] {
901            let pattern = format!(
902                "\"{field}\":{}",
903                serde_json::to_string(&original[field]).unwrap()
904            );
905            let changed = String::from_utf8(bytes.clone()).unwrap().replacen(
906                &pattern,
907                &format!("\"{field}\":\"altered\""),
908                1,
909            );
910            assert!(
911                PlanRecord::decode(changed.as_bytes(), &held).is_err(),
912                "{field}"
913            );
914        }
915        let mut trailing = bytes.clone();
916        trailing.push(b' ');
917        assert!(PlanRecord::decode(&trailing, &held).is_err());
918        assert!(PlanRecord::decode(&bytes[..bytes.len() - 1], &held).is_err());
919        let text = String::from_utf8(bytes.clone()).unwrap();
920        let wrong_profile = text.replace("podman-lifecycle", "docker-lifecycle");
921        assert!(PlanRecord::decode(wrong_profile.as_bytes(), &held).is_err());
922        let wrong_inputs = text.replace("\"name\":\"fixture\"", "\"name\":\"other\"");
923        assert_ne!(wrong_inputs, text);
924        assert!(PlanRecord::decode(wrong_inputs.as_bytes(), &held).is_err());
925        let duplicate_labels = text.replace("\"labels\":{", "\"labels\":{\"owner\":\"duplicate\",");
926        assert!(PlanRecord::decode(duplicate_labels.as_bytes(), &held).is_err());
927        let asserted_change = text.replace(&"a".repeat(64), &"f".repeat(64));
928        assert!(!PlanRecord::decode(asserted_change.as_bytes(), &held)
929            .unwrap()
930            .installation_verified());
931        let malformed_assertion = text.replace(&"a".repeat(64), "invalid");
932        assert!(PlanRecord::decode(malformed_assertion.as_bytes(), &held).is_err());
933        assert!(PlanRecord::decode(&vec![b' '; 16 * 1024 * 1024 + 1], &held).is_err());
934        let duplicate = text.replacen("{", "{\"schema\":\"extra\",", 1);
935        assert!(PlanRecord::decode(duplicate.as_bytes(), &held).is_err());
936        let unknown = text.replacen("{", "{\"unknown\":0,", 1);
937        assert!(PlanRecord::decode(unknown.as_bytes(), &held).is_err());
938        let mut wrong_identity = identity.clone();
939        wrong_identity.config_sha256 = "d".repeat(64);
940        assert!(PlanRecord::capture(&held, &captured, &wrong_identity, inputs).is_err());
941
942        let other = Fixture::new();
943        assert!(
944            PlanRecord::decode(&bytes, &ProtectedContext::load(&other.config).unwrap()).is_err()
945        );
946        assert!(captured
947            .match_context(&ProtectedContext::load(&other.config).unwrap())
948            .is_err());
949    }
950    fn evidence_fixture() -> (
951        Fixture,
952        ProtectedContext,
953        super::super::plan_record::PlanRecord,
954    ) {
955        use super::super::{
956            plan_record::{PlanInputs, PlanRecord},
957            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
958        };
959        let f = Fixture::new();
960        let held = ProtectedContext::load(&f.config).unwrap();
961        let id = "b".repeat(64);
962        let raw = serde_json::to_vec(
963            &serde_json::json!({"Id":id,"Config":{"ExposedPorts":{"80/tcp":{}}}}),
964        )
965        .unwrap();
966        let info = info(&held);
967        let mut calls = 0;
968        let captured = held
969            .capture_image_for_plan(&id, |_| {
970                calls += 1;
971                Ok((
972                    200,
973                    if calls == 2 {
974                        raw.clone()
975                    } else {
976                        info.clone()
977                    },
978                ))
979            })
980            .unwrap();
981        let identity = SlotIdentity {
982            profile: Profile {
983                protocol: PROTOCOL.into(),
984                product: PRODUCT.into(),
985                version: VERSION.into(),
986                engine: "podman".into(),
987            },
988            installation_sha256: "a".repeat(64),
989            executable_sha256: "c".repeat(64),
990            config_sha256: held.config_sha256().unwrap(),
991        };
992        let record = PlanRecord::capture(
993            &held,
994            &captured,
995            &identity,
996            PlanInputs {
997                name: "fixture".into(),
998                labels: [("owner".into(), "fixture".into())].into(),
999                port: None,
1000            },
1001        )
1002        .unwrap();
1003        (f, held, record)
1004    }
1005    fn replace_record_field(bytes: &[u8], field: &str, value: serde_json::Value) -> Vec<u8> {
1006        let original: serde_json::Value = serde_json::from_slice(bytes).unwrap();
1007        let old = format!(
1008            "\"{field}\":{}",
1009            serde_json::to_string(&original[field]).unwrap()
1010        );
1011        let new = format!("\"{field}\":{}", serde_json::to_string(&value).unwrap());
1012        let text = String::from_utf8(bytes.to_vec()).unwrap();
1013        assert!(text.contains(&old));
1014        text.replacen(&old, &new, 1).into_bytes()
1015    }
1016    #[test]
1017    fn evidence_exposure_change_requires_hash_but_is_not_authenticity() {
1018        use super::super::plan_record::PlanRecord;
1019        let (_f, held, record) = evidence_fixture();
1020        let bytes = record.encode().unwrap();
1021        let original: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1022        let image = original["image_json"]
1023            .as_str()
1024            .unwrap()
1025            .replace("80/tcp", "81/tcp");
1026        let changed = replace_record_field(&bytes, "image_json", serde_json::json!(image));
1027        assert!(PlanRecord::decode(&changed, &held).is_err());
1028        // Recomputed unkeyed hashes yield internally consistent evidence. They
1029        // cannot authenticate EXPOSE, which does not change native create body.
1030        let consistent = replace_record_field(
1031            &changed,
1032            "image_sha256",
1033            serde_json::json!(format!("{:x}", Sha256::digest(image.as_bytes()))),
1034        );
1035        let reopened = PlanRecord::decode(&consistent, &held).unwrap();
1036        assert_eq!(reopened.plan_bytes(), record.plan_bytes());
1037        assert!(!reopened.installation_verified());
1038    }
1039    #[test]
1040    fn evidence_nested_schema_and_decoded_bounds_refuse() {
1041        use super::super::plan_record::PlanRecord;
1042        let (_f, held, record) = evidence_fixture();
1043        let bytes = record.encode().unwrap();
1044        let text = String::from_utf8(bytes.clone()).unwrap();
1045        for (needle, replacement) in [
1046            ("\"profile\":{", "\"profile\":{\"engine\":\"podman\","),
1047            ("\"inputs\":{", "\"inputs\":{\"extra\":true,"),
1048            ("\"labels\":{", "\"labels\":{\"owner\":\"duplicate\","),
1049        ] {
1050            let altered = text.replacen(needle, replacement, 1);
1051            assert_ne!(altered, text);
1052            assert!(PlanRecord::decode(altered.as_bytes(), &held).is_err());
1053        }
1054        for field in ["config_json", "image_json"] {
1055            let altered = replace_record_field(
1056                &bytes,
1057                field,
1058                serde_json::json!(" ".repeat(1024 * 1024 + 1)),
1059            );
1060            assert!(PlanRecord::decode(&altered, &held).is_err());
1061        }
1062        let oversized_label = text.replacen(
1063            "\"owner\":\"fixture\"",
1064            &format!("\"owner\":\"{}\"", "x".repeat(4097)),
1065            1,
1066        );
1067        assert_ne!(oversized_label, text);
1068        assert!(PlanRecord::decode(oversized_label.as_bytes(), &held).is_err());
1069    }
1070    #[test]
1071    fn evidence_postcheck_replacement_remains_possible_and_non_authoritative() {
1072        use super::super::plan_record::PlanRecord;
1073        let (f, held, record) = evidence_fixture();
1074        let path = f.config.parent().unwrap().join("postcheck");
1075        record.create_new(&path, &held).unwrap();
1076        let intended = record.encode().unwrap();
1077        let replacement = replace_record_field(
1078            &intended,
1079            "asserted_installation_sha256",
1080            serde_json::json!("f".repeat(64)),
1081        );
1082        // Deterministic ordering after final return, no sleep/race timing.
1083        fs::write(&path, &replacement).unwrap();
1084        let reopened = PlanRecord::open(&path, &held).unwrap();
1085        assert_ne!(reopened.encode().unwrap(), intended);
1086        assert!(!reopened.installation_verified());
1087    }
1088    #[test]
1089    fn versioned_context_retains_wrapper_and_refuses_raw_record_adoption() {
1090        use super::super::plan_record::PlanRecord;
1091        let (f, raw, record) = evidence_fixture();
1092        let old_bytes = record.encode().unwrap();
1093        assert!(!raw.is_versioned());
1094        assert!(ProtectedContext::load_versioned(&f.config).is_err());
1095        let wrapper = VersionedContext {
1096            schema: CONTEXT_SCHEMA.into(),
1097            context: raw.snapshot().unwrap().0.clone(),
1098        };
1099        let bytes = serde_json::to_vec(&wrapper).unwrap();
1100        fs::write(&f.config, &bytes).unwrap();
1101        assert!(raw.revalidate().is_err());
1102        assert!(ProtectedContext::load(&f.config).is_err());
1103        let held = ProtectedContext::load_versioned(&f.config).unwrap();
1104        assert!(held.is_versioned());
1105        assert_eq!(held.snapshot().unwrap().1, bytes);
1106        assert_eq!(
1107            held.config_sha256().unwrap(),
1108            format!("{:x}", Sha256::digest(&bytes))
1109        );
1110        assert!(PlanRecord::decode(&old_bytes, &held).is_err());
1111        let info = info(&held);
1112        let mut calls = 0;
1113        let captured = held
1114            .capture_image_for_plan(&"b".repeat(64), |_| {
1115                calls += 1;
1116                Ok((
1117                    200,
1118                    if calls == 2 {
1119                        serde_json::to_vec(&serde_json::json!({"Id":"b".repeat(64),"Config":{}}))
1120                            .unwrap()
1121                    } else {
1122                        info.clone()
1123                    },
1124                ))
1125            })
1126            .unwrap();
1127        assert_eq!(captured.config_bytes(), bytes);
1128    }
1129    #[test]
1130    fn versioned_context_rejects_schema_duplicate_unknown_and_whitespace() {
1131        let f = Fixture::new();
1132        let raw = ProtectedContext::load(&f.config).unwrap();
1133        let wrapper = VersionedContext {
1134            schema: CONTEXT_SCHEMA.into(),
1135            context: raw.snapshot().unwrap().0.clone(),
1136        };
1137        let text = serde_json::to_string(&wrapper).unwrap();
1138        for bad in [
1139            text.replace(CONTEXT_SCHEMA, "other"),
1140            format!("{text} "),
1141            text.replacen("{", "{\"extra\":0,", 1),
1142            text.replacen("{", "{\"schema\":\"duplicate\",", 1),
1143        ] {
1144            fs::write(&f.config, bad).unwrap();
1145            assert!(ProtectedContext::load_versioned(&f.config).is_err());
1146        }
1147    }
1148    #[test]
1149    fn held_versioned_cursor_independence_and_record_roundtrip() {
1150        use super::super::{
1151            plan_record::{PlanInputs, PlanRecord},
1152            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
1153        };
1154        use std::io::{Seek, SeekFrom};
1155        let f = Fixture::new();
1156        let raw = ProtectedContext::load(&f.config).unwrap();
1157        let bytes = serde_json::to_vec(&VersionedContext {
1158            schema: CONTEXT_SCHEMA.into(),
1159            context: raw.snapshot().unwrap().0.clone(),
1160        })
1161        .unwrap();
1162        fs::write(&f.config, &bytes).unwrap();
1163        let mut file = File::open(&f.config).unwrap();
1164        file.seek(SeekFrom::End(0)).unwrap();
1165        let held = ProtectedContext::from_held_versioned(&f.config, file).unwrap();
1166        assert_eq!(held.snapshot().unwrap().1, bytes);
1167        let info = info(&held);
1168        let mut calls = 0;
1169        let id = "b".repeat(64);
1170        let captured = held
1171            .capture_image_for_plan(&id, |_| {
1172                calls += 1;
1173                Ok((
1174                    200,
1175                    if calls == 2 {
1176                        serde_json::to_vec(&serde_json::json!({"Id":id,"Config":{}})).unwrap()
1177                    } else {
1178                        info.clone()
1179                    },
1180                ))
1181            })
1182            .unwrap();
1183        let identity = SlotIdentity {
1184            profile: Profile {
1185                protocol: PROTOCOL.into(),
1186                product: PRODUCT.into(),
1187                version: VERSION.into(),
1188                engine: "podman".into(),
1189            },
1190            installation_sha256: "a".repeat(64),
1191            executable_sha256: "c".repeat(64),
1192            config_sha256: held.config_sha256().unwrap(),
1193        };
1194        let record = PlanRecord::capture(
1195            &held,
1196            &captured,
1197            &identity,
1198            PlanInputs {
1199                name: "fixture".into(),
1200                labels: [("owner".into(), "fixture".into())].into(),
1201                port: None,
1202            },
1203        )
1204        .unwrap();
1205        let path = f.dir.path().join("versioned-record");
1206        record.create_new(&path, &held).unwrap();
1207        let reopened = PlanRecord::open(&path, &held).unwrap();
1208        assert_eq!(reopened.encode().unwrap(), record.encode().unwrap());
1209        assert!(!reopened.installation_verified());
1210    }
1211    #[test]
1212    fn held_versioned_rejects_wrong_inode_replaced_path_and_writable_descriptor() {
1213        let f = Fixture::new();
1214        let raw = ProtectedContext::load(&f.config).unwrap();
1215        let bytes = serde_json::to_vec(&VersionedContext {
1216            schema: CONTEXT_SCHEMA.into(),
1217            context: raw.snapshot().unwrap().0.clone(),
1218        })
1219        .unwrap();
1220        fs::write(&f.config, &bytes).unwrap();
1221        let writable = OpenOptions::new()
1222            .read(true)
1223            .write(true)
1224            .open(&f.config)
1225            .unwrap();
1226        assert!(ProtectedContext::from_held_versioned(&f.config, writable).is_err());
1227        let old = File::open(&f.config).unwrap();
1228        fs::rename(&f.config, f.dir.path().join("original")).unwrap();
1229        fs::write(&f.config, &bytes).unwrap();
1230        fs::set_permissions(&f.config, fs::Permissions::from_mode(0o600)).unwrap();
1231        assert!(ProtectedContext::from_held_versioned(&f.config, old).is_err());
1232        let other = Fixture::new();
1233        assert!(ProtectedContext::from_held_versioned(
1234            &f.config,
1235            File::open(&other.config).unwrap()
1236        )
1237        .is_err());
1238    }
1239    fn installed_fixture() -> (Fixture, PathBuf) {
1240        let f = Fixture::new();
1241        let raw = ProtectedContext::load(&f.config).unwrap();
1242        let config = serde_json::to_vec(&VersionedContext {
1243            schema: CONTEXT_SCHEMA.into(),
1244            context: raw.snapshot().unwrap().0.clone(),
1245        })
1246        .unwrap();
1247        fs::write(&f.config, &config).unwrap();
1248        let exe = f.dir.path().join("adapter");
1249        fs::write(&exe, b"\x7fELFfake-no-execution").unwrap();
1250        fs::set_permissions(
1251            &exe,
1252            fs::Permissions::from_mode(if unsafe { libc::geteuid() } == 0 {
1253                0o755
1254            } else {
1255                0o700
1256            }),
1257        )
1258        .unwrap();
1259        let descriptor=format!("{{\"schema\":\"gate-runtime/podman-installation-v1\",\"product\":\"podman-lifecycle\",\"version\":\"0.2.0\",\"protocol\":\"gate-runtime/lifecycle-v2\",\"executable_path\":{},\"executable_sha256\":\"{:x}\",\"config_schema\":\"{}\"}}",serde_json::to_string(&exe).unwrap(),Sha256::digest(fs::read(&exe).unwrap()),CONTEXT_SCHEMA);
1260        let install = f.dir.path().join("installation.json");
1261        fs::write(&install, &descriptor).unwrap();
1262        fs::set_permissions(&install, fs::Permissions::from_mode(0o600)).unwrap();
1263        let host=format!("{{\"schema\":\"gate-runtime/podman-host-v1\",\"installation_path\":{},\"installation_sha256\":\"{:x}\",\"config_path\":{},\"config_sha256\":\"{:x}\"}}",serde_json::to_string(&install).unwrap(),Sha256::digest(descriptor.as_bytes()),serde_json::to_string(&f.config).unwrap(),Sha256::digest(&config));
1264        let path = f.dir.path().join("host.json");
1265        fs::write(&path, host).unwrap();
1266        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1267        (f, path)
1268    }
1269    #[test]
1270    fn held_installation_record_match_revalidates_without_upgrading_record() {
1271        use super::super::{
1272            installation::VerifiedPodmanInstallation,
1273            plan_record::{PlanInputs, PlanRecord},
1274        };
1275        let (f, path) = installed_fixture();
1276        let installed = VerifiedPodmanInstallation::load(&path).unwrap();
1277        let context = installed.context().unwrap();
1278        assert!(context.is_versioned());
1279        let info = info(context);
1280        let mut calls = 0;
1281        let id = "b".repeat(64);
1282        let capture = context
1283            .capture_image_for_plan(&id, |_| {
1284                calls += 1;
1285                Ok((
1286                    200,
1287                    if calls == 2 {
1288                        serde_json::to_vec(&serde_json::json!({"Id":id,"Config":{}})).unwrap()
1289                    } else {
1290                        info.clone()
1291                    },
1292                ))
1293            })
1294            .unwrap();
1295        let inputs = super::super::plan_record::PlanInputs {
1296            name: "fixture".into(),
1297            labels: [("owner".into(), "fixture".into())].into(),
1298            port: None,
1299        };
1300        let record = PlanRecord::capture(
1301            context,
1302            &capture,
1303            installed.identity().unwrap(),
1304            inputs.clone(),
1305        )
1306        .unwrap();
1307        installed
1308            .match_record(&record)
1309            .unwrap()
1310            .revalidate()
1311            .unwrap();
1312        assert!(!record.installation_verified());
1313        let mut mismatch = installed.identity().unwrap().clone();
1314        mismatch.executable_sha256 = "f".repeat(64);
1315        let wrong =
1316            PlanRecord::capture(context, &capture, &mismatch, PlanInputs { ..inputs }).unwrap();
1317        assert!(installed.match_record(&wrong).is_err());
1318        let matched = installed.match_record(&record).unwrap();
1319        fs::write(f.dir.path().join("adapter"), b"\x7fELFreplaced").unwrap();
1320        assert!(matched.revalidate().is_err());
1321        assert!(!record.installation_verified());
1322    }
1323    #[test]
1324    fn installation_substitution_modes_schema_and_digests_refuse() {
1325        use super::super::installation::VerifiedPodmanInstallation;
1326        for case in 0..7 {
1327            let (f, path) = installed_fixture();
1328            match case {
1329                0 => fs::set_permissions(
1330                    f.dir.path().join("adapter"),
1331                    fs::Permissions::from_mode(0o777),
1332                )
1333                .unwrap(),
1334                1 => {
1335                    fs::write(f.dir.path().join("adapter"), b"not ELF").unwrap();
1336                }
1337                2 => {
1338                    fs::write(&f.config, b"{}").unwrap();
1339                }
1340                3 => {
1341                    let text = fs::read_to_string(&path)
1342                        .unwrap()
1343                        .replace("podman-host-v1", "docker-host-v1");
1344                    fs::write(&path, text).unwrap();
1345                }
1346                4 => {
1347                    fs::hard_link(f.dir.path().join("adapter"), f.dir.path().join("alias"))
1348                        .unwrap();
1349                }
1350                5 => {
1351                    let text = fs::read_to_string(&path).unwrap();
1352                    fs::write(&path, format!("{text} ")).unwrap();
1353                }
1354                6 => {
1355                    let exe = f.dir.path().join("adapter");
1356                    fs::rename(&exe, f.dir.path().join("real-adapter")).unwrap();
1357                    std::os::unix::fs::symlink(f.dir.path().join("real-adapter"), &exe).unwrap();
1358                }
1359                _ => unreachable!(),
1360            }
1361            assert!(
1362                VerifiedPodmanInstallation::load(&path).is_err(),
1363                "case {case}"
1364            );
1365        }
1366    }
1367    #[test]
1368    fn held_config_rejects_opath_no_cloexec_hardlink_and_symlink() {
1369        for case in 0..4 {
1370            let (f, _) = installed_fixture();
1371            let file = if case == 0 {
1372                let path = std::ffi::CString::new(f.config.as_os_str().as_bytes()).unwrap();
1373                let fd = unsafe { libc::open(path.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
1374                assert!(fd >= 0);
1375                assert_ne!(unsafe { libc::fcntl(fd, libc::F_GETFL) } & libc::O_PATH, 0);
1376                unsafe { File::from_raw_fd(fd) }
1377            } else {
1378                File::open(&f.config).unwrap()
1379            };
1380            if case == 1 {
1381                assert_eq!(
1382                    unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, 0) },
1383                    0
1384                );
1385            }
1386            if case == 2 {
1387                fs::hard_link(&f.config, f.dir.path().join("config-link")).unwrap();
1388            }
1389            if case == 3 {
1390                fs::rename(&f.config, f.dir.path().join("config-original")).unwrap();
1391                std::os::unix::fs::symlink(f.dir.path().join("config-original"), &f.config)
1392                    .unwrap();
1393            }
1394            assert!(ProtectedContext::from_held_versioned(&f.config, file).is_err());
1395        }
1396    }
1397    #[test]
1398    fn every_held_installation_member_replacement_is_rejected() {
1399        use super::super::installation::VerifiedPodmanInstallation;
1400        for name in ["host.json", "installation.json", "context.json", "adapter"] {
1401            let (f, path) = installed_fixture();
1402            let installed = VerifiedPodmanInstallation::load(&path).unwrap();
1403            let member = f.dir.path().join(name);
1404            let bytes = fs::read(&member).unwrap();
1405            let mode = fs::metadata(&member).unwrap().permissions();
1406            fs::rename(&member, f.dir.path().join("old-member")).unwrap();
1407            fs::write(&member, bytes).unwrap();
1408            fs::set_permissions(&member, mode).unwrap();
1409            assert!(installed.revalidate().is_err(), "{name}");
1410        }
1411    }
1412    #[test]
1413    fn capture_request_matches_all_operator_fields_and_exact_image() {
1414        use super::super::plan_record::{PlanInputs, PlanPort};
1415        let (_f, _held, record) = evidence_fixture();
1416        let inputs = PlanInputs {
1417            name: "fixture".into(),
1418            labels: [("owner".into(), "fixture".into())].into(),
1419            port: None,
1420        };
1421        let id = "b".repeat(64);
1422        record.match_capture_request(&id, &inputs).unwrap();
1423        assert!(record
1424            .match_capture_request(&"c".repeat(64), &inputs)
1425            .is_err());
1426        for case in 0..4 {
1427            let mut changed = inputs.clone();
1428            match case {
1429                0 => changed.name = "another".into(),
1430                1 => {
1431                    changed.labels.insert("owner".into(), "another".into());
1432                }
1433                2 => changed.labels.clear(),
1434                3 => {
1435                    changed.port = Some(PlanPort {
1436                        container: 80,
1437                        host: 8080,
1438                    })
1439                }
1440                _ => unreachable!(),
1441            };
1442            assert!(record.match_capture_request(&id, &changed).is_err());
1443        }
1444    }
1445    #[test]
1446    fn capture_wire_roundtrip_and_binding_negatives() {
1447        use super::super::{
1448            capture_wire::*,
1449            plan_record::PlanInputs,
1450            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
1451        };
1452        let (_f, held, record) = evidence_fixture();
1453        let profile = Profile {
1454            protocol: PROTOCOL.into(),
1455            product: PRODUCT.into(),
1456            version: VERSION.into(),
1457            engine: "podman".into(),
1458        };
1459        let request = CaptureRequest {
1460            schema: "gate-runtime/podman-capture-v1".into(),
1461            profile: profile.clone(),
1462            invocation: "1".repeat(32),
1463            image_id: "b".repeat(64),
1464            inputs: PlanInputs {
1465                name: "fixture".into(),
1466                labels: [("owner".into(), "fixture".into())].into(),
1467                port: None,
1468            },
1469            timeout_ms: 1000,
1470        };
1471        let identity = SlotIdentity {
1472            profile,
1473            installation_sha256: "a".repeat(64),
1474            config_sha256: held.config_sha256().unwrap(),
1475            executable_sha256: "c".repeat(64),
1476        };
1477        let expected = CaptureExpected::bind(
1478            &request,
1479            &identity,
1480            "d".repeat(64),
1481            held.config.path.to_str().unwrap().into(),
1482        )
1483        .unwrap();
1484        let rbytes = request.encode().unwrap();
1485        CaptureRequest::decode(&rbytes).unwrap();
1486        let ebytes = expected.encode(&request).unwrap();
1487        CaptureExpected::decode(&ebytes, &request).unwrap();
1488        let response = encode_response(&record, &request, &expected, &held).unwrap();
1489        assert_eq!(
1490            decode_response(&response, &request, &expected, &held)
1491                .unwrap()
1492                .encode()
1493                .unwrap(),
1494            record.encode().unwrap()
1495        );
1496        for field in [
1497            "schema",
1498            "invocation",
1499            "operation_key",
1500            "request_sha256",
1501            "record_b64",
1502            "record_sha256",
1503        ] {
1504            let bad = replace_record_field(&response, field, serde_json::json!("invalid"));
1505            assert!(
1506                decode_response(&bad, &request, &expected, &held).is_err(),
1507                "{field}"
1508            );
1509        }
1510        for field in [
1511            "schema",
1512            "invocation",
1513            "request_sha256",
1514            "host_sha256",
1515            "installation_sha256",
1516            "config_sha256",
1517            "executable_sha256",
1518            "config_path",
1519            "operation_key",
1520        ] {
1521            let bad = replace_record_field(&ebytes, field, serde_json::json!("invalid"));
1522            assert!(CaptureExpected::decode(&bad, &request).is_err(), "{field}");
1523        }
1524        for (field, value) in [
1525            ("schema", serde_json::json!("gate-runtime/lifecycle-v2")),
1526            ("invocation", serde_json::json!("BAD")),
1527            ("timeout_ms", serde_json::json!(0)),
1528            ("timeout_ms", serde_json::json!(30001)),
1529            ("image_id", serde_json::json!("short")),
1530        ] {
1531            assert!(CaptureRequest::decode(&replace_record_field(&rbytes, field, value)).is_err());
1532        }
1533        let duplicate = String::from_utf8(rbytes.clone()).unwrap().replacen(
1534            "{",
1535            "{\"schema\":\"duplicate\",",
1536            1,
1537        );
1538        assert!(CaptureRequest::decode(duplicate.as_bytes()).is_err());
1539        let mut trailing = response.clone();
1540        trailing.push(b'\n');
1541        assert!(decode_response(&trailing, &request, &expected, &held).is_err());
1542        assert!(CaptureRequest::decode(&vec![b' '; 65537]).is_err());
1543        assert!(CaptureExpected::decode(&vec![b' '; 16385], &request).is_err());
1544        assert!(decode_response(
1545            &vec![b' '; 24 * 1024 * 1024 + 1],
1546            &request,
1547            &expected,
1548            &held
1549        )
1550        .is_err());
1551    }
1552    #[test]
1553    fn protected_capture_inputs_bind_original_fd_and_restore_cloexec() {
1554        use super::super::{
1555            capture_inputs,
1556            capture_wire::{CaptureExpected, CaptureRequest},
1557            installation::VerifiedPodmanInstallation,
1558            plan_record::PlanInputs,
1559        };
1560        let (f, host) = installed_fixture();
1561        let installed = VerifiedPodmanInstallation::load(&host).unwrap();
1562        let request = CaptureRequest {
1563            schema: "gate-runtime/podman-capture-v1".into(),
1564            profile: installed.identity().unwrap().profile.clone(),
1565            invocation: "1".repeat(32),
1566            image_id: "b".repeat(64),
1567            inputs: PlanInputs {
1568                name: "fixture".into(),
1569                labels: [("owner".into(), "fixture".into())].into(),
1570                port: None,
1571            },
1572            timeout_ms: 1000,
1573        };
1574        let expected = CaptureExpected::bind(
1575            &request,
1576            installed.identity().unwrap(),
1577            format!("{:x}", Sha256::digest(fs::read(&host).unwrap())),
1578            f.config.to_str().unwrap().into(),
1579        )
1580        .unwrap();
1581        let raw = request.encode().unwrap();
1582        let eb = expected.encode(&request).unwrap();
1583        let seals =
1584            libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK | libc::F_SEAL_SEAL;
1585        let config = File::open(&f.config).unwrap();
1586        let witness = config.try_clone().unwrap();
1587        assert_eq!(
1588            unsafe { libc::fcntl(config.as_raw_fd(), libc::F_SETFD, 0) },
1589            0
1590        );
1591        let parsed =
1592            capture_inputs::parse(config, capture_inputs::test_memory(&eb, seals, 0o600), &raw)
1593                .unwrap();
1594        assert_eq!(
1595            parsed.context().unwrap().snapshot().unwrap().1,
1596            fs::read(&f.config).unwrap()
1597        );
1598        assert_ne!(
1599            unsafe {
1600                libc::fcntl(
1601                    parsed.context().unwrap().config.file.as_raw_fd(),
1602                    libc::F_GETFD,
1603                )
1604            } & libc::FD_CLOEXEC,
1605            0
1606        );
1607        assert_eq!(parsed.expected().operation_key, expected.operation_key);
1608        assert_eq!(parsed.request().invocation, request.invocation);
1609        assert_ne!(
1610            unsafe { libc::fcntl(witness.as_raw_fd(), libc::F_GETFD) } & libc::FD_CLOEXEC,
1611            0
1612        );
1613        let other = Fixture::new();
1614        assert!(capture_inputs::parse(
1615            File::open(&other.config).unwrap(),
1616            capture_inputs::test_memory(&eb, seals, 0o600),
1617            &raw
1618        )
1619        .is_err());
1620        let altered = replace_record_field(&raw, "image_id", serde_json::json!("c".repeat(64)));
1621        assert!(capture_inputs::parse(
1622            File::open(&f.config).unwrap(),
1623            capture_inputs::test_memory(&eb, seals, 0o600),
1624            &altered
1625        )
1626        .is_err());
1627        assert!(capture_inputs::parse(
1628            File::open(&f.config).unwrap(),
1629            capture_inputs::test_memory(&eb, 0, 0o600),
1630            &raw
1631        )
1632        .is_err());
1633    }
1634}