#![cfg(all(feature = "embed", feature = "frontend", feature = "test-support"))]
mod support;
use std::fs;
#[cfg(feature = "unix-runtime")]
use std::path::Path;
use std::path::PathBuf;
use std::sync::{Arc, Barrier, Mutex, MutexGuard};
use std::thread;
use mxsh::ShellBuilder;
use mxsh::embed::StdioConfig;
use mxsh::policy::VariableAttributes;
use mxsh::runtime::testing::{InMemoryRuntime, StringStdioIn, StringStdioOut};
static MULTI_TENANT_POLICY_LOCK: Mutex<()> = Mutex::new(());
fn suite_lock() -> MutexGuard<'static, ()> {
MULTI_TENANT_POLICY_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(feature = "unix-runtime")]
#[derive(Debug, PartialEq, Eq)]
struct ExternalTenantRunResult {
statuses: Vec<i32>,
stdout: String,
stderr: String,
marker_contents: String,
}
#[derive(Debug, PartialEq, Eq)]
struct CliTenantRunResult {
exit_code: Option<i32>,
stdout: String,
history_exists: bool,
helper_not_found: bool,
}
#[cfg(feature = "unix-runtime")]
fn write_executable_script(path: &Path, script: &str) {
use std::os::unix::fs::PermissionsExt;
fs::write(path, script).expect("script should be writable");
let mut perms = fs::metadata(path)
.expect("script metadata should be readable")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).expect("script should be executable");
}
#[cfg(feature = "unix-runtime")]
fn spawn_multi_tenant_external_session(
barrier: Arc<Barrier>,
tenant_dir: PathBuf,
path_var: String,
script: String,
marker: PathBuf,
) -> thread::JoinHandle<ExternalTenantRunResult> {
thread::spawn(move || {
use mxsh::runtime::unix::UnixRuntime;
let stdin = StringStdioIn::new("");
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.multi_tenant()
.current_dir(&tenant_dir)
.env("PATH", path_var, VariableAttributes::EXPORT)
.stdio(StdioConfig {
stdin: stdin.fd(),
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = UnixRuntime::new();
barrier.wait();
let statuses = vec![
shell.run(&mut runtime, &script).status,
shell.run(&mut runtime, &script).status,
];
stdin.join();
ExternalTenantRunResult {
statuses,
stdout: stdout.collect(),
stderr: stderr.collect(),
marker_contents: fs::read_to_string(&marker).unwrap_or_default(),
}
})
}
fn spawn_multi_tenant_cli_session(
barrier: Arc<Barrier>,
env_path: PathBuf,
home_dir: PathBuf,
history_path: PathBuf,
) -> thread::JoinHandle<CliTenantRunResult> {
thread::spawn(move || {
let input = StringStdioIn::new("helper\nexit\n");
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let argv = vec!["mxsh".to_string()];
let mut shell = ShellBuilder::new()
.multi_tenant()
.interactive(true)
.env(
"ENV",
env_path.display().to_string(),
VariableAttributes::empty(),
)
.env(
"HOME",
home_dir.display().to_string(),
VariableAttributes::empty(),
)
.env(
"MXSH_HISTORY_FILE",
history_path.display().to_string(),
VariableAttributes::empty(),
)
.env("PS1", "", VariableAttributes::empty())
.env("PS2", "", VariableAttributes::empty())
.stdio(StdioConfig {
stdin: input.fd(),
stdout: stdout.fd(),
stderr: stderr.fd(),
})
.build(InMemoryRuntime::new())
.expect("shell should build");
barrier.wait();
let outcome = shell.run_cli(&argv);
input.join();
let stdout = stdout.collect();
let stderr = stderr.collect();
let history_exists = history_path.exists();
CliTenantRunResult {
exit_code: outcome.exit_code,
stdout,
history_exists,
helper_not_found: stderr.contains("helper: command not found"),
}
})
}
#[cfg(feature = "unix-runtime")]
#[test]
fn multi_tenant_external_child_cannot_resolve_helper_through_relative_path() {
let _guard = suite_lock();
use mxsh::runtime::unix::UnixRuntime;
let tenant = support::temp_path("multi-tenant-child-path");
fs::create_dir_all(&tenant).expect("tenant dir should be creatable");
let tool_name = format!("mxsh-tenant-helper-{}", std::process::id());
let tool = tenant.join(&tool_name);
write_executable_script(&tool, "#!/bin/sh\nprintf 'unsafe\\n'\n");
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.multi_tenant()
.current_dir(&tenant)
.env("PATH", ".:/usr/bin:/bin", VariableAttributes::EXPORT)
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = UnixRuntime::new();
let result = shell.run(&mut runtime, &format!("/bin/sh -c {tool_name}"));
let _ = fs::remove_dir_all(&tenant);
assert_eq!(result.status, 127);
assert_eq!(stdout.collect(), "");
assert!(stderr.collect().contains(&tool_name));
}
#[cfg(feature = "unix-runtime")]
#[test]
fn concurrent_multi_tenant_external_sessions_keep_path_and_cwd_isolated() {
let _guard = suite_lock();
for round in 0..4 {
let root = support::temp_path(&format!("multi-tenant-concurrent-external-{round}"));
let tenant_a = root.join("tenant-a");
let tenant_b = root.join("tenant-b");
let bin_a = tenant_a.join("bin");
let bin_b = tenant_b.join("bin");
fs::create_dir_all(&bin_a).expect("tenant-a bin should be creatable");
fs::create_dir_all(&bin_b).expect("tenant-b bin should be creatable");
let helper_name = format!("mxsh-tenant-helper-{}-{round}", std::process::id());
let marker_a = tenant_a.join("cwd-marker.log");
let marker_b = tenant_b.join("cwd-marker.log");
write_executable_script(
&bin_a.join(&helper_name),
"#!/bin/sh\nprintf 'tenant-a\\n' >> cwd-marker.log\nprintf 'tenant-a\\n'\n",
);
write_executable_script(
&bin_b.join(&helper_name),
"#!/bin/sh\nprintf 'tenant-b\\n' >> cwd-marker.log\nprintf 'tenant-b\\n'\n",
);
let barrier = Arc::new(Barrier::new(2));
let script = helper_name.clone();
let tenant_a_handle = spawn_multi_tenant_external_session(
barrier.clone(),
tenant_a.clone(),
format!("{}:/usr/bin:/bin", bin_a.display()),
script.clone(),
marker_a.clone(),
);
let tenant_b_handle = spawn_multi_tenant_external_session(
barrier,
tenant_b.clone(),
format!("{}:/usr/bin:/bin", bin_b.display()),
script,
marker_b.clone(),
);
let tenant_a_result = tenant_a_handle
.join()
.expect("tenant-a concurrent external session should join");
let tenant_b_result = tenant_b_handle
.join()
.expect("tenant-b concurrent external session should join");
assert_eq!(
tenant_a_result,
ExternalTenantRunResult {
statuses: vec![0, 0],
stdout: "tenant-a\ntenant-a\n".to_string(),
stderr: String::new(),
marker_contents: "tenant-a\ntenant-a\n".to_string(),
}
);
assert_eq!(
tenant_b_result,
ExternalTenantRunResult {
statuses: vec![0, 0],
stdout: "tenant-b\ntenant-b\n".to_string(),
stderr: String::new(),
marker_contents: "tenant-b\ntenant-b\n".to_string(),
}
);
let _ = fs::remove_dir_all(&root);
}
}
#[test]
fn multi_tenant_shell_does_not_source_ambient_startup_env() {
let _guard = suite_lock();
let env_path = support::temp_path("multi-tenant-startup-env");
fs::write(&env_path, "helper() { echo from_env; }\n").expect("write startup env file");
let input = StringStdioIn::new("helper\nexit\n");
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.multi_tenant()
.interactive(true)
.env(
"ENV",
env_path.display().to_string(),
VariableAttributes::empty(),
)
.env("PS1", "", VariableAttributes::empty())
.env("PS2", "", VariableAttributes::empty())
.stdio(StdioConfig {
stdin: input.fd(),
stdout: stdout.fd(),
stderr: stderr.fd(),
})
.new_session()
.expect("session should build");
let mut runtime = InMemoryRuntime::new();
shell.initialize(&mut runtime);
let result = shell
.run_interactive(&mut runtime)
.expect("interactive run should succeed");
input.join();
let _ = fs::remove_file(&env_path);
assert_eq!(result.exit_code, Some(127));
assert_eq!(stdout.collect(), "");
assert!(stderr.collect().contains("helper: command not found"));
}
#[test]
fn concurrent_multi_tenant_cli_sessions_ignore_startup_hooks_and_histories() {
let _guard = suite_lock();
for round in 0..4 {
let root = support::temp_path(&format!("multi-tenant-concurrent-cli-{round}"));
fs::create_dir_all(&root).expect("concurrent cli root should be creatable");
let env_a = root.join("tenant-a.env");
let env_b = root.join("tenant-b.env");
let home_a = root.join("tenant-a-home");
let home_b = root.join("tenant-b-home");
let history_a = root.join("tenant-a.history");
let history_b = root.join("tenant-b.history");
fs::create_dir_all(&home_a).expect("tenant-a home should be creatable");
fs::create_dir_all(&home_b).expect("tenant-b home should be creatable");
fs::write(&env_a, "helper() { echo from-tenant-a; }\n").expect("tenant-a env file");
fs::write(&env_b, "helper() { echo from-tenant-b; }\n").expect("tenant-b env file");
let barrier = Arc::new(Barrier::new(2));
let tenant_a_handle = spawn_multi_tenant_cli_session(
barrier.clone(),
env_a.clone(),
home_a.clone(),
history_a.clone(),
);
let tenant_b_handle =
spawn_multi_tenant_cli_session(barrier, env_b.clone(), home_b.clone(), history_b);
let tenant_a_result = tenant_a_handle
.join()
.expect("tenant-a concurrent cli session should join");
let tenant_b_result = tenant_b_handle
.join()
.expect("tenant-b concurrent cli session should join");
assert_eq!(
tenant_a_result,
CliTenantRunResult {
exit_code: Some(127),
stdout: String::new(),
history_exists: false,
helper_not_found: true,
}
);
assert_eq!(
tenant_b_result,
CliTenantRunResult {
exit_code: Some(127),
stdout: String::new(),
history_exists: false,
helper_not_found: true,
}
);
let _ = fs::remove_dir_all(&root);
}
}
#[test]
fn multi_tenant_cli_ignores_implicit_history_paths() {
let _guard = suite_lock();
let input = StringStdioIn::new("echo hi\nexit\n");
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let history_path = support::temp_path("multi-tenant-history");
let home_dir = support::temp_path("multi-tenant-home");
fs::create_dir_all(&home_dir).expect("create temp home");
let argv = vec!["mxsh".to_string()];
let mut shell = ShellBuilder::new()
.multi_tenant()
.interactive(true)
.env(
"HOME",
home_dir.display().to_string(),
VariableAttributes::empty(),
)
.env(
"MXSH_HISTORY_FILE",
history_path.display().to_string(),
VariableAttributes::empty(),
)
.env("PS1", "", VariableAttributes::empty())
.env("PS2", "", VariableAttributes::empty())
.stdio(StdioConfig {
stdin: input.fd(),
stdout: stdout.fd(),
stderr: stderr.fd(),
})
.build(InMemoryRuntime::new())
.expect("shell should build");
let outcome = shell.run_cli(&argv);
input.join();
let _ = fs::remove_dir(&home_dir);
assert_eq!(outcome.exit_code, Some(0));
assert_eq!(stdout.collect(), "hi\n");
assert_eq!(stderr.collect(), "");
assert!(
!history_path.exists(),
"implicit history file should be disabled"
);
}
#[test]
fn multi_tenant_shell_blocks_background_and_process_global_builtins() {
let _guard = suite_lock();
let stderr = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.multi_tenant()
.stdio(StdioConfig {
stderr: stderr.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = InMemoryRuntime::new();
assert_eq!(shell.run(&mut runtime, "echo hi &").status, 1);
assert_eq!(shell.run(&mut runtime, "jobs").status, 126);
assert_eq!(shell.run(&mut runtime, "exec echo hi").status, 126);
assert_eq!(shell.run(&mut runtime, ". ./tenant.rc").status, 126);
assert_eq!(shell.run(&mut runtime, "umask 022").status, 126);
assert_eq!(shell.run(&mut runtime, "ulimit -n 64").status, 126);
let stderr = stderr.collect();
assert!(stderr.contains("background jobs are disabled by shell security policy"));
assert!(stderr.contains("jobs: disabled by shell security policy"));
assert!(stderr.contains("exec: disabled by shell security policy"));
assert!(stderr.contains(".: disabled by shell security policy"));
assert!(stderr.contains("umask: disabled by shell security policy"));
assert!(stderr.contains("ulimit: disabled by shell security policy"));
}
#[test]
fn explicit_history_path_still_works_in_multi_tenant_mode() {
let _guard = suite_lock();
let input = StringStdioIn::new("echo hi\nexit\n");
let stdout = StringStdioOut::new();
let history_path = support::temp_path("multi-tenant-explicit-history");
let mut shell = ShellBuilder::new()
.multi_tenant()
.interactive(true)
.history_path(history_path.clone())
.env("PS1", "", VariableAttributes::empty())
.env("PS2", "", VariableAttributes::empty())
.stdio(StdioConfig {
stdin: input.fd(),
stdout: stdout.fd(),
stderr: stdout.fd(),
})
.new_session()
.expect("session should build");
let mut runtime = InMemoryRuntime::new();
shell.initialize(&mut runtime);
let result = shell
.run_interactive(&mut runtime)
.expect("interactive run should succeed");
input.join();
assert_eq!(result.exit_code, Some(0));
assert_eq!(stdout.collect(), "hi\n");
assert_eq!(
fs::read_to_string(&history_path).expect("history file should exist"),
"echo hi\nexit\n"
);
let _ = fs::remove_file(history_path);
}