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