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