use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::entities::profile::ToolId;
use super::{Tool, ToolContext, ToolOutcome};
pub const FS_READ_ID: &str = "fs_read";
pub const FS_WRITE_ID: &str = "fs_write";
pub const FS_LIST_ID: &str = "fs_list";
const MAX_READ_CHARS: usize = 50_000;
const MAX_LIST_ENTRIES: usize = 500;
#[derive(Clone)]
struct FsRoot {
root: Option<PathBuf>,
}
impl FsRoot {
fn new(root: Option<String>) -> Self {
Self {
root: root
.filter(|s| !s.trim().is_empty())
.map(|s| PathBuf::from(s.trim())),
}
}
fn resolve(&self, raw: &str, ctx: &ToolContext) -> Result<PathBuf> {
let loc = ctx.loc;
let raw = raw.trim();
if raw.is_empty() {
anyhow::bail!(loc.t("tool.fs.err.empty_path").to_string());
}
let requested = PathBuf::from(raw);
let Some(root) = &self.root else {
anyhow::bail!(loc.t("tool.fs.err.no_root").to_string());
};
let root = root.canonicalize().with_context(|| {
loc.tf(
"tool.fs.err.sandbox_unavailable",
&[("path", &root.display().to_string())],
)
})?;
let candidate = if requested.is_absolute() {
requested
} else {
root.join(&requested)
};
let canonical = match candidate.canonicalize() {
Ok(c) => c,
Err(_) => {
super::reach::refuse_dangling_link(&candidate, loc)?;
let parent = candidate
.parent()
.ok_or_else(|| anyhow::anyhow!(loc.t("tool.fs.err.no_parent").to_string()))?;
let parent = parent.canonicalize().with_context(|| {
loc.tf(
"tool.fs.err.parent_unavailable",
&[("path", &parent.display().to_string())],
)
})?;
let name = candidate
.file_name()
.ok_or_else(|| anyhow::anyhow!(loc.t("tool.fs.err.no_filename").to_string()))?;
parent.join(name)
}
};
if !canonical.starts_with(&root) {
anyhow::bail!(loc.tf(
"tool.fs.err.outside_sandbox",
&[("root", &root.display().to_string())]
));
}
super::reach::refuse_app_dirs(ctx, &canonical)?;
Ok(canonical)
}
}
fn arg_path(args: &serde_json::Value, loc: &crate::shared::i18n::Locale) -> Result<String> {
args.get("path")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!(loc.t("tool.fs.err.path_field_empty").to_string()))
}
fn truncate_chars(s: &str, max: usize, loc: &crate::shared::i18n::Locale) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut: String = s.chars().take(max).collect();
format!(
"{cut}\n{}",
loc.tf("tool.fs.truncated_read", &[("max", &max.to_string())])
)
}
pub struct FsRead {
fs: FsRoot,
}
impl FsRead {
pub fn new(root: Option<String>) -> Self {
Self {
fs: FsRoot::new(root),
}
}
}
#[async_trait::async_trait]
impl Tool for FsRead {
fn id(&self) -> ToolId {
FS_READ_ID.into()
}
fn concurrent(&self) -> bool {
true
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Files
}
fn ui_label(&self) -> &'static str {
"read file"
}
fn gate(&self) -> Option<crate::features::tools::meta::ToolGate> {
Some(crate::features::tools::meta::ToolGate::Fs)
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.fs_read.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {"path": {"type": "string", "description": loc.t("tool.fs.param.file_path")}},
"required": ["path"]
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let path = self.fs.resolve(&arg_path(&args, ctx.loc)?, ctx)?;
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(err) => {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.fs_read.result.read_failed",
&[
("path", &path.display().to_string()),
("err", &err.to_string()),
],
)));
}
};
let markup = crate::shared::text_decode::is_markup_path(&path);
let Some(file) = crate::shared::text_decode::decode_file(&bytes, markup, ctx.file_hint)
else {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.fs_read.result.binary",
&[("path", &path.display().to_string())],
)));
};
let text = if file.encoding == encoding_rs::UTF_8 {
file.text
} else {
let note = ctx.loc.tf(
"tool.fs_read.decoded_as",
&[("encoding", file.encoding.name())],
);
format!("{note}\n{}", file.text)
};
Ok(ToolOutcome::text(truncate_chars(
&text,
MAX_READ_CHARS,
ctx.loc,
)))
}
}
pub struct FsWrite {
fs: FsRoot,
}
impl FsWrite {
pub fn new(root: Option<String>) -> Self {
Self {
fs: FsRoot::new(root),
}
}
}
#[async_trait::async_trait]
impl Tool for FsWrite {
fn id(&self) -> ToolId {
FS_WRITE_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Files
}
fn danger(&self) -> bool {
true
}
fn ui_label(&self) -> &'static str {
"write file"
}
fn gate(&self) -> Option<crate::features::tools::meta::ToolGate> {
Some(crate::features::tools::meta::ToolGate::Fs)
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.fs_write.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": loc.t("tool.fs.param.file_path")},
"content": {"type": "string", "description": loc.t("tool.fs_write.param.content")},
"append": {"type": "boolean", "description": loc.t("tool.fs_write.param.append")}
},
"required": ["path", "content"]
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let path = self.fs.resolve(&arg_path(&args, ctx.loc)?, ctx)?;
let content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!(ctx.loc.t("tool.fs_write.err.content")))?;
let append = args
.get("append")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let result = if append {
append_to(&path, content).await
} else {
tokio::fs::write(&path, content.as_bytes()).await
};
let args = [
("path", path.display().to_string()),
("n", content.chars().count().to_string()),
];
match result {
Ok(()) => {
let key = if append {
"tool.fs_write.result.appended"
} else {
"tool.fs_write.result.written"
};
Ok(ToolOutcome::text(
ctx.loc.tf(key, &[("path", &args[0].1), ("n", &args[1].1)]),
))
}
Err(err) => Ok(ToolOutcome::text(ctx.loc.tf(
"tool.fs_write.result.write_failed",
&[("path", &args[0].1), ("err", &err.to_string())],
))),
}
}
}
async fn append_to(path: &Path, content: &str) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
file.write_all(content.as_bytes()).await?;
file.flush().await
}
pub struct FsList {
fs: FsRoot,
}
impl FsList {
pub fn new(root: Option<String>) -> Self {
Self {
fs: FsRoot::new(root),
}
}
}
#[async_trait::async_trait]
impl Tool for FsList {
fn id(&self) -> ToolId {
FS_LIST_ID.into()
}
fn concurrent(&self) -> bool {
true
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Files
}
fn ui_label(&self) -> &'static str {
"list files"
}
fn gate(&self) -> Option<crate::features::tools::meta::ToolGate> {
Some(crate::features::tools::meta::ToolGate::Fs)
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.fs_list.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {"path": {"type": "string", "description": loc.t("tool.fs.param.dir_path")}},
"required": ["path"]
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let path = self.fs.resolve(&arg_path(&args, ctx.loc)?, ctx)?;
let mut rd = match tokio::fs::read_dir(&path).await {
Ok(rd) => rd,
Err(err) => {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.fs_list.result.open_failed",
&[
("path", &path.display().to_string()),
("err", &err.to_string()),
],
)));
}
};
let mut entries: Vec<String> = Vec::new();
let mut truncated = false;
while let Some(entry) = rd.next_entry().await? {
if entries.len() >= MAX_LIST_ENTRIES {
truncated = true;
break;
}
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
entries.push(if is_dir { format!("{name}/") } else { name });
}
entries.sort();
if entries.is_empty() {
return Ok(ToolOutcome::text(ctx.loc.tf(
"tool.fs_list.result.empty",
&[("path", &path.display().to_string())],
)));
}
let mut out = format!(
"{}\n",
ctx.loc.tf(
"tool.fs_list.result.header",
&[
("path", &path.display().to_string()),
("n", &entries.len().to_string())
]
)
);
out.push_str(&entries.join("\n"));
if truncated {
out.push('\n');
out.push_str(&ctx.loc.tf(
"tool.fs_list.truncated",
&[("max", &MAX_LIST_ENTRIES.to_string())],
));
}
Ok(ToolOutcome::text(out))
}
}
#[cfg(test)]
mod tests {
use super::super::testkit::ctx_with_storage;
use super::*;
use uuid::Uuid;
#[tokio::test]
async fn write_then_read_roundtrip() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let dir = tempfile::tempdir().unwrap();
let root = Some(dir.path().to_string_lossy().to_string());
let file = dir.path().join("note.txt");
let path = file.to_string_lossy().to_string();
FsWrite::new(root.clone())
.invoke(&ctx, serde_json::json!({"path": path, "content": "привет"}))
.await
.unwrap();
let out = FsRead::new(root)
.invoke(&ctx, serde_json::json!({"path": path}))
.await
.unwrap();
assert_eq!(out.result, "привет");
}
#[tokio::test]
async fn read_decodes_a_legacy_file_and_refuses_a_binary() {
let (_d, _s, mut ctx) = ctx_with_storage(Uuid::new_v4());
ctx.file_hint = Some("ru");
let dir = tempfile::tempdir().unwrap();
let tool = FsRead::new(Some(dir.path().to_string_lossy().to_string()));
let note = dir.path().join("report.txt");
let prose = "Выручка за март составила сто двадцать тысяч, за апрель немного больше.";
std::fs::write(¬e, encoding_rs::WINDOWS_1251.encode(prose).0).unwrap();
let out = tool
.invoke(&ctx, serde_json::json!({"path": note.to_string_lossy()}))
.await
.unwrap()
.result;
assert!(out.contains(prose) && out.contains("windows-1251"), "{out}");
let blob = dir.path().join("blob.bin");
std::fs::write(&blob, [0x50, 0x4B, 0x03, 0x04, 0x00, 0x00]).unwrap();
let out = tool
.invoke(&ctx, serde_json::json!({"path": blob.to_string_lossy()}))
.await
.unwrap()
.result;
assert!(!out.contains('\0') && out.contains("blob.bin"), "{out}");
}
#[tokio::test]
async fn append_adds_to_end() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let dir = tempfile::tempdir().unwrap();
let root = Some(dir.path().to_string_lossy().to_string());
let path = dir.path().join("log.txt").to_string_lossy().to_string();
let w = FsWrite::new(root.clone());
w.invoke(&ctx, serde_json::json!({"path": path, "content": "a"}))
.await
.unwrap();
w.invoke(
&ctx,
serde_json::json!({"path": path, "content": "b", "append": true}),
)
.await
.unwrap();
let out = FsRead::new(root.clone())
.invoke(&ctx, serde_json::json!({"path": path}))
.await
.unwrap();
assert_eq!(out.result, "ab");
for c in ["c", "d", "e"] {
w.invoke(
&ctx,
serde_json::json!({"path": path, "content": c, "append": true}),
)
.await
.unwrap();
}
let out = FsRead::new(root)
.invoke(&ctx, serde_json::json!({"path": path}))
.await
.unwrap();
assert_eq!(out.result, "abcde");
}
#[tokio::test]
async fn list_shows_entries() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "x").unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let path = dir.path().to_string_lossy().to_string();
let out = FsList::new(Some(path.clone()))
.invoke(&ctx, serde_json::json!({"path": path}))
.await
.unwrap();
assert!(out.result.contains("a.txt"), "got: {}", out.result);
assert!(out.result.contains("sub/"), "got: {}", out.result);
}
#[tokio::test]
async fn read_missing_file_reports_error_not_panic() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let root = tempfile::tempdir().unwrap();
let out = FsRead::new(Some(root.path().to_string_lossy().to_string()))
.invoke(
&ctx,
serde_json::json!({"path": "definitely-not-a-real-file-xyz.txt"}),
)
.await
.unwrap();
assert!(
out.result.contains("Не удалось прочитать"),
"got: {}",
out.result
);
}
#[tokio::test]
async fn sandbox_blocks_outside_paths() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("inside.txt"), "ok").unwrap();
let fs_root = Some(root.path().to_string_lossy().to_string());
let inside = FsRead::new(fs_root.clone())
.invoke(&ctx, serde_json::json!({"path": "inside.txt"}))
.await
.unwrap();
assert_eq!(inside.result, "ok");
let escape = FsRead::new(fs_root)
.invoke(&ctx, serde_json::json!({"path": "../../etc/passwd"}))
.await;
assert!(escape.is_err(), "escaping the sandbox must be rejected");
}
#[tokio::test]
async fn sandbox_allows_writing_new_file_inside() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let root = tempfile::tempdir().unwrap();
let fs_root = Some(root.path().to_string_lossy().to_string());
let out = FsWrite::new(fs_root)
.invoke(
&ctx,
serde_json::json!({"path": "new.txt", "content": "data"}),
)
.await
.unwrap();
assert!(out.result.contains("Записано"), "got: {}", out.result);
assert_eq!(
std::fs::read_to_string(root.path().join("new.txt")).unwrap(),
"data"
);
}
#[tokio::test]
async fn rejects_empty_path() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
assert!(
FsRead::new(None)
.invoke(&ctx, serde_json::json!({"path": " "}))
.await
.is_err()
);
}
#[tokio::test]
async fn an_empty_root_refuses_every_call() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("note.txt");
std::fs::write(&file, "secret").unwrap();
let path = file.to_string_lossy().to_string();
let refusal = ctx.loc.t("tool.fs.err.no_root");
let read = FsRead::new(None)
.invoke(&ctx, serde_json::json!({"path": path}))
.await;
assert_eq!(read.unwrap_err().to_string(), refusal);
let write = FsWrite::new(Some(" ".into()))
.invoke(&ctx, serde_json::json!({"path": path, "content": "x"}))
.await;
assert_eq!(write.unwrap_err().to_string(), refusal);
let list = FsList::new(None)
.invoke(
&ctx,
serde_json::json!({"path": dir.path().to_string_lossy()}),
)
.await;
assert_eq!(list.unwrap_err().to_string(), refusal);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "secret");
}
#[tokio::test]
async fn the_data_root_is_out_of_reach_even_inside_the_root() {
let (data, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let settings = data.path().join("settings.json");
std::fs::write(&settings, "{}").unwrap();
let beside = tempfile::tempdir_in(data.path().parent().unwrap()).unwrap();
std::fs::write(beside.path().join("ok.txt"), "ok").unwrap();
let root = Some(data.path().parent().unwrap().to_string_lossy().to_string());
let refusal = ctx.loc.t("tool.fs.err.app_dir");
let read = FsRead::new(root.clone())
.invoke(
&ctx,
serde_json::json!({"path": settings.to_string_lossy()}),
)
.await;
assert_eq!(read.unwrap_err().to_string(), refusal);
let write = FsWrite::new(root.clone())
.invoke(
&ctx,
serde_json::json!({"path": settings.to_string_lossy(), "content": "{\"mcp\":1}"}),
)
.await;
assert_eq!(write.unwrap_err().to_string(), refusal);
let fresh = FsWrite::new(root.clone())
.invoke(
&ctx,
serde_json::json!({"path": data.path().join("new.json").to_string_lossy(), "content": "x"}),
)
.await;
assert_eq!(fresh.unwrap_err().to_string(), refusal);
let list = FsList::new(root.clone())
.invoke(
&ctx,
serde_json::json!({"path": data.path().to_string_lossy()}),
)
.await;
assert_eq!(list.unwrap_err().to_string(), refusal);
assert_eq!(std::fs::read_to_string(&settings).unwrap(), "{}");
assert!(!data.path().join("new.json").exists());
let ok = FsRead::new(root)
.invoke(
&ctx,
serde_json::json!({"path": beside.path().join("ok.txt").to_string_lossy()}),
)
.await
.unwrap();
assert_eq!(ok.result, "ok");
}
#[cfg(unix)]
#[tokio::test]
async fn a_dangling_link_inside_the_root_is_not_written_through() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("escaped.txt");
std::os::unix::fs::symlink(&target, root.path().join("notes.md")).unwrap();
let fs_root = Some(root.path().to_string_lossy().to_string());
let write = FsWrite::new(fs_root)
.invoke(
&ctx,
serde_json::json!({"path": "notes.md", "content": "payload"}),
)
.await;
assert_eq!(
write.unwrap_err().to_string(),
ctx.loc.t("tool.fs.err.dangling_link")
);
assert!(
!target.exists(),
"the write followed the link out of the root"
);
}
}