1use std::fs;
28use std::io;
29use std::path::{Component, Path, PathBuf};
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34pub const JOB_LABEL: &str = "runner-manager.job";
37
38pub const CONFIG_FILE: &str = "docker-policy.json";
40
41pub const LOG_DIR: &str = "docker-policy";
43pub const LOG_FILE: &str = "violations.jsonl";
45const LOG_ROTATE_BYTES: u64 = 1024 * 1024;
47
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Mode {
52 Off,
54 #[default]
56 Audit,
57 Enforce,
59}
60
61impl Mode {
62 #[must_use]
64 pub const fn as_str(self) -> &'static str {
65 match self {
66 Self::Off => "off",
67 Self::Audit => "audit",
68 Self::Enforce => "enforce",
69 }
70 }
71}
72
73impl std::str::FromStr for Mode {
74 type Err = String;
75
76 fn from_str(text: &str) -> Result<Self, Self::Err> {
77 [Self::Off, Self::Audit, Self::Enforce]
78 .into_iter()
79 .find(|mode| mode.as_str() == text)
80 .ok_or_else(|| format!("expected off, audit or enforce, not {text}"))
81 }
82}
83
84#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Config {
88 #[serde(default)]
89 pub mode: Mode,
90 #[serde(default)]
92 pub allow: Vec<PathBuf>,
93}
94
95impl Config {
96 pub fn load(path: &Path) -> io::Result<Self> {
102 runner_manager_platform::settings_file::load(path)
103 }
104
105 pub fn save(&self, path: &Path) -> io::Result<()> {
111 runner_manager_platform::settings_file::save(path, self)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Context {
118 pub mode: Mode,
119 pub allowed: Vec<PathBuf>,
121 #[serde(default)]
125 pub protected: Vec<PathBuf>,
126 pub log: PathBuf,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum Access {
133 Allowed,
134 Outside,
136 Protected,
138}
139
140impl Context {
141 #[must_use]
144 pub fn records(&self, violation: &Violation) -> bool {
145 self.mode != Mode::Off || violation.rule == PROTECTED_PATH
146 }
147
148 #[must_use]
151 pub fn refuses(&self, violation: &Violation) -> bool {
152 self.mode == Mode::Enforce || violation.rule == PROTECTED_PATH
153 }
154
155 #[must_use]
163 pub fn access(&self, source: &Path) -> Access {
164 let Some(resolved) = (source.is_absolute()
165 && !source
166 .components()
167 .any(|component| matches!(component, Component::ParentDir)))
168 .then(|| resolve(source))
169 .flatten() else {
170 return Access::Outside;
171 };
172 if self
173 .protected
174 .iter()
175 .any(|protected| resolved.starts_with(protected) || protected.starts_with(&resolved))
176 {
177 Access::Protected
178 } else if self.allowed.iter().any(|root| resolved.starts_with(root)) {
179 Access::Allowed
180 } else {
181 Access::Outside
182 }
183 }
184}
185
186const PROTECTED_PATH: &str = "protected_path";
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Violation {
192 pub rule: String,
193 pub detail: String,
194}
195
196impl Violation {
197 pub(crate) fn new(rule: &str, detail: impl Into<String>) -> Self {
198 let mut detail = detail.into();
199 if detail.len() > 300 {
200 let mut end = 300;
201 while !detail.is_char_boundary(end) {
202 end -= 1;
203 }
204 detail.truncate(end);
205 }
206 Self {
207 rule: rule.to_owned(),
208 detail,
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct Record {
216 pub at: String,
217 pub attempt: String,
218 pub repository: String,
219 pub rule: String,
220 pub detail: String,
221 pub refused: bool,
223}
224
225pub fn record(path: &Path, records: &[Record]) -> io::Result<()> {
231 use std::io::Write as _;
232 if records.is_empty() {
233 return Ok(());
234 }
235 if let Some(parent) = path.parent() {
236 fs::create_dir_all(parent)?;
237 }
238 if fs::metadata(path).is_ok_and(|metadata| metadata.len() > LOG_ROTATE_BYTES) {
239 let mut rotated = path.as_os_str().to_owned();
240 rotated.push(".1");
241 fs::rename(path, rotated)?;
242 }
243 let mut options = fs::OpenOptions::new();
244 options.create(true).append(true);
245 #[cfg(unix)]
246 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
247 let mut file = options.open(path)?;
248 for record in records {
249 let mut line = serde_json::to_vec(record).map_err(super::invalid)?;
250 line.push(b'\n');
251 file.write_all(&line)?;
252 }
253 Ok(())
254}
255
256#[must_use]
259pub fn read_records(path: &Path) -> Vec<Record> {
260 let mut rotated = path.as_os_str().to_owned();
261 rotated.push(".1");
262 [PathBuf::from(rotated), path.to_path_buf()]
263 .iter()
264 .filter_map(|path| fs::read_to_string(path).ok())
265 .flat_map(|text| {
266 text.lines()
267 .filter_map(|line| serde_json::from_str(line).ok())
268 .collect::<Vec<_>>()
269 })
270 .collect()
271}
272
273#[must_use]
276pub fn unversioned(endpoint: &str) -> &str {
277 let rest = endpoint.strip_prefix('/').unwrap_or(endpoint);
278 match rest.split_once('/') {
279 Some((version, tail))
280 if version.len() > 1
281 && version.starts_with('v')
282 && version[1..].chars().all(|c| c.is_ascii_digit() || c == '.') =>
283 {
284 &endpoint[endpoint.len() - tail.len() - 1..]
285 }
286 _ => endpoint,
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum Target<'a> {
293 Container(&'a str),
294 Exec(&'a str),
295 None,
296}
297
298#[must_use]
300pub fn target(endpoint: &str) -> Target<'_> {
301 let mut parts = endpoint.trim_start_matches('/').split('/');
302 match (parts.next(), parts.next()) {
303 (Some("containers"), Some(id)) if !matches!(id, "" | "create" | "json" | "prune") => {
304 Target::Container(id)
305 }
306 (Some("exec"), Some(id)) if !id.is_empty() => Target::Exec(id),
307 _ => Target::None,
308 }
309}
310
311#[must_use]
313pub fn check_endpoint(method: &str, endpoint: &str, query: &str) -> Vec<Violation> {
314 let mut violations = Vec::new();
315 let first = endpoint
316 .trim_start_matches('/')
317 .split('/')
318 .next()
319 .unwrap_or("");
320 if matches!(
321 first,
322 "plugins" | "swarm" | "services" | "secrets" | "configs" | "nodes" | "tasks"
323 ) {
324 violations.push(Violation::new(
325 "host_wide_api",
326 format!("{method} {endpoint} acts on the engine itself"),
327 ));
328 }
329 if first == "libpod" {
333 violations.push(Violation::new(
334 "unchecked_api",
335 format!("{method} {endpoint} is Podman's native API, which this policy cannot check"),
336 ));
337 }
338 if method == "POST"
339 && matches!(
340 endpoint,
341 "/containers/prune"
342 | "/volumes/prune"
343 | "/networks/prune"
344 | "/images/prune"
345 | "/build/prune"
346 )
347 {
348 violations.push(Violation::new(
349 "host_wide_prune",
350 format!("{endpoint} removes what other jobs and the host created"),
351 ));
352 }
353 if method == "POST"
354 && endpoint == "/build"
355 && query
356 .split('&')
357 .any(|field| field.eq_ignore_ascii_case("networkmode=host"))
358 {
359 violations.push(Violation::new(
360 "host_network",
361 "a build on the host's network",
362 ));
363 }
364 violations
365}
366
367#[must_use]
370pub fn check_container_create(body: &Value, allowed: &dyn Fn(&Path) -> Access) -> Vec<Violation> {
371 let mut violations = Vec::new();
372 let host = body.get("HostConfig").unwrap_or(&Value::Null);
373 if host.get("Privileged").and_then(Value::as_bool) == Some(true) {
374 violations.push(Violation::new("privileged", "a privileged container"));
375 }
376 for (key, rule) in [
377 ("NetworkMode", "host_network"),
378 ("PidMode", "host_pid"),
379 ("IpcMode", "host_ipc"),
380 ("UTSMode", "host_uts"),
381 ("UsernsMode", "host_userns"),
382 ("CgroupnsMode", "host_cgroupns"),
383 ] {
384 if host.get(key).and_then(Value::as_str) == Some("host") {
385 violations.push(Violation::new(rule, format!("{key}=host")));
386 }
387 }
388 if let Some(added) = host.get("CapAdd").and_then(Value::as_array)
389 && !added.is_empty()
390 {
391 let names = added
392 .iter()
393 .filter_map(Value::as_str)
394 .collect::<Vec<_>>()
395 .join(",");
396 violations.push(Violation::new(
397 "added_capability",
398 format!("CapAdd={names}"),
399 ));
400 }
401 for key in ["Devices", "DeviceCgroupRules", "DeviceRequests"] {
402 if host
403 .get(key)
404 .and_then(Value::as_array)
405 .is_some_and(|devices| !devices.is_empty())
406 {
407 violations.push(Violation::new("host_device", format!("{key} is set")));
408 }
409 }
410 if let Some(options) = host.get("SecurityOpt").and_then(Value::as_array) {
411 for option in options.iter().filter_map(Value::as_str) {
412 let lowered = option.to_ascii_lowercase();
413 if lowered.contains("unconfined")
414 || lowered == "label=disable"
415 || lowered == "label:disable"
416 || lowered.starts_with("no-new-privileges=false")
417 || lowered.starts_with("no-new-privileges:false")
418 {
419 violations.push(Violation::new(
420 "weakened_security",
421 format!("SecurityOpt={option}"),
422 ));
423 }
424 }
425 }
426 for key in ["MaskedPaths", "ReadonlyPaths"] {
429 if host.get(key).is_some_and(Value::is_array) {
430 violations.push(Violation::new(
431 "weakened_security",
432 format!("{key} replaces the engine's defaults"),
433 ));
434 }
435 }
436 if let Some(binds) = host.get("Binds").and_then(Value::as_array) {
437 for bind in binds.iter().filter_map(Value::as_str) {
438 let source = bind.split(':').next().unwrap_or("");
439 if source.starts_with('/') {
440 check_host_path(Path::new(source), allowed, &mut violations);
441 }
442 }
443 }
444 if let Some(mounts) = host.get("Mounts").and_then(Value::as_array) {
445 for mount in mounts {
446 match mount.get("Type").and_then(Value::as_str) {
447 Some("bind") => {
448 if let Some(source) = mount.get("Source").and_then(Value::as_str) {
449 check_host_path(Path::new(source), allowed, &mut violations);
450 }
451 }
452 Some("volume") => {
453 if let Some(device) = bound_device(
454 mount
455 .pointer("/VolumeOptions/DriverConfig/Options")
456 .unwrap_or(&Value::Null),
457 ) {
458 check_host_path(Path::new(device), allowed, &mut violations);
459 }
460 }
461 _ => {}
462 }
463 }
464 }
465 violations
466}
467
468#[must_use]
472pub fn referenced_containers(body: &Value) -> Vec<String> {
473 let host = body.get("HostConfig").unwrap_or(&Value::Null);
474 let mut referenced = Vec::new();
475 for key in ["NetworkMode", "PidMode", "IpcMode"] {
476 if let Some(other) = host
477 .get(key)
478 .and_then(Value::as_str)
479 .and_then(|mode| mode.strip_prefix("container:"))
480 {
481 referenced.push(other.to_owned());
482 }
483 }
484 if let Some(sources) = host.get("VolumesFrom").and_then(Value::as_array) {
485 referenced.extend(
486 sources
487 .iter()
488 .filter_map(Value::as_str)
489 .map(|source| source.split(':').next().unwrap_or(source).to_owned()),
490 );
491 }
492 referenced
493}
494
495#[must_use]
497pub fn check_exec_create(body: &Value) -> Vec<Violation> {
498 if body.get("Privileged").and_then(Value::as_bool) == Some(true) {
499 vec![Violation::new("privileged", "a privileged exec")]
500 } else {
501 Vec::new()
502 }
503}
504
505#[must_use]
507pub fn check_volume_create(body: &Value, allowed: &dyn Fn(&Path) -> Access) -> Vec<Violation> {
508 let mut violations = Vec::new();
509 if let Some(device) = bound_device(body.get("DriverOpts").unwrap_or(&Value::Null)) {
510 check_host_path(Path::new(device), allowed, &mut violations);
511 }
512 violations
513}
514
515#[must_use]
517pub fn check_network_create(body: &Value) -> Vec<Violation> {
518 match body.get("Driver").and_then(Value::as_str) {
519 Some(driver @ ("macvlan" | "ipvlan")) => vec![Violation::new(
520 "host_network",
521 format!("a {driver} network on the host's LAN"),
522 )],
523 _ => Vec::new(),
524 }
525}
526
527fn bound_device(options: &Value) -> Option<&str> {
531 options
532 .get("device")
533 .and_then(Value::as_str)
534 .filter(|device| device.starts_with('/'))
535}
536
537const ENGINE_SOCKETS: [&str; 2] = ["/var/run/docker.sock", "/run/docker.sock"];
540
541pub fn redirect_engine_socket(body: &mut Value, proxy: &Path) -> usize {
549 let is_engine = |source: &str| ENGINE_SOCKETS.contains(&source);
550 let proxy = proxy.display().to_string();
551 let mut changed = 0;
552 let Some(host) = body.get_mut("HostConfig").and_then(Value::as_object_mut) else {
553 return 0;
554 };
555 if let Some(binds) = host.get_mut("Binds").and_then(Value::as_array_mut) {
556 for bind in binds {
557 let Some(text) = bind.as_str() else { continue };
558 let (source, rest) = text.split_once(':').unwrap_or((text, ""));
559 if is_engine(source) {
560 *bind = Value::String(if rest.is_empty() {
561 proxy.clone()
562 } else {
563 format!("{proxy}:{rest}")
564 });
565 changed += 1;
566 }
567 }
568 }
569 if let Some(mounts) = host.get_mut("Mounts").and_then(Value::as_array_mut) {
570 for mount in mounts {
571 let bind = mount.get("Type").and_then(Value::as_str) == Some("bind");
572 if let Some(source) = mount.get_mut("Source")
573 && bind
574 && source.as_str().is_some_and(is_engine)
575 {
576 *source = Value::String(proxy.clone());
577 changed += 1;
578 }
579 }
580 }
581 changed
582}
583
584fn check_host_path(
585 source: &Path,
586 allowed: &dyn Fn(&Path) -> Access,
587 violations: &mut Vec<Violation>,
588) {
589 if ENGINE_SOCKETS
590 .iter()
591 .any(|socket| source == Path::new(socket))
592 {
593 violations.push(Violation::new(
594 "engine_socket",
595 format!("{} is the Docker engine itself", source.display()),
596 ));
597 return;
598 }
599 match allowed(source) {
600 Access::Allowed => {}
601 Access::Protected => violations.push(Violation::new(
602 PROTECTED_PATH,
603 format!("{} is a protected host path", source.display()),
604 )),
605 Access::Outside => violations.push(Violation::new(
606 "host_path",
607 format!("{} is outside the job's own directories", source.display()),
608 )),
609 }
610}
611
612#[must_use]
614pub fn resolve(path: &Path) -> Option<PathBuf> {
615 let mut existing = path.to_path_buf();
616 let mut rest = Vec::new();
617 loop {
618 match fs::canonicalize(&existing) {
619 Ok(mut resolved) => {
620 for component in rest.iter().rev() {
621 resolved.push(component);
622 }
623 return Some(resolved);
624 }
625 Err(_) => {
626 rest.push(existing.file_name()?.to_owned());
627 existing = existing.parent()?.to_path_buf();
628 }
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use serde_json::json;
637
638 fn rules(violations: &[Violation]) -> Vec<&str> {
639 violations
640 .iter()
641 .map(|violation| violation.rule.as_str())
642 .collect()
643 }
644
645 #[test]
646 fn the_version_prefix_is_dropped_and_targets_are_found() {
647 assert_eq!(
648 unversioned("/v1.47/containers/abc/json"),
649 "/containers/abc/json"
650 );
651 assert_eq!(unversioned("/containers/abc/json"), "/containers/abc/json");
652 assert_eq!(unversioned("/version"), "/version");
653 assert_eq!(target("/containers/abc/exec"), Target::Container("abc"));
654 assert_eq!(target("/containers/create"), Target::None);
655 assert_eq!(target("/containers/json"), Target::None);
656 assert_eq!(target("/exec/e1/start"), Target::Exec("e1"));
657 assert_eq!(target("/images/json"), Target::None);
658 }
659
660 #[test]
661 fn host_wide_endpoints_are_violations_and_ordinary_ones_are_not() {
662 assert_eq!(
663 rules(&check_endpoint("POST", "/swarm/init", "")),
664 ["host_wide_api"]
665 );
666 assert_eq!(
667 rules(&check_endpoint("POST", "/plugins/pull", "")),
668 ["host_wide_api"]
669 );
670 assert_eq!(
671 rules(&check_endpoint("POST", "/containers/prune", "")),
672 ["host_wide_prune"]
673 );
674 assert_eq!(
675 rules(&check_endpoint("POST", "/build", "t=x&networkmode=host")),
676 ["host_network"]
677 );
678 assert!(check_endpoint("POST", "/build", "t=x").is_empty());
679 assert_eq!(
680 rules(&check_endpoint("POST", "/images/prune", "")),
681 ["host_wide_prune"]
682 );
683 assert_eq!(
684 rules(&check_endpoint("POST", "/libpod/containers/create", "")),
685 ["unchecked_api"]
686 );
687 assert!(check_endpoint("GET", "/containers/json", "").is_empty());
688 assert!(check_endpoint("POST", "/images/create", "fromImage=x").is_empty());
689 }
690
691 #[test]
692 fn a_container_that_reaches_for_the_host_breaks_each_rule_it_touches() {
693 let body = json!({
694 "HostConfig": {
695 "Privileged": true,
696 "NetworkMode": "host",
697 "PidMode": "host",
698 "CapAdd": ["SYS_ADMIN"],
699 "Devices": [{"PathOnHost": "/dev/sda"}],
700 "SecurityOpt": ["seccomp=unconfined"],
701 "Binds": ["/etc:/host-etc:ro", "/var/run/docker.sock:/var/run/docker.sock", "named:/data"],
702 "Mounts": [{"Type": "bind", "Source": "/root", "Target": "/r"}]
703 }
704 });
705 let found = check_container_create(&body, &|_| Access::Outside);
706 assert_eq!(
707 rules(&found),
708 [
709 "privileged",
710 "host_network",
711 "host_pid",
712 "added_capability",
713 "host_device",
714 "weakened_security",
715 "host_path",
716 "engine_socket",
717 "host_path"
718 ]
719 );
720 }
721
722 #[test]
723 fn an_ordinary_job_container_breaks_nothing() {
724 let body = json!({
725 "Image": "node:22",
726 "HostConfig": {
727 "NetworkMode": "bridge",
728 "Binds": ["/attempt/_work:/__w", "/attempt/externals:/__e:ro", "cache:/cache"],
729 "Mounts": [{"Type": "tmpfs", "Target": "/tmp"}]
730 }
731 });
732 let found = check_container_create(&body, &|path| {
733 if path.starts_with("/attempt") {
734 Access::Allowed
735 } else {
736 Access::Outside
737 }
738 });
739 assert!(found.is_empty(), "{found:?}");
740 }
741
742 #[test]
743 fn containers_a_new_one_joins_or_borrows_from_are_named() {
744 let body = json!({"HostConfig": {
745 "NetworkMode": "container:db",
746 "PidMode": "host",
747 "VolumesFrom": ["data:ro", "cache"]
748 }});
749 assert_eq!(referenced_containers(&body), ["db", "data", "cache"]);
750 }
751
752 #[test]
753 fn volumes_and_networks_that_bind_the_host_are_violations() {
754 let bound = json!({"Driver": "local", "DriverOpts": {"type": "none", "o": "bind", "device": "/srv/secret"}});
755 assert_eq!(
756 rules(&check_volume_create(&bound, &|_| Access::Outside)),
757 ["host_path"]
758 );
759 assert!(check_volume_create(&json!({"Driver": "local"}), &|_| Access::Outside).is_empty());
760 let disk =
761 json!({"Driver": "local", "DriverOpts": {"type": "ext4", "device": "/dev/sda1"}});
762 assert_eq!(
763 rules(&check_volume_create(&disk, &|_| Access::Outside)),
764 ["host_path"],
765 "a host block device is mounted without any bind option"
766 );
767 assert_eq!(
768 rules(&check_container_create(
769 &json!({"HostConfig": {"MaskedPaths": [], "ReadonlyPaths": null}}),
770 &|_| Access::Outside
771 )),
772 ["weakened_security"]
773 );
774 assert_eq!(
775 rules(&check_network_create(&json!({"Driver": "macvlan"}))),
776 ["host_network"]
777 );
778 assert!(check_network_create(&json!({"Driver": "bridge"})).is_empty());
779 assert_eq!(
780 rules(&check_exec_create(&json!({"Privileged": true}))),
781 ["privileged"]
782 );
783 assert!(check_exec_create(&json!({"Cmd": ["sh"]})).is_empty());
784 }
785
786 #[cfg(unix)]
787 #[test]
788 fn a_link_inside_an_allowed_directory_does_not_carry_a_mount_outside_it() {
789 let root = tempfile::tempdir().unwrap();
790 let outside = tempfile::tempdir().unwrap();
791 let attempt = root.path().join("attempt");
792 fs::create_dir_all(attempt.join("_work")).unwrap();
793 std::os::unix::fs::symlink(outside.path(), attempt.join("escape")).unwrap();
794 let context = Context {
795 mode: Mode::Enforce,
796 allowed: vec![fs::canonicalize(&attempt).unwrap()],
797 protected: vec![],
798 log: root.path().join("log"),
799 };
800 let inside = |path: &Path| context.access(path) == Access::Allowed;
801 assert!(inside(&attempt.join("_work")));
802 assert!(inside(&attempt.join("_work/not/yet/created")));
803 assert!(!inside(&attempt.join("escape")));
804 assert!(!inside(&attempt.join("escape/inner")));
805 assert!(!inside(&attempt.join("_work/../../etc")));
806 assert!(!inside(Path::new("relative")));
807 }
808
809 #[cfg(unix)]
810 #[test]
811 fn a_protected_path_is_refused_even_inside_an_allowed_directory() {
812 let root = tempfile::tempdir().unwrap();
813 let shared = root.path().join("shared");
814 fs::create_dir_all(shared.join("keys")).unwrap();
815 fs::create_dir_all(shared.join("data")).unwrap();
816 let context = Context {
817 mode: Mode::Enforce,
818 allowed: vec![fs::canonicalize(&shared).unwrap()],
819 protected: vec![fs::canonicalize(shared.join("keys")).unwrap()],
820 log: root.path().join("log"),
821 };
822 assert_eq!(context.access(&shared.join("data")), Access::Allowed);
823 assert_eq!(context.access(&shared.join("keys/id")), Access::Protected);
824 assert_eq!(
825 context.access(&shared),
826 Access::Protected,
827 "a parent exposes it too"
828 );
829 assert_eq!(context.access(Path::new("/etc")), Access::Outside);
830 let body =
831 json!({"HostConfig": {"Binds": [format!("{}:/k", shared.join("keys").display())]}});
832 assert_eq!(
833 rules(&check_container_create(&body, &|path| context.access(path))),
834 ["protected_path"]
835 );
836 }
837
838 #[test]
839 fn a_protected_path_is_refused_in_every_mode_and_the_rest_only_when_enforced() {
840 let protected = Violation::new(PROTECTED_PATH, "/srv/keys");
841 let privileged = Violation::new("privileged", "a privileged container");
842 for (mode, recorded, refused) in [
843 (Mode::Off, false, false),
844 (Mode::Audit, true, false),
845 (Mode::Enforce, true, true),
846 ] {
847 let context = Context {
848 mode,
849 allowed: vec![],
850 protected: vec![],
851 log: PathBuf::from("log"),
852 };
853 assert!(context.records(&protected) && context.refuses(&protected));
854 assert_eq!(context.records(&privileged), recorded, "{mode:?}");
855 assert_eq!(context.refuses(&privileged), refused, "{mode:?}");
856 }
857 }
858
859 #[test]
860 fn the_engine_socket_a_job_container_is_given_becomes_the_proxy() {
861 let mut body = json!({"HostConfig": {
862 "Binds": ["/var/run/docker.sock:/var/run/docker.sock", "/attempt/_work:/__w"],
863 "Mounts": [{"Type": "bind", "Source": "/run/docker.sock", "Target": "/d.sock"}]
864 }});
865 let proxy = Path::new("/attempt/.docker-proxy/s");
866 assert_eq!(redirect_engine_socket(&mut body, proxy), 2);
867 assert_eq!(
868 body.pointer("/HostConfig/Binds/0").unwrap(),
869 "/attempt/.docker-proxy/s:/var/run/docker.sock"
870 );
871 assert_eq!(
872 body.pointer("/HostConfig/Binds/1").unwrap(),
873 "/attempt/_work:/__w"
874 );
875 assert_eq!(
876 body.pointer("/HostConfig/Mounts/0/Source").unwrap(),
877 "/attempt/.docker-proxy/s"
878 );
879 let allowed = |path: &Path| {
880 if path == proxy || path.starts_with("/attempt") {
881 Access::Allowed
882 } else {
883 Access::Outside
884 }
885 };
886 assert!(check_container_create(&body, &allowed).is_empty());
887 }
888
889 #[test]
890 fn records_round_trip_through_a_rotating_log() {
891 let directory = tempfile::tempdir().unwrap();
892 let log = directory.path().join(LOG_DIR).join(LOG_FILE);
893 let entry = Record {
894 at: "2026-11-10T00:00:00Z".into(),
895 attempt: "a".into(),
896 repository: "o/r".into(),
897 rule: "privileged".into(),
898 detail: "a privileged container".into(),
899 refused: false,
900 };
901 record(&log, std::slice::from_ref(&entry)).unwrap();
902 record(&log, std::slice::from_ref(&entry)).unwrap();
903 assert_eq!(read_records(&log), [entry.clone(), entry]);
904 }
905}