Skip to main content

runtimo_core/capabilities/
file_write.rs

1//! FileWrite capability — writes files with backup-before-mutate for undo support.
2//!
3//! Before overwriting an existing file, creates a backup via [`BackupManager`]
4//! so the operation can be rolled back. Supports both overwrite and append modes.
5//! Respects `dry_run` in the context to skip actual writes.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use runtimo_core::capabilities::FileWrite;
11//! use runtimo_core::capability::{Capability, Context};
12//! use serde_json::json;
13//! use std::path::PathBuf;
14//!
15//! let cap = FileWrite::new(PathBuf::from("/tmp/backups"));
16//! let result = cap.execute(
17//!     &json!({"path": "/tmp/output.txt", "content": "hello"}),
18//!     &Context { dry_run: false, job_id: "job1".into() },
19//! ).unwrap();
20//!
21//! assert!(result.success);
22//! assert_eq!(std::fs::read_to_string("/tmp/output.txt").unwrap(), "hello");
23//! ```
24
25use crate::backup::BackupManager;
26use crate::capability::{Capability, Context, Output};
27use crate::processes::ProcessSnapshot;
28use crate::telemetry::Telemetry;
29use crate::validation::path::{validate_path, PathContext};
30use crate::{Error, Result};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::path::PathBuf;
34
35/// Maximum content size allowed for writing (100 MB).
36const MAX_WRITE_SIZE: usize = 100 * 1024 * 1024;
37
38/// Maximum cumulative file size for append mode (100 MB).
39const MAX_APPEND_SIZE: usize = 100 * 1024 * 1024;
40
41/// Minimum free disk space required before writing (10 MB).
42const MIN_FREE_DISK_BYTES: u64 = 10 * 1024 * 1024;
43
44/// Critical files that must never be modified by capabilities.
45const CRITICAL_FILES: &[&str] = &[
46    ".bashrc",
47    ".bash_profile",
48    ".profile",
49    ".zshrc",
50    ".zshenv",
51    ".ssh/authorized_keys",
52    ".ssh/id_rsa",
53    ".ssh/id_ed25519",
54    ".ssh/config",
55    ".vimrc",
56    ".gitconfig",
57    ".netrc",
58    ".npmrc",
59    ".pypirc",
60    "authorized_keys",
61    "id_rsa",
62    "id_ed25519",
63];
64
65/// Arguments for the [`FileWrite`] capability.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct FileWriteArgs {
68    pub path: String,
69    pub content: String,
70    #[serde(default)]
71    pub append: bool,
72}
73
74/// Capability that writes file contents with backup-before-mutate.
75pub struct FileWrite {
76    backup_mgr: BackupManager,
77}
78
79impl FileWrite {
80    pub fn new(backup_dir: PathBuf) -> Result<Self> {
81        Ok(Self {
82            backup_mgr: BackupManager::new(backup_dir)?,
83        })
84    }
85}
86
87impl Capability for FileWrite {
88    fn name(&self) -> &'static str {
89        "FileWrite"
90    }
91
92    fn description(&self) -> &'static str {
93        "Write content to a file. Creates automatic backups of existing files for undo support. Supports append mode."
94    }
95
96    fn schema(&self) -> Value {
97        serde_json::json!({
98            "type": "object",
99            "properties": {
100                "path": { "type": "string" },
101                "content": { "type": "string" },
102                "append": { "type": "boolean" }
103            },
104            "required": ["path", "content"]
105        })
106    }
107
108    fn validate(&self, args: &Value) -> Result<()> {
109        let args: FileWriteArgs = serde_json::from_value(args.clone())
110            .map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
111
112        let ctx = PathContext {
113            require_exists: false,
114            require_file: false,
115            ..Default::default()
116        };
117
118        validate_path(&args.path, &ctx).map_err(Error::SchemaValidationFailed)?;
119
120        Ok(())
121    }
122
123    fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
124        let args: FileWriteArgs = serde_json::from_value(args.clone())
125            .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
126
127        if args.content.len() > MAX_WRITE_SIZE {
128            return Err(Error::ExecutionFailed(format!(
129                "Content too large: {} bytes (limit: {} bytes)",
130                args.content.len(),
131                MAX_WRITE_SIZE
132            )));
133        }
134
135        let telemetry_before = Telemetry::capture();
136        let process_before = ProcessSnapshot::capture();
137
138        let write_ctx = PathContext {
139            require_exists: false,
140            require_file: false,
141            ..Default::default()
142        };
143
144        let path = validate_path(&args.path, &write_ctx)
145            .map_err(|e| Error::ExecutionFailed(format!("path validation: {}", e)))?;
146
147        if is_critical_file(&path) {
148            return Err(Error::ExecutionFailed(format!(
149                "critical file denied: {}",
150                path.display()
151            )));
152        }
153
154        if let Err(e) = check_disk_space(&path, args.content.len()) {
155            return Err(Error::ExecutionFailed(e));
156        }
157
158        if args.append {
159            if let Ok(meta) = std::fs::metadata(&path) {
160                let existing = meta.len() as usize;
161                if existing.saturating_add(args.content.len()) > MAX_APPEND_SIZE {
162                    return Err(Error::ExecutionFailed(format!(
163                        "append would exceed max file size: {} + {} > {} bytes",
164                        existing,
165                        args.content.len(),
166                        MAX_APPEND_SIZE
167                    )));
168                }
169            }
170        }
171
172        if ctx.dry_run {
173            return Ok(Output {
174                success: true,
175                data: serde_json::json!({
176                    "path": path.display().to_string(),
177                    "content_length": args.content.len(),
178                    "dry_run": true,
179                    "backup_path": null,
180                    "telemetry_before": serde_json::to_value(&telemetry_before).unwrap_or(Value::Null),
181                    "process_before_count": process_before.summary.total_processes,
182                }),
183                message: Some(format!(
184                    "DRY RUN: would write {} bytes to {}",
185                    args.content.len(),
186                    path.display()
187                )),
188            });
189        }
190
191        let backup_path = if path.exists() {
192            match self.backup_mgr.create_backup(&path, &ctx.job_id) {
193                Ok(bp) => Some(bp),
194                Err(e) => return Err(Error::ExecutionFailed(format!("backup: {}", e))),
195            }
196        } else {
197            if let Some(parent) = path.parent() {
198                std::fs::create_dir_all(parent).map_err(|e| {
199                    Error::ExecutionFailed(format!("mkdir {}: {}", parent.display(), e))
200                })?;
201            }
202            None
203        };
204
205        let bytes_written = if args.append {
206            atomic_append(&path, &args.content)?
207        } else {
208            atomic_write(&path, &args.content)?
209        };
210
211        let telemetry_after = Telemetry::capture();
212        let process_after = ProcessSnapshot::capture();
213
214        Ok(Output {
215            success: true,
216            data: serde_json::json!({
217                "path": path.display().to_string(),
218                "bytes_written": bytes_written,
219                "append": args.append,
220                "backup_path": backup_path.map(|p| p.to_string_lossy().to_string()),
221                "telemetry_before": serde_json::to_value(&telemetry_before).unwrap_or(Value::Null),
222                "telemetry_after": serde_json::to_value(&telemetry_after).unwrap_or(Value::Null),
223                "process_before_count": process_before.summary.total_processes,
224                "process_after_count": process_after.summary.total_processes,
225            }),
226            message: Some(format!(
227                "Wrote {} bytes to {}",
228                bytes_written,
229                path.display()
230            )),
231        })
232    }
233}
234
235fn is_critical_file(path: &std::path::Path) -> bool {
236    let path_str = path.to_string_lossy();
237    for critical in CRITICAL_FILES {
238        if path_str.ends_with(critical) {
239            return true;
240        }
241    }
242    false
243}
244
245fn check_disk_space(path: &std::path::Path, content_size: usize) -> std::result::Result<(), String> {
246    let parent = path.parent().unwrap_or(std::path::Path::new("/"));
247    let output = std::process::Command::new("df")
248        .arg("-B1")
249        .arg(parent)
250        .output()
251        .map_err(|e| format!("df command failed: {}", e))?;
252
253    let stdout = String::from_utf8_lossy(&output.stdout);
254    if let Some(line) = stdout.lines().nth(1) {
255        let parts: Vec<&str> = line.split_whitespace().collect();
256        if parts.len() >= 4 {
257            if let Ok(available) = parts[3].parse::<u64>() {
258                let required = content_size as u64 + MIN_FREE_DISK_BYTES;
259                if available < required {
260                    return Err(format!(
261                        "insufficient disk space: {} bytes available, {} bytes required",
262                        available, required
263                    ));
264                }
265                return Ok(());
266            }
267        }
268    }
269    Ok(())
270}
271
272fn atomic_write(path: &std::path::Path, content: &str) -> Result<usize> {
273    use std::io::Write;
274
275    let tmp_name = format!(
276        ".{}.tmp",
277        path.file_name()
278            .map(|n| n.to_string_lossy())
279            .unwrap_or_default()
280    );
281    let tmp_path = path.parent().unwrap_or(std::path::Path::new(".")).join(&tmp_name);
282
283    {
284        let mut file = std::fs::File::create(&tmp_path).map_err(|e| {
285            Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
286        })?;
287        file.write_all(content.as_bytes()).map_err(|e| {
288            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
289        })?;
290        file.sync_all().map_err(|e| {
291            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
292        })?;
293    }
294
295    std::fs::rename(&tmp_path, path).map_err(|e| {
296        let _ = std::fs::remove_file(&tmp_path);
297        Error::ExecutionFailed(format!("atomic rename {} -> {}: {}", tmp_path.display(), path.display(), e))
298    })?;
299
300    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or(std::path::Path::new("."))) {
301        let _ = dir.sync_all();
302    }
303
304    Ok(content.len())
305}
306
307fn atomic_append(path: &std::path::Path, content: &str) -> Result<usize> {
308    use std::io::{Read, Write};
309
310    let existing = if path.exists() {
311        let mut file = std::fs::File::open(path).map_err(|e| {
312            Error::ExecutionFailed(format!("open {} for append: {}", path.display(), e))
313        })?;
314        let mut buf = Vec::new();
315        file.read_to_end(&mut buf).map_err(|e| {
316            Error::ExecutionFailed(format!("read {} for append: {}", path.display(), e))
317        })?;
318        buf
319    } else {
320        Vec::new()
321    };
322
323    let mut combined = existing;
324    combined.extend_from_slice(content.as_bytes());
325
326    let tmp_name = format!(
327        ".{}.tmp",
328        path.file_name()
329            .map(|n| n.to_string_lossy())
330            .unwrap_or_default()
331    );
332    let tmp_path = path.parent().unwrap_or(std::path::Path::new(".")).join(&tmp_name);
333
334    {
335        let mut file = std::fs::File::create(&tmp_path).map_err(|e| {
336            Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
337        })?;
338        file.write_all(&combined).map_err(|e| {
339            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
340        })?;
341        file.sync_all().map_err(|e| {
342            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
343        })?;
344    }
345
346    std::fs::rename(&tmp_path, path).map_err(|e| {
347        let _ = std::fs::remove_file(&tmp_path);
348        Error::ExecutionFailed(format!("atomic rename {} -> {}: {}", tmp_path.display(), path.display(), e))
349    })?;
350
351    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or(std::path::Path::new("."))) {
352        let _ = dir.sync_all();
353    }
354
355    Ok(content.len())
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn test_backup_dir() -> PathBuf {
363        std::env::temp_dir().join("runtimo_fw_test")
364    }
365
366    #[test]
367    fn writes_new_file() {
368        let target = std::env::temp_dir().join("runtimo_fw_new.txt");
369        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
370
371        let result = cap
372            .execute(
373                &serde_json::json!({
374                    "path": target.to_str().unwrap(),
375                    "content": "hello from runtimo"
376                }),
377                &Context {
378                    dry_run: false,
379                    job_id: "t1".into(),
380                    working_dir: std::env::temp_dir(),
381                },
382            )
383            .expect("Execution failed");
384
385        assert!(result.success);
386        assert_eq!(
387            std::fs::read_to_string(&target).unwrap(),
388            "hello from runtimo"
389        );
390
391        std::fs::remove_file(&target).ok();
392        std::fs::remove_dir_all(test_backup_dir()).ok();
393    }
394
395    #[test]
396    fn dry_run_does_not_write() {
397        let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
398        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
399
400        cap.execute(
401            &serde_json::json!({
402                "path": target.to_str().unwrap(),
403                "content": "should not exist"
404            }),
405            &Context {
406                dry_run: true,
407                job_id: "t2".into(),
408                working_dir: std::env::temp_dir(),
409            },
410        )
411        .expect("Execution failed");
412
413        assert!(!target.exists());
414        std::fs::remove_dir_all(test_backup_dir()).ok();
415    }
416
417    #[test]
418    fn rejects_path_traversal() {
419        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
420        let err = cap
421            .validate(&serde_json::json!({
422                "path": "../../../etc/passwd",
423                "content": "malicious"
424            }))
425            .unwrap_err();
426        assert!(err.to_string().contains("traversal"));
427        std::fs::remove_dir_all(test_backup_dir()).ok();
428    }
429
430    #[test]
431    fn rejects_critical_file() {
432        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
433        let err = cap
434            .execute(
435                &serde_json::json!({
436                    "path": "/tmp/.bashrc",
437                    "content": "malicious"
438                }),
439                &Context {
440                    dry_run: false,
441                    job_id: "t3".into(),
442                    working_dir: std::env::temp_dir(),
443                },
444            )
445            .unwrap_err();
446        assert!(err.to_string().contains("critical file"));
447        std::fs::remove_dir_all(test_backup_dir()).ok();
448    }
449
450    #[test]
451    fn atomic_write_produces_correct_content() {
452        let target = std::env::temp_dir().join("runtimo_fw_atomic.txt");
453        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
454
455        let result = cap
456            .execute(
457                &serde_json::json!({
458                    "path": target.to_str().unwrap(),
459                    "content": "atomic content"
460                }),
461                &Context {
462                    dry_run: false,
463                    job_id: "t4".into(),
464                    working_dir: std::env::temp_dir(),
465                },
466            )
467            .expect("Execution failed");
468
469        assert!(result.success);
470        assert_eq!(std::fs::read_to_string(&target).unwrap(), "atomic content");
471        let tmp = target.parent().unwrap().join(".runtimo_fw_atomic.txt.tmp");
472        assert!(!tmp.exists(), "temp file should not remain");
473
474        std::fs::remove_file(&target).ok();
475        std::fs::remove_dir_all(test_backup_dir()).ok();
476    }
477
478    #[test]
479    fn append_mode_works() {
480        let target = std::env::temp_dir().join("runtimo_fw_append.txt");
481        std::fs::write(&target, "initial").ok();
482
483        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
484
485        let result = cap
486            .execute(
487                &serde_json::json!({
488                    "path": target.to_str().unwrap(),
489                    "content": " appended",
490                    "append": true
491                }),
492                &Context {
493                    dry_run: false,
494                    job_id: "t5".into(),
495                    working_dir: std::env::temp_dir(),
496                },
497            )
498            .expect("Execution failed");
499
500        assert!(result.success);
501        assert_eq!(
502            std::fs::read_to_string(&target).unwrap(),
503            "initial appended"
504        );
505
506        std::fs::remove_file(&target).ok();
507        std::fs::remove_dir_all(test_backup_dir()).ok();
508    }
509
510    #[test]
511    fn dry_run_does_not_create_backup() {
512        let target = std::env::temp_dir().join("runtimo_fw_dry_backup.txt");
513        std::fs::write(&target, "existing content").ok();
514
515        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
516
517        let result = cap
518            .execute(
519                &serde_json::json!({
520                    "path": target.to_str().unwrap(),
521                    "content": "new content"
522                }),
523                &Context {
524                    dry_run: true,
525                    job_id: "t6".into(),
526                    working_dir: std::env::temp_dir(),
527                },
528            )
529            .expect("Execution failed");
530
531        assert!(result.success);
532        assert!(result.data["dry_run"].as_bool().unwrap());
533        assert_eq!(
534            std::fs::read_to_string(&target).unwrap(),
535            "existing content"
536        );
537        let backup_dir = test_backup_dir();
538        if backup_dir.exists() {
539            let entries: Vec<_> = std::fs::read_dir(&backup_dir)
540                .map(|d| d.filter_map(|e| e.ok()).collect::<Vec<_>>())
541                .unwrap_or_default();
542            assert!(entries.is_empty());
543        }
544
545        std::fs::remove_file(&target).ok();
546        std::fs::remove_dir_all(test_backup_dir()).ok();
547    }
548
549    #[test]
550    fn telemetry_included_in_output() {
551        let target = std::env::temp_dir().join("runtimo_fw_telemetry.txt");
552        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
553
554        let result = cap
555            .execute(
556                &serde_json::json!({
557                    "path": target.to_str().unwrap(),
558                    "content": "telemetry test"
559                }),
560                &Context {
561                    dry_run: false,
562                    job_id: "t7".into(),
563                    working_dir: std::env::temp_dir(),
564                },
565            )
566            .expect("Execution failed");
567
568        assert!(result.success);
569        assert!(result.data["telemetry_before"].is_object());
570        assert!(result.data["telemetry_after"].is_object());
571        assert!(result.data["process_before_count"].is_u64());
572        assert!(result.data["process_after_count"].is_u64());
573
574        std::fs::remove_file(&target).ok();
575        std::fs::remove_dir_all(test_backup_dir()).ok();
576    }
577}