yoi/crates/tools/tests/edge_cases.rs

316 lines
8.9 KiB
Rust

//! Edge-case regression tests that should stay green.
use std::sync::Arc;
use llm_worker::tool::{Tool, ToolDefinition};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use serde_json::json;
use tempfile::TempDir;
use tools::{ScopedFs, Tracker, core_builtin_tools};
struct Registry {
entries: Vec<(llm_worker::tool::ToolMeta, Arc<dyn Tool>)>,
}
impl Registry {
fn new(defs: Vec<ToolDefinition>) -> Self {
Self {
entries: defs.into_iter().map(|f| f()).collect(),
}
}
fn get(&self, name: &str) -> Arc<dyn Tool> {
self.entries
.iter()
.find(|(m, _)| m.name == name)
.map(|(_, t)| Arc::clone(t))
.unwrap()
}
}
fn setup() -> (TempDir, TempDir, Registry) {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let base = Scope::writable(dir.path()).unwrap();
let mut config = ScopeConfig {
allow: base.allow_rules(),
deny: base.deny_rules(),
};
config.allow.push(ScopeRule {
target: spill.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
});
let scope = Scope::from_config(&config).unwrap();
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
let tracker = Tracker::new();
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
(dir, spill, reg)
}
#[tokio::test]
async fn unicode_path_and_content() {
let (dir, _spill, reg) = setup();
let file = dir.path().join("日本語ファイル.txt");
let content = "こんにちは 🦀 世界\nabc\n";
let write = reg.get("Write");
write
.execute(
&json!({
"file_path": file.to_str().unwrap(),
"content": content,
})
.to_string(),
Default::default(),
)
.await
.unwrap();
let read = reg.get("Read");
let out = read
.execute(
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
Default::default(),
)
.await
.unwrap();
let body = out.content.unwrap();
assert!(body.contains("🦀"));
assert!(body.contains("こんにちは"));
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_to_outside_scope_is_rejected_for_write() {
use std::os::unix::fs::symlink;
let (dir, _spill, reg) = setup();
let outside = TempDir::new().unwrap();
let outside_target = outside.path().join("secret.txt");
std::fs::write(&outside_target, "secret").unwrap();
// Create a symlink inside the scope pointing to the outside file.
let link = dir.path().join("linked.txt");
symlink(&outside_target, &link).unwrap();
// Read through the symlink must be rejected because the resolved
// target sits outside the scope.
let read = reg.get("Read");
let read_err = read
.execute(
&json!({ "file_path": link.to_str().unwrap() }).to_string(),
Default::default(),
)
.await
.unwrap_err();
assert!(
format!("{read_err}").contains("outside allowed read scope"),
"symlink read escape not rejected: {read_err}"
);
assert!(
format!("{read_err}").contains(&outside_target.display().to_string()),
"symlink read diagnostic should include resolved target: {read_err}"
);
// Write through the symlink must be rejected for the same reason.
let write = reg.get("Write");
let err = write
.execute(
&json!({
"file_path": link.to_str().unwrap(),
"content": "overwritten",
})
.to_string(),
Default::default(),
)
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope"),
"symlink escape not rejected: {msg}"
);
assert!(
msg.contains("add the symlink target"),
"symlink escape diagnostic should include remediation: {msg}"
);
// Outside file must not have been touched.
assert_eq!(std::fs::read_to_string(&outside_target).unwrap(), "secret");
}
#[cfg(unix)]
#[tokio::test]
async fn broken_symlink_reports_target_and_repair_hint() {
use std::os::unix::fs::symlink;
let (dir, _spill, reg) = setup();
let link = dir.path().join("external-project");
let target = dir.path().join("missing-target");
symlink(&target, &link).unwrap();
let read = reg.get("Read");
let err = read
.execute(
&json!({ "file_path": link.to_str().unwrap() }).to_string(),
Default::default(),
)
.await
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("broken symlink"), "{msg}");
assert!(msg.contains(&link.display().to_string()), "{msg}");
assert!(msg.contains(&target.display().to_string()), "{msg}");
assert!(msg.contains("correct relative target"), "{msg}");
}
#[tokio::test]
async fn empty_file_read_and_edit() {
let (dir, _spill, reg) = setup();
let file = dir.path().join("empty.txt");
std::fs::write(&file, "").unwrap();
let read = reg.get("Read");
let out = read
.execute(
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
Default::default(),
)
.await
.unwrap();
assert!(out.summary.contains("0 line"));
// Edit on empty file must produce StringNotFound
let edit = reg.get("Edit");
let err = edit
.execute(
&json!({
"file_path": file.to_str().unwrap(),
"old_string": "foo",
"new_string": "bar",
})
.to_string(),
Default::default(),
)
.await
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("not found"));
}
#[tokio::test]
async fn very_long_single_line() {
let (dir, _spill, reg) = setup();
let file = dir.path().join("long.txt");
let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines
std::fs::write(&file, &big).unwrap();
let read = reg.get("Read");
let out = read
.execute(
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
Default::default(),
)
.await
.unwrap();
// Should return exactly 1 line
assert!(out.summary.contains("1 line"));
}
#[tokio::test]
async fn relative_path_is_rejected() {
let (_dir, _spill, reg) = setup();
let read = reg.get("Read");
let err = read
.execute(
&json!({ "file_path": "relative.txt" }).to_string(),
Default::default(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("absolute"));
}
#[tokio::test]
async fn directory_target_is_rejected_for_read() {
let (dir, _spill, reg) = setup();
let read = reg.get("Read");
let err = read
.execute(
&json!({ "file_path": dir.path().to_str().unwrap() }).to_string(),
Default::default(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("directory"));
}
#[tokio::test]
async fn deeply_nested_new_file_is_created() {
let (dir, _spill, reg) = setup();
let deep = dir.path().join("a/b/c/d/e/deep.txt");
let write = reg.get("Write");
write
.execute(
&json!({
"file_path": deep.to_str().unwrap(),
"content": "deep\n",
})
.to_string(),
Default::default(),
)
.await
.unwrap();
assert_eq!(std::fs::read_to_string(&deep).unwrap(), "deep\n");
}
#[tokio::test]
async fn replace_preserves_unicode() {
let (dir, _spill, reg) = setup();
let file = dir.path().join("u.txt");
std::fs::write(&file, "🦀 rust 🦀\n").unwrap();
let read = reg.get("Read");
read.execute(
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
Default::default(),
)
.await
.unwrap();
let edit = reg.get("Edit");
edit.execute(
&json!({
"file_path": file.to_str().unwrap(),
"old_string": "rust",
"new_string": "ラスト",
})
.to_string(),
Default::default(),
)
.await
.unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "🦀 ラスト 🦀\n");
}
#[tokio::test]
async fn grep_handles_unicode_pattern() {
let (dir, _spill, reg) = setup();
let file = dir.path().join("u.txt");
std::fs::write(&file, "English\n日本語\nрусский\n").unwrap();
let grep = reg.get("Grep");
let out = grep
.execute(
&json!({
"pattern": "日本語",
"output_mode": "content",
})
.to_string(),
Default::default(),
)
.await
.unwrap();
let body = out.content.unwrap();
assert!(body.contains("日本語"));
}