Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions crates/tinybox-jail/src/detect_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,22 @@ fn owned_selection_fails_closed_when_no_candidate_can_return_owned_child() {
assert_eq!(picked.name(), UNSUPPORTED_BACKEND_NAME);
assert!(!picked.is_owned_available());
}

/// Every jailed path asks for the owned-process backend. Off Windows that must
/// be the same backend the plain selection picks, so the switch to it (#22)
/// changes nothing there.
#[cfg(not(windows))]
#[test]
fn off_windows_the_owned_selection_is_the_plain_selection() {
assert_eq!(pick_owned_backend().name(), pick_backend().name());
}

/// On Windows the owned selection is `AppContainer`, the backend the plain
/// selection can never return.
#[cfg(windows)]
#[test]
fn on_windows_the_owned_selection_is_appcontainer() {
let backend = pick_owned_backend();
assert_eq!(backend.name(), "appcontainer");
assert!(backend.is_owned_available());
}
28 changes: 25 additions & 3 deletions crates/tinybox-jail/src/jail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,14 +578,23 @@ pub trait JailBackend: Send + Sync {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ChildStatus {
code: Option<i32>,
signal: Option<i32>,
}

impl ChildStatus {
/// Construct status from the native process exit code. Signal termination
/// is represented by `None` on platforms that expose it that way.
#[must_use]
pub const fn from_code(code: Option<i32>) -> Self {
Self { code }
Self { code, signal: None }
}

/// The same status, recording the signal that ended the process on
/// platforms that report one.
#[must_use]
pub const fn with_signal(mut self, signal: Option<i32>) -> Self {
self.signal = signal;
self
}

/// Exit code when the process has one (signal termination has none).
Expand All @@ -594,6 +603,12 @@ impl ChildStatus {
self.code
}

/// The signal that ended the process, when it was signalled (Unix).
#[must_use]
pub const fn signal(self) -> Option<i32> {
self.signal
}

/// Whether the process returned exit code zero.
#[must_use]
pub const fn success(self) -> bool {
Expand Down Expand Up @@ -752,8 +767,15 @@ impl OwnedProcess for StdOwnedProcess {

fn try_wait(&mut self) -> std::io::Result<Option<ChildStatus>> {
self.0.try_wait().map(|status| {
status.map(|status| ChildStatus {
code: status.code(),
status.map(|status| {
#[cfg(unix)]
let signal = {
use std::os::unix::process::ExitStatusExt;
status.signal()
};
#[cfg(not(unix))]
let signal = None;
ChildStatus::from_code(status.code()).with_signal(signal)
})
})
}
Expand Down
41 changes: 41 additions & 0 deletions crates/tinybox-jail/src/jail_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,44 @@ fn nested_denial_refuses_only_paths_deeper_than_a_direct_child() {
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{nested}");
}
}

#[test]
fn a_status_from_an_exit_code_carries_no_signal() {
let status = ChildStatus::from_code(Some(3));
assert_eq!(status.code(), Some(3));
assert_eq!(status.signal(), None);
assert_eq!(
ChildStatus::from_code(None).with_signal(Some(9)).signal(),
Some(9)
);
}

#[cfg(unix)]
#[test]
fn an_owned_child_ended_by_a_signal_reports_the_signal() -> std::io::Result<()> {
use std::time::{Duration, Instant};

let root = tempfile::tempdir()?;
let jail = Jail::new(root.path(), "owned-signal");
let mut command = std::process::Command::new("/bin/sh");
command.args(["-c", "kill -9 $$"]);

let mut child = crate::spawn_owned_captured_with(&crate::NoopBackend, &jail, command)?;
let deadline = Instant::now() + Duration::from_secs(5);
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if Instant::now() >= deadline {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"child did not exit",
));
}
std::thread::sleep(Duration::from_millis(10));
};

assert_eq!(status.signal(), Some(9));
assert_eq!(status.code(), None);
Ok(())
}
4 changes: 4 additions & 0 deletions crates/tinybox-jail/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ pub fn default_backend() -> Arc<dyn JailBackend> {
}

/// Cached default backend for the process-owned child API.
///
/// Every jailed path in the module uses this one. Off Windows it is the same
/// backend [`default_backend`] picks; on Windows it is `AppContainer`, which
/// `default_backend` can never return.
pub fn default_owned_backend() -> Arc<dyn JailBackend> {
DEFAULT_OWNED_BACKEND
.get_or_init(detect::pick_owned_backend)
Expand Down
18 changes: 17 additions & 1 deletion crates/tinybox-jail/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,12 +775,28 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<()
fn build_command_line(cmd: &Command) -> String {
// Minimal CommandLineToArgvW-compatible quoting. std's internal
// `make_command_line` is private; this mirrors the same rules.
//
// cmd.exe does not parse its command line that way: after `/C` (or `/K`)
// it takes the rest verbatim, and the native shell hands it an already
// quoted `raw_arg` for exactly that. Re-quoting it would escape those
// quotes and cmd would run `\"script\"` as a program name, so for cmd
// everything after `/C` is passed through as given.
let mut out = String::new();
let prog = cmd.get_program().to_string_lossy().into_owned();
push_arg(&mut out, &prog);
let is_cmd = std::path::Path::new(&prog).file_name().is_some_and(|name| {
name.eq_ignore_ascii_case("cmd.exe") || name.eq_ignore_ascii_case("cmd")
});
let mut verbatim = false;
for a in cmd.get_args() {
out.push(' ');
push_arg(&mut out, &a.to_string_lossy());
let a = a.to_string_lossy();
if verbatim {
out.push_str(&a);
} else {
push_arg(&mut out, &a);
verbatim = is_cmd && (a.eq_ignore_ascii_case("/c") || a.eq_ignore_ascii_case("/k"));
}
}
out
}
Expand Down
25 changes: 25 additions & 0 deletions crates/tinybox-jail/src/windows_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,28 @@ fn wait_for_child(child: &mut crate::OwnedChild) -> std::io::Result<crate::Child
std::thread::sleep(std::time::Duration::from_millis(10));
}
}

#[test]
fn a_cmd_script_after_slash_c_reaches_cmd_verbatim() {
use std::os::windows::process::CommandExt as _;
// The native shell's own invocation: cmd strips this one pair of quotes.
let mut cmd = Command::new(r"C:\Windows\System32\cmd.exe");
cmd.args(["/D", "/E:ON", "/V:OFF", "/S", "/C"])
.raw_arg("\"echo jailed\"");

assert_eq!(
build_command_line(&cmd),
r#"C:\Windows\System32\cmd.exe /D /E:ON /V:OFF /S /C "echo jailed""#
);
}

#[test]
fn other_programs_keep_command_line_to_argv_quoting() {
let mut cmd = Command::new(r"C:\tools\node.exe");
cmd.args(["/C", "a \"b\""]);

assert_eq!(
build_command_line(&cmd),
r#"C:\tools\node.exe /C "a \"b\"""#
);
}
9 changes: 7 additions & 2 deletions crates/tinybox-module/src/tinybus_module/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,13 @@ grant remain default-denied on allow-list backends.

The jail owns a separate native collector for each execution. Output replay,
Cancel, ReleaseOutput, Close and Shutdown use the same retained cleanup barriers
as StartExec. Windows rejects this operation until its native jail has a
supervised child bridge; explicit ordinary local execution remains available.
as StartExec. Every jailed path (native shell, `StartJailedExec`, stdio) uses the
owned-process backend, which on Windows is `AppContainer`: it runs each child in
its own Job Object. `AppContainer` enforces filesystem, network, CPU, memory and
process-count limits, but not `Subprocess`, and reports no isolation level, so a
jail with `allow_subprocess: false`, `require_process_isolation`, or a read
denial overlapping its grants is refused there, with the reason in the error
message.


## Observed SSH execution
Expand Down
7 changes: 5 additions & 2 deletions crates/tinybox-module/src/tinybus_module/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,14 @@ impl BoxService {
/// network namespace) reports its weaker level here. Admission decisions
/// use the per-jail form in `native_jail::prepare`.
async fn jail_status(&self) -> TinyBusResult<tinybox_bus::JailStatus> {
let backend = tinybox_jail::default_backend();
// The owned-process backend is the one every jailed path uses. On
// Windows it is `AppContainer`, which can run work only through the
// owned API, so its plain `is_available` is always false.
let backend = tinybox_jail::default_owned_backend();
let support = backend.constraint_support();
std::future::ready(Ok(tinybox_bus::JailStatus {
backend: backend.name().into(),
available: backend.is_available(),
available: backend.is_owned_available(),
isolation: backend.isolation().to_string(),
suitable_for_untrusted_code: backend.is_suitable_for_untrusted_code(),
filesystem: support
Expand Down
62 changes: 60 additions & 2 deletions crates/tinybox-module/src/tinybus_module/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ async fn serve_test_storage(bus: &MemoryBus) -> tinybus::Result<ServedTestStorag
async fn jail_status_reports_the_detected_backend_without_starting_a_process() -> tinybus::Result<()>
{
let status = BoxService::default().jail_status().await?;
let backend = tinybox_jail::default_backend();
// The owned-process backend every jailed path uses: AppContainer on
// Windows, where the legacy selection is "unsupported".
let backend = tinybox_jail::default_owned_backend();
assert_eq!(status.backend, backend.name());
assert_eq!(status.available, backend.is_available());
assert_eq!(status.available, backend.is_owned_available());
assert_eq!(status.isolation, backend.isolation().to_string());
assert_eq!(
status.suitable_for_untrusted_code,
Expand Down Expand Up @@ -1520,6 +1522,62 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup()
Ok(())
}

/// On Windows a jailed native shell runs under `AppContainer`, through the
/// owned-process backend (#22). Before, every Windows jail was refused.
#[cfg(windows)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_native_shell_runs_a_jailed_command_under_appcontainer_on_windows()
-> Result<(), Box<dyn std::error::Error>> {
let state = tempfile::tempdir()?;
let workspace = tempfile::tempdir()?;
let bus = MemoryBus::new();
Broker::new().spawn(bus.clone());
let _storage_host = serve_test_storage(&bus).await?;
let service = Connection::connect(bus.connect().await?).await?;
setup(service.clone()).await?;
let client = Connection::connect(bus.connect().await?).await?;
let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?;
let mut request = run_native_shell_request("echo jailed", state.path(), None);
request.create.workspace =
tinybox_bus::Workspace::Directory(workspace.path().to_string_lossy().into_owned());
request.shell.jail = Some(tinybox_bus::NativeJailConfig {
read_only: Vec::new(),
read_write: Vec::new(),
allow_net: true,
allow_subprocess: true,
enforcement: tinybox_bus::JailEnforcement::Enforced,
require_process_isolation: false,
deny_read: Vec::new(),
resources: None,
});

let result: tinybox_bus::RunNativeShellResult =
proxy.call("RunNativeShell", (request,)).await?;
let tinybox_bus::RunNativeShellResult::Finished {
exit_code,
stdout,
stderr,
timed_out,
..
} = result
else {
return Err(format!("a jailed shell should finish: {result:?}").into());
};
assert_eq!(
exit_code,
0,
"stdout={:?} stderr={:?}",
String::from_utf8_lossy(&stdout),
String::from_utf8_lossy(&stderr)
);
assert!(!timed_out);
assert_eq!(
String::from_utf8_lossy(&stdout).replace("\r\n", "\n"),
"jailed\n"
);
Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_native_shell_cancels_a_shell_that_outlives_its_timeout()
-> Result<(), Box<dyn std::error::Error>> {
Expand Down
Loading
Loading