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()?;
218        context.validate_local_socket_policy()?;
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    pub fn config_sha256(&self) -> Result<String> {
269        self.revalidate()?;
270        Ok(format!("{:x}", Sha256::digest(&self.bytes)))
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::os::unix::{
278        fs::{symlink, PermissionsExt},
279        net::UnixListener,
280    };
281    struct Fixture {
282        dir: tempfile::TempDir,
283        _listener: UnixListener,
284        config: PathBuf,
285        socket: PathBuf,
286    }
287    impl Fixture {
288        fn new() -> Self {
289            // HOME is only a test scratch location, never production identity.
290            // A protected short parent is required; assertions do not bypass checks.
291            let dir = tempfile::tempdir_in(
292                std::env::var_os("HOME").expect("protected test HOME required"),
293            )
294            .unwrap();
295            fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
296            let socket = dir.path().join("engine.sock");
297            let listener = UnixListener::bind(&socket).unwrap();
298            fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap();
299            let m = fs::metadata(&socket).unwrap();
300            let value = serde_json::json!({"operator_epoch":"a".repeat(32),"principal_uid":unsafe{libc::geteuid()},"observation":{"engine":"podman","api_surface":"libpod","selected_api":"6.1.4","server_version":"6.1.4","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()}}});
301            let config = dir.path().join("context.json");
302            fs::write(&config, serde_json::to_vec(&value).unwrap()).unwrap();
303            fs::set_permissions(&config, fs::Permissions::from_mode(0o600)).unwrap();
304            Self {
305                dir,
306                _listener: listener,
307                config,
308                socket,
309            }
310        }
311    }
312    #[test]
313    fn socket_handle_preserves_path_only_and_cloexec() {
314        let f = Fixture::new();
315        let held = Held::open(&f.socket, true).unwrap();
316        let fd = held.file.as_raw_fd();
317        assert_ne!(unsafe { libc::fcntl(fd, libc::F_GETFL) } & libc::O_PATH, 0);
318        assert_ne!(
319            unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
320            0
321        );
322        let mut byte = 0u8;
323        assert_eq!(
324            unsafe { libc::read(fd, (&mut byte as *mut u8).cast(), 1) },
325            -1
326        );
327        assert_eq!(
328            std::io::Error::last_os_error().raw_os_error(),
329            Some(libc::EBADF)
330        );
331        assert_eq!(
332            held.file.metadata().unwrap().ino(),
333            fs::metadata(&f.socket).unwrap().ino()
334        );
335    }
336    #[test]
337    fn exact_bytes_and_socket_snapshot() {
338        let f = Fixture::new();
339        let held = ProtectedContext::load(&f.config).unwrap();
340        assert_eq!(held.snapshot().unwrap().1, fs::read(&f.config).unwrap());
341        assert_eq!(
342            held.config_sha256().unwrap(),
343            format!("{:x}", Sha256::digest(fs::read(&f.config).unwrap()))
344        );
345    }
346    #[test]
347    fn config_replacement_same_bytes_is_rejected() {
348        let f = Fixture::new();
349        let held = ProtectedContext::load(&f.config).unwrap();
350        let replacement = f.dir.path().join("new");
351        fs::write(&replacement, fs::read(&f.config).unwrap()).unwrap();
352        fs::set_permissions(&replacement, fs::Permissions::from_mode(0o600)).unwrap();
353        fs::rename(replacement, &f.config).unwrap();
354        assert!(held.snapshot().is_err());
355    }
356    #[test]
357    fn socket_replacement_is_rejected() {
358        let f = Fixture::new();
359        let held = ProtectedContext::load(&f.config).unwrap();
360        fs::remove_file(&f.socket).unwrap();
361        let _new = UnixListener::bind(&f.socket).unwrap();
362        fs::set_permissions(&f.socket, fs::Permissions::from_mode(0o600)).unwrap();
363        assert!(held.revalidate().is_err());
364    }
365    #[test]
366    fn config_socket_and_ancestor_permission_changes_rejected() {
367        for which in 0..3 {
368            let f = Fixture::new();
369            let held = ProtectedContext::load(&f.config).unwrap();
370            let path = match which {
371                0 => &f.config,
372                1 => &f.socket,
373                _ => f.dir.path(),
374            };
375            fs::set_permissions(path, fs::Permissions::from_mode(0o777)).unwrap();
376            assert!(held.revalidate().is_err());
377            assert!(ProtectedContext::load(&f.config).is_err());
378        }
379    }
380    #[test]
381    fn symlink_and_hardlink_config_refused() {
382        let f = Fixture::new();
383        let link = f.dir.path().join("link");
384        symlink(&f.config, &link).unwrap();
385        assert!(ProtectedContext::load(&link).is_err());
386        fs::remove_file(&link).unwrap();
387        fs::hard_link(&f.config, &link).unwrap();
388        assert!(ProtectedContext::load(&f.config).is_err());
389    }
390    #[test]
391    fn socket_symlink_and_regular_file_refused() {
392        for link in [false, true] {
393            let f = Fixture::new();
394            let held = ProtectedContext::load(&f.config).unwrap();
395            let old = f.dir.path().join("original.sock");
396            fs::rename(&f.socket, &old).unwrap();
397            if link {
398                symlink(&old, &f.socket).unwrap();
399            } else {
400                fs::write(&f.socket, b"not a socket").unwrap();
401            }
402            assert!(held.revalidate().is_err());
403            assert!(ProtectedContext::load(&f.config).is_err());
404        }
405    }
406    #[test]
407    fn changed_bytes_wrong_principal_metadata_and_duplicates_refused() {
408        for which in 0..4 {
409            let f = Fixture::new();
410            let held = ProtectedContext::load(&f.config).unwrap();
411            let mut v: serde_json::Value =
412                serde_json::from_slice(&fs::read(&f.config).unwrap()).unwrap();
413            let bytes = match which {
414                0 => {
415                    v["principal_uid"] = (u64::from(unsafe { libc::geteuid() }) + 1).into();
416                    serde_json::to_vec(&v).unwrap()
417                }
418                1 => {
419                    v["observation"]["socket"]["inode"] = 0.into();
420                    serde_json::to_vec(&v).unwrap()
421                }
422                2 => b"{}".to_vec(),
423                _ => serde_json::to_string(&v)
424                    .unwrap()
425                    .replacen(
426                        "\"operator_epoch\":",
427                        "\"operator_epoch\":\"bad\",\"operator_epoch\":",
428                        1,
429                    )
430                    .into_bytes(),
431            };
432            fs::write(&f.config, bytes).unwrap();
433            assert!(held.revalidate().is_err());
434            assert!(ProtectedContext::load(&f.config).is_err());
435        }
436    }
437    fn info(held: &ProtectedContext) -> Vec<u8> {
438        let (c, _) = held.snapshot().unwrap();
439        let o = &c.observation;
440        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":"6.1.4","Version":"6.1.4"}})).unwrap()
441    }
442    fn absence(id: &str) -> Vec<u8> {
443        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()
444    }
445    #[test]
446    fn readonly_sequence_releases_result_only_after_final_info() {
447        let f = Fixture::new();
448        let held = ProtectedContext::load(&f.config).unwrap();
449        let body = info(&held);
450        let id = "a".repeat(64);
451        let mut routes = vec![];
452        let result = held
453            .inspect_with(&id, |route| {
454                routes.push(route.to_owned());
455                Ok(if routes.len() == 2 {
456                    (404, absence(&id))
457                } else {
458                    (200, body.clone())
459                })
460            })
461            .unwrap();
462        assert_eq!(result, super::super::native_status::InspectOutcome::Missing);
463        assert_eq!(
464            routes,
465            vec![
466                super::super::observation::INFO_ROUTE.to_string(),
467                super::super::native_status::inspect_route(&id).unwrap(),
468                super::super::observation::INFO_ROUTE.to_string()
469            ]
470        );
471    }
472    #[test]
473    fn readonly_info_failure_and_invalid_id_do_not_inspect() {
474        let f = Fixture::new();
475        let held = ProtectedContext::load(&f.config).unwrap();
476        let mut calls = 0;
477        assert!(held
478            .inspect_with("name", |_| {
479                calls += 1;
480                Ok((200, vec![]))
481            })
482            .is_err());
483        assert_eq!(calls, 0);
484        for status in [200, 401, 500] {
485            calls = 0;
486            assert!(held
487                .inspect_with(&"a".repeat(64), |_| {
488                    calls += 1;
489                    Ok((status, b"{}".to_vec()))
490                })
491                .is_err());
492            assert_eq!(calls, 1);
493        }
494    }
495    #[test]
496    fn readonly_final_failure_or_observation_change_withholds_absence() {
497        for change in 0..3 {
498            let f = Fixture::new();
499            let held = ProtectedContext::load(&f.config).unwrap();
500            let body = info(&held);
501            let id = "a".repeat(64);
502            let mut calls = 0;
503            assert!(held
504                .inspect_with(&id, |_| {
505                    calls += 1;
506                    match calls {
507                        1 => Ok((200, body.clone())),
508                        2 => Ok((404, absence(&id))),
509                        _ => match change {
510                            0 => Err("timeout"),
511                            1 => Ok((500, body.clone())),
512                            _ => {
513                                let mut v: serde_json::Value =
514                                    serde_json::from_slice(&body).unwrap();
515                                v["host"]["hostname"] = "other".into();
516                                Ok((200, serde_json::to_vec(&v).unwrap()))
517                            }
518                        },
519                    }
520                })
521                .is_err());
522            assert_eq!(calls, 3);
523        }
524    }
525    #[test]
526    fn readonly_revalidates_after_each_exchange_without_retry() {
527        for boundary in 1..=3 {
528            let f = Fixture::new();
529            let held = ProtectedContext::load(&f.config).unwrap();
530            let body = info(&held);
531            let id = "a".repeat(64);
532            let mut calls = 0;
533            assert!(held
534                .inspect_with(&id, |_| {
535                    calls += 1;
536                    if calls == boundary {
537                        fs::set_permissions(&f.config, fs::Permissions::from_mode(0o644)).unwrap();
538                    }
539                    Ok(if calls == 2 {
540                        (404, absence(&id))
541                    } else {
542                        (200, body.clone())
543                    })
544                })
545                .is_err());
546            assert_eq!(calls, boundary);
547        }
548    }
549    #[test]
550    fn readonly_initial_observation_mismatch_stops_before_inspect() {
551        let f = Fixture::new();
552        let held = ProtectedContext::load(&f.config).unwrap();
553        let mut v: serde_json::Value = serde_json::from_slice(&info(&held)).unwrap();
554        v["store"]["runRoot"] = "/run/other".into();
555        let mut calls = 0;
556        assert!(held
557            .inspect_with(&"a".repeat(64), |_| {
558                calls += 1;
559                Ok((200, serde_json::to_vec(&v).unwrap()))
560            })
561            .is_err());
562        assert_eq!(calls, 1);
563    }
564    #[test]
565    fn readonly_socket_change_at_each_boundary_rejects_result() {
566        for boundary in 1..=3 {
567            let f = Fixture::new();
568            let held = ProtectedContext::load(&f.config).unwrap();
569            let body = info(&held);
570            let id = "a".repeat(64);
571            let mut calls = 0;
572            assert!(held
573                .inspect_with(&id, |_| {
574                    calls += 1;
575                    if calls == boundary {
576                        fs::set_permissions(&f.socket, fs::Permissions::from_mode(0o660)).unwrap();
577                    }
578                    Ok(if calls == 2 {
579                        (404, absence(&id))
580                    } else {
581                        (200, body.clone())
582                    })
583                })
584                .is_err());
585            assert_eq!(calls, boundary);
586        }
587    }
588    #[test]
589    fn readonly_inspect_failure_does_not_retry() {
590        let f = Fixture::new();
591        let held = ProtectedContext::load(&f.config).unwrap();
592        let body = info(&held);
593        let mut calls = 0;
594        assert!(held
595            .inspect_with(&"a".repeat(64), |_| {
596                calls += 1;
597                if calls == 1 {
598                    Ok((200, body.clone()))
599                } else {
600                    Err("cancelled")
601                }
602            })
603            .is_err());
604        assert_eq!(calls, 2);
605    }
606    #[test]
607    fn readonly_present_is_also_finally_checked() {
608        let f = Fixture::new();
609        let held = ProtectedContext::load(&f.config).unwrap();
610        let body = info(&held);
611        let id = "a".repeat(64);
612        let mut calls = 0;
613        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();
614        assert_eq!(calls, 3);
615        assert!(
616            matches!(result,super::super::native_status::InspectOutcome::Present(state) if state.status=="initialized")
617        );
618    }
619    #[test]
620    fn ownership_composition_binds_body_and_final_observation() {
621        use super::super::ownership::ExpectedContainer;
622        use std::collections::BTreeMap;
623        for failure in 0..3 {
624            let f = Fixture::new();
625            let held = ProtectedContext::load(&f.config).unwrap();
626            let info_body = info(&held);
627            let owner = ExpectedContainer {
628                id: "a".repeat(64),
629                name: "fixture".into(),
630                image_id: "b".repeat(64),
631                labels: BTreeMap::from([("lease".into(), "owned".into())]),
632                port: None,
633                image_exposed_ports: Default::default(),
634            };
635            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}});
636            if failure == 1 {
637                object["Config"]["Labels"]["lease"] = "foreign".into();
638            }
639            let mut calls = 0;
640            let result = held.inspect_owned_with(&owner, |_| {
641                calls += 1;
642                if failure == 2 && calls == 3 {
643                    return Err("timeout");
644                }
645                Ok((
646                    200,
647                    if calls == 2 {
648                        serde_json::to_vec(&object).unwrap()
649                    } else {
650                        info_body.clone()
651                    },
652                ))
653            });
654            if failure == 0 {
655                assert_eq!(result.unwrap().status, "running");
656                assert_eq!(calls, 3);
657            } else {
658                assert!(result.is_err());
659                assert_eq!(calls, if failure == 1 { 2 } else { 3 });
660            }
661        }
662    }
663    #[test]
664    fn image_metadata_is_withheld_until_final_protected_observation() {
665        for failure in 0..3 {
666            let f = Fixture::new();
667            let held = ProtectedContext::load(&f.config).unwrap();
668            let info_body = info(&held);
669            let id = "b".repeat(64);
670            let mut routes = vec![];
671            let image = serde_json::json!({"Id":id,"Labels":{"image":"value"},"Config":{"Labels":{"image":"value"},"ExposedPorts":{"80/tcp":{}}}});
672            let result = held.capture_image_for_plan(&id, |route| {
673                routes.push(route.to_string());
674                if failure == 1 && routes.len() == 2 {
675                    return Ok((404, b"{}".to_vec()));
676                }
677                if failure == 2 && routes.len() == 3 {
678                    return Err("cancelled");
679                }
680                Ok((
681                    200,
682                    if routes.len() == 2 {
683                        serde_json::to_vec(&image).unwrap()
684                    } else {
685                        info_body.clone()
686                    },
687                ))
688            });
689            if failure == 0 {
690                let facts = result.unwrap();
691                assert_eq!(facts.facts().id(), id);
692                assert_eq!(routes.len(), 3);
693            } else {
694                assert!(result.is_err());
695                assert_eq!(routes.len(), if failure == 1 { 2 } else { 3 });
696            }
697            assert_eq!(
698                routes[1],
699                super::super::native_image::image_route(&id).unwrap()
700            );
701        }
702    }
703    #[test]
704    fn capture_retains_exact_bytes_and_refuses_another_context() {
705        let f = Fixture::new();
706        let held = ProtectedContext::load(&f.config).unwrap();
707        let id = "b".repeat(64);
708        let raw = format!("{{ \"Id\":\"{id}\",\"Config\":{{}} }}").into_bytes();
709        let info = info(&held);
710        let mut calls = 0;
711        let captured = held
712            .capture_image_for_plan(&id, |_| {
713                calls += 1;
714                Ok((
715                    200,
716                    if calls == 2 {
717                        raw.clone()
718                    } else {
719                        info.clone()
720                    },
721                ))
722            })
723            .unwrap();
724        assert_eq!(calls, 3);
725        assert_eq!(captured.image_bytes(), raw);
726        assert_eq!(captured.config_bytes(), held.snapshot().unwrap().1);
727        assert_eq!(
728            captured.operator_epoch(),
729            held.snapshot().unwrap().0.operator_epoch
730        );
731        assert_eq!(captured.facts().id(), id);
732        captured.match_context(&held).unwrap();
733        use super::super::{
734            plan_record::{PlanInputs, PlanRecord},
735            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
736        };
737        use sha2::{Digest, Sha256};
738        let identity = SlotIdentity {
739            profile: Profile {
740                protocol: PROTOCOL.into(),
741                product: PRODUCT.into(),
742                version: VERSION.into(),
743                engine: "podman".into(),
744            },
745            installation_sha256: "a".repeat(64),
746            executable_sha256: "c".repeat(64),
747            config_sha256: format!("{:x}", Sha256::digest(captured.config_bytes())),
748        };
749        let inputs = PlanInputs {
750            name: "fixture".into(),
751            labels: [("owner".into(), "fixture".into())].into(),
752            port: None,
753        };
754        let record = PlanRecord::capture(&held, &captured, &identity, inputs.clone()).unwrap();
755        let bytes = record.encode().unwrap();
756        let reopened = PlanRecord::decode(&bytes, &held).unwrap();
757        let substituted = f.config.parent().unwrap().join("substituted-record");
758        let replacement = String::from_utf8(bytes.clone())
759            .unwrap()
760            .replace(&"a".repeat(64), &"f".repeat(64));
761        // This is independently valid evidence, so only exact intended-byte
762        // comparison rejects substitution, not ordinary record validation.
763        PlanRecord::decode(replacement.as_bytes(), &held).unwrap();
764        assert_eq!(
765            record
766                .create_checked(&substituted, &held, || {
767                    std::fs::write(&substituted, replacement.as_bytes()).unwrap();
768                    Ok(())
769                })
770                .unwrap_err(),
771            "created record differs from intended evidence"
772        );
773        assert_eq!(std::fs::read(&substituted).unwrap(), replacement.as_bytes());
774        let disk = f.config.parent().unwrap().join("plan-record");
775        record.create_new(&disk, &held).unwrap();
776        assert_eq!(
777            PlanRecord::open(&disk, &held).unwrap().encode().unwrap(),
778            bytes
779        );
780        assert!(record.create_new(&disk, &held).is_err());
781        std::fs::set_permissions(&disk, std::fs::Permissions::from_mode(0o644)).unwrap();
782        assert!(PlanRecord::open(&disk, &held).is_err());
783        std::fs::set_permissions(&disk, std::fs::Permissions::from_mode(0o600)).unwrap();
784        let linked = disk.with_extension("link");
785        std::fs::hard_link(&disk, &linked).unwrap();
786        assert!(PlanRecord::open(&disk, &held).is_err());
787        std::fs::remove_file(&linked).unwrap();
788        std::fs::write(&disk, b"{").unwrap();
789        assert!(PlanRecord::open(&disk, &held).is_err());
790
791        assert_eq!(reopened.plan_bytes(), record.plan_bytes());
792        assert!(!reopened.installation_verified());
793        assert_eq!(format!("{captured:?}"), "CapturedImage { .. }");
794        let original: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
795        for field in [
796            "schema",
797            "operator_epoch",
798            "config_json",
799            "config_sha256",
800            "image_id",
801            "image_json",
802            "image_sha256",
803            "plan_json",
804            "plan_sha256",
805        ] {
806            let pattern = format!(
807                "\"{field}\":{}",
808                serde_json::to_string(&original[field]).unwrap()
809            );
810            let changed = String::from_utf8(bytes.clone()).unwrap().replacen(
811                &pattern,
812                &format!("\"{field}\":\"altered\""),
813                1,
814            );
815            assert!(
816                PlanRecord::decode(changed.as_bytes(), &held).is_err(),
817                "{field}"
818            );
819        }
820        let mut trailing = bytes.clone();
821        trailing.push(b' ');
822        assert!(PlanRecord::decode(&trailing, &held).is_err());
823        assert!(PlanRecord::decode(&bytes[..bytes.len() - 1], &held).is_err());
824        let text = String::from_utf8(bytes.clone()).unwrap();
825        let wrong_profile = text.replace("podman-lifecycle", "docker-lifecycle");
826        assert!(PlanRecord::decode(wrong_profile.as_bytes(), &held).is_err());
827        let wrong_inputs = text.replace("\"name\":\"fixture\"", "\"name\":\"other\"");
828        assert_ne!(wrong_inputs, text);
829        assert!(PlanRecord::decode(wrong_inputs.as_bytes(), &held).is_err());
830        let duplicate_labels = text.replace("\"labels\":{", "\"labels\":{\"owner\":\"duplicate\",");
831        assert!(PlanRecord::decode(duplicate_labels.as_bytes(), &held).is_err());
832        let asserted_change = text.replace(&"a".repeat(64), &"f".repeat(64));
833        assert!(!PlanRecord::decode(asserted_change.as_bytes(), &held)
834            .unwrap()
835            .installation_verified());
836        let malformed_assertion = text.replace(&"a".repeat(64), "invalid");
837        assert!(PlanRecord::decode(malformed_assertion.as_bytes(), &held).is_err());
838        assert!(PlanRecord::decode(&vec![b' '; 16 * 1024 * 1024 + 1], &held).is_err());
839        let duplicate = text.replacen("{", "{\"schema\":\"extra\",", 1);
840        assert!(PlanRecord::decode(duplicate.as_bytes(), &held).is_err());
841        let unknown = text.replacen("{", "{\"unknown\":0,", 1);
842        assert!(PlanRecord::decode(unknown.as_bytes(), &held).is_err());
843        let mut wrong_identity = identity.clone();
844        wrong_identity.config_sha256 = "d".repeat(64);
845        assert!(PlanRecord::capture(&held, &captured, &wrong_identity, inputs).is_err());
846
847        let other = Fixture::new();
848        assert!(
849            PlanRecord::decode(&bytes, &ProtectedContext::load(&other.config).unwrap()).is_err()
850        );
851        assert!(captured
852            .match_context(&ProtectedContext::load(&other.config).unwrap())
853            .is_err());
854    }
855    fn evidence_fixture() -> (
856        Fixture,
857        ProtectedContext,
858        super::super::plan_record::PlanRecord,
859    ) {
860        use super::super::{
861            plan_record::{PlanInputs, PlanRecord},
862            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
863        };
864        let f = Fixture::new();
865        let held = ProtectedContext::load(&f.config).unwrap();
866        let id = "b".repeat(64);
867        let raw = serde_json::to_vec(
868            &serde_json::json!({"Id":id,"Config":{"ExposedPorts":{"80/tcp":{}}}}),
869        )
870        .unwrap();
871        let info = info(&held);
872        let mut calls = 0;
873        let captured = held
874            .capture_image_for_plan(&id, |_| {
875                calls += 1;
876                Ok((
877                    200,
878                    if calls == 2 {
879                        raw.clone()
880                    } else {
881                        info.clone()
882                    },
883                ))
884            })
885            .unwrap();
886        let identity = SlotIdentity {
887            profile: Profile {
888                protocol: PROTOCOL.into(),
889                product: PRODUCT.into(),
890                version: VERSION.into(),
891                engine: "podman".into(),
892            },
893            installation_sha256: "a".repeat(64),
894            executable_sha256: "c".repeat(64),
895            config_sha256: held.config_sha256().unwrap(),
896        };
897        let record = PlanRecord::capture(
898            &held,
899            &captured,
900            &identity,
901            PlanInputs {
902                name: "fixture".into(),
903                labels: [("owner".into(), "fixture".into())].into(),
904                port: None,
905            },
906        )
907        .unwrap();
908        (f, held, record)
909    }
910    fn replace_record_field(bytes: &[u8], field: &str, value: serde_json::Value) -> Vec<u8> {
911        let original: serde_json::Value = serde_json::from_slice(bytes).unwrap();
912        let old = format!(
913            "\"{field}\":{}",
914            serde_json::to_string(&original[field]).unwrap()
915        );
916        let new = format!("\"{field}\":{}", serde_json::to_string(&value).unwrap());
917        let text = String::from_utf8(bytes.to_vec()).unwrap();
918        assert!(text.contains(&old));
919        text.replacen(&old, &new, 1).into_bytes()
920    }
921    #[test]
922    fn evidence_exposure_change_requires_hash_but_is_not_authenticity() {
923        use super::super::plan_record::PlanRecord;
924        let (_f, held, record) = evidence_fixture();
925        let bytes = record.encode().unwrap();
926        let original: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
927        let image = original["image_json"]
928            .as_str()
929            .unwrap()
930            .replace("80/tcp", "81/tcp");
931        let changed = replace_record_field(&bytes, "image_json", serde_json::json!(image));
932        assert!(PlanRecord::decode(&changed, &held).is_err());
933        // Recomputed unkeyed hashes yield internally consistent evidence. They
934        // cannot authenticate EXPOSE, which does not change native create body.
935        let consistent = replace_record_field(
936            &changed,
937            "image_sha256",
938            serde_json::json!(format!("{:x}", Sha256::digest(image.as_bytes()))),
939        );
940        let reopened = PlanRecord::decode(&consistent, &held).unwrap();
941        assert_eq!(reopened.plan_bytes(), record.plan_bytes());
942        assert!(!reopened.installation_verified());
943    }
944    #[test]
945    fn evidence_nested_schema_and_decoded_bounds_refuse() {
946        use super::super::plan_record::PlanRecord;
947        let (_f, held, record) = evidence_fixture();
948        let bytes = record.encode().unwrap();
949        let text = String::from_utf8(bytes.clone()).unwrap();
950        for (needle, replacement) in [
951            ("\"profile\":{", "\"profile\":{\"engine\":\"podman\","),
952            ("\"inputs\":{", "\"inputs\":{\"extra\":true,"),
953            ("\"labels\":{", "\"labels\":{\"owner\":\"duplicate\","),
954        ] {
955            let altered = text.replacen(needle, replacement, 1);
956            assert_ne!(altered, text);
957            assert!(PlanRecord::decode(altered.as_bytes(), &held).is_err());
958        }
959        for field in ["config_json", "image_json"] {
960            let altered = replace_record_field(
961                &bytes,
962                field,
963                serde_json::json!(" ".repeat(1024 * 1024 + 1)),
964            );
965            assert!(PlanRecord::decode(&altered, &held).is_err());
966        }
967        let oversized_label = text.replacen(
968            "\"owner\":\"fixture\"",
969            &format!("\"owner\":\"{}\"", "x".repeat(4097)),
970            1,
971        );
972        assert_ne!(oversized_label, text);
973        assert!(PlanRecord::decode(oversized_label.as_bytes(), &held).is_err());
974    }
975    #[test]
976    fn evidence_postcheck_replacement_remains_possible_and_non_authoritative() {
977        use super::super::plan_record::PlanRecord;
978        let (f, held, record) = evidence_fixture();
979        let path = f.config.parent().unwrap().join("postcheck");
980        record.create_new(&path, &held).unwrap();
981        let intended = record.encode().unwrap();
982        let replacement = replace_record_field(
983            &intended,
984            "asserted_installation_sha256",
985            serde_json::json!("f".repeat(64)),
986        );
987        // Deterministic ordering after final return, no sleep/race timing.
988        fs::write(&path, &replacement).unwrap();
989        let reopened = PlanRecord::open(&path, &held).unwrap();
990        assert_ne!(reopened.encode().unwrap(), intended);
991        assert!(!reopened.installation_verified());
992    }
993    #[test]
994    fn versioned_context_retains_wrapper_and_refuses_raw_record_adoption() {
995        use super::super::plan_record::PlanRecord;
996        let (f, raw, record) = evidence_fixture();
997        let old_bytes = record.encode().unwrap();
998        assert!(!raw.is_versioned());
999        assert!(ProtectedContext::load_versioned(&f.config).is_err());
1000        let wrapper = VersionedContext {
1001            schema: CONTEXT_SCHEMA.into(),
1002            context: raw.snapshot().unwrap().0.clone(),
1003        };
1004        let bytes = serde_json::to_vec(&wrapper).unwrap();
1005        fs::write(&f.config, &bytes).unwrap();
1006        assert!(raw.revalidate().is_err());
1007        assert!(ProtectedContext::load(&f.config).is_err());
1008        let held = ProtectedContext::load_versioned(&f.config).unwrap();
1009        assert!(held.is_versioned());
1010        assert_eq!(held.snapshot().unwrap().1, bytes);
1011        assert_eq!(
1012            held.config_sha256().unwrap(),
1013            format!("{:x}", Sha256::digest(&bytes))
1014        );
1015        assert!(PlanRecord::decode(&old_bytes, &held).is_err());
1016        let info = info(&held);
1017        let mut calls = 0;
1018        let captured = held
1019            .capture_image_for_plan(&"b".repeat(64), |_| {
1020                calls += 1;
1021                Ok((
1022                    200,
1023                    if calls == 2 {
1024                        serde_json::to_vec(&serde_json::json!({"Id":"b".repeat(64),"Config":{}}))
1025                            .unwrap()
1026                    } else {
1027                        info.clone()
1028                    },
1029                ))
1030            })
1031            .unwrap();
1032        assert_eq!(captured.config_bytes(), bytes);
1033    }
1034    #[test]
1035    fn versioned_context_rejects_schema_duplicate_unknown_and_whitespace() {
1036        let f = Fixture::new();
1037        let raw = ProtectedContext::load(&f.config).unwrap();
1038        let wrapper = VersionedContext {
1039            schema: CONTEXT_SCHEMA.into(),
1040            context: raw.snapshot().unwrap().0.clone(),
1041        };
1042        let text = serde_json::to_string(&wrapper).unwrap();
1043        for bad in [
1044            text.replace(CONTEXT_SCHEMA, "other"),
1045            format!("{text} "),
1046            text.replacen("{", "{\"extra\":0,", 1),
1047            text.replacen("{", "{\"schema\":\"duplicate\",", 1),
1048        ] {
1049            fs::write(&f.config, bad).unwrap();
1050            assert!(ProtectedContext::load_versioned(&f.config).is_err());
1051        }
1052    }
1053    #[test]
1054    fn held_versioned_cursor_independence_and_record_roundtrip() {
1055        use super::super::{
1056            plan_record::{PlanInputs, PlanRecord},
1057            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
1058        };
1059        use std::io::{Seek, SeekFrom};
1060        let f = Fixture::new();
1061        let raw = ProtectedContext::load(&f.config).unwrap();
1062        let bytes = serde_json::to_vec(&VersionedContext {
1063            schema: CONTEXT_SCHEMA.into(),
1064            context: raw.snapshot().unwrap().0.clone(),
1065        })
1066        .unwrap();
1067        fs::write(&f.config, &bytes).unwrap();
1068        let mut file = File::open(&f.config).unwrap();
1069        file.seek(SeekFrom::End(0)).unwrap();
1070        let held = ProtectedContext::from_held_versioned(&f.config, file).unwrap();
1071        assert_eq!(held.snapshot().unwrap().1, bytes);
1072        let info = info(&held);
1073        let mut calls = 0;
1074        let id = "b".repeat(64);
1075        let captured = held
1076            .capture_image_for_plan(&id, |_| {
1077                calls += 1;
1078                Ok((
1079                    200,
1080                    if calls == 2 {
1081                        serde_json::to_vec(&serde_json::json!({"Id":id,"Config":{}})).unwrap()
1082                    } else {
1083                        info.clone()
1084                    },
1085                ))
1086            })
1087            .unwrap();
1088        let identity = SlotIdentity {
1089            profile: Profile {
1090                protocol: PROTOCOL.into(),
1091                product: PRODUCT.into(),
1092                version: VERSION.into(),
1093                engine: "podman".into(),
1094            },
1095            installation_sha256: "a".repeat(64),
1096            executable_sha256: "c".repeat(64),
1097            config_sha256: held.config_sha256().unwrap(),
1098        };
1099        let record = PlanRecord::capture(
1100            &held,
1101            &captured,
1102            &identity,
1103            PlanInputs {
1104                name: "fixture".into(),
1105                labels: [("owner".into(), "fixture".into())].into(),
1106                port: None,
1107            },
1108        )
1109        .unwrap();
1110        let path = f.dir.path().join("versioned-record");
1111        record.create_new(&path, &held).unwrap();
1112        let reopened = PlanRecord::open(&path, &held).unwrap();
1113        assert_eq!(reopened.encode().unwrap(), record.encode().unwrap());
1114        assert!(!reopened.installation_verified());
1115    }
1116    #[test]
1117    fn held_versioned_rejects_wrong_inode_replaced_path_and_writable_descriptor() {
1118        let f = Fixture::new();
1119        let raw = ProtectedContext::load(&f.config).unwrap();
1120        let bytes = serde_json::to_vec(&VersionedContext {
1121            schema: CONTEXT_SCHEMA.into(),
1122            context: raw.snapshot().unwrap().0.clone(),
1123        })
1124        .unwrap();
1125        fs::write(&f.config, &bytes).unwrap();
1126        let writable = OpenOptions::new()
1127            .read(true)
1128            .write(true)
1129            .open(&f.config)
1130            .unwrap();
1131        assert!(ProtectedContext::from_held_versioned(&f.config, writable).is_err());
1132        let old = File::open(&f.config).unwrap();
1133        fs::rename(&f.config, f.dir.path().join("original")).unwrap();
1134        fs::write(&f.config, &bytes).unwrap();
1135        fs::set_permissions(&f.config, fs::Permissions::from_mode(0o600)).unwrap();
1136        assert!(ProtectedContext::from_held_versioned(&f.config, old).is_err());
1137        let other = Fixture::new();
1138        assert!(ProtectedContext::from_held_versioned(
1139            &f.config,
1140            File::open(&other.config).unwrap()
1141        )
1142        .is_err());
1143    }
1144    fn installed_fixture() -> (Fixture, PathBuf) {
1145        let f = Fixture::new();
1146        let raw = ProtectedContext::load(&f.config).unwrap();
1147        let config = serde_json::to_vec(&VersionedContext {
1148            schema: CONTEXT_SCHEMA.into(),
1149            context: raw.snapshot().unwrap().0.clone(),
1150        })
1151        .unwrap();
1152        fs::write(&f.config, &config).unwrap();
1153        let exe = f.dir.path().join("adapter");
1154        fs::write(&exe, b"\x7fELFfake-no-execution").unwrap();
1155        fs::set_permissions(
1156            &exe,
1157            fs::Permissions::from_mode(if unsafe { libc::geteuid() } == 0 {
1158                0o755
1159            } else {
1160                0o700
1161            }),
1162        )
1163        .unwrap();
1164        let descriptor=format!("{{\"schema\":\"gate-runtime/podman-installation-v1\",\"product\":\"podman-lifecycle\",\"version\":\"0.1.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);
1165        let install = f.dir.path().join("installation.json");
1166        fs::write(&install, &descriptor).unwrap();
1167        fs::set_permissions(&install, fs::Permissions::from_mode(0o600)).unwrap();
1168        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));
1169        let path = f.dir.path().join("host.json");
1170        fs::write(&path, host).unwrap();
1171        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1172        (f, path)
1173    }
1174    #[test]
1175    fn held_installation_record_match_revalidates_without_upgrading_record() {
1176        use super::super::{
1177            installation::VerifiedPodmanInstallation,
1178            plan_record::{PlanInputs, PlanRecord},
1179        };
1180        let (f, path) = installed_fixture();
1181        let installed = VerifiedPodmanInstallation::load(&path).unwrap();
1182        let context = installed.context().unwrap();
1183        assert!(context.is_versioned());
1184        let info = info(context);
1185        let mut calls = 0;
1186        let id = "b".repeat(64);
1187        let capture = context
1188            .capture_image_for_plan(&id, |_| {
1189                calls += 1;
1190                Ok((
1191                    200,
1192                    if calls == 2 {
1193                        serde_json::to_vec(&serde_json::json!({"Id":id,"Config":{}})).unwrap()
1194                    } else {
1195                        info.clone()
1196                    },
1197                ))
1198            })
1199            .unwrap();
1200        let inputs = super::super::plan_record::PlanInputs {
1201            name: "fixture".into(),
1202            labels: [("owner".into(), "fixture".into())].into(),
1203            port: None,
1204        };
1205        let record = PlanRecord::capture(
1206            context,
1207            &capture,
1208            installed.identity().unwrap(),
1209            inputs.clone(),
1210        )
1211        .unwrap();
1212        installed
1213            .match_record(&record)
1214            .unwrap()
1215            .revalidate()
1216            .unwrap();
1217        assert!(!record.installation_verified());
1218        let mut mismatch = installed.identity().unwrap().clone();
1219        mismatch.executable_sha256 = "f".repeat(64);
1220        let wrong =
1221            PlanRecord::capture(context, &capture, &mismatch, PlanInputs { ..inputs }).unwrap();
1222        assert!(installed.match_record(&wrong).is_err());
1223        let matched = installed.match_record(&record).unwrap();
1224        fs::write(f.dir.path().join("adapter"), b"\x7fELFreplaced").unwrap();
1225        assert!(matched.revalidate().is_err());
1226        assert!(!record.installation_verified());
1227    }
1228    #[test]
1229    fn installation_substitution_modes_schema_and_digests_refuse() {
1230        use super::super::installation::VerifiedPodmanInstallation;
1231        for case in 0..7 {
1232            let (f, path) = installed_fixture();
1233            match case {
1234                0 => fs::set_permissions(
1235                    f.dir.path().join("adapter"),
1236                    fs::Permissions::from_mode(0o777),
1237                )
1238                .unwrap(),
1239                1 => {
1240                    fs::write(f.dir.path().join("adapter"), b"not ELF").unwrap();
1241                }
1242                2 => {
1243                    fs::write(&f.config, b"{}").unwrap();
1244                }
1245                3 => {
1246                    let text = fs::read_to_string(&path)
1247                        .unwrap()
1248                        .replace("podman-host-v1", "docker-host-v1");
1249                    fs::write(&path, text).unwrap();
1250                }
1251                4 => {
1252                    fs::hard_link(f.dir.path().join("adapter"), f.dir.path().join("alias"))
1253                        .unwrap();
1254                }
1255                5 => {
1256                    let text = fs::read_to_string(&path).unwrap();
1257                    fs::write(&path, format!("{text} ")).unwrap();
1258                }
1259                6 => {
1260                    let exe = f.dir.path().join("adapter");
1261                    fs::rename(&exe, f.dir.path().join("real-adapter")).unwrap();
1262                    std::os::unix::fs::symlink(f.dir.path().join("real-adapter"), &exe).unwrap();
1263                }
1264                _ => unreachable!(),
1265            }
1266            assert!(
1267                VerifiedPodmanInstallation::load(&path).is_err(),
1268                "case {case}"
1269            );
1270        }
1271    }
1272    #[test]
1273    fn held_config_rejects_opath_no_cloexec_hardlink_and_symlink() {
1274        for case in 0..4 {
1275            let (f, _) = installed_fixture();
1276            let file = if case == 0 {
1277                let path = std::ffi::CString::new(f.config.as_os_str().as_bytes()).unwrap();
1278                let fd = unsafe { libc::open(path.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
1279                assert!(fd >= 0);
1280                assert_ne!(unsafe { libc::fcntl(fd, libc::F_GETFL) } & libc::O_PATH, 0);
1281                unsafe { File::from_raw_fd(fd) }
1282            } else {
1283                File::open(&f.config).unwrap()
1284            };
1285            if case == 1 {
1286                assert_eq!(
1287                    unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, 0) },
1288                    0
1289                );
1290            }
1291            if case == 2 {
1292                fs::hard_link(&f.config, f.dir.path().join("config-link")).unwrap();
1293            }
1294            if case == 3 {
1295                fs::rename(&f.config, f.dir.path().join("config-original")).unwrap();
1296                std::os::unix::fs::symlink(f.dir.path().join("config-original"), &f.config)
1297                    .unwrap();
1298            }
1299            assert!(ProtectedContext::from_held_versioned(&f.config, file).is_err());
1300        }
1301    }
1302    #[test]
1303    fn every_held_installation_member_replacement_is_rejected() {
1304        use super::super::installation::VerifiedPodmanInstallation;
1305        for name in ["host.json", "installation.json", "context.json", "adapter"] {
1306            let (f, path) = installed_fixture();
1307            let installed = VerifiedPodmanInstallation::load(&path).unwrap();
1308            let member = f.dir.path().join(name);
1309            let bytes = fs::read(&member).unwrap();
1310            let mode = fs::metadata(&member).unwrap().permissions();
1311            fs::rename(&member, f.dir.path().join("old-member")).unwrap();
1312            fs::write(&member, bytes).unwrap();
1313            fs::set_permissions(&member, mode).unwrap();
1314            assert!(installed.revalidate().is_err(), "{name}");
1315        }
1316    }
1317    #[test]
1318    fn capture_request_matches_all_operator_fields_and_exact_image() {
1319        use super::super::plan_record::{PlanInputs, PlanPort};
1320        let (_f, _held, record) = evidence_fixture();
1321        let inputs = PlanInputs {
1322            name: "fixture".into(),
1323            labels: [("owner".into(), "fixture".into())].into(),
1324            port: None,
1325        };
1326        let id = "b".repeat(64);
1327        record.match_capture_request(&id, &inputs).unwrap();
1328        assert!(record
1329            .match_capture_request(&"c".repeat(64), &inputs)
1330            .is_err());
1331        for case in 0..4 {
1332            let mut changed = inputs.clone();
1333            match case {
1334                0 => changed.name = "another".into(),
1335                1 => {
1336                    changed.labels.insert("owner".into(), "another".into());
1337                }
1338                2 => changed.labels.clear(),
1339                3 => {
1340                    changed.port = Some(PlanPort {
1341                        container: 80,
1342                        host: 8080,
1343                    })
1344                }
1345                _ => unreachable!(),
1346            };
1347            assert!(record.match_capture_request(&id, &changed).is_err());
1348        }
1349    }
1350    #[test]
1351    fn capture_wire_roundtrip_and_binding_negatives() {
1352        use super::super::{
1353            capture_wire::*,
1354            plan_record::PlanInputs,
1355            profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
1356        };
1357        let (_f, held, record) = evidence_fixture();
1358        let profile = Profile {
1359            protocol: PROTOCOL.into(),
1360            product: PRODUCT.into(),
1361            version: VERSION.into(),
1362            engine: "podman".into(),
1363        };
1364        let request = CaptureRequest {
1365            schema: "gate-runtime/podman-capture-v1".into(),
1366            profile: profile.clone(),
1367            invocation: "1".repeat(32),
1368            image_id: "b".repeat(64),
1369            inputs: PlanInputs {
1370                name: "fixture".into(),
1371                labels: [("owner".into(), "fixture".into())].into(),
1372                port: None,
1373            },
1374            timeout_ms: 1000,
1375        };
1376        let identity = SlotIdentity {
1377            profile,
1378            installation_sha256: "a".repeat(64),
1379            config_sha256: held.config_sha256().unwrap(),
1380            executable_sha256: "c".repeat(64),
1381        };
1382        let expected = CaptureExpected::bind(
1383            &request,
1384            &identity,
1385            "d".repeat(64),
1386            held.config.path.to_str().unwrap().into(),
1387        )
1388        .unwrap();
1389        let rbytes = request.encode().unwrap();
1390        CaptureRequest::decode(&rbytes).unwrap();
1391        let ebytes = expected.encode(&request).unwrap();
1392        CaptureExpected::decode(&ebytes, &request).unwrap();
1393        let response = encode_response(&record, &request, &expected, &held).unwrap();
1394        assert_eq!(
1395            decode_response(&response, &request, &expected, &held)
1396                .unwrap()
1397                .encode()
1398                .unwrap(),
1399            record.encode().unwrap()
1400        );
1401        for field in [
1402            "schema",
1403            "invocation",
1404            "operation_key",
1405            "request_sha256",
1406            "record_b64",
1407            "record_sha256",
1408        ] {
1409            let bad = replace_record_field(&response, field, serde_json::json!("invalid"));
1410            assert!(
1411                decode_response(&bad, &request, &expected, &held).is_err(),
1412                "{field}"
1413            );
1414        }
1415        for field in [
1416            "schema",
1417            "invocation",
1418            "request_sha256",
1419            "host_sha256",
1420            "installation_sha256",
1421            "config_sha256",
1422            "executable_sha256",
1423            "config_path",
1424            "operation_key",
1425        ] {
1426            let bad = replace_record_field(&ebytes, field, serde_json::json!("invalid"));
1427            assert!(CaptureExpected::decode(&bad, &request).is_err(), "{field}");
1428        }
1429        for (field, value) in [
1430            ("schema", serde_json::json!("gate-runtime/lifecycle-v2")),
1431            ("invocation", serde_json::json!("BAD")),
1432            ("timeout_ms", serde_json::json!(0)),
1433            ("timeout_ms", serde_json::json!(30001)),
1434            ("image_id", serde_json::json!("short")),
1435        ] {
1436            assert!(CaptureRequest::decode(&replace_record_field(&rbytes, field, value)).is_err());
1437        }
1438        let duplicate = String::from_utf8(rbytes.clone()).unwrap().replacen(
1439            "{",
1440            "{\"schema\":\"duplicate\",",
1441            1,
1442        );
1443        assert!(CaptureRequest::decode(duplicate.as_bytes()).is_err());
1444        let mut trailing = response.clone();
1445        trailing.push(b'\n');
1446        assert!(decode_response(&trailing, &request, &expected, &held).is_err());
1447        assert!(CaptureRequest::decode(&vec![b' '; 65537]).is_err());
1448        assert!(CaptureExpected::decode(&vec![b' '; 16385], &request).is_err());
1449        assert!(decode_response(
1450            &vec![b' '; 24 * 1024 * 1024 + 1],
1451            &request,
1452            &expected,
1453            &held
1454        )
1455        .is_err());
1456    }
1457    #[test]
1458    fn protected_capture_inputs_bind_original_fd_and_restore_cloexec() {
1459        use super::super::{
1460            capture_inputs,
1461            capture_wire::{CaptureExpected, CaptureRequest},
1462            installation::VerifiedPodmanInstallation,
1463            plan_record::PlanInputs,
1464        };
1465        let (f, host) = installed_fixture();
1466        let installed = VerifiedPodmanInstallation::load(&host).unwrap();
1467        let request = CaptureRequest {
1468            schema: "gate-runtime/podman-capture-v1".into(),
1469            profile: installed.identity().unwrap().profile.clone(),
1470            invocation: "1".repeat(32),
1471            image_id: "b".repeat(64),
1472            inputs: PlanInputs {
1473                name: "fixture".into(),
1474                labels: [("owner".into(), "fixture".into())].into(),
1475                port: None,
1476            },
1477            timeout_ms: 1000,
1478        };
1479        let expected = CaptureExpected::bind(
1480            &request,
1481            installed.identity().unwrap(),
1482            format!("{:x}", Sha256::digest(fs::read(&host).unwrap())),
1483            f.config.to_str().unwrap().into(),
1484        )
1485        .unwrap();
1486        let raw = request.encode().unwrap();
1487        let eb = expected.encode(&request).unwrap();
1488        let seals =
1489            libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK | libc::F_SEAL_SEAL;
1490        let config = File::open(&f.config).unwrap();
1491        let witness = config.try_clone().unwrap();
1492        assert_eq!(
1493            unsafe { libc::fcntl(config.as_raw_fd(), libc::F_SETFD, 0) },
1494            0
1495        );
1496        let parsed =
1497            capture_inputs::parse(config, capture_inputs::test_memory(&eb, seals, 0o600), &raw)
1498                .unwrap();
1499        assert_eq!(
1500            parsed.context().unwrap().snapshot().unwrap().1,
1501            fs::read(&f.config).unwrap()
1502        );
1503        assert_ne!(
1504            unsafe {
1505                libc::fcntl(
1506                    parsed.context().unwrap().config.file.as_raw_fd(),
1507                    libc::F_GETFD,
1508                )
1509            } & libc::FD_CLOEXEC,
1510            0
1511        );
1512        assert_eq!(parsed.expected().operation_key, expected.operation_key);
1513        assert_eq!(parsed.request().invocation, request.invocation);
1514        assert_ne!(
1515            unsafe { libc::fcntl(witness.as_raw_fd(), libc::F_GETFD) } & libc::FD_CLOEXEC,
1516            0
1517        );
1518        let other = Fixture::new();
1519        assert!(capture_inputs::parse(
1520            File::open(&other.config).unwrap(),
1521            capture_inputs::test_memory(&eb, seals, 0o600),
1522            &raw
1523        )
1524        .is_err());
1525        let altered = replace_record_field(&raw, "image_id", serde_json::json!("c".repeat(64)));
1526        assert!(capture_inputs::parse(
1527            File::open(&f.config).unwrap(),
1528            capture_inputs::test_memory(&eb, seals, 0o600),
1529            &altered
1530        )
1531        .is_err());
1532        assert!(capture_inputs::parse(
1533            File::open(&f.config).unwrap(),
1534            capture_inputs::test_memory(&eb, 0, 0o600),
1535            &raw
1536        )
1537        .is_err());
1538    }
1539}