use crate::observability::ReasonCode;
use crate::{CommandRequest, NativeSandbox, OutputObserver, OutputSummary, SandboxPolicy};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Default)]
struct RecordingObserver {
summary: Mutex<Option<OutputSummary>>,
}
#[async_trait]
impl OutputObserver for RecordingObserver {
async fn on_output_delta(&self, _delta: &str) {}
async fn on_output_complete(&self, summary: &OutputSummary) {
*self.summary.lock().await = Some(*summary);
}
}
#[tokio::test]
async fn gate2_policy_output_ceiling_truncates_capture() {
let workspace = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::a3s_bash_baseline();
policy.resources.max_output_bytes = 4_096;
let sandbox = NativeSandbox::with_policy(workspace.path(), policy).unwrap();
let observer = Arc::new(RecordingObserver::default());
#[cfg(not(windows))]
let command = "dd if=/dev/zero bs=20000 count=1 2>/dev/null | tr '\\0' x";
#[cfg(windows)]
let command = "[Console]::Out.Write([string]::new('x', 20000))";
let output = sandbox
.execute(CommandRequest {
command: command.into(),
timeout_ms: 30_000,
output_observer: Some(observer.clone()),
env: None,
})
.await
.unwrap();
let summary = observer
.summary
.lock()
.await
.expect("observer completion callback was not invoked");
assert!(summary.total_bytes >= 20_000, "{summary:?}");
assert_eq!(summary.captured_bytes, 4_096);
assert!(summary.truncated);
assert!(output.stdout.contains("truncated") || output.stderr.contains("truncated"));
}
#[tokio::test]
async fn gate2_policy_timeout_ceiling_caps_request() {
let workspace = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::a3s_bash_baseline();
policy.resources.timeout_ms = 200;
let sandbox = NativeSandbox::with_policy(workspace.path(), policy).unwrap();
#[cfg(not(windows))]
let command = "sleep 5";
#[cfg(windows)]
let command = "Start-Sleep -Seconds 5";
let output = sandbox
.execute(CommandRequest {
command: command.into(),
timeout_ms: 30_000,
output_observer: None,
env: None,
})
.await
.unwrap();
assert!(output.timed_out, "expected timeout under policy ceiling");
let events = sandbox.audit_log().snapshot();
assert!(events
.iter()
.any(|event| event.reason_code == ReasonCode::Timeout));
}
#[cfg(any(target_os = "linux", windows))]
#[tokio::test]
async fn gate2_memory_ceiling_stops_overallocation() {
let workspace = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::a3s_bash_baseline();
policy.resources.max_memory_bytes = Some(64 * 1024 * 1024);
let sandbox = NativeSandbox::with_policy(workspace.path(), policy).unwrap();
#[cfg(target_os = "linux")]
let command = "perl -e 'my $x = \"x\" x (256 * 1024 * 1024); print length($x)'";
#[cfg(windows)]
let command = "$buf = New-Object byte[] (256MB); Write-Output $buf.Length";
let output = sandbox
.execute(CommandRequest {
command: command.into(),
timeout_ms: 30_000,
output_observer: None,
env: None,
})
.await
.unwrap();
let combined = format!("{}{}", output.stdout, output.stderr);
assert!(
output.exit_code != 0 || output.timed_out || !combined.contains("268435456"),
"memory ceiling should prevent successful 256MiB allocation; stdout={} stderr={} exit={}",
output.stdout,
output.stderr,
output.exit_code
);
}
#[cfg(target_os = "macos")]
#[test]
fn gate2_macos_memory_limit_fails_closed_at_policy() {
let workspace = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::a3s_bash_baseline();
policy.resources.max_memory_bytes = Some(64 * 1024 * 1024);
let error = format!(
"{:#}",
NativeSandbox::with_policy(workspace.path(), policy).unwrap_err()
);
assert!(
error.contains("memory limit"),
"macOS must fail closed instead of claiming unenforceable RLIMIT_AS: {error}"
);
}
#[cfg(windows)]
#[tokio::test]
async fn gate2_windows_process_limit_blocks_extra_children() {
let workspace = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::a3s_bash_baseline();
policy.resources.max_processes = Some(2);
let sandbox = NativeSandbox::with_policy(workspace.path(), policy).unwrap();
let output = sandbox
.execute(CommandRequest {
command: "1..8 | ForEach-Object { Start-Process -NoNewWindow pwsh -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 2' }; Start-Sleep -Seconds 1; Write-Output spawned"
.into(),
timeout_ms: 15_000,
output_observer: None,
env: None,
})
.await
.unwrap();
let stderr = output.stderr.to_ascii_lowercase();
assert!(
output.exit_code != 0
|| !output.stdout.contains("spawned")
|| stderr.contains("quota")
|| output.stderr.contains("Start-Process"),
"Job process limit should prevent spawning many children; stdout={} stderr={}",
output.stdout,
output.stderr
);
}
#[cfg(not(windows))]
#[test]
fn gate2_unix_process_limit_fails_closed_at_policy() {
let workspace = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::a3s_bash_baseline();
policy.resources.max_processes = Some(8);
let error = format!(
"{:#}",
NativeSandbox::with_policy(workspace.path(), policy).unwrap_err()
);
assert!(
error.contains("process limit"),
"Unix must fail closed instead of claiming RLIMIT_NPROC tree quotas: {error}"
);
}