#![cfg(feature = "realfs")]
use bashkit::Bash;
use std::path::Path;
fn setup_host_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.txt"), "hello world\n").unwrap();
std::fs::create_dir(dir.path().join("subdir")).unwrap();
std::fs::write(dir.path().join("subdir/nested.txt"), "nested\n").unwrap();
std::fs::write(dir.path().join("data.csv"), "a,1\nb,2\nc,3\n").unwrap();
dir
}
#[tokio::test]
async fn readonly_root_overlay_cat() {
let dir = setup_host_dir();
let mut bash = Bash::builder().mount_real_readonly(dir.path()).build();
let result = bash.exec("cat /hello.txt").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn readonly_root_overlay_ls() {
let dir = setup_host_dir();
let mut bash = Bash::builder().mount_real_readonly(dir.path()).build();
let result = bash.exec("ls /").await.unwrap();
assert!(result.stdout.contains("hello.txt"));
assert!(result.stdout.contains("subdir"));
}
#[tokio::test]
async fn readonly_root_overlay_nested() {
let dir = setup_host_dir();
let mut bash = Bash::builder().mount_real_readonly(dir.path()).build();
let result = bash.exec("cat /subdir/nested.txt").await.unwrap();
assert_eq!(result.stdout, "nested\n");
}
#[tokio::test]
async fn readonly_root_overlay_write_goes_to_memory() {
let dir = setup_host_dir();
let mut bash = Bash::builder().mount_real_readonly(dir.path()).build();
bash.exec("echo 'vfs only' > /new_file.txt").await.unwrap();
let result = bash.exec("cat /new_file.txt").await.unwrap();
assert_eq!(result.stdout, "vfs only\n");
assert!(!dir.path().join("new_file.txt").exists());
}
#[tokio::test]
async fn readonly_root_overlay_pipes() {
let dir = setup_host_dir();
let mut bash = Bash::builder().mount_real_readonly(dir.path()).build();
let result = bash.exec("cat /data.csv | grep b").await.unwrap();
assert_eq!(result.stdout, "b,2\n");
}
#[tokio::test]
async fn readonly_root_overlay_wc() {
let dir = setup_host_dir();
let mut bash = Bash::builder().mount_real_readonly(dir.path()).build();
let result = bash.exec("wc -l < /data.csv").await.unwrap();
assert_eq!(result.stdout.trim(), "3");
}
#[tokio::test]
async fn readonly_mount_at_path_cat() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let result = bash.exec("cat /mnt/data/hello.txt").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
}
#[tokio::test]
async fn readonly_mount_at_path_ls() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let result = bash.exec("ls /mnt/data").await.unwrap();
assert!(result.stdout.contains("hello.txt"));
assert!(result.stdout.contains("subdir"));
}
#[tokio::test]
async fn readonly_mount_at_path_vfs_root_intact() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let result = bash
.exec("test -d /tmp && echo yes || echo no")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "yes");
bash.exec("echo test > /tmp/test.txt").await.unwrap();
let result = bash.exec("cat /tmp/test.txt").await.unwrap();
assert_eq!(result.stdout, "test\n");
}
#[tokio::test]
async fn readwrite_mount_modifies_host() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readwrite_at(dir.path(), "/workspace")
.build();
let result = bash.exec("cat /workspace/hello.txt").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
bash.exec("echo 'modified by bash' > /workspace/hello.txt")
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("hello.txt")).unwrap();
assert_eq!(content, "modified by bash\n");
bash.exec("echo 'appended line' >> /workspace/hello.txt")
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("hello.txt")).unwrap();
assert!(
content.contains("appended line"),
"append should modify host file, got: {:?}",
content
);
}
#[tokio::test]
async fn readwrite_mount_creates_files_on_host() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readwrite_at(dir.path(), "/workspace")
.build();
bash.exec("echo 'new' > /workspace/created.txt")
.await
.unwrap();
assert!(dir.path().join("created.txt").exists());
let content = std::fs::read_to_string(dir.path().join("created.txt")).unwrap();
assert_eq!(content, "new\n");
}
#[tokio::test]
async fn readwrite_mount_creates_dirs_on_host() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readwrite_at(dir.path(), "/workspace")
.build();
bash.exec("mkdir -p /workspace/a/b/c").await.unwrap();
assert!(dir.path().join("a/b/c").is_dir());
}
#[tokio::test]
async fn readwrite_root_overlay() {
let dir = setup_host_dir();
let mut bash = Bash::builder().mount_real_readwrite(dir.path()).build();
let result = bash.exec("cat /hello.txt").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
bash.exec("echo 'overlay' > /overlay_file.txt")
.await
.unwrap();
let result = bash.exec("cat /overlay_file.txt").await.unwrap();
assert_eq!(result.stdout, "overlay\n");
}
#[tokio::test]
async fn multiple_readonly_mounts() {
let dir1 = setup_host_dir();
let dir2 = tempfile::tempdir().unwrap();
std::fs::write(dir2.path().join("other.txt"), "from dir2\n").unwrap();
let mut bash = Bash::builder()
.mount_real_readonly_at(dir1.path(), "/mnt/a")
.mount_real_readonly_at(dir2.path(), "/mnt/b")
.build();
let result = bash.exec("cat /mnt/a/hello.txt").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
let result = bash.exec("cat /mnt/b/other.txt").await.unwrap();
assert_eq!(result.stdout, "from dir2\n");
}
#[tokio::test]
async fn mixed_readonly_and_text_mounts() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/host")
.mount_text("/config/app.toml", "key = 'value'\n")
.build();
let result = bash.exec("cat /mnt/host/hello.txt").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
let result = bash.exec("cat /config/app.toml").await.unwrap();
assert_eq!(result.stdout, "key = 'value'\n");
}
#[tokio::test]
async fn path_traversal_blocked_via_bash() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let result = bash
.exec("cat /mnt/data/../../etc/passwd 2>&1")
.await
.unwrap();
assert!(result.exit_code != 0 || !result.stdout.contains("root:"));
}
#[tokio::test]
async fn direct_fs_api_read() {
let dir = setup_host_dir();
let bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let fs = bash.fs();
let content = fs
.read_file(Path::new("/mnt/data/hello.txt"))
.await
.unwrap();
assert_eq!(content, b"hello world\n");
}
#[tokio::test]
async fn direct_fs_api_stat() {
let dir = setup_host_dir();
let bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let fs = bash.fs();
let meta = fs.stat(Path::new("/mnt/data/hello.txt")).await.unwrap();
assert!(meta.file_type.is_file());
assert_eq!(meta.size, 12); }
#[tokio::test]
async fn direct_fs_api_exists() {
let dir = setup_host_dir();
let bash = Bash::builder()
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let fs = bash.fs();
assert!(fs.exists(Path::new("/mnt/data/hello.txt")).await.unwrap());
assert!(!fs.exists(Path::new("/mnt/data/nope.txt")).await.unwrap());
}
#[tokio::test]
async fn realfs_symlink_absolute_escape_blocked() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readwrite_at(dir.path(), "/mnt/workspace")
.build();
let r = bash
.exec("ln -s /etc/passwd /mnt/workspace/escape 2>&1; echo $?")
.await
.unwrap();
assert!(
r.stdout.trim().ends_with('1')
|| r.stdout.contains("not allowed")
|| r.stdout.contains("Permission denied"),
"Symlink creation should be blocked, got: {}",
r.stdout
);
}
#[tokio::test]
async fn realfs_symlink_relative_escape_blocked() {
let dir = setup_host_dir();
let mut bash = Bash::builder()
.mount_real_readwrite_at(dir.path(), "/mnt/workspace")
.build();
let r = bash
.exec("ln -s ../../../../etc/passwd /mnt/workspace/escape 2>&1; echo $?")
.await
.unwrap();
assert!(
r.stdout.trim().ends_with('1')
|| r.stdout.contains("not allowed")
|| r.stdout.contains("Permission denied"),
"Relative symlink escape should be blocked, got: {}",
r.stdout
);
}
#[tokio::test]
async fn realfs_symlink_within_mount_allowed() {
let dir = setup_host_dir();
std::fs::write(dir.path().join("original.txt"), "content").unwrap();
let mut bash = Bash::builder()
.mount_real_readwrite_at(dir.path(), "/mnt/workspace")
.build();
let r = bash
.exec("ln -s original.txt /mnt/workspace/link.txt 2>&1; echo $?")
.await
.unwrap();
assert!(
r.stdout.trim().ends_with('0'),
"Symlink within mount should succeed, got stdout: {} stderr: {}",
r.stdout,
r.stderr
);
}
#[tokio::test]
async fn mount_allowlist_blocks_unlisted_path() {
let dir = setup_host_dir();
std::fs::write(dir.path().join("data.txt"), "secret").unwrap();
let mut bash = Bash::builder()
.allowed_mount_paths(["/nonexistent/allowed"])
.mount_real_readonly_at(dir.path(), "/mnt/data")
.build();
let r = bash
.exec("cat /mnt/data/data.txt 2>&1; echo $?")
.await
.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Mount outside allowlist should be blocked, got: {}",
r.stdout
);
}
#[tokio::test]
async fn mount_sensitive_path_blocked() {
let mut bash = Bash::builder()
.mount_real_readonly_at("/proc", "/mnt/proc")
.build();
let r = bash.exec("ls /mnt/proc 2>&1; echo $?").await.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Sensitive path /proc should be blocked, got: {}",
r.stdout
);
}
#[tokio::test]
async fn mount_allowlist_blocks_dotdot_escape() {
let sandbox = tempfile::tempdir().unwrap();
let allowed_root = sandbox.path().join("allowed");
let secret_root = sandbox.path().join("secret");
std::fs::create_dir_all(&allowed_root).unwrap();
std::fs::create_dir_all(&secret_root).unwrap();
std::fs::write(secret_root.join("data.txt"), "top-secret\n").unwrap();
let escaped_mount = allowed_root.join("../secret");
let mut bash = Bash::builder()
.allowed_mount_paths([&allowed_root])
.mount_real_readonly_at(&escaped_mount, "/mnt/data")
.build();
let r = bash
.exec("cat /mnt/data/data.txt 2>&1; echo $?")
.await
.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Dot-dot allowlist escape should be blocked, got: {}",
r.stdout
);
}
#[cfg(unix)]
#[tokio::test]
async fn mount_allowlist_blocks_symlink_escape() {
use std::os::unix::fs::symlink;
let sandbox = tempfile::tempdir().unwrap();
let allowed_root = sandbox.path().join("allowed");
let secret_root = sandbox.path().join("secret");
std::fs::create_dir_all(&allowed_root).unwrap();
std::fs::create_dir_all(&secret_root).unwrap();
std::fs::write(secret_root.join("data.txt"), "top-secret\n").unwrap();
let link_path = allowed_root.join("escape_link");
symlink(&secret_root, &link_path).unwrap();
let mut bash = Bash::builder()
.allowed_mount_paths([&allowed_root])
.mount_real_readonly_at(&link_path, "/mnt/data")
.build();
let r = bash
.exec("cat /mnt/data/data.txt 2>&1; echo $?")
.await
.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Symlink allowlist escape should be blocked, got: {}",
r.stdout
);
}
#[tokio::test]
async fn runtime_mount_readonly() {
use bashkit::{PosixFs, RealFs, RealFsMode};
use std::sync::Arc;
let dir = setup_host_dir();
let mut bash = Bash::new();
let backend = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
let fs: Arc<dyn bashkit::FileSystem> = Arc::new(PosixFs::new(backend));
bash.mount("/mnt/host", fs).unwrap();
let result = bash.exec("cat /mnt/host/hello.txt").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
}
#[tokio::test]
async fn runtime_unmount() {
use bashkit::{PosixFs, RealFs, RealFsMode};
use std::sync::Arc;
let dir = setup_host_dir();
let mut bash = Bash::new();
let backend = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
let fs: Arc<dyn bashkit::FileSystem> = Arc::new(PosixFs::new(backend));
bash.mount("/mnt/host", fs).unwrap();
let result = bash.exec("cat /mnt/host/hello.txt").await.unwrap();
assert_eq!(result.exit_code, 0);
bash.unmount("/mnt/host").unwrap();
let result = bash.exec("cat /mnt/host/hello.txt 2>&1").await.unwrap();
assert_ne!(
result.exit_code, 0,
"file should not be accessible after unmount"
);
}
#[tokio::test]
async fn runtime_mount_readwrite() {
use bashkit::{PosixFs, RealFs, RealFsMode};
use std::sync::Arc;
let dir = setup_host_dir();
let mut bash = Bash::new();
let backend = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
let fs: Arc<dyn bashkit::FileSystem> = Arc::new(PosixFs::new(backend));
bash.mount("/workspace", fs).unwrap();
bash.exec("echo 'runtime write' > /workspace/runtime.txt")
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("runtime.txt")).unwrap();
assert_eq!(content, "runtime write\n");
}