dwarves-gui/tests/robot_exec_sandbox.rs
//! **The `exe` pane's run is containerised — guarded, and watched failing.**
//!
//! Decided 2026-08-06: ▶ KÖR used to spawn the built artifact as a bare child process
//! with the operator's environment, cwd and write access. "What does this build do?" was
//! answered by letting it do it, to this machine. Now it runs inside a container with the
//! artifact bind-mounted read-only and no network.
//!
//! The property worth guarding needs no engine to check: **the command the container runs
//! is the CONTAINER path, and the mount is read-only.** A spec carrying the host path
//! would be a bare-process run wearing a container's clothes, and that is the exact
//! regression these tests are pointed at.
//!
//! Nothing here starts a container. The one-per-pane rule is guarded structurally
//! ([`every_start_tears_the_previous_one_down_first`]) rather than by racing two.
use dwarves_gui::boot_run::{ArtifactExecLauncher, ContainerLauncher, ExecSandbox};
/// A host path that is obviously not a container path, so the two cannot be confused.
const HOST: &str = "/home/someone/checkout/target/release/orten-server";
/// **The container runs the container path. Never the host path.**
///
/// Watched red by asserting the two strings differ first: if the sandbox handed the host
/// path through, `cmd()[0]` would equal `HOST` and the assertion below would be
/// unfalsifiable rather than merely wrong.
#[test]
fn the_command_is_the_container_path_and_never_the_host_path() {
let s = ExecSandbox::new(HOST, vec![]);
// RED — the discrimination itself. These must not be the same string, or nothing
// below distinguishes a sandboxed run from a bare one.
assert_ne!(
s.in_container(),
HOST,
"the in-container path IS the host path, so the guard cannot tell them apart"
);
let cmd = s.cmd();
assert_eq!(
cmd.first().map(String::as_str),
Some("/artefakt/orten-server")
);
assert!(
!cmd.iter().any(|a| a.contains("/home/")),
"a host path reached the container's argv: {cmd:?}"
);
// The mount point is the one the design names, not an incidental /tmp.
assert!(s.in_container().starts_with(ExecSandbox::MOUNT_DIR));
}
/// The artifact is mounted READ-ONLY. "It cannot write over anything outside" is the
/// whole reason for the change, and a read-write bind gives it back the artifact itself.
#[test]
fn the_artifact_is_mounted_read_only() {
let bind = ExecSandbox::new(HOST, vec![]).bind();
assert_eq!(bind, format!("{HOST}:/artefakt/orten-server:ro"));
// RED — the same assertion stated as the failure it prevents.
assert!(
bind.ends_with(":ro"),
"the artifact is mounted read-write: {bind}"
);
assert!(!bind.ends_with(":rw"), "{bind}");
}
/// The operator's argv rides AFTER the artifact, in the order typed — the pane is still
/// "an argv field and stdout", only the process is somewhere safer.
#[test]
fn the_typed_argv_rides_after_the_artifact_in_order() {
let s = ExecSandbox::new(HOST, vec!["--help".into(), "-v".into()]);
assert_eq!(s.cmd(), vec!["/artefakt/orten-server", "--help", "-v"]);
// RED — an empty argv is just the artifact, so the two cases are not the same shape.
assert_eq!(ExecSandbox::new(HOST, vec![]).cmd().len(), 1);
}
/// The pane SHOWS the sandbox rather than implying it: an operator reading stdout has to
/// be able to see it came out of a container, which one, and that it had no network.
#[test]
fn the_banner_names_the_image_the_mount_and_the_missing_network() {
let s = ExecSandbox::new(HOST, vec![]);
let b = s.banner();
assert!(
b.contains(&s.image),
"the banner does not name the image: {b}"
);
assert!(
b.contains(":ro"),
"the banner does not say the mount is read-only: {b}"
);
assert!(
b.contains("utan nät"),
"the banner does not say there is no network: {b}"
);
assert!(!s.image.is_empty(), "the sandbox has no image at all");
}
/// **Without an OCI engine the run REFUSES.** It does not quietly become the bare process
/// it replaced — that fallback is the one outcome this whole change exists to remove.
///
/// Only meaningful in a build without the engine, which is the default build.
#[cfg(not(feature = "container-run"))]
#[test]
fn without_an_oci_engine_the_run_refuses_instead_of_running_against_the_host() {
let launcher = ArtifactExecLauncher {
sandbox: ExecSandbox::new(HOST, vec!["--help".into()]),
};
let Err(err) = launcher.launch("orten-server", std::path::Path::new("/nonexistent")) else {
panic!("a build with no OCI engine cannot have run anything");
};
assert!(
err.contains("dwarves-launch-3"),
"the refusal carries no stable code: {err}"
);
assert!(
err.contains("container-run"),
"the refusal does not say what to rebuild with: {err}"
);
// RED — the refusal must not read as "ran it anyway, somewhere else".
assert!(
!err.to_ascii_lowercase().contains("host process"),
"the refusal offers a host fallback: {err}"
);
}
/// **One running container per pane.**
///
/// The rule is structural, so the guard is: every assignment to the forge's `live` slot
/// is immediately preceded by the teardown call. Racing two real containers to prove it
/// would be the opposite of keeping concurrent containers down.
///
/// Watched red on a synthetic source that assigns without tearing down first, and on one
/// that has no assignments at all (which would otherwise pass vacuously).
#[test]
fn every_start_tears_the_previous_one_down_first() {
/// Every `self.live = Some(` in `src` that is NOT immediately preceded by
/// `self.take_live();`, by line number.
fn unguarded(src: &str) -> Vec<usize> {
let lines: Vec<&str> = src.lines().collect();
lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim_start().starts_with("self.live = Some("))
.filter(|(i, _)| {
*i == 0
|| !lines[..*i]
.iter()
.rev()
.find(|p| !p.trim().is_empty() && !p.trim_start().starts_with("//"))
.is_some_and(|p| p.trim() == "self.take_live();")
})
.map(|(i, _)| i + 1)
.collect()
}
// RED 1 — an assignment with no teardown before it is found.
let bad = "fn a() {\n self.live = Some(x);\n}\n";
assert_eq!(
unguarded(bad),
vec![2],
"the scanner cannot see an unguarded start"
);
// RED 2 — a teardown that is not the immediately preceding statement does not count.
let sneaky = "fn a() {\n self.take_live();\n let y = 1;\n self.live = Some(x);\n}\n";
assert_eq!(
unguarded(sneaky),
vec![4],
"an intervening statement was accepted"
);
// The guarded shape passes, comments and blank lines in between allowed.
let good = "fn a() {\n self.take_live();\n // a comment\n\n self.live = Some(x);\n}\n";
assert!(unguarded(good).is_empty(), "the guarded shape was rejected");
// RED 3 — the real file must actually CONTAIN starts, or the green below is vacuous.
const FORGE: &str = include_str!("../src/build_forge.rs");
let starts = FORGE
.lines()
.filter(|l| l.trim_start().starts_with("self.live = Some("))
.count();
assert!(
starts >= 2,
"the forge starts nothing, so the rule guards nothing"
);
// GREEN, and it means something because of RED 3.
let found = unguarded(FORGE);
assert!(
found.is_empty(),
"build_forge.rs starts a container without tearing the previous one down, at \
line(s) {found:?} — that is two containers alive at once"
);
}
/// **The real thing: an artifact run through podman, proven by what it CANNOT see.**
///
/// Every check above is about the plan. This one starts a container (`--features
/// container-run`, the OCI engine linked) and asserts the two properties that separate a
/// sandboxed run from the bare process it replaced:
///
/// * a file sitting NEXT TO the artifact on the host, at an absolute path the artifact
/// knows, is **not visible** — a bare child process would have read it;
/// * a write into the mount **fails** — it is `:ro`.
///
/// The artifact under test is a `/bin/sh` script, so nothing here depends on the host's
/// libc matching the image's. It is watched red by the same script: the script itself
/// reports `HOST-FILE-VISIBLE` when it can see the sibling, so a run that escaped the
/// container fails this test with its own words rather than silently passing.
///
/// ONE container, torn down by the engine on exit (`run_to_completion`). No second test
/// in this file starts one.
#[cfg(feature = "container-run")]
#[test]
fn a_real_container_run_cannot_see_the_host_file_beside_the_artifact() {
#[allow(unused_imports)]
use dwarves_gui::boot_run::SerialConsole;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
// The sibling the artifact must NOT be able to read. A bare child process would.
let secret = dir.path().join("granne.txt");
std::fs::write(&secret, "this is host-only\n").expect("write sibling");
let artifact = dir.path().join("orten-probe");
let mut f = std::fs::File::create(&artifact).expect("create artifact");
write!(
f,
"#!/bin/sh\n\
echo \"ARGV:$*\"\n\
if [ -e '{}' ]; then echo HOST-FILE-VISIBLE; else echo HOST-FILE-HIDDEN; fi\n\
if echo x > '{}' 2>/dev/null; then echo MOUNT-WRITABLE; else echo MOUNT-READONLY; fi\n",
secret.display(),
ExecSandbox::MOUNT_DIR.to_string() + "/orten-probe",
)
.expect("write script");
drop(f);
std::fs::set_permissions(&artifact, std::fs::Permissions::from_mode(0o755)).expect("chmod +x");
let sandbox = ExecSandbox::new(&artifact, vec!["--hej".into()]);
let launcher = ArtifactExecLauncher { sandbox };
let mut console = match launcher.launch("orten-probe", dir.path()) {
Ok(c) => c,
Err(e) => {
// An unreachable engine is NOT a pass. Say which, and fail.
panic!("the OCI engine did not start the run: {e}");
}
};
// Drain until terminal or the budget runs out — a container run is not instant.
for _ in 0..600 {
let _ = console.log();
if !console.phase().is_running() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
// KEEP DRAINING PAST THE EXIT. This script finishes in milliseconds, and the exit is
// observable before the engine's follow-task has put a single line in the buffer — so
// a loop that stops at the first terminal phase reads an EMPTY log and every
// assertion below "passes" by finding no forbidden string in nothing. That is the
// shape this file exists to catch, and it was caught here first: the run came back
// green-looking with no output at all. A pane drains across frames for the same
// reason; this is that, bounded.
let mut log = String::new();
for _ in 0..50 {
log = console.log();
if !log.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
console.kill();
log = console.log();
assert!(
!log.is_empty(),
"the container produced no output at all, so nothing below is being tested"
);
assert!(
log.contains("ARGV:--hej"),
"the argv the operator typed never reached the process: {log}"
);
assert!(
log.contains("HOST-FILE-HIDDEN"),
"the run could see a host file beside the artifact — it is not sandboxed: {log}"
);
assert!(
!log.contains("HOST-FILE-VISIBLE"),
"the artifact read a host-only file: {log}"
);
assert!(
log.contains("MOUNT-READONLY"),
"the artifact could write into its own mount: {log}"
);
}