#![allow(clippy::unwrap_used, clippy::expect_used)]
#![cfg(feature = "localfs")]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use kaish_kernel::interpreter::ExecResult;
use kaish_kernel::trash::{TrashBackend, TrashEntry, TrashError};
use kaish_kernel::{Kernel, KernelConfig};
fn tempdir() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("kaish-trash-")
.tempdir_in(env!("CARGO_TARGET_TMPDIR"))
.expect("tempdir under CARGO_TARGET_TMPDIR")
}
struct Session {
kernel: Kernel,
}
fn kernel_at(dir: &Path) -> Session {
let config = KernelConfig::repl()
.with_cwd(dir.to_path_buf())
.with_trash(false);
Session { kernel: Kernel::new(config).expect("kernel") }
}
async fn run(session: &Session, script: &str) -> ExecResult {
session.kernel.execute(script).await.expect("kernel execute")
}
#[derive(Default)]
struct MockTrash {
trashed: Mutex<Vec<PathBuf>>,
snapshots: Mutex<Vec<(PathBuf, Vec<u8>)>>,
fail: bool,
}
impl MockTrash {
fn failing() -> Self {
Self { fail: true, ..Self::default() }
}
fn trashed_paths(&self) -> Vec<PathBuf> {
self.trashed.lock().expect("mock lock").clone()
}
fn snapshots(&self) -> Vec<(PathBuf, Vec<u8>)> {
self.snapshots.lock().expect("mock lock").clone()
}
}
#[async_trait]
impl TrashBackend for MockTrash {
async fn trash(&self, path: &Path) -> Result<(), TrashError> {
if self.fail {
return Err(TrashError::Backend("mock trash refused".into()));
}
self.trashed
.lock()
.expect("mock lock")
.push(path.to_path_buf());
Ok(())
}
async fn trash_bytes(&self, original_path: &Path, bytes: &[u8]) -> Result<(), TrashError> {
if self.fail {
return Err(TrashError::Backend("mock trash refused".into()));
}
self.snapshots
.lock()
.expect("mock lock")
.push((original_path.to_path_buf(), bytes.to_vec()));
Ok(())
}
async fn list(&self, _filter: Option<&str>) -> Result<Vec<TrashEntry>, TrashError> {
Ok(Vec::new())
}
async fn find_by_name(&self, _name: &str) -> Result<Vec<TrashEntry>, TrashError> {
Ok(Vec::new())
}
async fn restore(&self, _entries: Vec<TrashEntry>) -> Result<(), TrashError> {
Ok(())
}
async fn purge_all(&self) -> Result<usize, TrashError> {
Ok(0)
}
}
fn kernel_with_trash(dir: &Path, mock: &Arc<MockTrash>) -> Session {
let mut session = kernel_at(dir);
session
.kernel
.set_trash_backend(Some(Arc::clone(mock) as Arc<dyn TrashBackend>));
session
}
#[tokio::test]
async fn trash_small_file_delegates_to_backend() {
let dir = tempdir();
std::fs::write(dir.path().join("keep.txt"), "data").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "rm keep.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let trashed = mock.trashed_paths();
assert_eq!(trashed.len(), 1, "exactly one trash call: {trashed:?}");
assert!(
trashed[0].ends_with("keep.txt"),
"trash received the real path: {trashed:?}"
);
assert!(
dir.path().join("keep.txt").exists(),
"rm must delegate removal to the trash backend, not delete as well"
);
}
#[tokio::test]
async fn trash_directory_always_trashes_without_recursive_flag() {
let dir = tempdir();
std::fs::create_dir(dir.path().join("sub")).expect("mkdir");
std::fs::write(dir.path().join("sub/inner.txt"), "x").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "rm sub").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let trashed = mock.trashed_paths();
assert_eq!(trashed.len(), 1, "one trash call for the dir: {trashed:?}");
assert!(trashed[0].ends_with("sub"));
}
#[tokio::test]
async fn trash_catches_a_small_file() {
let dir = tempdir();
std::fs::write(dir.path().join("small.txt"), "tiny").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "rm small.txt").await;
assert_eq!(r.code, 0, "trash should catch the delete, err: {}", r.err);
assert_eq!(mock.trashed_paths().len(), 1);
}
#[tokio::test]
async fn trash_failure_is_loud_and_never_falls_through_to_delete() {
let dir = tempdir();
std::fs::write(dir.path().join("guarded.txt"), "data").expect("write");
let mock = Arc::new(MockTrash::failing());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "rm guarded.txt").await;
assert_eq!(r.code, 1, "trash failure must be an error, not silent");
assert!(
r.err.contains("trash failed"),
"error should name the trash failure: {}",
r.err
);
assert!(
dir.path().join("guarded.txt").exists(),
"trash failure fell through to permanent delete"
);
}
#[tokio::test]
async fn trash_backend_absent_fails_loud() {
let dir = tempdir();
std::fs::write(dir.path().join("orphan.txt"), "data").expect("write");
let mut session = kernel_at(dir.path());
session.kernel.set_trash_backend(None);
run(&session, "set -o trash").await;
let r = run(&session, "rm orphan.txt").await;
assert_eq!(r.code, 1, "missing backend must be an error");
assert!(
r.err.contains("trash backend not available"),
"error should name the missing backend: {}",
r.err
);
assert!(
dir.path().join("orphan.txt").exists(),
"missing trash backend must not fall through to delete"
);
}
fn isolated_kernel_with_trash(mock: &Arc<MockTrash>) -> Session {
let kernel = Kernel::new(KernelConfig::isolated()).expect("kernel");
let mut session = Session { kernel };
session
.kernel
.set_trash_backend(Some(Arc::clone(mock) as Arc<dyn TrashBackend>));
session
}
#[tokio::test]
async fn tee_overwrite_under_trash_snapshots_prior_bytes_first() {
let dir = tempdir();
std::fs::write(dir.path().join("doc.txt"), "old").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "echo new | tee doc.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "one byte-snapshot for the overwrite: {snaps:?}");
assert!(snaps[0].0.ends_with("doc.txt"));
assert_eq!(snaps[0].1, b"old", "the snapshot captured the prior content");
assert!(mock.trashed_paths().is_empty(), "overwrite copies, never moves the file");
let now = std::fs::read_to_string(dir.path().join("doc.txt")).expect("read");
assert_eq!(now, "new\n", "the new content is written after the snapshot");
}
#[tokio::test]
async fn tee_new_file_and_append_are_not_overwrites() {
let dir = tempdir();
std::fs::write(dir.path().join("log.txt"), "line1\n").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
let r = run(&session, "echo hi | tee fresh.txt").await;
assert_eq!(r.code, 0, "new file should not gate: {}", r.err);
let r2 = run(&session, "echo line2 | tee -a log.txt").await;
assert_eq!(r2.code, 0, "append should not gate: {}", r2.err);
assert_eq!(
std::fs::read_to_string(dir.path().join("log.txt")).expect("read"),
"line1\nline2\n"
);
}
#[tokio::test]
async fn tee_overlay_overwrite_snapshots_bytes_via_trash_bytes() {
let mock = Arc::new(MockTrash::default());
let session = isolated_kernel_with_trash(&mock);
run(&session, "set -o trash").await;
run(&session, "write /v/f.txt \"original\"").await; let r = run(&session, "echo new | tee /v/f.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "one byte-snapshot for the overlay overwrite: {snaps:?}");
assert!(snaps[0].0.ends_with("f.txt"));
assert_eq!(snaps[0].1, b"original", "the captured bytes are the prior content");
assert!(mock.trashed_paths().is_empty(), "no real-path trash for an overlay file");
let out = run(&session, "cat /v/f.txt").await;
assert_eq!(out.text_out().trim(), "new", "new content written after the snapshot");
}
const PATCH_SCRIPT: &str = "patch f.txt <<'EOF'\n--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-old\n+new\nEOF\n";
#[tokio::test]
async fn patch_overwrite_under_trash_snapshots_prior_bytes() {
let dir = tempdir();
std::fs::write(dir.path().join("f.txt"), "old\n").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, PATCH_SCRIPT).await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "one byte-snapshot before the patch: {snaps:?}");
assert_eq!(snaps[0].1, b"old\n", "snapshot captured the prior content");
assert_eq!(
std::fs::read_to_string(dir.path().join("f.txt")).expect("read"),
"new\n"
);
}
#[tokio::test]
async fn patch_explicit_file_multi_group_diff_snapshots_once() {
let dir = tempdir();
std::fs::write(dir.path().join("f.txt"), "alpha\nbeta\n").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
let script = "patch f.txt <<'EOF'\n\
--- a/x\n+++ b/x\n@@ -1 +1 @@\n-alpha\n+ALPHA\n\
--- a/y\n+++ b/y\n@@ -2 +2 @@\n-beta\n+BETA\n\
EOF\n";
run(&session, "set -o trash").await;
let r = run(&session, script).await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(
snaps.len(),
1,
"the explicit target is deduped to one snapshot: {snaps:?}"
);
assert_eq!(snaps[0].1, b"alpha\nbeta\n", "snapshot captured prior content");
assert_eq!(
std::fs::read_to_string(dir.path().join("f.txt")).expect("read"),
"ALPHA\nBETA\n"
);
}
#[tokio::test]
async fn patch_dry_run_snapshots_nothing() {
let dir = tempdir();
std::fs::write(dir.path().join("f.txt"), "old\n").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
let dry = PATCH_SCRIPT.replace("patch f.txt", "patch --dry-run f.txt");
let r = run(&session, &dry).await;
assert_eq!(r.code, 0, "dry-run never writes, so it never gates: {}", r.err);
assert_eq!(
std::fs::read_to_string(dir.path().join("f.txt")).expect("read"),
"old\n",
"dry-run leaves the file untouched"
);
}
#[tokio::test]
async fn sed_in_place_under_trash_snapshots_prior_bytes() {
let dir = tempdir();
std::fs::write(dir.path().join("f.txt"), "old\n").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "sed -i 's/old/new/' f.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "one byte-snapshot before the in-place write: {snaps:?}");
assert_eq!(snaps[0].1, b"old\n", "snapshot captured the prior content");
assert_eq!(
std::fs::read_to_string(dir.path().join("f.txt")).expect("read"),
"new\n"
);
}
#[tokio::test]
async fn sed_in_place_multi_file_and_e_flag_edits_all() {
let dir = tempdir();
std::fs::write(dir.path().join("a.txt"), "old\n").expect("write");
std::fs::write(dir.path().join("b.txt"), "old\n").expect("write");
let session = kernel_at(dir.path());
let r = run(&session, "sed -i -e 's/old/new/' a.txt b.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
assert_eq!(std::fs::read_to_string(dir.path().join("a.txt")).expect("read"), "new\n");
assert_eq!(std::fs::read_to_string(dir.path().join("b.txt")).expect("read"), "new\n");
}
#[tokio::test]
async fn sed_in_place_continues_past_per_file_error() {
let dir = tempdir();
std::fs::write(dir.path().join("good.txt"), "old\n").expect("write");
let session = kernel_at(dir.path());
let r = run(&session, "sed -i 's/old/new/' missing.txt good.txt").await;
assert_eq!(r.code, 1, "a per-file failure yields a non-zero exit: {}", r.err);
assert!(r.err.contains("missing.txt"), "error names the bad file: {}", r.err);
assert_eq!(
std::fs::read_to_string(dir.path().join("good.txt")).expect("read"),
"new\n",
"the good file is still edited"
);
}
#[tokio::test]
async fn sed_in_place_without_operands_is_a_loud_error() {
let dir = tempdir();
let session = kernel_at(dir.path());
let r = run(&session, "echo hi | sed -i 's/h/H/'").await;
assert_eq!(r.code, 1, "no file operands must error: {}", r.err);
assert!(r.err.contains("requires file operands"), "err: {}", r.err);
}
#[tokio::test]
async fn write_overwrite_under_trash_snapshots_prior_bytes() {
let dir = tempdir();
std::fs::write(dir.path().join("doc.txt"), "old").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "write doc.txt \"new\"").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "one snapshot for the overwrite: {snaps:?}");
assert_eq!(snaps[0].1, b"old", "the snapshot captured the prior content");
assert_eq!(std::fs::read_to_string(dir.path().join("doc.txt")).unwrap(), "new");
}
#[tokio::test]
async fn write_new_file_is_not_an_overwrite() {
let dir = tempdir();
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
let r = run(&session, "write fresh.txt \"hi\"").await;
assert_eq!(r.code, 0, "a new file has nothing to lose: {}", r.err);
assert_eq!(std::fs::read_to_string(dir.path().join("fresh.txt")).unwrap(), "hi");
}
#[tokio::test]
async fn overwrite_too_big_for_trash_is_written_directly() {
let dir = tempdir();
std::fs::write(dir.path().join("big.txt"), "0123456789").expect("write"); let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "kaish-trash config max-size 2").await; run(&session, "set -o trash").await;
let r = run(&session, "write big.txt \"new\"").await;
assert_eq!(r.code, 0, "an oversize overwrite is not held: {}", r.err);
assert!(mock.snapshots().is_empty(), "no snapshot when over the cap");
assert_eq!(
std::fs::read_to_string(dir.path().join("big.txt")).unwrap(),
"new",
"the overwrite lands, with no recoverable prior copy"
);
}
#[tokio::test]
async fn cp_overwrite_under_trash_snapshots_prior_bytes() {
let dir = tempdir();
std::fs::write(dir.path().join("src.txt"), "fresh").expect("write");
std::fs::write(dir.path().join("dst.txt"), "old").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "cp src.txt dst.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "snapshot of the prior destination: {snaps:?}");
assert_eq!(snaps[0].1, b"old");
assert_eq!(std::fs::read_to_string(dir.path().join("dst.txt")).unwrap(), "fresh");
}
#[tokio::test]
async fn cp_into_existing_directory_does_not_snapshot_the_dir() {
let dir = tempdir();
std::fs::write(dir.path().join("src.txt"), "data").expect("write");
std::fs::create_dir(dir.path().join("d")).expect("mkdir");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
let r = run(&session, "cp src.txt d").await;
assert_eq!(r.code, 0, "cp into a directory must not snapshot the dir: {}", r.err);
assert_eq!(std::fs::read_to_string(dir.path().join("d/src.txt")).unwrap(), "data");
}
#[tokio::test]
async fn mv_overwrite_under_trash_snapshots_prior_bytes() {
let dir = tempdir();
std::fs::write(dir.path().join("src.txt"), "fresh").expect("write");
std::fs::write(dir.path().join("dst.txt"), "old").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "mv src.txt dst.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "snapshot of the prior destination: {snaps:?}");
assert_eq!(snaps[0].1, b"old");
assert_eq!(std::fs::read_to_string(dir.path().join("dst.txt")).unwrap(), "fresh");
}
#[tokio::test]
async fn dd_of_overwrite_under_trash_snapshots_prior_bytes() {
let dir = tempdir();
std::fs::write(dir.path().join("in.bin"), "fresh").expect("write");
std::fs::write(dir.path().join("out.bin"), "old").expect("write");
let mock = Arc::new(MockTrash::default());
let session = kernel_with_trash(dir.path(), &mock);
run(&session, "set -o trash").await;
let r = run(&session, "dd if=in.bin of=out.bin").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let snaps = mock.snapshots();
assert_eq!(snaps.len(), 1, "snapshot of the prior of= file: {snaps:?}");
assert_eq!(snaps[0].1, b"old");
assert_eq!(std::fs::read_to_string(dir.path().join("out.bin")).unwrap(), "fresh");
}
#[tokio::test]
async fn trash_empty_asks_and_the_confirm_flag_it_names_is_accepted() {
let dir = tempdir();
let session = kernel_at(dir.path());
let refused = run(&session, "kaish-trash empty").await;
assert_eq!(refused.code, 2, "a bare empty must refuse: {refused:?}");
assert!(refused.err.contains("irreversible"), "{}", refused.err);
assert!(
refused.err.contains("--confirm"),
"the refusal must name the flag: {}",
refused.err
);
let confirmed = run(&session, "kaish-trash empty --confirm").await;
assert_ne!(
confirmed.code, 2,
"the advertised re-run must not be refused again: {confirmed:?}"
);
assert!(
!confirmed.err.contains("a value is required"),
"--confirm must take no value: {}",
confirmed.err
);
let old_spelling = run(&session, "kaish-trash empty --confirm=deadbeef").await;
assert_eq!(
old_spelling.code, 2,
"--confirm=<nonce> no longer parses: {old_spelling:?}"
);
}