use super::Tool;
use crate::config::SafetyConfig;
use crate::errors::ToolError;
use crate::safety::path_validator::PathValidator;
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, RwLock};
use tempfile::NamedTempFile;
pub(super) static SAFETY_CONFIG: OnceLock<RwLock<SafetyConfig>> = OnceLock::new();
pub fn init_safety_config(config: &SafetyConfig) {
let lock = SAFETY_CONFIG.get_or_init(|| RwLock::new(config.clone()));
if let Ok(mut guard) = lock.write() {
*guard = config.clone();
}
}
#[cfg(test)]
pub(crate) fn reset_safety_config_for_tests() {
let lock = SAFETY_CONFIG.get_or_init(|| RwLock::new(SafetyConfig::default()));
if let Ok(mut guard) = lock.write() {
*guard = SafetyConfig::default();
}
}
const MAX_READ_SIZE: u64 = 50 * 1024 * 1024;
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone)]
struct FileSnapshot {
content_hash: u64,
last_modified: u64,
}
static FILE_SNAPSHOTS: OnceLock<Mutex<HashMap<String, FileSnapshot>>> = OnceLock::new();
fn get_snapshots() -> &'static Mutex<HashMap<String, FileSnapshot>> {
FILE_SNAPSHOTS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(crate) fn record_file_snapshot(path: &str, content: &str) {
let last_modified = std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let content_hash = hasher.finish();
if let Ok(mut guard) = get_snapshots().lock() {
guard.insert(
path.to_string(),
FileSnapshot {
content_hash,
last_modified,
},
);
}
}
pub(crate) fn clear_file_snapshot(path: &str) {
if let Ok(mut guard) = get_snapshots().lock() {
guard.remove(path);
}
}
pub(crate) fn is_file_stale(path: &str) -> Option<bool> {
let guard = get_snapshots().lock().ok()?;
let snapshot = guard.get(path)?;
let metadata = std::fs::metadata(path).ok()?;
let current_mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())?;
if snapshot.last_modified != current_mtime {
return Some(true);
}
let current_bytes = std::fs::read(path).ok()?;
let current_text = String::from_utf8_lossy(¤t_bytes);
let mut hasher = DefaultHasher::new();
current_text.hash(&mut hasher);
let current_hash = hasher.finish();
Some(snapshot.content_hash != current_hash)
}
pub(crate) async fn read_file_with_encoding(path: &Path) -> Result<(String, Vec<u8>)> {
let bytes = tokio::fs::read(path).await?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok((text, bytes))
}
fn detect_line_ending(text: &str) -> &'static str {
if text.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
pub(crate) fn preserve_line_endings(content: &str, line_ending: &str) -> String {
let normalized = content.replace("\r\n", "\n");
if line_ending == "\r\n" {
normalized.replace('\n', "\r\n")
} else {
normalized
}
}
#[derive(Default)]
pub struct FileRead {
pub safety_config: Option<SafetyConfig>,
}
#[derive(Default)]
pub struct FileWrite {
pub safety_config: Option<SafetyConfig>,
}
#[derive(Default)]
pub struct FileEdit {
pub safety_config: Option<SafetyConfig>,
}
#[derive(Default)]
pub struct FileDelete {
pub safety_config: Option<SafetyConfig>,
}
#[derive(Default)]
pub struct FileMultiEdit {
pub safety_config: Option<SafetyConfig>,
}
#[derive(Default)]
pub struct DirectoryTree {
pub safety_config: Option<SafetyConfig>,
}
impl FileRead {
pub fn new() -> Self {
Self::default()
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
impl FileWrite {
pub fn new() -> Self {
Self {
safety_config: None,
}
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
impl FileEdit {
pub fn new() -> Self {
Self {
safety_config: None,
}
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
impl FileDelete {
pub fn new() -> Self {
Self {
safety_config: None,
}
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
impl FileMultiEdit {
pub fn new() -> Self {
Self {
safety_config: None,
}
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
impl DirectoryTree {
pub fn new() -> Self {
Self {
safety_config: None,
}
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
#[async_trait]
impl Tool for FileRead {
fn name(&self) -> &str {
"file_read"
}
fn description(&self) -> &str {
"Read file contents. Use for examining code, configs, or any text file."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute or relative path to the file"
},
"line_range": {
"type": "array",
"items": {"type": "integer"},
"minItems": 2,
"maxItems": 2,
"description": "Optional [start, end] line range (1-indexed, inclusive)"
}
},
"required": ["path"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct Args {
path: String,
line_range: Option<(usize, usize)>,
}
let args: Args = serde_json::from_value(args)?;
let safety = resolve_safety_config(self.safety_config.as_ref());
validate_tool_path(&args.path, &safety)?;
let path = PathBuf::from(&args.path);
if let Some((start, end)) = args.line_range {
let (selected_content, lines_scanned, lossy, reached_eof) =
read_line_slice(&path, start, end).await?;
let lines_returned = selected_content.lines().count();
if reached_eof {
return Ok(serde_json::json!({
"content": selected_content,
"lines_returned": lines_returned,
"total_lines": lines_scanned,
"truncated": false,
"encoding": if lossy { "utf-8-lossy" } else { "utf-8" },
"valid_utf8": !lossy
}));
} else {
return Ok(serde_json::json!({
"content": selected_content,
"lines_returned": lines_returned,
"total_lines": null,
"has_more": true,
"truncated": true,
"encoding": if lossy { "utf-8-lossy" } else { "utf-8" },
"valid_utf8": !lossy
}));
}
}
if let Ok(metadata) = tokio::fs::metadata(&path).await {
if metadata.len() > MAX_READ_SIZE {
return Err(ToolError::FileTooLarge {
size: metadata.len(),
limit: MAX_READ_SIZE,
}
.into());
}
}
let (content, bytes) = read_file_with_encoding(&path).await?;
let valid_utf8 = std::str::from_utf8(&bytes).is_ok();
record_file_snapshot(&args.path, &content);
let total_lines = content.lines().count();
Ok(serde_json::json!({
"content": content,
"total_lines": total_lines,
"truncated": false,
"encoding": if valid_utf8 { "utf-8" } else { "utf-8-lossy" },
"valid_utf8": valid_utf8
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::read_only()
}
}
#[async_trait]
impl Tool for FileWrite {
fn name(&self) -> &str {
"file_write"
}
fn description(&self) -> &str {
"Write or overwrite entire file. Creates parent directories if needed."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
"backup": {"type": "boolean", "default": true}
},
"required": ["path", "content"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct Args {
path: String,
content: String,
#[serde(default = "default_true")]
backup: bool,
}
let args: Args = serde_json::from_value(args)?;
let safety = resolve_safety_config(self.safety_config.as_ref());
validate_tool_path(&args.path, &safety)?;
let path = PathBuf::from(&args.path);
if args.content.len() > MAX_WRITE_SIZE {
return Err(ToolError::WriteTooLarge {
size: args.content.len(),
limit: MAX_WRITE_SIZE,
}
.into());
}
if path.exists() {
if let Some(true) = is_file_stale(&args.path) {
return Err(ToolError::FileStale {
path: args.path.clone(),
}
.into());
}
}
let content_to_write = if path.exists() {
let (existing, existing_bytes) = read_file_with_encoding(&path).await?;
ensure_valid_utf8(&existing_bytes, &args.path, "file_write")?;
let line_ending = detect_line_ending(&existing);
preserve_line_endings(&args.content, line_ending)
} else {
args.content.clone()
};
if path.exists() {
if let Ok(existing) = tokio::fs::read_to_string(&path).await {
if existing == content_to_write {
return Err(ToolError::EditNoOp.into());
}
}
}
validate_rust_source_if_needed(&path, &content_to_write)?;
if args.backup && path.exists() {
let backup_path = format!("{}.bak", args.path);
tokio::fs::copy(&path, &backup_path).await?;
}
write_atomic(&path, &content_to_write).await?;
clear_file_snapshot(&args.path);
Ok(serde_json::json!({
"success": true,
"bytes_written": content_to_write.len(),
"path": args.path
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::file_write()
}
}
#[async_trait]
impl Tool for FileEdit {
fn name(&self) -> &str {
"file_edit"
}
fn description(&self) -> &str {
"Apply surgical edit to file. The old_str must match EXACTLY once. Include enough context to ensure unique match."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"old_str": {"type": "string", "description": "Exact string to find (must be unique)"},
"new_str": {"type": "string", "description": "Replacement string (empty to delete)"}
},
"required": ["path", "old_str", "new_str"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct Args {
path: String,
old_str: String,
new_str: String,
}
let args: Args = serde_json::from_value(args)?;
let safety = resolve_safety_config(self.safety_config.as_ref());
validate_tool_path(&args.path, &safety)?;
if let Some(true) = is_file_stale(&args.path) {
return Err(ToolError::FileStale {
path: args.path.clone(),
}
.into());
}
let (content, original_bytes) = read_file_with_encoding(Path::new(&args.path)).await?;
ensure_valid_utf8(&original_bytes, &args.path, "file_edit")?;
let line_ending = detect_line_ending(&content);
let matches = content.matches(&args.old_str).count();
if matches == 0 {
return Err(ToolError::EditStringNotFound.into());
}
if matches > 1 {
return Err(ToolError::EditStringMultiple { count: matches }.into());
}
if args.old_str == args.new_str {
return Err(ToolError::EditNoOp.into());
}
if args.new_str.contains(&args.old_str) && content.contains(&args.new_str) {
bail!(
"file_edit duplicate insertion rejected: the requested replacement block is already present in {}. Re-read the file and make a different targeted edit.",
args.path
);
}
if !content.is_empty() {
let ratio = args.old_str.len() as f64 / content.len() as f64;
if ratio > 0.85 {
bail!(
"file_edit rejected: old_str matches {:.0}% of {}. \
Use a smaller, targeted edit with surrounding context, \
or use file_write if you truly intend to replace the entire file.",
ratio * 100.0,
args.path
);
}
}
let new_content = content.replace(&args.old_str, &args.new_str);
let new_content = preserve_line_endings(&new_content, line_ending);
validate_rust_source_if_needed(Path::new(&args.path), &new_content)?;
write_atomic(Path::new(&args.path), &new_content).await?;
clear_file_snapshot(&args.path);
Ok(serde_json::json!({
"success": true,
"matches_found": 1,
"path": args.path
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::file_write()
}
}
#[async_trait]
impl Tool for FileDelete {
fn name(&self) -> &str {
"file_delete"
}
fn description(&self) -> &str {
"Delete a file. Use with caution -- this is irreversible without version control."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute or relative path to the file to delete"
}
},
"required": ["path"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct Args {
path: String,
}
let args: Args = serde_json::from_value(args)?;
let safety = resolve_safety_config(self.safety_config.as_ref());
validate_tool_path(&args.path, &safety)?;
let path = PathBuf::from(&args.path);
if !path.exists() {
return Err(ToolError::FileNotFound {
path: args.path.clone(),
}
.into());
}
if path.is_dir() {
return Err(ToolError::PathIsDirectory {
path: args.path.clone(),
}
.into());
}
if let Some(true) = is_file_stale(&args.path) {
return Err(ToolError::FileStale {
path: args.path.clone(),
}
.into());
}
tokio::fs::remove_file(&path)
.await
.with_context(|| format!("Failed to delete file: {}", args.path))?;
clear_file_snapshot(&args.path);
Ok(serde_json::json!({
"deleted": true,
"path": args.path
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::file_destructive()
}
}
#[async_trait]
impl Tool for FileMultiEdit {
fn name(&self) -> &str {
"file_multi_edit"
}
fn description(&self) -> &str {
"Apply multiple surgical edits atomically. If any edit fails validation, NONE are applied."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"edits": {
"type": "array",
"description": "Ordered list of edits to apply",
"items": {
"type": "object",
"properties": {
"path": {"type": "string"},
"old_str": {"type": "string", "description": "Exact string to find (must be unique)"},
"new_str": {"type": "string", "description": "Replacement string"}
},
"required": ["path", "old_str", "new_str"]
}
}
},
"required": ["edits"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct EditItem {
path: String,
old_str: String,
new_str: String,
}
#[derive(Deserialize)]
struct Args {
edits: Vec<EditItem>,
}
let args: Args = serde_json::from_value(args)?;
let safety = resolve_safety_config(self.safety_config.as_ref());
if args.edits.is_empty() {
return Err(ToolError::InvalidToolCall {
name: "file_multi_edit".to_string(),
message: "No edits provided".to_string(),
}
.into());
}
for edit in &args.edits {
validate_tool_path(&edit.path, &safety)?;
if let Some(true) = is_file_stale(&edit.path) {
return Err(ToolError::FileStale {
path: edit.path.clone(),
}
.into());
}
}
let mut edits_by_file: HashMap<String, Vec<(usize, &EditItem)>> = HashMap::new();
for (idx, edit) in args.edits.iter().enumerate() {
edits_by_file
.entry(edit.path.clone())
.or_default()
.push((idx, edit));
}
let mut file_contents: HashMap<String, String> = HashMap::new();
let mut file_line_endings: HashMap<String, &'static str> = HashMap::new();
for (path, edits) in &edits_by_file {
let (content, original_bytes) = read_file_with_encoding(Path::new(path)).await?;
ensure_valid_utf8(&original_bytes, path, "file_multi_edit")?;
file_line_endings.insert(path.clone(), detect_line_ending(&content));
for (idx, edit) in edits {
let matches = content.matches(&edit.old_str).count();
if matches == 0 {
return Err(ToolError::Execution {
name: "file_multi_edit".to_string(),
message: format!("Edit {}: old_str not found in {}", idx, edit.path),
}
.into());
}
if matches > 1 {
return Err(ToolError::Execution {
name: "file_multi_edit".to_string(),
message: format!(
"Edit {}: old_str matches {} times in {} (expected exactly 1)",
idx, matches, edit.path
),
}
.into());
}
if edit.old_str == edit.new_str {
return Err(ToolError::Execution {
name: "file_multi_edit".to_string(),
message: format!(
"Edit {}: old_str and new_str are identical in {} — no-op edit",
idx, edit.path
),
}
.into());
}
}
if edits.len() > 1 {
let mut ranges = Vec::new();
for (_idx, edit) in edits {
let byte_pos =
content
.find(&edit.old_str)
.ok_or_else(|| ToolError::Execution {
name: "file_multi_edit".to_string(),
message: format!("old_str not found in {}", edit.path),
})?;
let before = &content[..byte_pos];
let start_line = before.lines().count() + 1;
let end_line = start_line + edit.old_str.lines().count().saturating_sub(1);
ranges.push((start_line, end_line, edit));
}
for i in 0..ranges.len() {
for j in (i + 1)..ranges.len() {
let (s1, e1, edit1) = &ranges[i];
let (s2, e2, _edit2) = &ranges[j];
if s1 <= e2 && s2 <= e1 {
return Err(ToolError::Execution {
name: "file_multi_edit".to_string(),
message: format!(
"Edits overlap in {}: lines {}-{} and {}-{}",
edit1.path, s1, e1, s2, e2
),
}
.into());
}
}
}
}
file_contents.insert(path.clone(), content);
}
let mut sorted_paths: Vec<&String> = edits_by_file.keys().collect();
sorted_paths.sort();
let mut finals: Vec<(PathBuf, String)> = Vec::with_capacity(sorted_paths.len());
for path in sorted_paths {
let edits = &edits_by_file[path];
let content = file_contents.get_mut(path).unwrap();
let line_ending = file_line_endings.get(path).copied().unwrap_or("\n");
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
for (_idx, edit) in edits {
let pos = content.find(&edit.old_str).unwrap();
replacements.push((pos, edit.old_str.len(), edit.new_str.clone()));
}
replacements.sort_by_key(|r| r.0);
replacements.reverse();
for (pos, len, new_str) in replacements {
content.replace_range(pos..pos + len, &new_str);
}
let final_content = preserve_line_endings(content, line_ending);
validate_rust_source_if_needed(Path::new(path), &final_content)?;
finals.push((PathBuf::from(path), final_content));
}
write_all_atomic(&finals).await?;
for (path, _) in &finals {
clear_file_snapshot(&path.to_string_lossy());
}
let files_changed: Vec<String> = finals
.iter()
.map(|(path, _)| path.to_string_lossy().into_owned())
.collect();
Ok(serde_json::json!({
"success": true,
"edits_applied": args.edits.len(),
"files_changed": files_changed.len(),
"files": files_changed
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::file_write()
}
}
#[derive(Serialize)]
struct TreeNode {
name: String,
#[serde(rename = "type")]
type_: String,
#[serde(skip_serializing_if = "Option::is_none")]
size: Option<u64>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
children: Vec<TreeNode>,
}
fn insert_tree_entry(root: &mut TreeNode, relative: &Path, type_: &str, size: u64) {
let mut components: Vec<String> = relative
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
if components.is_empty() {
return;
}
let file_name = components.pop().unwrap();
let mut current = root;
for component in components {
let child_idx = current
.children
.iter()
.position(|c| c.name == component && c.type_ == "directory");
let idx = match child_idx {
Some(idx) => idx,
None => {
current.children.push(TreeNode {
name: component,
type_: "directory".to_string(),
size: None,
children: Vec::new(),
});
current.children.len() - 1
}
};
current = &mut current.children[idx];
}
if type_ == "directory" {
if let Some(existing) = current
.children
.iter_mut()
.find(|c| c.name == file_name && c.type_ == "directory")
{
existing.size = Some(size);
return;
}
}
current.children.push(TreeNode {
name: file_name,
type_: type_.to_string(),
size: Some(size),
children: Vec::new(),
});
}
fn sort_tree_node(node: &mut TreeNode) {
node.children.sort_by(|a, b| {
let a_dir = a.type_ == "directory";
let b_dir = b.type_ == "directory";
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
}
});
for child in &mut node.children {
sort_tree_node(child);
}
}
fn count_tree_nodes(node: &TreeNode) -> usize {
1 + node.children.iter().map(count_tree_nodes).sum::<usize>()
}
#[async_trait]
impl Tool for DirectoryTree {
fn name(&self) -> &str {
"directory_tree"
}
fn description(&self) -> &str {
"Return a nested directory tree. Use to understand project layout and parent/child relationships."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"max_depth": {"type": "integer", "default": 3},
"include_hidden": {"type": "boolean", "default": false}
},
"required": ["path"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct Args {
path: String,
#[serde(default = "default_three")]
max_depth: usize,
#[serde(default)]
include_hidden: bool,
}
let args: Args = serde_json::from_value(args)?;
let safety = resolve_safety_config(self.safety_config.as_ref());
validate_tool_path(&args.path, &safety)?;
let walk_path = args.path.clone();
let max_depth = args.max_depth;
let include_hidden = args.include_hidden;
let tree: TreeNode = tokio::task::spawn_blocking(move || {
const SKIP_DIRS: &[&str] = &[
"target",
"node_modules",
"dist",
"build",
"__pycache__",
".worktrees",
"vendor",
"pkg",
"out",
"cmake-build-debug",
];
let walker = walkdir::WalkDir::new(&walk_path)
.max_depth(max_depth)
.into_iter()
.filter_entry(|e| {
if include_hidden {
return true;
}
if e.depth() == 0 {
return true;
}
let name = e.file_name().to_str().unwrap_or("");
!name.starts_with('.') && !SKIP_DIRS.contains(&name)
});
#[derive(Serialize)]
struct EntryInfo {
path: PathBuf,
type_: &'static str,
size: u64,
}
let mut entries: Vec<EntryInfo> = Vec::new();
for entry in walker.filter_map(|e| e.ok()) {
let path = entry.path();
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
entries.push(EntryInfo {
path: path.to_path_buf(),
type_: if metadata.is_dir() {
"directory"
} else {
"file"
},
size: metadata.len(),
});
}
let root_name = Path::new(&walk_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| walk_path.clone());
let mut root = TreeNode {
name: root_name,
type_: "directory".to_string(),
size: None,
children: Vec::new(),
};
let walk_path_buf = PathBuf::from(&walk_path);
for entry in entries {
let relative = match entry.path.strip_prefix(&walk_path_buf) {
Ok(r) if !r.as_os_str().is_empty() => r,
_ => continue,
};
insert_tree_entry(&mut root, relative, entry.type_, entry.size);
}
sort_tree_node(&mut root);
root
})
.await?;
let total = count_tree_nodes(&tree);
Ok(serde_json::json!({
"root": args.path,
"tree": tree,
"total": total
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::read_only()
}
}
fn default_true() -> bool {
true
}
fn default_three() -> usize {
3
}
pub(crate) fn resolve_safety_config(instance_config: Option<&SafetyConfig>) -> SafetyConfig {
if let Some(cfg) = instance_config {
return cfg.clone();
}
SAFETY_CONFIG
.get()
.and_then(|lock| lock.read().ok().map(|guard| guard.clone()))
.unwrap_or_default()
}
pub(crate) fn validate_tool_path(path: &str, config: &SafetyConfig) -> Result<()> {
#[cfg(test)]
{
if std::env::var("SELFWARE_TEST_MODE").is_ok() {
if !path.starts_with("tests/e2e-projects/") && !path.starts_with("/tmp/selfware-test-")
{
anyhow::bail!("Test mode only valid for test fixtures, got: {}", path);
}
return Ok(());
}
}
let working_dir = std::env::current_dir().unwrap_or_else(|_| ".".into());
PathValidator::new(config, working_dir)
.validate(path)
.map_err(|e| anyhow::anyhow!(e))
}
fn validate_rust_source_if_needed(path: &Path, content: &str) -> Result<()> {
let is_rust_source = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("rs"));
if !is_rust_source {
return Ok(());
}
syn::parse_file(content).map(|_| ()).map_err(|err| {
ToolError::InvalidRustSyntax {
path: path.display().to_string(),
message: err.to_string(),
}
.into()
})
}
async fn read_line_slice(
path: &Path,
start: usize,
end: usize,
) -> Result<(String, usize, bool, bool)> {
use tokio::io::{AsyncBufReadExt, BufReader};
let effective_end = end.max(start);
let file = tokio::fs::File::open(path).await?;
let mut reader = BufReader::new(file);
let mut selected: Vec<String> = Vec::new();
let mut lineno = 0usize;
let mut lossy = false;
let mut reached_eof = false;
let mut buf: Vec<u8> = Vec::new();
loop {
buf.clear();
let n = reader.read_until(b'\n', &mut buf).await?;
if n == 0 {
reached_eof = true;
break;
}
lineno += 1;
if lineno >= start && lineno <= effective_end {
if std::str::from_utf8(&buf).is_err() {
lossy = true;
}
let mut line = String::from_utf8_lossy(&buf).into_owned();
if line.ends_with('\n') {
line.pop();
if line.ends_with('\r') {
line.pop();
}
}
selected.push(line);
}
if lineno >= effective_end {
break; }
}
Ok((selected.join("\n"), lineno, lossy, reached_eof))
}
pub(crate) async fn write_atomic(path: &Path, content: &str) -> Result<()> {
write_all_atomic(&[(path.to_path_buf(), content.to_string())]).await
}
#[cfg(unix)]
fn existing_file_mode(path: &Path) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).ok().map(|m| m.permissions().mode())
}
pub(crate) async fn write_all_atomic(files: &[(PathBuf, String)]) -> Result<()> {
let mut pre_images: Vec<(PathBuf, Option<Vec<u8>>)> = Vec::with_capacity(files.len());
for (path, _) in files {
pre_images.push((path.clone(), tokio::fs::read(path).await.ok()));
}
let files_owned: Vec<(PathBuf, String)> = files.to_vec();
tokio::task::spawn_blocking(move || {
let mut staged: Vec<(NamedTempFile, PathBuf)> = Vec::with_capacity(files_owned.len());
for (path, content) in &files_owned {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid file path (no parent)"))?;
std::fs::create_dir_all(parent)?;
let mut temp = NamedTempFile::new_in(parent)?;
temp.write_all(content.as_bytes())?;
#[cfg(unix)]
if let Some(mode) = existing_file_mode(path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
temp.path(),
std::fs::Permissions::from_mode(mode & 0o7777),
)?;
}
staged.push((temp, path.clone()));
}
for (idx, (temp, path)) in staged.into_iter().enumerate() {
if let Err(e) = temp.persist(&path) {
for (rb_path, pre_image) in pre_images.iter().take(idx) {
match pre_image {
Some(bytes) => {
let _ = std::fs::write(rb_path, bytes);
}
None => {
let _ = std::fs::remove_file(rb_path);
}
}
}
return Err(anyhow::anyhow!(
"Failed to persist atomic write to {}: {} (rolled back {} earlier file(s))",
path.display(),
e,
idx
));
}
}
Ok(())
})
.await?
}
fn ensure_valid_utf8(bytes: &[u8], path: &str, tool: &str) -> Result<()> {
if std::str::from_utf8(bytes).is_err() {
return Err(ToolError::Execution {
name: tool.to_string(),
message: format!(
"Refusing to modify {}: the file is not valid UTF-8 (binary or another \
encoding). Editing it through a lossy decode would corrupt its contents. \
If a full overwrite of a non-text file is truly intended, delete it first.",
path
),
}
.into());
}
Ok(())
}
#[cfg(test)]
#[path = "../../tests/unit/tools/file/file_test.rs"]
mod tests;