1use 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 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 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#[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 pub fn load(path: &Path) -> Result<Self> {
174 Self::load_format(path, false)
175 }
176 pub fn load_versioned(path: &Path) -> Result<Self> {
179 Self::load_format(path, true)
180 }
181 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 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 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 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 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 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}