server/scan-worker/tests/sidecar.rs
//! **The scan worker as a SIDECAR** (lane B4, A31 §7.3): the real
//! `holger-scan-worker serve` binary on a unix socket, and the server's own
//! [`ScanWorker`] over a `Remote` sandbox that asks it. This is the Kubernetes
//! shape without Kubernetes: two processes, one shared directory, no runtime.
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use holger_sandbox::{EngineKind, Sandbox, SandboxConfig};
use server_lib::sandbox::{JobTemplate, ScanWorker};
use server_lib::scanner::{ScanSubject, ScannerKind};
struct Sidecar {
child: Child,
_tmp: tempfile::TempDir,
share: PathBuf,
socket: PathBuf,
}
impl Drop for Sidecar {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn start() -> Sidecar {
let tmp = tempfile::tempdir().unwrap();
let share = tmp.path().join("share");
let scratch = tmp.path().join("scratch");
std::fs::create_dir_all(&share).unwrap();
std::fs::create_dir_all(&scratch).unwrap();
let socket = share.join("worker.sock");
let child = Command::new(env!("CARGO_BIN_EXE_holger-scan-worker"))
.arg("serve")
.arg("--socket")
.arg(&socket)
.arg("--share")
.arg(&share)
.arg("--scratch")
.arg(&scratch)
.stdin(Stdio::null())
.spawn()
.expect("the sidecar starts");
wait_for(&socket);
Sidecar {
child,
_tmp: tmp,
share,
socket,
}
}
fn wait_for(socket: &Path) {
let t = Instant::now();
while !socket.exists() {
assert!(
t.elapsed() < Duration::from_secs(20),
"the sidecar never bound {}",
socket.display()
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn worker(side: &Sidecar) -> Arc<ScanWorker> {
let cfg = SandboxConfig {
engine: EngineKind::Remote,
socket: Some(side.socket.display().to_string()),
work_dir: Some(side.share.display().to_string()),
..Default::default()
};
let template = JobTemplate {
format_name: "raw".into(),
inspect: holger_inspect::InspectConfig::default(),
kinds: vec![
ScannerKind::Inspection,
ScannerKind::License,
ScannerKind::Sbom,
],
license: Default::default(),
allow_pickle: Vec::new(),
secrets: None,
};
Arc::new(ScanWorker::with_sandbox(Sandbox::from_config(cfg).unwrap(), template).unwrap())
}
#[test]
fn the_server_scans_through_the_sidecar_and_the_report_is_checked() {
let side = start();
let w = worker(&side);
assert!(w.is_isolated());
w.ready().expect("the sidecar answers");
let bytes = b"hello from the shared directory\n";
let r = w
.report(&ScanSubject::new("hello.txt", "raw", bytes))
.expect("the scan runs in the sidecar");
use sha2::Digest as _;
let want: String = sha2::Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert_eq!(r.sha256, want, "the worker read the bytes the server wrote");
assert_eq!(w.runs(), 1);
}
#[test]
fn the_probe_through_the_sidecar_shows_no_new_privileges_and_no_network_client() {
let side = start();
let v = worker(&side)
.probe()
.expect("the probe runs in the sidecar");
assert_eq!(v["no_new_privs"], "1", "{v}");
assert!(v["engine"].as_str().unwrap().contains("sidecar"), "{v}");
}
/// **The probe fits the job's own limits** (lane OX-HOLFLAV). In a Kubernetes pod
/// `GET /-/sandbox/probe` answered `[holger-sandbox-3] the scan worker was killed by
/// its limits`: the probe built its 1 GiB "past the scratch cap" write as one 1 GiB
/// buffer, which the sidecar's `RLIMIT_AS` (the job's memory plus 64 MiB) refuses,
/// and a write past `RLIMIT_FSIZE` is a SIGXFSZ, not an error. The probe now writes
/// its scratch in the job's own `TMPDIR` in 1 MiB pieces and reads the cap as a
/// refusal, under a job memory far below 1 GiB.
#[test]
fn the_probe_runs_inside_a_small_job_memory_and_scratch_limit() {
let side = start();
let cfg = SandboxConfig {
engine: EngineKind::Remote,
socket: Some(side.socket.display().to_string()),
work_dir: Some(side.share.display().to_string()),
limits: holger_sandbox::Limits {
memory_mib: 256,
scratch_mib: 32,
..Default::default()
},
..Default::default()
};
let template = JobTemplate {
format_name: "raw".into(),
inspect: holger_inspect::InspectConfig::default(),
kinds: vec![ScannerKind::Inspection],
license: Default::default(),
allow_pickle: Vec::new(),
secrets: None,
};
let w = ScanWorker::with_sandbox(Sandbox::from_config(cfg).unwrap(), template).unwrap();
let v = w
.probe()
.expect("the probe runs inside a 256 MiB job with a 32 MiB scratch");
assert_eq!(v["write_scratch"], "written", "{v}");
assert!(
v["write_past_scratch_cap"]
.as_str()
.unwrap()
.starts_with("refused"),
"the scratch cap holds: {v}"
);
}
#[test]
fn the_sidecar_gone_makes_the_worker_not_ready_by_name() {
let mut side = start();
let w = worker(&side);
w.ready().unwrap();
let _ = side.child.kill();
let _ = side.child.wait();
let _ = std::fs::remove_file(&side.socket);
let e = w.ready().unwrap_err();
assert_eq!(e.code().code, "holger-sandbox-1", "{e}");
}
/// The sidecar is PID 1 in its container, which ignores a signal it has no
/// handler for: without one the kubelet waits out the grace period and kills it.
#[test]
fn sigterm_ends_the_sidecar_and_takes_its_socket_with_it() {
let mut side = start();
let pid = rustix::process::Pid::from_raw(side.child.id() as i32).unwrap();
rustix::process::kill_process(pid, rustix::process::Signal::TERM).unwrap();
let t = Instant::now();
let status = loop {
if let Some(s) = side.child.try_wait().unwrap() {
break s;
}
assert!(
t.elapsed() < Duration::from_secs(5),
"the sidecar ignored SIGTERM"
);
std::thread::sleep(Duration::from_millis(20));
};
assert!(status.success(), "a clean exit: {status:?}");
assert!(!side.socket.exists(), "the socket is removed");
}