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. Rejects directory
6//! paths (cannot write to a directory).
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use runtimo_core::capabilities::FileWrite;
12//!;
13//! use runtimo_core::capability::{Capability, Context};
14//! use serde_json::json;
15//!
16//! let cap = FileWrite::new();
17//! let result = cap.execute(
18//!     &json!({"path": "/tmp/output.txt", "content": "hello"}),
19//!     &Context { dry_run: false, job_id: "job1".into(), working_dir: std::env::temp_dir() },
20//! ).unwrap();
21//!
22//! assert_eq!(result.status, "ok");
23//! assert_eq!(std::fs::read_to_string("/tmp/output.txt").unwrap(), "hello");
24//! ```
25
26use crate::backup::BackupManager;
27use crate::capability::{CapabilityError, Context, Output, TypedCapability};
28use crate::processes::ProcessSnapshot;
29use crate::telemetry::Telemetry;
30use crate::validation::path::{validate_path, PathContext};
31use crate::{Error, Result};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
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    ".env",
61    ".env.*",
62    "authorized_keys",
63    "id_rsa",
64    "id_ed25519",
65];
66
67/// Input parameters for [`FileWrite::execute`].
68///
69/// The target file is backed up before any write occurs, making the
70/// operation reversible through the undo system.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[allow(clippy::exhaustive_structs)] // args struct — fields are the contract
73pub struct FileWriteArgs {
74    /// Absolute path to the file to write.
75    pub path: String,
76    /// Content to write (overwrites existing content unless `append` is set).
77    pub content: String,
78    /// When true, append to the file instead of overwriting.
79    #[serde(default)]
80    pub append: bool,
81}
82
83/// Capability that writes file contents with backup-before-mutate.
84///
85/// Every write creates a timestamped backup via [`BackupManager`], enabling
86/// rollback through the undo system. The backup is created *before* the
87/// mutation, so a failed write still leaves a recoverable state.
88pub struct FileWrite {
89    backup_mgr: BackupManager,
90}
91
92impl FileWrite {
93    /// Create a new `FileWrite` capability backed by the default backup directory.
94    ///
95    /// The backup directory is derived from `data_dir()` as `data_dir().join("backups")`.
96    /// This eliminates external configuration of the backup path (ADR-C28).
97    #[allow(clippy::missing_errors_doc)] // Error path is self-documenting — propagates BackupManager::new
98    pub fn new() -> Result<Self> {
99        let backup_dir = crate::utils::backup_dir();
100        Ok(Self {
101            backup_mgr: BackupManager::new(backup_dir)?,
102        })
103    }
104}
105
106impl TypedCapability for FileWrite {
107    type Args = FileWriteArgs;
108
109    fn name(&self) -> &'static str {
110        "FileWrite"
111    }
112
113    fn description(&self) -> &'static str {
114        "write file. auto-backup for undo. append ok."
115    }
116
117    fn schema(&self) -> Value {
118        serde_json::json!({
119            "type": "object",
120            "properties": {
121                "path": { "type": "string" },
122                "content": { "type": "string" },
123                "append": { "type": "boolean" }
124            },
125            "required": ["path", "content"]
126        })
127    }
128
129    fn execute(
130        &self,
131        args: FileWriteArgs,
132        ctx: &Context,
133    ) -> std::result::Result<Output, CapabilityError> {
134        if args.content.len() > MAX_WRITE_SIZE {
135            return Err(CapabilityError::InvalidArgs(format!(
136                "Content too large: {} bytes (limit: {} bytes)",
137                args.content.len(),
138                MAX_WRITE_SIZE
139            )));
140        }
141
142        let telemetry_before = Telemetry::capture();
143        let process_before = ProcessSnapshot::capture();
144
145        let write_ctx = PathContext {
146            require_exists: false,
147            require_file: false,
148            ..Default::default()
149        };
150
151        let path = validate_path(&args.path, &write_ctx)
152            .map_err(|e| CapabilityError::PermissionDenied(format!("path validation: {}", e)))?;
153
154        // Reject directory paths — cannot write to a directory
155        if path.exists() && path.is_dir() {
156            return Err(CapabilityError::PermissionDenied(format!(
157                "path is a directory: {}",
158                path.display()
159            )));
160        }
161
162        if is_critical_file(&path) {
163            return Err(CapabilityError::PermissionDenied(format!(
164                "critical file denied: {}",
165                path.display()
166            )));
167        }
168
169        if let Err(e) = check_disk_space(&path, args.content.len()) {
170            return Err(CapabilityError::PermissionDenied(e));
171        }
172
173        if args.append {
174            if let Ok(meta) = std::fs::metadata(&path) {
175                let existing = usize::try_from(meta.len()).unwrap_or(usize::MAX);
176                if existing.saturating_add(args.content.len()) > MAX_APPEND_SIZE {
177                    return Err(CapabilityError::InvalidArgs(format!(
178                        "append would exceed max file size: {} + {} > {} bytes",
179                        existing,
180                        args.content.len(),
181                        MAX_APPEND_SIZE
182                    )));
183                }
184            }
185        }
186
187        if ctx.dry_run {
188            let mut out = Output::ok(format!(
189                "DRY RUN: would write {} bytes to {}",
190                args.content.len(),
191                path.display()
192            ));
193            out.data = Some(serde_json::json!({
194                "path": path.display().to_string(),
195                "content_length": args.content.len(),
196                "dry_run": true,
197                "backup_path": null,
198                "telemetry_before": serde_json::to_value(&telemetry_before).unwrap_or(Value::Null),
199                "process_before_count": process_before.summary.total_processes,
200            }));
201            return Ok(out);
202        }
203
204        let backup_path = if path.exists() {
205            match self.backup_mgr.create_backup(&path, &ctx.job_id) {
206                Ok(bp) => Some(bp),
207                Err(e) => return Err(CapabilityError::Internal(format!("backup: {}", e))),
208            }
209        } else {
210            if let Some(parent) = path.parent() {
211                std::fs::create_dir_all(parent).map_err(|e| {
212                    CapabilityError::Io(std::io::Error::other(format!(
213                        "mkdir {}: {}",
214                        parent.display(),
215                        e
216                    )))
217                })?;
218            }
219            None
220        };
221
222        let bytes_written = if args.append {
223            atomic_append(&path, &args.content)
224                .map_err(|e| CapabilityError::Internal(e.to_string()))?
225        } else {
226            atomic_write(&path, &args.content)
227                .map_err(|e| CapabilityError::Internal(e.to_string()))?
228        };
229
230        let telemetry_after = Telemetry::capture();
231        let process_after = ProcessSnapshot::capture();
232
233        let mut out = Output::ok(format!(
234            "Wrote {} bytes to {}",
235            bytes_written,
236            path.display()
237        ));
238        out.data = Some(serde_json::json!({
239            "path": path.display().to_string(),
240            "bytes_written": bytes_written,
241            "append": args.append,
242            "backup_path": backup_path.map(|p| p.to_string_lossy().to_string()),
243            "telemetry_before": serde_json::to_value(&telemetry_before).unwrap_or(Value::Null),
244            "telemetry_after": serde_json::to_value(&telemetry_after).unwrap_or(Value::Null),
245            "process_before_count": process_before.summary.total_processes,
246            "process_after_count": process_after.summary.total_processes,
247        }));
248        Ok(out)
249    }
250}
251
252fn is_critical_file(path: &std::path::Path) -> bool {
253    let path_str = path.to_string_lossy();
254    let filename = path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default();
255    for critical in CRITICAL_FILES {
256        if critical.contains('*') {
257            if glob_match(critical, &filename) {
258                return true;
259            }
260        } else if path_str.ends_with(critical) {
261            return true;
262        }
263    }
264    false
265}
266
267fn glob_match(pattern: &str, text: &str) -> bool {
268    if pattern == "*" {
269        return true;
270    }
271    let parts: Vec<&str> = pattern.split('*').collect();
272    if parts.len() != 2 {
273        return text == pattern;
274    }
275    let Some(prefix) = parts.first() else {
276        return text == pattern;
277    };
278    let Some(suffix) = parts.get(1) else {
279        return text == pattern;
280    };
281    text.starts_with(prefix) && text.ends_with(suffix)
282}
283
284fn check_disk_space(
285    path: &std::path::Path,
286    content_size: usize,
287) -> std::result::Result<(), String> {
288    let parent = path.parent().unwrap_or_else(|| std::path::Path::new("/"));
289
290    // Parent may not exist yet (create_dir_all runs later). Skip df — disk
291    // check is meaningless for a path that doesn't exist on the filesystem.
292    if !parent.exists() {
293        return Ok(());
294    }
295
296    let output = std::process::Command::new("df")
297        .arg("-B1")
298        .arg(parent)
299        .output()
300        .map_err(|e| format!("df command failed: {}", e))?;
301
302    if !output.status.success() {
303        let stderr = String::from_utf8_lossy(&output.stderr);
304        return Err(format!("df command failed: {}", stderr.trim()));
305    }
306
307    let stdout = String::from_utf8_lossy(&output.stdout);
308    let mut lines = stdout.lines();
309
310    let Some(header) = lines.next() else {
311        return Ok(());
312    };
313    let headers: Vec<&str> = header.split_whitespace().collect();
314    let avail_idx = headers
315        .iter()
316        .position(|&h| h.eq_ignore_ascii_case("Available") || h.eq_ignore_ascii_case("Avail"));
317
318    if let Some(line) = lines.next() {
319        let parts: Vec<&str> = line.split_whitespace().collect();
320        let idx = avail_idx.unwrap_or(3); // fall back to column 3 (GNU default)
321        if let Some(available_str) = parts.get(idx) {
322            if let Ok(available) = available_str.parse::<u64>() {
323                let required = (content_size as u64).saturating_add(MIN_FREE_DISK_BYTES);
324                if available < required {
325                    return Err(format!(
326                        "insufficient disk space: {} bytes available, {} bytes required",
327                        available, required
328                    ));
329                }
330                return Ok(());
331            }
332        }
333    }
334    Ok(())
335}
336
337// ── Platform-specific O_NOFOLLOW helpers ─────────────────────────────────────
338
339/// Opens a file for writing with `O_NOFOLLOW` (symlink protection) on Unix.
340///
341/// On Linux, `O_NOFOLLOW` causes `open()` to fail with `ELOOP` if the
342/// target is a symlink — preventing symlink-based path traversal attacks.
343///
344/// On non-Unix platforms, `O_NOFOLLOW` is not available. The file is opened
345/// without symlink protection — security depends on parent directory
346/// permissions instead.
347#[cfg(unix)]
348fn open_write_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
349    use std::os::unix::fs::OpenOptionsExt;
350    std::fs::OpenOptions::new()
351        .write(true)
352        .create(true)
353        .truncate(true)
354        .custom_flags(libc::O_NOFOLLOW)
355        .open(path)
356}
357
358#[cfg(not(unix))]
359fn open_write_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
360    // O_NOFOLLOW not available — symlink protection depends on parent dir permissions
361    std::fs::OpenOptions::new()
362        .write(true)
363        .create(true)
364        .truncate(true)
365        .open(path)
366}
367
368/// Opens a file for reading with `O_NOFOLLOW` on Unix, or without on other
369/// platforms. See [`open_write_nofollow`] for details.
370#[cfg(unix)]
371fn open_read_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
372    use std::os::unix::fs::OpenOptionsExt;
373    std::fs::OpenOptions::new()
374        .read(true)
375        .custom_flags(libc::O_NOFOLLOW)
376        .open(path)
377}
378
379#[cfg(not(unix))]
380fn open_read_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
381    std::fs::OpenOptions::new().read(true).open(path)
382}
383
384fn atomic_write(path: &std::path::Path, content: &str) -> Result<usize> {
385    use std::io::Write;
386
387    let tmp_name = format!(
388        ".{}.tmp",
389        path.file_name()
390            .map(|n| n.to_string_lossy())
391            .unwrap_or_default()
392    );
393    let tmp_path = path
394        .parent()
395        .unwrap_or_else(|| std::path::Path::new("."))
396        .join(&tmp_name);
397
398    {
399        let mut file = open_write_nofollow(&tmp_path).map_err(|e| {
400            Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
401        })?;
402        file.write_all(content.as_bytes()).map_err(|e| {
403            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
404        })?;
405        file.sync_all().map_err(|e| {
406            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
407        })?;
408    }
409
410    std::fs::rename(&tmp_path, path).map_err(|e| {
411        let _ = std::fs::remove_file(&tmp_path);
412        Error::ExecutionFailed(format!(
413            "atomic rename {} -> {}: {}",
414            tmp_path.display(),
415            path.display(),
416            e
417        ))
418    })?;
419
420    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
421    {
422        let _ = dir.sync_all();
423    }
424
425    Ok(content.len())
426}
427
428fn atomic_append(path: &std::path::Path, content: &str) -> Result<usize> {
429    use std::io::{Read, Write};
430
431    let existing = if path.exists() {
432        let mut file = open_read_nofollow(path).map_err(|e| {
433            Error::ExecutionFailed(format!("open {} for append: {}", path.display(), e))
434        })?;
435        let mut buf = Vec::new();
436        file.read_to_end(&mut buf).map_err(|e| {
437            Error::ExecutionFailed(format!("read {} for append: {}", path.display(), e))
438        })?;
439        buf
440    } else {
441        Vec::new()
442    };
443
444    let mut combined = existing;
445    combined.extend_from_slice(content.as_bytes());
446
447    let tmp_name = format!(
448        ".{}.tmp",
449        path.file_name()
450            .map(|n| n.to_string_lossy())
451            .unwrap_or_default()
452    );
453    let tmp_path = path
454        .parent()
455        .unwrap_or_else(|| std::path::Path::new("."))
456        .join(&tmp_name);
457
458    {
459        let mut file = open_write_nofollow(&tmp_path).map_err(|e| {
460            Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
461        })?;
462        file.write_all(&combined).map_err(|e| {
463            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
464        })?;
465        file.sync_all().map_err(|e| {
466            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
467        })?;
468    }
469
470    std::fs::rename(&tmp_path, path).map_err(|e| {
471        let _ = std::fs::remove_file(&tmp_path);
472        Error::ExecutionFailed(format!(
473            "atomic rename {} -> {}: {}",
474            tmp_path.display(),
475            path.display(),
476            e
477        ))
478    })?;
479
480    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
481    {
482        let _ = dir.sync_all();
483    }
484
485    Ok(content.len())
486}
487
488#[cfg(test)]
489#[allow(clippy::expect_used)]
490mod tests {
491    use super::*;
492    use std::path::PathBuf;
493
494    fn test_backup_dir() -> PathBuf {
495        std::env::temp_dir().join("runtimo_fw_test")
496    }
497
498    fn test_ctx(job_id: &str) -> Context {
499        Context {
500            dry_run: false,
501            job_id: job_id.into(),
502            working_dir: std::env::temp_dir(),
503        }
504    }
505
506    fn dry_ctx(job_id: &str) -> Context {
507        Context {
508            dry_run: true,
509            job_id: job_id.into(),
510            working_dir: std::env::temp_dir(),
511        }
512    }
513
514    #[test]
515    fn writes_new_file() {
516        let target = std::env::temp_dir().join("runtimo_fw_new.txt");
517        let cap = FileWrite::new().expect("Failed to create FileWrite");
518
519        let result = TypedCapability::execute(
520            &cap,
521            FileWriteArgs {
522                path: target.to_str().unwrap().to_string(),
523                content: "hello from runtimo".to_string(),
524                append: false,
525            },
526            &test_ctx("t1"),
527        )
528        .expect("Execution failed");
529
530        assert_eq!(result.status, "ok");
531        assert_eq!(
532            std::fs::read_to_string(&target).unwrap(),
533            "hello from runtimo"
534        );
535
536        std::fs::remove_file(&target).ok();
537        std::fs::remove_dir_all(test_backup_dir()).ok();
538    }
539
540    #[test]
541    fn dry_run_does_not_write() {
542        let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
543        let cap = FileWrite::new().expect("Failed to create FileWrite");
544
545        TypedCapability::execute(
546            &cap,
547            FileWriteArgs {
548                path: target.to_str().unwrap().to_string(),
549                content: "should not exist".to_string(),
550                append: false,
551            },
552            &dry_ctx("t2"),
553        )
554        .expect("Execution failed");
555
556        assert!(!target.exists());
557        std::fs::remove_dir_all(test_backup_dir()).ok();
558    }
559
560    #[test]
561    fn rejects_path_traversal() {
562        let cap = FileWrite::new().expect("Failed to create FileWrite");
563        let err = TypedCapability::execute(
564            &cap,
565            FileWriteArgs {
566                path: "../../../etc/passwd".to_string(),
567                content: "malicious".to_string(),
568                append: false,
569            },
570            &test_ctx("t3"),
571        )
572        .unwrap_err();
573        assert!(err.to_string().contains("traversal"));
574        std::fs::remove_dir_all(test_backup_dir()).ok();
575    }
576
577    #[test]
578    fn rejects_critical_file() {
579        let cap = FileWrite::new().expect("Failed to create FileWrite");
580        let err = TypedCapability::execute(
581            &cap,
582            FileWriteArgs {
583                path: "/tmp/.bashrc".to_string(),
584                content: "malicious".to_string(),
585                append: false,
586            },
587            &test_ctx("t4"),
588        )
589        .unwrap_err();
590        assert!(err.to_string().contains("critical file"));
591        std::fs::remove_dir_all(test_backup_dir()).ok();
592    }
593
594    #[test]
595    fn atomic_write_produces_correct_content() {
596        let target = std::env::temp_dir().join("runtimo_fw_atomic.txt");
597        let cap = FileWrite::new().expect("Failed to create FileWrite");
598
599        let result = TypedCapability::execute(
600            &cap,
601            FileWriteArgs {
602                path: target.to_str().unwrap().to_string(),
603                content: "atomic content".to_string(),
604                append: false,
605            },
606            &test_ctx("t5"),
607        )
608        .expect("Execution failed");
609
610        assert_eq!(result.status, "ok");
611        assert_eq!(std::fs::read_to_string(&target).unwrap(), "atomic content");
612        let tmp = target.parent().unwrap().join(".runtimo_fw_atomic.txt.tmp");
613        assert!(!tmp.exists(), "temp file should not remain");
614
615        std::fs::remove_file(&target).ok();
616        std::fs::remove_dir_all(test_backup_dir()).ok();
617    }
618
619    #[test]
620    fn append_mode_works() {
621        let target = std::env::temp_dir().join("runtimo_fw_append.txt");
622        std::fs::write(&target, "initial").ok();
623
624        let cap = FileWrite::new().expect("Failed to create FileWrite");
625
626        let result = TypedCapability::execute(
627            &cap,
628            FileWriteArgs {
629                path: target.to_str().unwrap().to_string(),
630                content: " appended".to_string(),
631                append: true,
632            },
633            &test_ctx("t6"),
634        )
635        .expect("Execution failed");
636
637        assert_eq!(result.status, "ok");
638        assert_eq!(
639            std::fs::read_to_string(&target).unwrap(),
640            "initial appended"
641        );
642
643        std::fs::remove_file(&target).ok();
644        std::fs::remove_dir_all(test_backup_dir()).ok();
645    }
646
647    #[test]
648    fn dry_run_does_not_create_backup() {
649        let target = std::env::temp_dir().join("runtimo_fw_dry_backup.txt");
650        std::fs::write(&target, "existing content").ok();
651
652        // Use unique backup dir to avoid pollution from parallel tests
653        let backup_dir = std::env::temp_dir().join("runtimo_fw_dry_backup_test");
654        let _ = std::fs::remove_dir_all(&backup_dir);
655        let cap = FileWrite::new().expect("Failed to create FileWrite");
656
657        let result = TypedCapability::execute(
658            &cap,
659            FileWriteArgs {
660                path: target.to_str().unwrap().to_string(),
661                content: "new content".to_string(),
662                append: false,
663            },
664            &dry_ctx("t7"),
665        )
666        .expect("Execution failed");
667
668        assert_eq!(result.status, "ok");
669        assert!(result.data.as_ref().unwrap()["dry_run"].as_bool().unwrap());
670        assert_eq!(
671            std::fs::read_to_string(&target).unwrap(),
672            "existing content"
673        );
674        if backup_dir.exists() {
675            let entries: Vec<_> = std::fs::read_dir(&backup_dir)
676                .map(|d| d.filter_map(|e| e.ok()).collect::<Vec<_>>())
677                .unwrap_or_default();
678            assert!(entries.is_empty());
679        }
680
681        std::fs::remove_file(&target).ok();
682        std::fs::remove_dir_all(&backup_dir).ok();
683    }
684
685    #[test]
686    fn telemetry_included_in_output() {
687        let target = std::env::temp_dir().join("runtimo_fw_telemetry.txt");
688        let cap = FileWrite::new().expect("Failed to create FileWrite");
689
690        let result = TypedCapability::execute(
691            &cap,
692            FileWriteArgs {
693                path: target.to_str().unwrap().to_string(),
694                content: "telemetry test".to_string(),
695                append: false,
696            },
697            &test_ctx("t8"),
698        )
699        .expect("Execution failed");
700
701        assert_eq!(result.status, "ok");
702        let data = result.data.as_ref().unwrap();
703        assert!(data["telemetry_before"].is_object());
704        assert!(data["telemetry_after"].is_object());
705        assert!(data["process_before_count"].is_u64());
706        assert!(data["process_after_count"].is_u64());
707
708        std::fs::remove_file(&target).ok();
709        std::fs::remove_dir_all(test_backup_dir()).ok();
710    }
711
712    #[test]
713    fn test_check_disk_space_writable_tmp_is_ok() {
714        let result = check_disk_space(&std::env::temp_dir().join("runtimo_df_test.txt"), 100);
715        assert!(result.is_ok(), "df on /tmp should succeed");
716    }
717
718    #[test]
719    fn test_content_too_large_rejected() {
720        let cap = FileWrite::new().expect("Failed to create FileWrite");
721        let large_content = "x".repeat(101 * 1024 * 1024); // > 100MB
722        let result = TypedCapability::execute(
723            &cap,
724            FileWriteArgs {
725                path: "/tmp/runtimo_large_test.txt".to_string(),
726                content: large_content,
727                append: false,
728            },
729            &test_ctx("t9"),
730        );
731        assert!(result.is_err());
732        assert!(result.unwrap_err().to_string().contains("too large"));
733
734        std::fs::remove_dir_all(test_backup_dir()).ok();
735    }
736
737    #[test]
738    fn test_critical_file_ssh_authorized_keys_blocked() {
739        let cap = FileWrite::new().expect("Failed to create FileWrite");
740        let result = TypedCapability::execute(
741            &cap,
742            FileWriteArgs {
743                path: "/tmp/.ssh/authorized_keys".to_string(),
744                content: "ssh-rsa AAA...".to_string(),
745                append: false,
746            },
747            &test_ctx("t10"),
748        );
749        assert!(result.is_err());
750        assert!(result.unwrap_err().to_string().contains("critical file"));
751
752        std::fs::remove_dir_all(test_backup_dir()).ok();
753    }
754
755    #[test]
756    fn test_atomic_write_syncs_directory() {
757        let target = std::env::temp_dir().join("runtimo_fw_sync.txt");
758        let cap = FileWrite::new().expect("Failed to create FileWrite");
759
760        let result = TypedCapability::execute(
761            &cap,
762            FileWriteArgs {
763                path: target.to_str().unwrap().to_string(),
764                content: "sync test".to_string(),
765                append: false,
766            },
767            &test_ctx("t11"),
768        )
769        .expect("Execution failed");
770
771        assert_eq!(result.status, "ok");
772
773        let tmp = target.parent().unwrap().join(".runtimo_fw_sync.txt.tmp");
774        assert!(
775            !tmp.exists(),
776            "temp file should be cleaned up after atomic rename"
777        );
778
779        std::fs::remove_file(&target).ok();
780        std::fs::remove_dir_all(test_backup_dir()).ok();
781    }
782
783    #[test]
784    fn test_append_exceeds_max_size_rejected() {
785        let target = std::env::temp_dir().join("runtimo_fw_append_overflow.txt");
786        std::fs::write(&target, "x".repeat(99 * 1024 * 1024)).ok(); // 99MB existing
787
788        let cap = FileWrite::new().expect("Failed to create FileWrite");
789        let large_append = "y".repeat(2 * 1024 * 1024); // +2MB = 101MB > 100MB
790        let result = TypedCapability::execute(
791            &cap,
792            FileWriteArgs {
793                path: target.to_str().unwrap().to_string(),
794                content: large_append,
795                append: true,
796            },
797            &test_ctx("t12"),
798        );
799        assert!(result.is_err());
800        assert!(result.unwrap_err().to_string().contains("exceed"));
801
802        std::fs::remove_file(&target).ok();
803        std::fs::remove_dir_all(test_backup_dir()).ok();
804    }
805}