code_mesh_core/tool/
write.rs

1//! Enhanced Write tool implementation
2//! Features atomic writes, backup creation, permission system, and comprehensive validation
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7use std::path::{Path, PathBuf};
8use tokio::fs;
9use uuid::Uuid;
10use chrono::Utc;
11
12use super::{Tool, ToolContext, ToolResult, ToolError};
13use super::permission::{PermissionRequest, RiskLevel, create_permission_request};
14
15/// Tool for writing content to files
16pub struct WriteTool;
17
18#[derive(Debug, Deserialize)]
19struct WriteParams {
20    #[serde(rename = "filePath")]
21    file_path: String,
22    content: String,
23    #[serde(default)]
24    create_backup: Option<bool>,
25    #[serde(default)]
26    force_overwrite: Option<bool>,
27}
28
29#[async_trait]
30impl Tool for WriteTool {
31    fn id(&self) -> &str {
32        "write"
33    }
34    
35    fn description(&self) -> &str {
36        "Write content to a file with atomic operations and backup support"
37    }
38    
39    fn parameters_schema(&self) -> Value {
40        json!({
41            "type": "object",
42            "properties": {
43                "filePath": {
44                    "type": "string",
45                    "description": "The absolute path to the file to write (must be absolute, not relative)"
46                },
47                "content": {
48                    "type": "string",
49                    "description": "The content to write to the file"
50                },
51                "createBackup": {
52                    "type": "boolean",
53                    "description": "Create a backup of existing file before overwriting",
54                    "default": true
55                },
56                "forceOverwrite": {
57                    "type": "boolean",
58                    "description": "Force overwrite without confirmation for existing files",
59                    "default": false
60                }
61            },
62            "required": ["filePath", "content"]
63        })
64    }
65    
66    async fn execute(
67        &self,
68        args: Value,
69        ctx: ToolContext,
70    ) -> Result<ToolResult, ToolError> {
71        let params: WriteParams = serde_json::from_value(args)
72            .map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
73        
74        // Validate file path is absolute
75        let path = if PathBuf::from(&params.file_path).is_absolute() {
76            PathBuf::from(&params.file_path)
77        } else {
78            return Err(ToolError::InvalidParameters(
79                "filePath must be absolute, not relative".to_string()
80            ));
81        };
82        
83        // Check if file already exists
84        let file_exists = path.exists();
85        let is_new_file = !file_exists;
86        
87        // Validate write permissions and safety
88        self.validate_write_operation(&path, &params, &ctx).await?;
89        
90        // Request permission for the operation
91        let risk_level = if is_new_file {
92            RiskLevel::Low
93        } else {
94            RiskLevel::Medium
95        };
96        
97        let permission_request = create_permission_request(
98            Uuid::new_v4().to_string(),
99            ctx.session_id.clone(),
100            if is_new_file {
101                format!("Create new file: {}", path.display())
102            } else {
103                format!("Overwrite existing file: {}", path.display())
104            },
105            risk_level,
106            json!({
107                "filePath": path.to_string_lossy(),
108                "content": params.content.chars().take(200).collect::<String>(),
109                "contentLength": params.content.len(),
110                "isNewFile": is_new_file,
111                "createBackup": params.create_backup.unwrap_or(true)
112            }),
113        );
114        
115        // For now, we'll skip permission checking in the basic implementation
116        // In a full implementation, this would integrate with the permission system
117        
118        // Create backup if requested and file exists
119        let backup_path = if file_exists && params.create_backup.unwrap_or(true) {
120            Some(self.create_backup(&path).await?)
121        } else {
122            None
123        };
124        
125        // Create parent directories if needed
126        if let Some(parent) = path.parent() {
127            fs::create_dir_all(parent).await.map_err(|e| {
128                ToolError::ExecutionFailed(format!("Failed to create parent directories: {}", e))
129            })?;
130        }
131        
132        // Perform atomic write
133        let write_result = self.atomic_write(&path, &params.content).await;
134        
135        match write_result {
136            Ok(()) => {
137                // Success - clean up any temporary backup if we don't need it
138                // (In a full implementation, we might keep backups for rollback)
139                
140                let line_count = params.content.lines().count();
141                let byte_count = params.content.len();
142                
143                // Calculate relative path for display
144                let relative_path = path
145                    .strip_prefix(&ctx.working_directory)
146                    .unwrap_or(&path)
147                    .to_string_lossy()
148                    .to_string();
149                
150                // Prepare comprehensive metadata
151                let metadata = json!({
152                    "path": path.to_string_lossy(),
153                    "relative_path": relative_path,
154                    "lines_written": line_count,
155                    "bytes_written": byte_count,
156                    "was_new_file": is_new_file,
157                    "backup_created": backup_path.is_some(),
158                    "backup_path": backup_path.as_ref().map(|p| p.to_string_lossy().to_string()),
159                    "timestamp": Utc::now().to_rfc3339(),
160                    "content_preview": params.content.lines().take(3).collect::<Vec<_>>().join("\n")
161                });
162                
163                let action = if is_new_file { "Created" } else { "Updated" };
164                
165                Ok(ToolResult {
166                    title: relative_path,
167                    metadata,
168                    output: format!(
169                        "{} file with {} bytes ({} lines){}.",
170                        action,
171                        byte_count,
172                        line_count,
173                        if backup_path.is_some() { " (backup created)" } else { "" }
174                    ),
175                })
176            }
177            Err(e) => {
178                // Restore from backup if write failed and we have one
179                if let Some(backup) = &backup_path {
180                    if let Err(restore_err) = fs::rename(backup, &path).await {
181                        tracing::warn!("Failed to restore backup after write failure: {}", restore_err);
182                    }
183                }
184                Err(e)
185            }
186        }
187    }
188}
189
190impl WriteTool {
191    /// Validate that the write operation is safe and allowed
192    async fn validate_write_operation(
193        &self,
194        path: &Path,
195        params: &WriteParams,
196        ctx: &ToolContext,
197    ) -> Result<(), ToolError> {
198        // Check if path is within allowed directories (security check)
199        if !self.is_path_allowed(path, &ctx.working_directory) {
200            return Err(ToolError::PermissionDenied(format!(
201                "Writing outside of working directory is not allowed: {}",
202                path.display()
203            )));
204        }
205        
206        // Check for dangerous file extensions
207        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
208            match ext.to_lowercase().as_str() {
209                "exe" | "bat" | "cmd" | "com" | "scr" | "msi" => {
210                    return Err(ToolError::PermissionDenied(
211                        "Writing executable files is not allowed for security reasons".to_string()
212                    ));
213                }
214                _ => {}
215            }
216        }
217        
218        // Check content size limits
219        if params.content.len() > 50_000_000 { // 50MB limit
220            return Err(ToolError::InvalidParameters(
221                "Content too large (>50MB). Consider breaking into smaller files.".to_string()
222            ));
223        }
224        
225        // Validate content is valid UTF-8 (already ensured by serde, but good to be explicit)
226        if !params.content.is_ascii() && params.content.chars().any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t') {
227            return Err(ToolError::InvalidParameters(
228                "Content contains invalid control characters".to_string()
229            ));
230        }
231        
232        Ok(())
233    }
234    
235    /// Check if a path is allowed for writing
236    fn is_path_allowed(&self, target_path: &Path, working_dir: &Path) -> bool {
237        // Must be within or equal to working directory
238        target_path.starts_with(working_dir) || target_path == working_dir
239    }
240    
241    /// Create a backup of an existing file
242    async fn create_backup(&self, original_path: &Path) -> Result<PathBuf, ToolError> {
243        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
244        let backup_name = format!(
245            "{}.backup.{}",
246            original_path.file_name()
247                .and_then(|n| n.to_str())
248                .unwrap_or("unknown"),
249            timestamp
250        );
251        
252        let backup_path = original_path.parent()
253            .unwrap_or(Path::new("."))
254            .join(backup_name);
255        
256        fs::copy(original_path, &backup_path).await.map_err(|e| {
257            ToolError::ExecutionFailed(format!("Failed to create backup: {}", e))
258        })?;
259        
260        Ok(backup_path)
261    }
262    
263    /// Perform atomic write operation
264    async fn atomic_write(&self, target_path: &Path, content: &str) -> Result<(), ToolError> {
265        // Create temporary file in the same directory
266        let temp_name = format!(
267            ".{}.tmp.{}",
268            target_path.file_name()
269                .and_then(|n| n.to_str())
270                .unwrap_or("unknown"),
271            Uuid::new_v4().simple()
272        );
273        
274        let temp_path = target_path.parent()
275            .unwrap_or(Path::new("."))
276            .join(temp_name);
277        
278        // Write to temporary file first
279        fs::write(&temp_path, content).await.map_err(|e| {
280            ToolError::ExecutionFailed(format!("Failed to write temporary file: {}", e))
281        })?;
282        
283        // Atomically move temporary file to target location
284        fs::rename(&temp_path, target_path).await.map_err(|e| {
285            // Clean up temporary file on failure
286            if let Err(cleanup_err) = std::fs::remove_file(&temp_path) {
287                tracing::warn!("Failed to clean up temporary file {}: {}", temp_path.display(), cleanup_err);
288            }
289            ToolError::ExecutionFailed(format!("Failed to move temporary file to target: {}", e))
290        })?;
291        
292        Ok(())
293    }
294}