use bashkit::{
Bash, DirEntry, Error, FileSystem, FileSystemExt, FileType, InMemoryFs, Metadata, MountableFs,
OverlayFs, Result, async_trait,
};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
#[tokio::test]
async fn test_dev_null_stdout_redirect() {
let mut bash = Bash::builder().build();
let result = bash.exec("echo hello > /dev/null").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_stderr_redirect() {
let mut bash = Bash::builder().build();
let result = bash.exec("echo error 2>/dev/null >&2").await.unwrap();
assert_eq!(result.stderr, "");
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_stderr_redirect_direct() {
let mut bash = Bash::builder().build();
let result = bash
.exec("f() { echo err >&2; }; f 2>/dev/null")
.await
.unwrap();
assert_eq!(result.stderr, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_append_stdout() {
let mut bash = Bash::builder().build();
let result = bash.exec("echo hello >> /dev/null").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_append_stderr() {
let mut bash = Bash::builder().build();
let result = bash
.exec("f() { echo err >&2; }; f 2>>/dev/null")
.await
.unwrap();
assert_eq!(result.stderr, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_output_both() {
let mut bash = Bash::builder().build();
let result = bash.exec("echo test &>/dev/null").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.stderr, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_input_redirect() {
let mut bash = Bash::builder().build();
let result = bash.exec("cat < /dev/null").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_input_with_wc() {
let mut bash = Bash::builder().build();
let result = bash.exec("wc -l < /dev/null").await.unwrap();
assert_eq!(result.stdout.trim(), "0");
}
#[tokio::test]
async fn test_dev_null_path_normalization_parent_dir() {
let mut bash = Bash::builder().build();
let result = bash.exec("echo test > /dev/../dev/null").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_path_normalization_current_dir() {
let mut bash = Bash::builder().build();
let result = bash.exec("echo test > /dev/./null").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_path_normalization_complex() {
let mut bash = Bash::builder().build();
let result = bash
.exec("echo test > /tmp/../dev/../dev/./null")
.await
.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
struct TrackingFs {
inner: InMemoryFs,
write_count: AtomicUsize,
dev_null_intercepted: AtomicBool,
writes: RwLock<Vec<PathBuf>>,
}
impl TrackingFs {
fn new() -> Self {
Self {
inner: InMemoryFs::new(),
write_count: AtomicUsize::new(0),
dev_null_intercepted: AtomicBool::new(false),
writes: RwLock::new(Vec::new()),
}
}
fn get_write_count(&self) -> usize {
self.write_count.load(Ordering::SeqCst)
}
fn was_dev_null_intercepted(&self) -> bool {
self.dev_null_intercepted.load(Ordering::SeqCst)
}
fn get_writes(&self) -> Vec<PathBuf> {
self.writes.read().unwrap().clone()
}
}
#[async_trait]
impl FileSystemExt for TrackingFs {}
#[async_trait]
impl FileSystem for TrackingFs {
async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
self.inner.read_file(path).await
}
async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
self.write_count.fetch_add(1, Ordering::SeqCst);
self.writes.write().unwrap().push(path.to_path_buf());
let normalized = normalize_path(path);
if normalized == Path::new("/dev/null") {
self.dev_null_intercepted.store(true, Ordering::SeqCst);
}
self.inner.write_file(path, content).await
}
async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
self.write_count.fetch_add(1, Ordering::SeqCst);
self.writes.write().unwrap().push(path.to_path_buf());
let normalized = normalize_path(path);
if normalized == Path::new("/dev/null") {
self.dev_null_intercepted.store(true, Ordering::SeqCst);
}
self.inner.append_file(path, content).await
}
async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
self.inner.mkdir(path, recursive).await
}
async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
self.inner.remove(path, recursive).await
}
async fn stat(&self, path: &Path) -> Result<Metadata> {
self.inner.stat(path).await
}
async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
self.inner.read_dir(path).await
}
async fn exists(&self, path: &Path) -> Result<bool> {
self.inner.exists(path).await
}
async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
self.inner.rename(from, to).await
}
async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
self.inner.copy(from, to).await
}
async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
self.inner.symlink(target, link).await
}
async fn read_link(&self, path: &Path) -> Result<PathBuf> {
self.inner.read_link(path).await
}
async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
self.inner.chmod(path, mode).await
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::RootDir => result.push("/"),
std::path::Component::Normal(name) => result.push(name),
std::path::Component::ParentDir => {
result.pop();
}
_ => {}
}
}
if result.as_os_str().is_empty() {
result.push("/");
}
result
}
#[tokio::test]
async fn test_custom_fs_cannot_intercept_dev_null_output() {
let fs = Arc::new(TrackingFs::new());
let mut bash = Bash::builder().fs(fs.clone()).build();
bash.exec("echo test > /dev/null").await.unwrap();
assert!(
!fs.was_dev_null_intercepted(),
"Security violation: /dev/null write reached filesystem!"
);
}
#[tokio::test]
async fn test_custom_fs_cannot_intercept_dev_null_append() {
let fs = Arc::new(TrackingFs::new());
let mut bash = Bash::builder().fs(fs.clone()).build();
bash.exec("echo test >> /dev/null").await.unwrap();
assert!(
!fs.was_dev_null_intercepted(),
"Security violation: /dev/null append reached filesystem!"
);
}
#[tokio::test]
async fn test_custom_fs_cannot_intercept_dev_null_stderr() {
let fs = Arc::new(TrackingFs::new());
let mut bash = Bash::builder().fs(fs.clone()).build();
bash.exec("echo error >&2 2>/dev/null").await.unwrap();
assert!(
!fs.was_dev_null_intercepted(),
"Security violation: /dev/null stderr redirect reached filesystem!"
);
}
#[tokio::test]
async fn test_custom_fs_cannot_intercept_dev_null_with_normalization() {
let fs = Arc::new(TrackingFs::new());
let mut bash = Bash::builder().fs(fs.clone()).build();
bash.exec("echo test > /dev/../dev/null").await.unwrap();
bash.exec("echo test > /dev/./null").await.unwrap();
bash.exec("echo test > /tmp/../dev/null").await.unwrap();
assert!(
!fs.was_dev_null_intercepted(),
"Security violation: normalized /dev/null path reached filesystem!"
);
}
#[tokio::test]
async fn test_regular_file_writes_still_work() {
let fs = Arc::new(TrackingFs::new());
let mut bash = Bash::builder().fs(fs.clone()).build();
bash.exec("echo hello > /tmp/test.txt").await.unwrap();
assert!(fs.get_write_count() > 0, "Regular writes should work");
assert!(
fs.get_writes().contains(&PathBuf::from("/tmp/test.txt")),
"Write to /tmp/test.txt should be tracked"
);
}
#[tokio::test]
async fn test_overlay_fs_cannot_bypass_dev_null() {
let base = Arc::new(TrackingFs::new());
let overlay = Arc::new(OverlayFs::new(base.clone()));
let mut bash = Bash::builder().fs(overlay).build();
bash.exec("echo test > /dev/null").await.unwrap();
assert!(
!base.was_dev_null_intercepted(),
"Security violation: /dev/null bypassed through OverlayFs!"
);
}
#[tokio::test]
async fn test_overlay_fs_with_path_normalization() {
let base = Arc::new(TrackingFs::new());
let overlay = Arc::new(OverlayFs::new(base.clone()));
let mut bash = Bash::builder().fs(overlay).build();
bash.exec("echo test > /dev/../dev/null").await.unwrap();
assert!(
!base.was_dev_null_intercepted(),
"Security violation: normalized /dev/null bypassed through OverlayFs!"
);
}
struct MaliciousDevFs {
intercepted: AtomicBool,
}
impl MaliciousDevFs {
fn new() -> Self {
Self {
intercepted: AtomicBool::new(false),
}
}
fn was_intercepted(&self) -> bool {
self.intercepted.load(Ordering::SeqCst)
}
}
#[async_trait]
impl FileSystemExt for MaliciousDevFs {}
#[async_trait]
impl FileSystem for MaliciousDevFs {
async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
if path.ends_with("null") {
self.intercepted.store(true, Ordering::SeqCst);
}
Ok(vec![])
}
async fn write_file(&self, path: &Path, _content: &[u8]) -> Result<()> {
if path.ends_with("null") {
self.intercepted.store(true, Ordering::SeqCst);
}
Ok(())
}
async fn append_file(&self, path: &Path, _content: &[u8]) -> Result<()> {
if path.ends_with("null") {
self.intercepted.store(true, Ordering::SeqCst);
}
Ok(())
}
async fn mkdir(&self, _path: &Path, _recursive: bool) -> Result<()> {
Ok(())
}
async fn remove(&self, _path: &Path, _recursive: bool) -> Result<()> {
Ok(())
}
async fn stat(&self, path: &Path) -> Result<Metadata> {
Ok(Metadata {
file_type: if path.ends_with("null") {
FileType::File
} else {
FileType::Directory
},
size: 0,
mode: 0o666,
modified: SystemTime::now(),
created: SystemTime::now(),
})
}
async fn read_dir(&self, _path: &Path) -> Result<Vec<DirEntry>> {
Ok(vec![DirEntry {
name: "null".to_string(),
metadata: Metadata {
file_type: FileType::File,
size: 0,
mode: 0o666,
modified: SystemTime::now(),
created: SystemTime::now(),
},
}])
}
async fn exists(&self, _path: &Path) -> Result<bool> {
Ok(true)
}
async fn rename(&self, _from: &Path, _to: &Path) -> Result<()> {
Ok(())
}
async fn copy(&self, _from: &Path, _to: &Path) -> Result<()> {
Ok(())
}
async fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> {
Ok(())
}
async fn read_link(&self, _path: &Path) -> Result<PathBuf> {
Err(Error::Io(std::io::Error::other("not a symlink")))
}
async fn chmod(&self, _path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
}
#[tokio::test]
async fn test_mounted_dev_fs_cannot_intercept_dev_null() {
let root = Arc::new(InMemoryFs::new());
let malicious_dev = Arc::new(MaliciousDevFs::new());
let mountable = MountableFs::new(root);
mountable.mount("/dev", malicious_dev.clone()).unwrap();
let mut bash = Bash::builder().fs(Arc::new(mountable)).build();
bash.exec("echo test > /dev/null").await.unwrap();
assert!(
!malicious_dev.was_intercepted(),
"Security violation: /dev/null was intercepted by mounted filesystem!"
);
}
#[tokio::test]
async fn test_mounted_dev_fs_cannot_intercept_normalized_paths() {
let root = Arc::new(InMemoryFs::new());
let malicious_dev = Arc::new(MaliciousDevFs::new());
let mountable = MountableFs::new(root);
mountable.mount("/dev", malicious_dev.clone()).unwrap();
let mut bash = Bash::builder().fs(Arc::new(mountable)).build();
bash.exec("echo test > /dev/../dev/null").await.unwrap();
bash.exec("echo test > /tmp/../dev/null").await.unwrap();
assert!(
!malicious_dev.was_intercepted(),
"Security violation: normalized /dev/null paths were intercepted!"
);
}
#[tokio::test]
async fn test_dev_null_with_other_redirects() {
let mut bash = Bash::builder().build();
let _result = bash
.exec("echo out; echo err >&2; echo out > /tmp/out.txt 2>/dev/null")
.await
.unwrap();
let content = bash.exec("cat /tmp/out.txt").await.unwrap();
assert_eq!(content.stdout.trim(), "out");
}
#[tokio::test]
async fn test_multiple_dev_null_redirects() {
let mut bash = Bash::builder().build();
let result = bash
.exec("echo out >/dev/null 2>/dev/null; echo done")
.await
.unwrap();
assert_eq!(result.stdout, "done\n");
}
#[tokio::test]
async fn test_dev_null_empty_output() {
let mut bash = Bash::builder().build();
let result = bash.exec("true > /dev/null").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_large_output() {
let mut bash = Bash::builder().build();
let result = bash
.exec("echo 'line1 line2 line3 line4 line5 line6 line7 line8 line9 line10' > /dev/null")
.await
.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dev_null_in_pipeline() {
let mut bash = Bash::builder().build();
let result = bash
.exec("echo hello | cat > /dev/null; echo done")
.await
.unwrap();
assert_eq!(result.stdout, "done\n");
}
#[tokio::test]
async fn test_dev_null_in_subshell() {
let mut bash = Bash::builder().build();
let result = bash.exec("(echo hidden > /dev/null); echo visible").await;
assert!(result.is_ok());
let result = result.unwrap();
assert_eq!(result.stdout, "visible\n");
}