use async_trait::async_trait;
use std::path::{Path, PathBuf};
use crate::ast::Value;
use crate::backend::{BackendError, KernelBackend};
use crate::interpreter::ExecResult;
use crate::tools::{ExecContext, Tool, ToolArgs, ToolSchema, ParamSchema};
pub struct Rm;
#[derive(Debug, PartialEq)]
enum RmAction {
Trash(PathBuf),
Delete,
Latch,
}
fn decide_rm_action(
trash_enabled: bool,
latch_enabled: bool,
real_path: Option<&Path>,
file_size: Option<u64>,
trash_max_size: u64,
is_dir: bool,
) -> RmAction {
if trash_enabled {
if let Some(rp) = real_path {
let excluded = rp.starts_with("/tmp")
|| rp.starts_with("/v");
if !excluded {
if is_dir {
return RmAction::Trash(rp.to_path_buf());
}
let size = file_size.unwrap_or(0);
if size <= trash_max_size {
return RmAction::Trash(rp.to_path_buf());
}
if latch_enabled {
return RmAction::Latch;
}
return RmAction::Delete;
}
}
}
if latch_enabled {
return RmAction::Latch;
}
RmAction::Delete
}
#[async_trait]
impl Tool for Rm {
fn name(&self) -> &str {
"rm"
}
fn schema(&self) -> ToolSchema {
ToolSchema::new("rm", "Remove files and directories")
.param(ParamSchema::required("path", "string", "Path to remove"))
.param(ParamSchema::optional(
"recursive",
"bool",
Value::Bool(false),
"Remove directories and their contents recursively (-r)",
).with_aliases(["-r", "-R"]))
.param(ParamSchema::optional(
"force",
"bool",
Value::Bool(false),
"Ignore nonexistent files, never prompt (-f)",
).with_aliases(["-f"]))
.param(ParamSchema::optional(
"confirm",
"string",
Value::Null,
"Confirmation nonce for latch-gated operations (--confirm=NONCE)",
))
.example("Remove a file", "rm temp.txt")
.example("Remove directory recursively", "rm -rf build/")
.example("Confirm latched removal", "rm --confirm=a3f7b2c1 bigfile.bin")
}
async fn execute(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
let path = match args.get_string("path", 0) {
Some(p) => p,
None => return ExecResult::failure(1, "rm: missing path argument"),
};
let recursive = args.has_flag("recursive") || args.has_flag("r");
let force = args.has_flag("force") || args.has_flag("f");
let confirm = args.get_named("confirm").and_then(|v| match v {
Value::String(s) => Some(s.clone()),
_ => None,
});
let resolved = ctx.resolve_path(&path);
let stat = match ctx.backend.stat(Path::new(&resolved)).await {
Ok(info) => Some(info),
Err(BackendError::NotFound(_)) if force => return ExecResult::success(""),
Err(BackendError::NotFound(_)) => return ExecResult::failure(1, format!("rm: {}: No such file or directory", path)),
Err(e) => return ExecResult::failure(1, format!("rm: {}: {}", path, e)),
};
let trash_enabled = ctx.scope.trash_enabled();
let latch_enabled = ctx.scope.latch_enabled();
let trash_max_size = ctx.scope.trash_max_size();
let real_path = ctx.backend.resolve_real_path(Path::new(&resolved));
let file_size = stat.as_ref().map(|s| s.size);
let is_dir = stat.as_ref().is_some_and(|s| s.is_dir());
let action = decide_rm_action(
trash_enabled,
latch_enabled,
real_path.as_deref(),
file_size,
trash_max_size,
is_dir,
);
match action {
RmAction::Trash(real) => {
let trash_backend = match ctx.trash_backend.as_ref() {
Some(tb) => tb,
None => return ExecResult::failure(1, "rm: trash backend not available"),
};
let real_display = real.display().to_string();
match trash_backend.trash(&real).await {
Ok(()) => ExecResult::success(""),
Err(e) => {
ExecResult::failure(1, format!(
"rm: {}: trash failed: {} (use `set +o trash` to delete permanently)",
real_display, e
))
}
}
}
RmAction::Latch => {
if let Some(nonce) = &confirm {
match ctx.verify_nonce(nonce, "rm", &[&path]) {
Ok(()) => {
match remove_path(&*ctx.backend, Path::new(&resolved), recursive, force).await {
Ok(()) => ExecResult::success(""),
Err(e) => ExecResult::failure(1, format!("rm: {}: {}", path, e)),
}
}
Err(e) => ExecResult::failure(1, format!("rm: {}: {}", path, e)),
}
} else {
ctx.latch_result("rm", &[&path], "latch enabled", |nonce| {
format!("rm --confirm=\"{}\" {}", nonce, path)
})
}
}
RmAction::Delete => {
match remove_path(&*ctx.backend, Path::new(&resolved), recursive, force).await {
Ok(()) => ExecResult::success(""),
Err(e) => ExecResult::failure(1, format!("rm: {}: {}", path, e)),
}
}
}
}
}
async fn remove_path(backend: &dyn KernelBackend, path: &Path, recursive: bool, force: bool) -> Result<(), BackendError> {
match backend.stat(path).await {
Ok(info) => {
if info.is_dir() && recursive {
remove_dir_recursive(backend, path).await?;
}
backend.remove(path, false).await
}
Err(BackendError::NotFound(_)) if force => {
Ok(())
}
Err(e) => Err(e),
}
}
fn remove_dir_recursive<'a>(
backend: &'a dyn KernelBackend,
dir: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), BackendError>> + Send + 'a>> {
Box::pin(async move {
let entries = backend.list(dir).await?;
for entry in entries {
let child_path: PathBuf = dir.join(&entry.name);
if entry.is_dir() {
remove_dir_recursive(backend, &child_path).await?;
backend.remove(&child_path, false).await?;
} else {
backend.remove(&child_path, false).await?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
async fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("file.txt"), b"data").await.unwrap();
mem.mkdir(Path::new("emptydir")).await.unwrap();
mem.write(Path::new("fulldir/file.txt"), b"data").await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_rm_file() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_empty_dir() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/emptydir".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/emptydir")).await);
}
#[tokio::test]
async fn test_rm_non_empty_dir_fails() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/fulldir".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(ctx.backend.exists(Path::new("/fulldir")).await);
}
#[tokio::test]
async fn test_rm_nonexistent() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(!result.ok());
}
#[tokio::test]
async fn test_rm_no_arg() {
let mut ctx = make_ctx().await;
let args = ToolArgs::new();
let result = Rm.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("missing"));
}
#[tokio::test]
async fn test_rm_r_recursive() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/fulldir".into()));
args.flags.insert("r".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/fulldir")).await);
assert!(!ctx.backend.exists(Path::new("/fulldir/file.txt")).await);
}
#[tokio::test]
async fn test_rm_recursive_flag() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/fulldir".into()));
args.flags.insert("recursive".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/fulldir")).await);
}
#[tokio::test]
async fn test_rm_f_force_nonexistent() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
args.flags.insert("f".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok()); }
#[tokio::test]
async fn test_rm_force_flag_nonexistent() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
args.flags.insert("force".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
}
async fn make_deep_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("deep/a/b/c/file.txt"), b"data").await.unwrap();
mem.write(Path::new("deep/a/sibling.txt"), b"data").await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_rm_r_deeply_nested() {
let mut ctx = make_deep_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/deep".into()));
args.flags.insert("r".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/deep")).await);
assert!(!ctx.backend.exists(Path::new("/deep/a")).await);
assert!(!ctx.backend.exists(Path::new("/deep/a/b")).await);
}
#[tokio::test]
async fn test_rm_latch_off_deletes_normally() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_latch_on_no_confirm_returns_code_2() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 2);
assert!(result.err.contains("confirmation required"));
assert!(result.err.contains("Authorized: /file.txt"));
assert!(result.err.contains("--confirm="));
assert!(result.err.contains("60 seconds"));
let data = result.data.as_ref().expect("latch result should have data");
if let Value::Json(json) = data {
assert!(json["nonce"].is_string());
assert_eq!(json["command"], "rm");
assert_eq!(json["paths"], serde_json::json!(["/file.txt"]));
assert_eq!(json["ttl"], 60);
assert!(json["hint"].as_str().unwrap().contains("--confirm="));
} else {
panic!("expected Value::Json, got {:?}", data);
}
assert!(ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_latch_on_valid_confirm_deletes() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let nonce = ctx.nonce_store.issue("rm", &["/file.txt"]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
args.named.insert("confirm".to_string(), Value::String(nonce));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_latch_on_invalid_confirm_fails() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
args.named.insert("confirm".to_string(), Value::String("bogus123".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(result.err.contains("invalid nonce"));
assert!(ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_latch_on_force_nonexistent() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
args.flags.insert("f".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
}
#[tokio::test]
async fn test_rm_latch_on_nonexistent_no_force() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(result.err.contains("No such file"));
}
#[tokio::test]
async fn test_rm_latch_nonce_reuse_idempotent() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let nonce = ctx.nonce_store.issue("rm", &["/file.txt"]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
args.named.insert("confirm".to_string(), Value::String(nonce.clone()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
let mut args2 = ToolArgs::new();
args2.positional.push(Value::String("/file.txt".into()));
args2.named.insert("confirm".to_string(), Value::String(nonce));
let result2 = Rm.execute(args2, &mut ctx).await;
assert_eq!(result2.code, 1);
}
#[tokio::test]
async fn test_rm_latch_error_message_is_parseable() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 2);
let err = &result.err;
assert!(err.contains("rm --confirm="));
assert!(err.contains("/file.txt"));
assert!(err.contains("60 seconds"));
let confirm_prefix = "rm --confirm=\"";
let idx = err.find(confirm_prefix).expect("should contain confirm prefix");
let nonce_start = idx + confirm_prefix.len();
let nonce: String = err[nonce_start..].chars().take(8).collect();
assert_eq!(nonce.len(), 8);
assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_decide_rm_action_no_flags() {
let action = decide_rm_action(false, false, None, Some(100), 10_000_000, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_latch_only() {
let action = decide_rm_action(false, true, None, Some(100), 10_000_000, false);
assert_eq!(action, RmAction::Latch);
}
#[test]
fn test_decide_rm_action_trash_small_file() {
let real = PathBuf::from("/home/user/file.txt");
let action = decide_rm_action(true, false, Some(&real), Some(100), 10_000_000, false);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_trash_small_with_latch() {
let real = PathBuf::from("/home/user/file.txt");
let action = decide_rm_action(true, true, Some(&real), Some(100), 10_000_000, false);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_trash_large_no_latch() {
let real = PathBuf::from("/home/user/bigfile.bin");
let action = decide_rm_action(true, false, Some(&real), Some(100_000_000), 10_000_000, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_trash_large_with_latch() {
let real = PathBuf::from("/home/user/bigfile.bin");
let action = decide_rm_action(true, true, Some(&real), Some(100_000_000), 10_000_000, false);
assert_eq!(action, RmAction::Latch);
}
#[test]
fn test_decide_rm_action_trash_virtual_path() {
let action = decide_rm_action(true, false, None, Some(100), 10_000_000, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_trash_excluded_tmp() {
let real = PathBuf::from("/tmp/scratch");
let action = decide_rm_action(true, false, Some(&real), Some(100), 10_000_000, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_trash_excluded_v() {
let real = PathBuf::from("/v/jobs/something");
let action = decide_rm_action(true, false, Some(&real), Some(100), 10_000_000, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_dir_always_trashes() {
let real = PathBuf::from("/home/user/mydir");
let action = decide_rm_action(true, false, Some(&real), Some(0), 10_000_000, true);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_dir_trashes_with_latch() {
let real = PathBuf::from("/home/user/mydir");
let action = decide_rm_action(true, true, Some(&real), Some(0), 10_000_000, true);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_dir_excluded_tmp() {
let real = PathBuf::from("/tmp/mydir");
let action = decide_rm_action(true, false, Some(&real), Some(0), 10_000_000, true);
assert_eq!(action, RmAction::Delete);
}
#[derive(Debug, PartialEq)]
enum Outcome {
Deleted,
Trashed,
Latched,
}
fn matrix_action_to_outcome(action: &RmAction) -> Outcome {
match action {
RmAction::Trash(_) => Outcome::Trashed,
RmAction::Delete => Outcome::Deleted,
RmAction::Latch => Outcome::Latched,
}
}
#[test]
fn test_composition_matrix() {
let real = PathBuf::from("/home/user/file.txt");
let small = 100u64;
let large = 100_000_000u64;
let max = 10_000_000u64;
let cases = vec![
(false, false, small, false, Outcome::Deleted),
(false, true, small, false, Outcome::Latched),
(true, false, small, false, Outcome::Trashed),
(true, true, small, false, Outcome::Trashed), (false, false, large, false, Outcome::Deleted),
(false, true, large, false, Outcome::Latched),
(true, false, large, false, Outcome::Deleted), (true, true, large, false, Outcome::Latched), (true, false, 0, true, Outcome::Trashed),
(true, true, 0, true, Outcome::Trashed),
(false, false, 0, true, Outcome::Deleted),
(false, true, 0, true, Outcome::Latched),
];
for (trash, latch, size, is_dir, expected) in cases {
let action = decide_rm_action(trash, latch, Some(&real), Some(size), max, is_dir);
let outcome = matrix_action_to_outcome(&action);
assert_eq!(
outcome, expected,
"trash={}, latch={}, size={}, is_dir={}: expected {:?}, got {:?}",
trash, latch, size, is_dir, expected, outcome
);
}
}
}