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