Skip to main content

runtimo_core/capabilities/
file_write.rs

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