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
310fn atomic_write(path: &std::path::Path, content: &str) -> Result<usize> {
311    use std::io::Write;
312    use std::os::unix::fs::OpenOptionsExt;
313
314    let tmp_name = format!(
315        ".{}.tmp",
316        path.file_name()
317            .map(|n| n.to_string_lossy())
318            .unwrap_or_default()
319    );
320    let tmp_path = path
321        .parent()
322        .unwrap_or_else(|| std::path::Path::new("."))
323        .join(&tmp_name);
324
325    {
326        #[allow(clippy::cast_possible_wrap)] // libc::O_NOFOLLOW is i32
327        let mut file = std::fs::OpenOptions::new()
328            .write(true)
329            .create(true)
330            .truncate(true)
331            .custom_flags(libc::O_NOFOLLOW)
332            .open(&tmp_path)
333            .map_err(|e| {
334                Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
335            })?;
336        file.write_all(content.as_bytes()).map_err(|e| {
337            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
338        })?;
339        file.sync_all().map_err(|e| {
340            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
341        })?;
342    }
343
344    std::fs::rename(&tmp_path, path).map_err(|e| {
345        let _ = std::fs::remove_file(&tmp_path);
346        Error::ExecutionFailed(format!(
347            "atomic rename {} -> {}: {}",
348            tmp_path.display(),
349            path.display(),
350            e
351        ))
352    })?;
353
354    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
355    {
356        let _ = dir.sync_all();
357    }
358
359    Ok(content.len())
360}
361
362fn atomic_append(path: &std::path::Path, content: &str) -> Result<usize> {
363    use std::io::{Read, Write};
364    use std::os::unix::fs::OpenOptionsExt;
365
366    let existing = if path.exists() {
367        #[allow(clippy::cast_possible_wrap)] // libc::O_NOFOLLOW is i32
368        let mut file = std::fs::OpenOptions::new()
369            .read(true)
370            .custom_flags(libc::O_NOFOLLOW)
371            .open(path)
372            .map_err(|e| {
373                Error::ExecutionFailed(format!("open {} for append: {}", path.display(), e))
374            })?;
375        let mut buf = Vec::new();
376        file.read_to_end(&mut buf).map_err(|e| {
377            Error::ExecutionFailed(format!("read {} for append: {}", path.display(), e))
378        })?;
379        buf
380    } else {
381        Vec::new()
382    };
383
384    let mut combined = existing;
385    combined.extend_from_slice(content.as_bytes());
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        #[allow(clippy::cast_possible_wrap)] // libc::O_NOFOLLOW is i32
400        let mut file = std::fs::OpenOptions::new()
401            .write(true)
402            .create(true)
403            .truncate(true)
404            .custom_flags(libc::O_NOFOLLOW)
405            .open(&tmp_path)
406            .map_err(|e| {
407                Error::ExecutionFailed(format!("create temp {}: {}", tmp_path.display(), e))
408            })?;
409        file.write_all(&combined).map_err(|e| {
410            Error::ExecutionFailed(format!("write temp {}: {}", tmp_path.display(), e))
411        })?;
412        file.sync_all().map_err(|e| {
413            Error::ExecutionFailed(format!("fsync temp {}: {}", tmp_path.display(), e))
414        })?;
415    }
416
417    std::fs::rename(&tmp_path, path).map_err(|e| {
418        let _ = std::fs::remove_file(&tmp_path);
419        Error::ExecutionFailed(format!(
420            "atomic rename {} -> {}: {}",
421            tmp_path.display(),
422            path.display(),
423            e
424        ))
425    })?;
426
427    if let Ok(dir) = std::fs::File::open(path.parent().unwrap_or_else(|| std::path::Path::new(".")))
428    {
429        let _ = dir.sync_all();
430    }
431
432    Ok(content.len())
433}
434
435#[cfg(test)]
436#[allow(clippy::expect_used)]
437mod tests {
438    use super::*;
439
440    fn test_backup_dir() -> PathBuf {
441        std::env::temp_dir().join("runtimo_fw_test")
442    }
443
444    #[test]
445    fn writes_new_file() {
446        let target = std::env::temp_dir().join("runtimo_fw_new.txt");
447        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
448
449        let result = cap
450            .execute(
451                &serde_json::json!({
452                    "path": target.to_str().unwrap(),
453                    "content": "hello from runtimo"
454                }),
455                &Context {
456                    dry_run: false,
457                    job_id: "t1".into(),
458                    working_dir: std::env::temp_dir(),
459                },
460            )
461            .expect("Execution failed");
462
463        assert!(result.success);
464        assert_eq!(
465            std::fs::read_to_string(&target).unwrap(),
466            "hello from runtimo"
467        );
468
469        std::fs::remove_file(&target).ok();
470        std::fs::remove_dir_all(test_backup_dir()).ok();
471    }
472
473    #[test]
474    fn dry_run_does_not_write() {
475        let target = std::env::temp_dir().join("runtimo_fw_dry.txt");
476        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
477
478        cap.execute(
479            &serde_json::json!({
480                "path": target.to_str().unwrap(),
481                "content": "should not exist"
482            }),
483            &Context {
484                dry_run: true,
485                job_id: "t2".into(),
486                working_dir: std::env::temp_dir(),
487            },
488        )
489        .expect("Execution failed");
490
491        assert!(!target.exists());
492        std::fs::remove_dir_all(test_backup_dir()).ok();
493    }
494
495    #[test]
496    fn rejects_path_traversal() {
497        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
498        let err = cap
499            .validate(&serde_json::json!({
500                "path": "../../../etc/passwd",
501                "content": "malicious"
502            }))
503            .unwrap_err();
504        assert!(err.to_string().contains("traversal"));
505        std::fs::remove_dir_all(test_backup_dir()).ok();
506    }
507
508    #[test]
509    fn rejects_critical_file() {
510        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
511        let err = cap
512            .execute(
513                &serde_json::json!({
514                    "path": "/tmp/.bashrc",
515                    "content": "malicious"
516                }),
517                &Context {
518                    dry_run: false,
519                    job_id: "t3".into(),
520                    working_dir: std::env::temp_dir(),
521                },
522            )
523            .unwrap_err();
524        assert!(err.to_string().contains("critical file"));
525        std::fs::remove_dir_all(test_backup_dir()).ok();
526    }
527
528    #[test]
529    fn atomic_write_produces_correct_content() {
530        let target = std::env::temp_dir().join("runtimo_fw_atomic.txt");
531        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
532
533        let result = cap
534            .execute(
535                &serde_json::json!({
536                    "path": target.to_str().unwrap(),
537                    "content": "atomic content"
538                }),
539                &Context {
540                    dry_run: false,
541                    job_id: "t4".into(),
542                    working_dir: std::env::temp_dir(),
543                },
544            )
545            .expect("Execution failed");
546
547        assert!(result.success);
548        assert_eq!(std::fs::read_to_string(&target).unwrap(), "atomic content");
549        let tmp = target.parent().unwrap().join(".runtimo_fw_atomic.txt.tmp");
550        assert!(!tmp.exists(), "temp file should not remain");
551
552        std::fs::remove_file(&target).ok();
553        std::fs::remove_dir_all(test_backup_dir()).ok();
554    }
555
556    #[test]
557    fn append_mode_works() {
558        let target = std::env::temp_dir().join("runtimo_fw_append.txt");
559        std::fs::write(&target, "initial").ok();
560
561        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
562
563        let result = cap
564            .execute(
565                &serde_json::json!({
566                    "path": target.to_str().unwrap(),
567                    "content": " appended",
568                    "append": true
569                }),
570                &Context {
571                    dry_run: false,
572                    job_id: "t5".into(),
573                    working_dir: std::env::temp_dir(),
574                },
575            )
576            .expect("Execution failed");
577
578        assert!(result.success);
579        assert_eq!(
580            std::fs::read_to_string(&target).unwrap(),
581            "initial appended"
582        );
583
584        std::fs::remove_file(&target).ok();
585        std::fs::remove_dir_all(test_backup_dir()).ok();
586    }
587
588    #[test]
589    fn dry_run_does_not_create_backup() {
590        let target = std::env::temp_dir().join("runtimo_fw_dry_backup.txt");
591        std::fs::write(&target, "existing content").ok();
592
593        // Use unique backup dir to avoid pollution from parallel tests
594        let backup_dir = std::env::temp_dir().join("runtimo_fw_dry_backup_test");
595        let _ = std::fs::remove_dir_all(&backup_dir);
596        let cap = FileWrite::new(backup_dir.clone()).expect("Failed to create FileWrite");
597
598        let result = cap
599            .execute(
600                &serde_json::json!({
601                    "path": target.to_str().unwrap(),
602                    "content": "new content"
603                }),
604                &Context {
605                    dry_run: true,
606                    job_id: "t6".into(),
607                    working_dir: std::env::temp_dir(),
608                },
609            )
610            .expect("Execution failed");
611
612        assert!(result.success);
613        assert!(result.data["dry_run"].as_bool().unwrap());
614        assert_eq!(
615            std::fs::read_to_string(&target).unwrap(),
616            "existing content"
617        );
618        if backup_dir.exists() {
619            let entries: Vec<_> = std::fs::read_dir(&backup_dir)
620                .map(|d| d.filter_map(|e| e.ok()).collect::<Vec<_>>())
621                .unwrap_or_default();
622            assert!(entries.is_empty());
623        }
624
625        std::fs::remove_file(&target).ok();
626        std::fs::remove_dir_all(&backup_dir).ok();
627    }
628
629    #[test]
630    fn telemetry_included_in_output() {
631        let target = std::env::temp_dir().join("runtimo_fw_telemetry.txt");
632        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
633
634        let result = cap
635            .execute(
636                &serde_json::json!({
637                    "path": target.to_str().unwrap(),
638                    "content": "telemetry test"
639                }),
640                &Context {
641                    dry_run: false,
642                    job_id: "t7".into(),
643                    working_dir: std::env::temp_dir(),
644                },
645            )
646            .expect("Execution failed");
647
648        assert!(result.success);
649        assert!(result.data["telemetry_before"].is_object());
650        assert!(result.data["telemetry_after"].is_object());
651        assert!(result.data["process_before_count"].is_u64());
652        assert!(result.data["process_after_count"].is_u64());
653
654        std::fs::remove_file(&target).ok();
655        std::fs::remove_dir_all(test_backup_dir()).ok();
656    }
657
658    #[test]
659    fn test_check_disk_space_writable_tmp_is_ok() {
660        let result = check_disk_space(&std::env::temp_dir().join("runtimo_df_test.txt"), 100);
661        assert!(result.is_ok(), "df on /tmp should succeed");
662    }
663
664    #[test]
665    fn test_content_too_large_rejected() {
666        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
667        let large_content = "x".repeat(101 * 1024 * 1024); // > 100MB
668        let result = cap.execute(
669            &serde_json::json!({
670                "path": "/tmp/runtimo_large_test.txt",
671                "content": large_content
672            }),
673            &Context {
674                dry_run: false,
675                job_id: "t8".into(),
676                working_dir: std::env::temp_dir(),
677            },
678        );
679        assert!(result.is_err());
680        assert!(result.unwrap_err().to_string().contains("too large"));
681
682        std::fs::remove_dir_all(test_backup_dir()).ok();
683    }
684
685    #[test]
686    fn test_critical_file_ssh_authorized_keys_blocked() {
687        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
688        let result = cap.execute(
689            &serde_json::json!({
690                "path": "/tmp/.ssh/authorized_keys",
691                "content": "ssh-rsa AAA..."
692            }),
693            &Context {
694                dry_run: false,
695                job_id: "t9".into(),
696                working_dir: std::env::temp_dir(),
697            },
698        );
699        assert!(result.is_err());
700        assert!(result.unwrap_err().to_string().contains("critical file"));
701
702        std::fs::remove_dir_all(test_backup_dir()).ok();
703    }
704
705    #[test]
706    fn test_atomic_write_syncs_directory() {
707        let target = std::env::temp_dir().join("runtimo_fw_sync.txt");
708        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
709
710        let result = cap
711            .execute(
712                &serde_json::json!({
713                    "path": target.to_str().unwrap(),
714                    "content": "sync test"
715                }),
716                &Context {
717                    dry_run: false,
718                    job_id: "t10".into(),
719                    working_dir: std::env::temp_dir(),
720                },
721            )
722            .expect("Execution failed");
723
724        assert!(result.success);
725
726        let tmp = target.parent().unwrap().join(".runtimo_fw_sync.txt.tmp");
727        assert!(
728            !tmp.exists(),
729            "temp file should be cleaned up after atomic rename"
730        );
731
732        std::fs::remove_file(&target).ok();
733        std::fs::remove_dir_all(test_backup_dir()).ok();
734    }
735
736    #[test]
737    fn test_append_exceeds_max_size_rejected() {
738        let target = std::env::temp_dir().join("runtimo_fw_append_overflow.txt");
739        std::fs::write(&target, "x".repeat(99 * 1024 * 1024)).ok(); // 99MB existing
740
741        let cap = FileWrite::new(test_backup_dir()).expect("Failed to create FileWrite");
742        let large_append = "y".repeat(2 * 1024 * 1024); // +2MB = 101MB > 100MB
743        let result = cap.execute(
744            &serde_json::json!({
745                "path": target.to_str().unwrap(),
746                "content": large_append,
747                "append": true
748            }),
749            &Context {
750                dry_run: false,
751                job_id: "t11".into(),
752                working_dir: std::env::temp_dir(),
753            },
754        );
755        assert!(result.is_err());
756        assert!(result.unwrap_err().to_string().contains("exceed"));
757
758        std::fs::remove_file(&target).ok();
759        std::fs::remove_dir_all(test_backup_dir()).ok();
760    }
761}