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