use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use harn_vm::orchestration::{
pop_execution_policy, push_execution_policy, CapabilityPolicy, ProcessSandboxPolicy,
SandboxProfile,
};
use harn_vm::process_sandbox::conformance::{
judge, ConformanceCase, Expectation, Observation, SpawnRoute, Verdict,
};
use harn_vm::security::SessionEnvironment;
use harn_vm::VmValue;
use serde::Serialize;
use crate::tools::ToolsCapability;
use crate::{BuiltinRegistry, HostlibCapability};
const OUTSIDE_CONTENT: &str = "harn-conformance-outside-content";
#[derive(Clone, Debug, Serialize)]
pub struct ConformanceReport {
pub backend: String,
pub filesystem_mechanism: String,
pub enforcing: bool,
pub cases: Vec<CaseReport>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CaseReport {
pub case: &'static str,
pub route: SpawnRoute,
pub expected: Option<Observation>,
pub observed: Option<Observation>,
pub target: String,
#[serde(flatten)]
pub verdict: Verdict,
pub detail: String,
}
impl ConformanceReport {
pub fn failing(&self) -> Vec<&CaseReport> {
self.cases
.iter()
.filter(|case| case.verdict.is_failure())
.collect()
}
pub fn not_measured(&self) -> Vec<&CaseReport> {
self.cases
.iter()
.filter(|case| matches!(case.verdict, Verdict::NotMeasured { .. }))
.collect()
}
pub fn not_enforced(&self) -> Vec<&CaseReport> {
self.cases
.iter()
.filter(|case| matches!(case.verdict, Verdict::NotEnforced { .. }))
.collect()
}
pub fn conforming(&self) -> usize {
self.cases
.iter()
.filter(|case| case.verdict == Verdict::Conforms)
.count()
}
pub fn summary_line(&self) -> String {
let failing: Vec<&str> = self.failing().iter().map(|case| case.case).collect();
let not_measured = self.not_measured().len();
let not_enforced = self.not_enforced().len();
let conforming = self.conforming();
format!(
"harn.sandbox_conformance_summary backend={} cases={} conforms={conforming} \
not_measured={not_measured} not_enforced={not_enforced} not_applicable={} failed={} \
failing={failing:?}",
self.backend,
self.cases.len(),
self.cases.len() - conforming - not_measured - not_enforced - failing.len(),
failing.len(),
)
}
}
impl CaseReport {
pub fn receipt_line(&self) -> String {
format!(
"harn.sandbox_conformance case={} route={:?} expected={:?} observed={:?} verdict={} {}",
self.case,
self.route,
self.expected,
self.observed,
serde_json::to_string(&self.verdict).unwrap_or_default(),
self.detail,
)
}
}
pub fn run_conformance() -> ConformanceReport {
let backend = harn_vm::process_sandbox::active_backend_name().to_string();
let enforcing = harn_vm::process_sandbox::active_backend_filesystem_available();
let cases = ConformanceCase::ALL
.iter()
.map(|&case| run_case(case, enforcing))
.collect();
ConformanceReport {
backend,
filesystem_mechanism: harn_vm::process_sandbox::active_backend_filesystem_mechanism()
.to_string(),
enforcing,
cases,
}
}
fn run_case(case: ConformanceCase, enforcing: bool) -> CaseReport {
let layout = match Layout::new() {
Ok(layout) => layout,
Err(error) => return broken(case, format!("could not build the case layout: {error}")),
};
let policy = case_policy(case, &layout);
let expectation = case.expectation(&policy);
let expected = match expectation {
Expectation::Observe(observation) => Some(observation),
Expectation::DeclaredNotEnforced => Some(Observation::Admitted),
Expectation::NotApplicable(_) => None,
};
let (argv, target) = probe(case, &layout);
let report = |observed: Option<Observation>, verdict: Verdict, detail: String| CaseReport {
case: case.id(),
route: case.route(),
expected,
observed,
target: target.clone(),
verdict,
detail,
};
if expected.is_none() {
let verdict = judge(case, expectation, enforcing, Observation::Refused, &target);
return report(None, verdict, String::new());
}
if is_wrapper_case(case) {
if !cargo_on_path() {
let reason = "cargo is not installed, so no Rust build could be measured".to_string();
return report(None, Verdict::NotMeasured { reason }, String::new());
}
if let Err(error) = write_wrapper_crate(case, &layout) {
let reason = format!("could not write the wrapper crate: {error}");
return report(None, Verdict::ProbeBroken { reason }, String::new());
}
}
let _scope = CaseScope::enter(policy.clone());
let child = match spawn(case, &layout, &argv) {
Ok(child) => child,
Err(reason) => return report(None, Verdict::ProbeBroken { reason }, String::new()),
};
let mut child = child;
let observed = if is_wrapper_case(case) {
let decision = harn_vm::process_sandbox::rustc_wrapper::rustc_wrapper_decision(
&policy,
&layout.workspace,
&[],
);
child.detail = format!(
"{} decision={:?} wrapper={:?} reason={:?}",
child.detail, decision.disposition, decision.wrapper, decision.reason
);
observe_wrapper(case, &child, &target, &decision)
} else {
observe(case, &layout, &child, &target)
};
let observed = match observed {
Ok(observed) => observed,
Err(Unmeasured::Broken(reason)) => {
return report(None, Verdict::ProbeBroken { reason }, child.detail)
}
Err(Unmeasured::NothingToMeasure(reason)) => {
return report(None, Verdict::NotMeasured { reason }, child.detail)
}
};
let leaked = match &observed {
Observed::Env { leaked, .. } if !leaked.is_empty() => leaked.join(","),
_ => target.clone(),
};
let observation = observed.observation();
let verdict = judge(case, expectation, enforcing, observation, &leaked);
report(Some(observation), verdict, child.detail)
}
fn broken(case: ConformanceCase, reason: String) -> CaseReport {
CaseReport {
case: case.id(),
route: case.route(),
expected: None,
observed: None,
target: String::new(),
verdict: Verdict::ProbeBroken { reason },
detail: String::new(),
}
}
struct Layout {
_root: tempfile::TempDir,
workspace: PathBuf,
outside: PathBuf,
socket_root: PathBuf,
credentials: PathBuf,
listener: std::net::TcpListener,
shared_temp: tempfile::TempDir,
}
impl Layout {
fn new() -> std::io::Result<Self> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| std::io::Error::other("no HOME or USERPROFILE to root the layout in"))?;
let root = tempfile::Builder::new()
.prefix(".harn-sandbox-conformance-")
.tempdir_in(home)?;
let base = if cfg!(windows) {
root.path().to_path_buf()
} else {
root.path().canonicalize()?
};
let workspace = base.join("workspace");
let outside = base.join("outside");
let socket_root = base.join("sockets");
let credentials = workspace.join(".harn-conformance-credentials");
for dir in [&workspace, &outside, &socket_root, &credentials] {
std::fs::create_dir_all(dir)?;
}
std::fs::write(outside.join("secret.txt"), OUTSIDE_CONTENT)?;
std::fs::write(credentials.join("secret.txt"), OUTSIDE_CONTENT)?;
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let shared_temp = tempfile::Builder::new()
.prefix("harn-sandbox-conformance-sibling-")
.tempdir()?;
std::fs::write(shared_temp.path().join("secret.txt"), OUTSIDE_CONTENT)?;
Ok(Self {
_root: root,
workspace,
outside,
socket_root,
credentials,
listener,
shared_temp,
})
}
}
fn case_policy(case: ConformanceCase, layout: &Layout) -> CapabilityPolicy {
let mut policy = CapabilityPolicy {
sandbox_profile: SandboxProfile::Worktree,
workspace_roots: vec![layout.workspace.display().to_string()],
side_effect_level: Some("process_exec".to_string()),
..CapabilityPolicy::default()
};
let socket_roots = vec![layout.socket_root.display().to_string()];
match case {
ConformanceCase::UnixSocketBindUnderRoot
| ConformanceCase::UnixSocketBindOutsideRootRefused => {
policy.process_sandbox = Box::new(ProcessSandboxPolicy {
unix_socket_roots: socket_roots,
..ProcessSandboxPolicy::default()
});
}
ConformanceCase::UnixSocketBindUnderRootWithNetwork => {
policy.side_effect_level = Some("network".to_string());
policy.process_sandbox = Box::new(ProcessSandboxPolicy {
unix_socket_roots: socket_roots,
..ProcessSandboxPolicy::default()
});
}
ConformanceCase::DeniedCredentialReadRefused => {
policy.process_sandbox = Box::new(ProcessSandboxPolicy {
read_deny_roots: vec![layout.credentials.display().to_string()],
..ProcessSandboxPolicy::default()
});
}
ConformanceCase::NetworkConnectAdmitted => {
policy.side_effect_level = Some("network".to_string());
}
_ => {}
}
policy
}
fn probe(case: ConformanceCase, layout: &Layout) -> (Vec<String>, String) {
let owned = |argv: &[&str]| argv.iter().map(|arg| arg.to_string()).collect::<Vec<_>>();
match case {
ConformanceCase::WorkspaceWriteAdmitted => {
let target = layout.workspace.join("probe.txt");
(write_argv(&target), target.display().to_string())
}
ConformanceCase::OutsideWriteRefused | ConformanceCase::GuardianOutsideWriteRefused => {
let target = layout.outside.join("probe.txt");
(write_argv(&target), target.display().to_string())
}
ConformanceCase::OutsideReadRefused => read_probe(&layout.outside.join("secret.txt")),
ConformanceCase::SiblingTempReadRefused => {
read_probe(&layout.shared_temp.path().join("secret.txt"))
}
ConformanceCase::DeniedCredentialReadRefused => {
read_probe(&layout.credentials.join("secret.txt"))
}
ConformanceCase::NetworkConnectRefused | ConformanceCase::NetworkConnectAdmitted => {
match layout.listener.local_addr() {
Ok(address) => (connect_argv(address), address.to_string()),
Err(error) => (Vec::new(), format!("no listener address: {error}")),
}
}
ConformanceCase::AtomicReplaceAdmitted => {
let target = layout.workspace.join("atomic.txt");
let script = format!(
"ObjC.import('Foundation'); \
$.NSString.alloc.initWithUTF8String('probe').dataUsingEncoding(4)\
.writeToURLOptionsError($.NSURL.fileURLWithPath({:?}), 1, null)",
target.display().to_string()
);
(
owned(&["osascript", "-l", "JavaScript", "-e", &script]),
target.display().to_string(),
)
}
ConformanceCase::SessionTempWriteAdmitted => {
let target =
harn_vm::process_sandbox::workspace_local_tmpdir(&case_policy(case, layout))
.map(|dir| dir.join(SESSION_TEMP_PROBE))
.unwrap_or_default();
let argv = if cfg!(windows) {
owned(&[
"cmd",
"/c",
"echo",
"probe>",
&format!("%TEMP%\\{SESSION_TEMP_PROBE}"),
])
} else {
owned(&[
"sh",
"-c",
&format!("touch \"$TMPDIR/{SESSION_TEMP_PROBE}\""),
])
};
(argv, target.display().to_string())
}
ConformanceCase::UndeclaredEnvironmentNameWithheld
| ConformanceCase::GuardianUndeclaredEnvironmentNameWithheld => {
let argv = if cfg!(windows) {
owned(&["cmd", "/c", "set"])
} else {
owned(&["env"])
};
(argv, "undeclared launcher environment".to_string())
}
ConformanceCase::UnixSocketBindUnderRoot
| ConformanceCase::UnixSocketBindUnderRootWithNetwork => {
let target = layout.socket_root.join("probe.sock");
(bind_argv(&target), target.display().to_string())
}
ConformanceCase::UnixSocketBindOutsideRootRefused => {
let target = layout.outside.join("probe.sock");
(bind_argv(&target), target.display().to_string())
}
ConformanceCase::RustcWrapperThatRunsIsKept
| ConformanceCase::RustcWrapperThatCannotRunIsSwitchedOff
| ConformanceCase::RustcWrapperThatDaemonizesIsSwitchedOff => (
owned(&["cargo", "build", "--offline"]),
wrapper_log(case, layout).display().to_string(),
),
}
}
const SESSION_TEMP_PROBE: &str = "harn-conformance-session-temp";
fn read_probe(target: &Path) -> (Vec<String>, String) {
let path = target.display().to_string();
let argv = if cfg!(windows) {
vec!["cmd".into(), "/c".into(), "type".into(), path.clone()]
} else {
vec!["cat".into(), path.clone()]
};
(argv, path)
}
fn write_argv(target: &Path) -> Vec<String> {
let path = target.display().to_string();
if cfg!(windows) {
vec![
"cmd".into(),
"/c".into(),
"echo".into(),
"probe>".into(),
path,
]
} else {
vec!["touch".into(), path]
}
}
fn bind_argv(target: &Path) -> Vec<String> {
if cfg!(windows) {
return vec!["cmd".into(), "/c".into(), "exit 0".into()];
}
vec![
"perl".into(),
"-MSocket".into(),
"-e".into(),
"socket(S, PF_UNIX, SOCK_STREAM, 0) or die \"socket: $!\"; \
bind(S, sockaddr_un($ARGV[0])) or die \"bind: $!\";"
.into(),
target.display().to_string(),
]
}
fn connect_argv(address: std::net::SocketAddr) -> Vec<String> {
if cfg!(windows) {
return vec![
"powershell".into(),
"-NoProfile".into(),
"-NonInteractive".into(),
"-Command".into(),
format!(
"$c = New-Object Net.Sockets.TcpClient; $c.Connect('{}', {}); $c.Close()",
address.ip(),
address.port()
),
];
}
vec![
"perl".into(),
"-MIO::Socket::INET".into(),
"-e".into(),
"IO::Socket::INET->new(PeerAddr => $ARGV[0], Proto => 'tcp') or die \"connect: $!\";"
.into(),
address.to_string(),
]
}
struct Child {
spawn_refused: bool,
exit: Option<i64>,
stdout: String,
detail: String,
}
fn call(name: &str, request: harn_vm::value::DictMap) -> Result<VmValue, String> {
let mut registry = BuiltinRegistry::new();
ToolsCapability.register_builtins(&mut registry);
let entry = registry
.find(name)
.ok_or_else(|| format!("{name} is not registered"))?;
(entry.handler)(&[VmValue::dict(request)]).map_err(|error| error.to_string())
}
fn spawn(case: ConformanceCase, layout: &Layout, argv: &[String]) -> Result<Child, String> {
let text = |value: &str| VmValue::String(arcstr::ArcStr::from(value));
let mut request = harn_vm::value::DictMap::new();
request.insert(
"argv".into(),
VmValue::List(Arc::new(argv.iter().map(|arg| text(arg)).collect())),
);
request.insert("cwd".into(), text(&layout.workspace.display().to_string()));
if case.route() == SpawnRoute::Guardian {
request.insert("background".into(), VmValue::Bool(true));
}
let response = match call("hostlib_tools_run_command", request) {
Ok(VmValue::Dict(dict)) => dict,
Ok(other) => return Err(format!("run_command answered {other:?}")),
Err(error) => {
return Ok(Child {
spawn_refused: true,
exit: None,
stdout: String::new(),
detail: error,
})
}
};
let response = if case.route() == SpawnRoute::Guardian {
let Some(VmValue::String(handle)) = response.get("handle_id") else {
return Err("a background start returned no handle".to_string());
};
let mut wait = harn_vm::value::DictMap::new();
wait.insert("handle_id".into(), VmValue::String(handle.clone()));
wait.insert("timeout_ms".into(), VmValue::Int(20_000));
match call("hostlib_tools_wait_command", wait) {
Ok(VmValue::Dict(dict)) => dict,
other => return Err(format!("wait_command answered {other:?}")),
}
} else {
response
};
let field = |key: &str| match response.get(key) {
Some(VmValue::String(text)) => text.to_string(),
_ => String::new(),
};
let exit = match response.get("exit_code") {
Some(VmValue::Int(code)) => Some(*code),
_ => None,
};
let stderr = field("stderr");
let stderr_tail = stderr.lines().last().unwrap_or_default();
Ok(Child {
spawn_refused: false,
exit,
stdout: field("stdout"),
detail: format!("exit_code={exit:?} stderr_tail={stderr_tail:?}"),
})
}
enum Observed {
Effect(Observation),
Env { leaked: Vec<String> },
}
impl Observed {
fn observation(&self) -> Observation {
match self {
Self::Effect(observation) => *observation,
Self::Env { leaked } if leaked.is_empty() => Observation::Refused,
Self::Env { .. } => Observation::Admitted,
}
}
}
enum Unmeasured {
Broken(String),
NothingToMeasure(String),
}
fn observe(
case: ConformanceCase,
layout: &Layout,
child: &Child,
target: &str,
) -> Result<Observed, Unmeasured> {
if child.spawn_refused {
return Ok(Observed::Effect(Observation::SpawnRefused));
}
let took_effect = |effect: bool| {
Observed::Effect(if effect {
Observation::Admitted
} else {
Observation::Refused
})
};
match case {
ConformanceCase::OutsideReadRefused
| ConformanceCase::SiblingTempReadRefused
| ConformanceCase::DeniedCredentialReadRefused => {
Ok(took_effect(child.stdout.contains(OUTSIDE_CONTENT)))
}
ConformanceCase::NetworkConnectRefused | ConformanceCase::NetworkConnectAdmitted => {
match layout.listener.accept() {
Ok(_) => Ok(took_effect(true)),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
Ok(took_effect(false))
}
Err(error) => Err(Unmeasured::Broken(format!(
"could not read the loopback listener: {error}"
))),
}
}
ConformanceCase::UndeclaredEnvironmentNameWithheld
| ConformanceCase::GuardianUndeclaredEnvironmentNameWithheld => {
observe_environment(&child.stdout)
}
_ => Ok(took_effect(Path::new(target).exists())),
}
}
fn observe_environment(stdout: &str) -> Result<Observed, Unmeasured> {
let child: BTreeMap<String, String> = stdout
.lines()
.filter_map(|line| line.split_once('='))
.map(|(name, value)| (name.trim().to_string(), value.to_string()))
.filter(|(name, _)| !name.is_empty())
.collect();
if !child.keys().any(|name| name.eq_ignore_ascii_case("PATH")) {
return Err(Unmeasured::Broken(
"the child reported no PATH, so its environment was not read".to_string(),
));
}
let mut declared = SessionEnvironment::isolated().admitted_environment_names();
declared.extend(
harn_vm::process_sandbox::deterministic_message_locale_env()
.into_iter()
.map(|(name, _)| name),
);
declared.push(harn_vm::process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV.to_string());
let undeclared: Vec<(String, String)> = std::env::vars()
.filter(|(name, _)| !declared.contains(name))
.collect();
if undeclared.is_empty() {
return Err(Unmeasured::NothingToMeasure(
"the launcher holds no name the session leaves undeclared, so there was nothing \
to withhold"
.to_string(),
));
}
let leaked = undeclared
.into_iter()
.filter(|(name, value)| child.get(name) == Some(value))
.map(|(name, _)| name)
.collect();
Ok(Observed::Env { leaked })
}
fn is_wrapper_case(case: ConformanceCase) -> bool {
matches!(
case,
ConformanceCase::RustcWrapperThatRunsIsKept
| ConformanceCase::RustcWrapperThatCannotRunIsSwitchedOff
| ConformanceCase::RustcWrapperThatDaemonizesIsSwitchedOff
)
}
const DAEMON_MARKER_SECONDS: &str = "30713";
fn daemon_marker_alive() -> bool {
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
let mut system = System::new();
system.refresh_processes_specifics(
ProcessesToUpdate::All,
true,
ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always),
);
system.processes().values().any(|process| {
let cmd = process.cmd();
cmd.first()
.is_some_and(|arg| arg.to_string_lossy().ends_with("sleep"))
&& cmd.iter().any(|arg| arg == DAEMON_MARKER_SECONDS)
})
}
fn cargo_on_path() -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join("cargo").is_file()))
}
fn wrapper_log(case: ConformanceCase, layout: &Layout) -> PathBuf {
match case {
ConformanceCase::RustcWrapperThatCannotRunIsSwitchedOff => {
layout.outside.join("wrapper.log")
}
_ => layout.workspace.join("wrapper.log"),
}
}
fn write_wrapper_crate(case: ConformanceCase, layout: &Layout) -> std::io::Result<()> {
let workspace = &layout.workspace;
std::fs::create_dir_all(workspace.join("src"))?;
std::fs::create_dir_all(workspace.join(".cargo"))?;
std::fs::write(
workspace.join("Cargo.toml"),
"[package]\nname = \"seeded\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
publish = false\n\n[workspace]\n",
)?;
std::fs::write(workspace.join("src").join("main.rs"), "fn main() {}\n")?;
let wrapper = workspace.join("log-wrapper.sh");
let log = wrapper_log(case, layout);
let detach = if case == ConformanceCase::RustcWrapperThatDaemonizesIsSwitchedOff {
format!("( sleep {DAEMON_MARKER_SECONDS} </dev/null >/dev/null 2>&1 & )\n")
} else {
String::new()
};
std::fs::write(
&wrapper,
format!(
"#!/bin/sh\n{detach}echo \"$*\" >> '{}' || exit 1\nexec \"$@\"\n",
log.display()
),
)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755))?;
}
std::fs::write(
workspace.join(".cargo").join("config.toml"),
format!("[build]\nrustc-wrapper = \"{}\"\n", wrapper.display()),
)
}
fn observe_wrapper(
case: ConformanceCase,
child: &Child,
log: &str,
decision: &harn_vm::process_sandbox::rustc_wrapper::RustcWrapperDecision,
) -> Result<Observed, Unmeasured> {
use harn_vm::process_sandbox::rustc_wrapper::RustcWrapperDisposition;
if child.spawn_refused {
return Ok(Observed::Effect(Observation::SpawnRefused));
}
let built = child.exit == Some(0);
let wrapped_the_crate = std::fs::read_to_string(log)
.map(|text| text.contains("--crate-name seeded"))
.unwrap_or(false);
let (behaved, receipt) = match case {
ConformanceCase::RustcWrapperThatRunsIsKept => (
built && wrapped_the_crate,
decision.disposition == RustcWrapperDisposition::Kept,
),
ConformanceCase::RustcWrapperThatDaemonizesIsSwitchedOff => (
built && !wrapped_the_crate && !daemon_marker_alive(),
decision.disposition == RustcWrapperDisposition::Disabled,
),
_ => (
built && !wrapped_the_crate,
decision.disposition == RustcWrapperDisposition::Disabled,
),
};
if behaved != receipt {
return Err(Unmeasured::Broken(format!(
"the recorded decision ({:?}: {}) disagrees with the build: completed={built} \
wrapper_ran_on_the_crate={wrapped_the_crate}",
decision.disposition, decision.reason
)));
}
Ok(Observed::Effect(if behaved {
Observation::Admitted
} else {
Observation::Refused
}))
}
struct CaseScope {
previous_environment: Option<SessionEnvironment>,
}
impl CaseScope {
fn enter(policy: CapabilityPolicy) -> Self {
let previous_environment = harn_vm::stdlib::process::current_session_environment();
push_execution_policy(policy);
harn_vm::stdlib::process::set_session_environment(Some(SessionEnvironment::isolated()));
Self {
previous_environment,
}
}
}
impl Drop for CaseScope {
fn drop(&mut self) {
harn_vm::stdlib::process::set_session_environment(self.previous_environment.take());
pop_execution_policy();
}
}