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 file. auto-backup for undo. append ok."
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
248    // Parent may not exist yet (create_dir_all runs later). Skip df — disk
249    // check is meaningless for a path that doesn't exist on the filesystem.
250    if !parent.exists() {
251        return Ok(());
252    }
253
254    let output = std::process::Command::new("df")
255        .arg("-B1")
256        .arg(parent)
257        .output()
258        .map_err(|e| format!("df command failed: {}", e))?;
259
260    if !output.status.success() {
261        let stderr = String::from_utf8_lossy(&output.stderr);
262        return Err(format!("df command failed: {}", stderr.trim()));
263    }
264
265    let stdout = String::from_utf8_lossy(&output.stdout);
266    let mut lines = stdout.lines();
267
268    let header = match lines.next() {
269        Some(h) => h,
270        None => return Ok(()),
271    };
272    let headers: Vec<&str> = header.split_whitespace().collect();
273    let avail_idx = headers
274        .iter()
275        .position(|&h| h.eq_ignore_ascii_case("Available") || h.eq_ignore_ascii_case("Avail"));
276
277    if let Some(line) = lines.next() {
278        let parts: Vec<&str> = line.split_whitespace().collect();
279        let idx = avail_idx.unwrap_or(3); // fall back to column 3 (GNU default)
280        if let Some(available_str) = parts.get(idx) {
281            if let Ok(available) = available_str.parse::<u64>() {
282                let required = content_size as u64 + MIN_FREE_DISK_BYTES;
283                if available < required {
284                    return Err(format!(
285                        "insufficient disk space: {} bytes available, {} bytes required",
286                        available, required
287                    ));
288                }
289                return Ok(());
290            }
291        }
292    }
293    Ok(())
294}
295
296fn atomic_write(path: &std::path::Path, content: &str) -> Result<usize> {
297    use std::io::Write;
298
299    let tmp_name = format!(
300        ".{}.tmp",
301        path.file_name()
302            .map(|n| n.to_string_lossy())
303            .unwrap_or_default()
304    );
305    let tmp_path = path.parent().unwrap_or(std::path::Path::new(".")).join(&tmp_name);
306
307    {
308        let mut file = std::fs::File::create(&tmp_path).map_err(|e| {
309            Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
310        })?;
311        file.write_all(content.as_bytes()).map_err(|e| {
312            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
313        })?;
314        file.sync_all().map_err(|e| {
315            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
316        })?;
317    }
318
319    std::fs::rename(&tmp_path, path).map_err(|e| {
320        let _ = std::fs::remove_file(&tmp_path);
321        Error::ExecutionFailed(format!("atomic rename {} -> {}: {}", tmp_path.display(), path.display(), e))
322    })?;
323
324    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or(std::path::Path::new("."))) {
325        let _ = dir.sync_all();
326    }
327
328    Ok(content.len())
329}
330
331fn atomic_append(path: &std::path::Path, content: &str) -> Result<usize> {
332    use std::io::{Read, Write};
333
334    let existing = if path.exists() {
335        let mut file = std::fs::File::open(path).map_err(|e| {
336            Error::ExecutionFailed(format!("open {} for append: {}", path.display(), e))
337        })?;
338        let mut buf = Vec::new();
339        file.read_to_end(&mut buf).map_err(|e| {
340            Error::ExecutionFailed(format!("read {} for append: {}", path.display(), e))
341        })?;
342        buf
343    } else {
344        Vec::new()
345    };
346
347    let mut combined = existing;
348    combined.extend_from_slice(content.as_bytes());
349
350    let tmp_name = format!(
351        ".{}.tmp",
352        path.file_name()
353            .map(|n| n.to_string_lossy())
354            .unwrap_or_default()
355    );
356    let tmp_path = path.parent().unwrap_or(std::path::Path::new(".")).join(&tmp_name);
357
358    {
359        let mut file = std::fs::File::create(&tmp_path).map_err(|e| {
360            Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
361        })?;
362        file.write_all(&combined).map_err(|e| {
363            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
364        })?;
365        file.sync_all().map_err(|e| {
366            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
367        })?;
368    }
369
370    std::fs::rename(&tmp_path, path).map_err(|e| {
371        let _ = std::fs::remove_file(&tmp_path);
372        Error::ExecutionFailed(format!("atomic rename {} -> {}: {}", tmp_path.display(), path.display(), e))
373    })?;
374
375    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or(std::path::Path::new("."))) {
376        let _ = dir.sync_all();
377    }
378
379    Ok(content.len())
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn test_backup_dir() -> PathBuf {
387        std::env::temp_dir().join("runtimo_fw_test")
388    }
389
390    #[test]
391    fn writes_new_file() {
392        let target = std::env::temp_dir().join("runtimo_fw_new.txt");
393        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
394
395        let result = cap
396            .execute(
397                &serde_json::json!({
398                    "path": target.to_str().unwrap(),
399                    "content": "hello from runtimo"
400                }),
401                &Context {
402                    dry_run: false,
403                    job_id: "t1".into(),
404                    working_dir: std::env::temp_dir(),
405                },
406            )
407            .expect("Execution failed");
408
409        assert!(result.success);
410        assert_eq!(
411            std::fs::read_to_string(&target).unwrap(),
412            "hello from runtimo"
413        );
414
415        std::fs::remove_file(&target).ok();
416        std::fs::remove_dir_all(test_backup_dir()).ok();
417    }
418
419    #[test]
420    fn dry_run_does_not_write() {
421        let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
422        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
423
424        cap.execute(
425            &serde_json::json!({
426                "path": target.to_str().unwrap(),
427                "content": "should not exist"
428            }),
429            &Context {
430                dry_run: true,
431                job_id: "t2".into(),
432                working_dir: std::env::temp_dir(),
433            },
434        )
435        .expect("Execution failed");
436
437        assert!(!target.exists());
438        std::fs::remove_dir_all(test_backup_dir()).ok();
439    }
440
441    #[test]
442    fn rejects_path_traversal() {
443        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
444        let err = cap
445            .validate(&serde_json::json!({
446                "path": "../../../etc/passwd",
447                "content": "malicious"
448            }))
449            .unwrap_err();
450        assert!(err.to_string().contains("traversal"));
451        std::fs::remove_dir_all(test_backup_dir()).ok();
452    }
453
454    #[test]
455    fn rejects_critical_file() {
456        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
457        let err = cap
458            .execute(
459                &serde_json::json!({
460                    "path": "/tmp/.bashrc",
461                    "content": "malicious"
462                }),
463                &Context {
464                    dry_run: false,
465                    job_id: "t3".into(),
466                    working_dir: std::env::temp_dir(),
467                },
468            )
469            .unwrap_err();
470        assert!(err.to_string().contains("critical file"));
471        std::fs::remove_dir_all(test_backup_dir()).ok();
472    }
473
474    #[test]
475    fn atomic_write_produces_correct_content() {
476        let target = std::env::temp_dir().join("runtimo_fw_atomic.txt");
477        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
478
479        let result = cap
480            .execute(
481                &serde_json::json!({
482                    "path": target.to_str().unwrap(),
483                    "content": "atomic content"
484                }),
485                &Context {
486                    dry_run: false,
487                    job_id: "t4".into(),
488                    working_dir: std::env::temp_dir(),
489                },
490            )
491            .expect("Execution failed");
492
493        assert!(result.success);
494        assert_eq!(std::fs::read_to_string(&target).unwrap(), "atomic content");
495        let tmp = target.parent().unwrap().join(".runtimo_fw_atomic.txt.tmp");
496        assert!(!tmp.exists(), "temp file should not remain");
497
498        std::fs::remove_file(&target).ok();
499        std::fs::remove_dir_all(test_backup_dir()).ok();
500    }
501
502    #[test]
503    fn append_mode_works() {
504        let target = std::env::temp_dir().join("runtimo_fw_append.txt");
505        std::fs::write(&target, "initial").ok();
506
507        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
508
509        let result = cap
510            .execute(
511                &serde_json::json!({
512                    "path": target.to_str().unwrap(),
513                    "content": " appended",
514                    "append": true
515                }),
516                &Context {
517                    dry_run: false,
518                    job_id: "t5".into(),
519                    working_dir: std::env::temp_dir(),
520                },
521            )
522            .expect("Execution failed");
523
524        assert!(result.success);
525        assert_eq!(
526            std::fs::read_to_string(&target).unwrap(),
527            "initial appended"
528        );
529
530        std::fs::remove_file(&target).ok();
531        std::fs::remove_dir_all(test_backup_dir()).ok();
532    }
533
534    #[test]
535    fn dry_run_does_not_create_backup() {
536        let target = std::env::temp_dir().join("runtimo_fw_dry_backup.txt");
537        std::fs::write(&target, "existing content").ok();
538
539        // Use unique backup dir to avoid pollution from parallel tests
540        let backup_dir = std::env::temp_dir().join("runtimo_fw_dry_backup_test");
541        let _ = std::fs::remove_dir_all(&backup_dir);
542        let cap = FileWrite::new(backup_dir.clone()).expect("Failed to create FileWrite");
543
544        let result = cap
545            .execute(
546                &serde_json::json!({
547                    "path": target.to_str().unwrap(),
548                    "content": "new content"
549                }),
550                &Context {
551                    dry_run: true,
552                    job_id: "t6".into(),
553                    working_dir: std::env::temp_dir(),
554                },
555            )
556            .expect("Execution failed");
557
558        assert!(result.success);
559        assert!(result.data["dry_run"].as_bool().unwrap());
560        assert_eq!(
561            std::fs::read_to_string(&target).unwrap(),
562            "existing content"
563        );
564        if backup_dir.exists() {
565            let entries: Vec<_> = std::fs::read_dir(&backup_dir)
566                .map(|d| d.filter_map(|e| e.ok()).collect::<Vec<_>>())
567                .unwrap_or_default();
568            assert!(entries.is_empty());
569        }
570
571        std::fs::remove_file(&target).ok();
572        std::fs::remove_dir_all(&backup_dir).ok();
573    }
574
575    #[test]
576    fn telemetry_included_in_output() {
577        let target = std::env::temp_dir().join("runtimo_fw_telemetry.txt");
578        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
579
580        let result = cap
581            .execute(
582                &serde_json::json!({
583                    "path": target.to_str().unwrap(),
584                    "content": "telemetry test"
585                }),
586                &Context {
587                    dry_run: false,
588                    job_id: "t7".into(),
589                    working_dir: std::env::temp_dir(),
590                },
591            )
592            .expect("Execution failed");
593
594        assert!(result.success);
595        assert!(result.data["telemetry_before"].is_object());
596        assert!(result.data["telemetry_after"].is_object());
597        assert!(result.data["process_before_count"].is_u64());
598        assert!(result.data["process_after_count"].is_u64());
599
600        std::fs::remove_file(&target).ok();
601        std::fs::remove_dir_all(test_backup_dir()).ok();
602    }
603
604    #[test]
605    fn test_check_disk_space_writable_tmp_is_ok() {
606        let result = check_disk_space(
607            &std::env::temp_dir().join("runtimo_df_test.txt"),
608            100,
609        );
610        assert!(result.is_ok(), "df on /tmp should succeed");
611    }
612
613    #[test]
614    fn test_content_too_large_rejected() {
615        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
616        let large_content = "x".repeat(101 * 1024 * 1024); // > 100MB
617        let result = cap.execute(
618            &serde_json::json!({
619                "path": "/tmp/runtimo_large_test.txt",
620                "content": large_content
621            }),
622            &Context {
623                dry_run: false,
624                job_id: "t8".into(),
625                working_dir: std::env::temp_dir(),
626            },
627        );
628        assert!(result.is_err());
629        assert!(result.unwrap_err().to_string().contains("too large"));
630
631        std::fs::remove_dir_all(test_backup_dir()).ok();
632    }
633
634    #[test]
635    fn test_critical_file_ssh_authorized_keys_blocked() {
636        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
637        let result = cap.execute(
638            &serde_json::json!({
639                "path": "/tmp/.ssh/authorized_keys",
640                "content": "ssh-rsa AAA..."
641            }),
642            &Context {
643                dry_run: false,
644                job_id: "t9".into(),
645                working_dir: std::env::temp_dir(),
646            },
647        );
648        assert!(result.is_err());
649        assert!(result.unwrap_err().to_string().contains("critical file"));
650
651        std::fs::remove_dir_all(test_backup_dir()).ok();
652    }
653
654    #[test]
655    fn test_atomic_write_syncs_directory() {
656        let target = std::env::temp_dir().join("runtimo_fw_sync.txt");
657        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
658
659        let result = cap
660            .execute(
661                &serde_json::json!({
662                    "path": target.to_str().unwrap(),
663                    "content": "sync test"
664                }),
665                &Context {
666                    dry_run: false,
667                    job_id: "t10".into(),
668                    working_dir: std::env::temp_dir(),
669                },
670            )
671            .expect("Execution failed");
672
673        assert!(result.success);
674
675        let tmp = target.parent().unwrap().join(".runtimo_fw_sync.txt.tmp");
676        assert!(!tmp.exists(), "temp file should be cleaned up after atomic rename");
677
678        std::fs::remove_file(&target).ok();
679        std::fs::remove_dir_all(test_backup_dir()).ok();
680    }
681
682    #[test]
683    fn test_append_exceeds_max_size_rejected() {
684        let target = std::env::temp_dir().join("runtimo_fw_append_overflow.txt");
685        std::fs::write(&target, "x".repeat(99 * 1024 * 1024)).ok(); // 99MB existing
686
687        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
688        let large_append = "y".repeat(2 * 1024 * 1024); // +2MB = 101MB > 100MB
689        let result = cap.execute(
690            &serde_json::json!({
691                "path": target.to_str().unwrap(),
692                "content": large_append,
693                "append": true
694            }),
695            &Context {
696                dry_run: false,
697                job_id: "t11".into(),
698                working_dir: std::env::temp_dir(),
699            },
700        );
701        assert!(result.is_err());
702        assert!(result.unwrap_err().to_string().contains("exceed"));
703
704        std::fs::remove_file(&target).ok();
705        std::fs::remove_dir_all(test_backup_dir()).ok();
706    }
707}