simple_fs/safer_remove/
safer_remove_impl.rs

1use crate::SPath;
2use crate::error::{Cause, PathAndCause};
3use crate::safer_remove::SaferRemoveOptions;
4use crate::{Error, Result};
5use std::fs;
6
7/// Safely deletes a directory if it passes safety checks.
8///
9/// Safety checks (based on options):
10/// - If `restrict_to_current_dir` is true, the directory path must be below the current directory
11/// - If `must_contain_any` is set, the path must contain at least one of the specified patterns
12/// - If `must_contain_all` is set, the path must contain all of the specified patterns
13///
14/// Returns Ok(true) if the directory was deleted, Ok(false) if it didn't exist.
15/// Returns an error if safety checks fail or deletion fails.
16pub fn safer_remove_dir<'a>(dir_path: &SPath, options: impl Into<SaferRemoveOptions<'a>>) -> Result<bool> {
17	let options = options.into();
18
19	// If path doesn't exist, just return false
20	if !dir_path.exists() {
21		return Ok(false);
22	}
23
24	check_path_for_deletion_safety::<true>(dir_path, &options)?;
25
26	// Perform the deletion
27	fs::remove_dir_all(dir_path.as_std_path()).map_err(|e| {
28		Error::DirNotSafeToRemove(PathAndCause {
29			path: dir_path.to_string(),
30			cause: Cause::Io(Box::new(e)),
31		})
32	})?;
33
34	Ok(true)
35}
36
37/// Safely deletes a file if it passes safety checks.
38///
39/// Safety checks (based on options):
40/// - If `restrict_to_current_dir` is true, the file path must be below the current directory
41/// - If `must_contain_any` is set, the path must contain at least one of the specified patterns
42/// - If `must_contain_all` is set, the path must contain all of the specified patterns
43///
44/// Returns Ok(true) if the file was deleted, Ok(false) if it didn't exist.
45/// Returns an error if safety checks fail or deletion fails.
46pub fn safer_remove_file<'a>(file_path: &SPath, options: impl Into<SaferRemoveOptions<'a>>) -> Result<bool> {
47	let options = options.into();
48
49	// If path doesn't exist, just return false
50	if !file_path.exists() {
51		return Ok(false);
52	}
53
54	check_path_for_deletion_safety::<false>(file_path, &options)?;
55
56	// Perform the deletion
57	fs::remove_file(file_path.as_std_path()).map_err(|e| {
58		Error::FileNotSafeToRemove(PathAndCause {
59			path: file_path.to_string(),
60			cause: Cause::Io(Box::new(e)),
61		})
62	})?;
63
64	Ok(true)
65}
66
67// region:    --- Support
68
69/// Performs safety checks before deletion based on the provided options:
70/// 1. If `restrict_to_current_dir` is true, path must be below the current working directory.
71/// 2. If `must_contain_any` is set, path must contain at least one of those patterns.
72/// 3. If `must_contain_all` is set, path must contain all of those patterns.
73///
74/// The const generic IS_DIR determines whether this is checking a directory (true) or file (false).
75fn check_path_for_deletion_safety<const IS_DIR: bool>(path: &SPath, options: &SaferRemoveOptions<'_>) -> Result<()> {
76	// Resolve the path to absolute
77	let resolved = path.canonicalize()?;
78	let resolved_str = resolved.as_str();
79	let path_str = path.as_str();
80
81	// -- Safety checks
82	let mut error_causes = Vec::new();
83
84	// Check that the path is below current directory (if enabled)
85	if options.restrict_to_current_dir {
86		let current_dir = std::env::current_dir().map_err(|e| {
87			let pac = PathAndCause {
88				path: path.to_string(),
89				cause: Cause::Io(Box::new(e)),
90			};
91			if IS_DIR {
92				Error::DirNotSafeToRemove(pac)
93			} else {
94				Error::FileNotSafeToRemove(pac)
95			}
96		})?;
97		let current_dir_path = SPath::from_std_path_buf(current_dir)?;
98		let current_resolved = current_dir_path.canonicalize()?;
99		let current_str = current_resolved.as_str();
100
101		if !resolved_str.starts_with(current_str) {
102			error_causes.push(format!("is not below current directory '{current_resolved}'"));
103		}
104	}
105
106	// Check must_contain_any
107	if let Some(patterns) = options.must_contain_any {
108		if patterns.is_empty() {
109			error_causes.push("must_contain_any cannot be an empty list (use None to disable)".to_string());
110		} else {
111			let has_any = patterns.iter().any(|s| path_str.contains(s));
112			if !has_any {
113				error_causes.push(format!("does not contain any of the required patterns: {patterns:?}"));
114			}
115		}
116	}
117
118	// Check must_contain_all
119	if let Some(patterns) = options.must_contain_all {
120		if patterns.is_empty() {
121			error_causes.push("must_contain_all cannot be an empty list (use None to disable)".to_string());
122		} else {
123			let missing: Vec<_> = patterns.iter().filter(|s| !path_str.contains(*s)).collect();
124			if !missing.is_empty() {
125				error_causes.push(format!("does not contain all required patterns, missing: {missing:?}"));
126			}
127		}
128	}
129
130	if !error_causes.is_empty() {
131		let cause_msg = format!("Safety check failed: {}", error_causes.join("; "));
132		let path_and_cause = PathAndCause {
133			path: path.to_string(),
134			cause: Cause::Custom(cause_msg),
135		};
136
137		if IS_DIR {
138			return Err(Error::DirNotSafeToRemove(path_and_cause));
139		} else {
140			return Err(Error::FileNotSafeToRemove(path_and_cause));
141		}
142	}
143
144	Ok(())
145}
146
147// endregion: --- Support