Skip to content
Merged
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
18 changes: 15 additions & 3 deletions crates/bashkit/src/builtins/fileops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ impl Builtin for Mkdir {

if let Err(e) = ctx.fs.mkdir(&path, recursive).await {
return Ok(ExecResult::err(
format!("mkdir: cannot create directory '{}': {}\n", dir, e),
format!(
"mkdir: cannot create directory '{}': {}\n",
dir,
crate::error::io_error_reason(&e)
),
1,
));
}
Expand Down Expand Up @@ -772,12 +776,20 @@ impl Builtin for Touch {
continue;
}
if let Err(e) = ctx.fs.write_file(&path, &[]).await {
return fail(format!("touch: cannot touch '{}': {}\n", file, e));
return fail(format!(
"touch: cannot touch '{}': {}\n",
file,
crate::error::io_error_reason(&e)
));
}
}

if let Err(e) = ctx.fs.set_modified_time(&path, target_time).await {
return fail(format!("touch: cannot touch '{}': {}\n", file, e));
return fail(format!(
"touch: cannot touch '{}': {}\n",
file,
crate::error::io_error_reason(&e)
));
}
}

Expand Down
50 changes: 42 additions & 8 deletions crates/bashkit/src/builtins/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,34 @@ impl Builtin for False {

/// The exit builtin - exit the shell with a status code.
/// Bash truncates exit codes to 8-bit unsigned range (0-255) via `& 0xFF`.
/// Without an argument it exits with `$?`; a non-numeric argument is
/// reported and exits 2; more than one argument is an error (status 1) that
/// abandons the rest of the line instead of exiting.
pub struct Exit;

#[async_trait]
impl Builtin for Exit {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let exit_code = ctx
.args
.first()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0)
& 0xFF;
let exit_code = match ctx.args.first() {
None => ctx.shell.as_ref().map_or(0, |s| s.last_exit_code),
Some(arg) => match arg.trim().parse::<i64>() {
Ok(n) => (n & 0xFF) as i32,
Err(_) => {
let mut result = ExecResult::with_control_flow(ControlFlow::Exit(2));
result.exit_code = 2;
result.stderr =
format!("bash: exit: {arg}: numeric argument required\n").into();
return Ok(result);
}
},
};
if ctx.args.len() > 1 {
// bash discards the rest of the line (`jump_to_top_level(DISCARD)`):
// the script resumes at the next line, a subshell ends.
let mut result = ExecResult::err("bash: exit: too many arguments\n", 1);
result.control_flow = ControlFlow::Abort;
return Ok(result);
}

Ok(ExecResult {
exit_code,
Expand Down Expand Up @@ -286,13 +303,30 @@ mod tests {
}

#[tokio::test]
async fn exit_non_numeric_defaults_zero() {
async fn exit_non_numeric_is_usage_error() {
let (fs, mut cwd, mut variables) = setup().await;
let env = HashMap::new();
let args = vec!["abc".to_string()];
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
let result = Exit.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.exit_code, 2);
assert_eq!(result.control_flow, ControlFlow::Exit(2));
assert!(
result
.stderr
.contains("exit: abc: numeric argument required")
);
}

#[tokio::test]
async fn exit_too_many_args_aborts_the_line() {
let (fs, mut cwd, mut variables) = setup().await;
let env = HashMap::new();
let args = vec!["1".to_string(), "2".to_string()];
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None);
let result = Exit.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert_eq!(result.control_flow, ControlFlow::Abort);
}

// ==================== break ====================
Expand Down
62 changes: 58 additions & 4 deletions crates/bashkit/src/fs/rootfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
//! modifying, removing, or renaming one fails with "Permission denied". New
//! files may still be created under `/etc`, `/usr/bin`, ... (they go to the
//! session filesystem), which keeps scripts that `mkdir -p /etc/app` working.
//! - `/proc` is the exception: like Linux procfs, nothing new can be created
//! in it (`echo x > /proc/new` fails with "No such file or directory"),
//! unless the session filesystem itself holds the parent directory.
//! - `/bin` and `/usr/bin` hold a stub per registered command (not shell-only
//! builtins) so `ls /usr/bin`, `[ -x /usr/bin/env ]`, `which`, and
//! `#!/usr/bin/env` work. Executing a stub dispatches the builtin.
Expand Down Expand Up @@ -141,6 +144,9 @@ fn is_ancestor(path: &Path) -> bool {
SYS_ANCESTORS.iter().any(|a| path == Path::new(a))
}

/// The procfs tree: a pseudo-filesystem that refuses new entries.
const PROC_TREE: &str = "/proc";

fn permission_denied() -> crate::Error {
IoError::new(ErrorKind::PermissionDenied, "Permission denied").into()
}
Expand Down Expand Up @@ -359,9 +365,18 @@ impl RootFs {
}

/// Create a missing parent that the system layer provides, so new files
/// can be created under `/etc` and friends.
/// can be created under `/etc` and friends. `/proc` refuses new entries
/// like Linux procfs (`ENOENT`), unless the session filesystem holds the
/// parent (an embedder's own `/proc` tree).
async fn ensure_parent(&self, path: &Path) -> Result<()> {
let p = normalize_path(path);
if p.starts_with(PROC_TREE)
&& let Some(parent) = p.parent()
&& !self.inner.exists(parent).await.unwrap_or(false)
&& self.served_by_sys(&p).await.is_none()
{
return Err(IoError::new(ErrorKind::NotFound, "No such file or directory").into());
}
if let Some(parent) = p.parent()
&& Self::sys_path(parent).is_some()
&& !self.inner.exists(parent).await.unwrap_or(false)
Expand Down Expand Up @@ -404,9 +419,7 @@ impl FileSystem for RootFs {
}
return Err(IoError::new(ErrorKind::AlreadyExists, "File exists").into());
}
if !recursive {
self.ensure_parent(path).await?;
}
self.ensure_parent(path).await?;
self.inner.mkdir(path, recursive).await
}

Expand Down Expand Up @@ -626,6 +639,47 @@ mod tests {
);
}

#[tokio::test]
async fn proc_refuses_new_entries_like_procfs() {
let fs = rootfs();
for path in ["/proc/naopode", "/proc/self/new"] {
let err = fs.write_file(Path::new(path), b"y").await.unwrap_err();
assert_eq!(
crate::error::io_error_reason(&err),
"No such file or directory"
);
assert!(fs.append_file(Path::new(path), b"y").await.is_err());
assert!(fs.mkdir(Path::new(path), false).await.is_err());
assert!(!fs.exists(Path::new(path)).await.unwrap());
}
assert!(fs.mkdir(Path::new("/proc/a/b"), true).await.is_err());
assert!(
fs.symlink(Path::new("/tmp"), Path::new("/proc/link"))
.await
.is_err()
);
// Existing entries stay read-only, not missing.
assert!(
fs.write_file(Path::new("/proc/version"), b"y")
.await
.is_err()
);
}

#[tokio::test]
async fn embedder_proc_dir_accepts_new_files() {
let inner = Arc::new(InMemoryFs::new());
inner.mkdir(Path::new("/proc/app"), true).await.unwrap();
let fs = RootFs::new(inner, "sandbox", "bashkit-sandbox");
fs.write_file(Path::new("/proc/app/state"), b"ok")
.await
.unwrap();
assert_eq!(
fs.read_file(Path::new("/proc/app/state")).await.unwrap(),
b"ok"
);
}

#[tokio::test]
async fn session_files_shadow_system_files() {
let inner = Arc::new(InMemoryFs::new());
Expand Down
Loading
Loading