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