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