Skip to main content

diskforge_core/
cleaner.rs

1use std::fs;
2use std::io::ErrorKind;
3use std::path::Path;
4
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum CleanError {
9    #[error("path does not exist: {0}")]
10    NotFound(String),
11    #[error("permission denied: {0}")]
12    PermissionDenied(String),
13    #[error("io error removing {path}: {source}")]
14    Io {
15        path: String,
16        source: std::io::Error,
17    },
18}
19
20/// Result of a single deletion
21#[derive(Debug)]
22pub struct CleanedItem {
23    pub path: String,
24    pub size: u64,
25    pub success: bool,
26    pub error: Option<String>,
27}
28
29/// Run a shell command to clean (used for Docker, etc.)
30pub fn run_cleanup_command(command: &str, size: u64) -> CleanedItem {
31    let result = std::process::Command::new("sh")
32        .args(["-c", command])
33        .output();
34    match result {
35        Ok(output) if output.status.success() => CleanedItem {
36            path: command.to_string(),
37            size,
38            success: true,
39            error: None,
40        },
41        Ok(output) => CleanedItem {
42            path: command.to_string(),
43            size: 0,
44            success: false,
45            error: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()),
46        },
47        Err(e) => CleanedItem {
48            path: command.to_string(),
49            size: 0,
50            success: false,
51            error: Some(e.to_string()),
52        },
53    }
54}
55
56/// Delete a single file. On permission denied, retries with elevated privileges via macOS auth dialog.
57pub fn remove_file(path: &Path, size: u64) -> CleanedItem {
58    let path_str = path.to_string_lossy().to_string();
59
60    if !path.exists() {
61        return CleanedItem {
62            path: path_str,
63            size: 0,
64            success: false,
65            error: Some("path does not exist".into()),
66        };
67    }
68
69    match fs::remove_file(path) {
70        Ok(()) => CleanedItem {
71            path: path_str,
72            size,
73            success: true,
74            error: None,
75        },
76        Err(e) if e.kind() == ErrorKind::PermissionDenied => elevated_remove(&path_str, size),
77        Err(e) => CleanedItem {
78            path: path_str,
79            size: 0,
80            success: false,
81            error: Some(e.to_string()),
82        },
83    }
84}
85
86/// Delete a path — dispatches to `remove_file` or `remove_dir` based on path type
87pub fn remove_path(path: &Path, size: u64) -> CleanedItem {
88    if path.is_file() {
89        remove_file(path, size)
90    } else {
91        remove_dir(path, size)
92    }
93}
94
95/// Delete a directory and all its contents. On permission denied, retries with elevated privileges.
96pub fn remove_dir(path: &Path, size: u64) -> CleanedItem {
97    let path_str = path.to_string_lossy().to_string();
98
99    if !path.exists() {
100        return CleanedItem {
101            path: path_str,
102            size: 0,
103            success: false,
104            error: Some("path does not exist".into()),
105        };
106    }
107
108    match fs::remove_dir_all(path) {
109        Ok(()) => CleanedItem {
110            path: path_str,
111            size,
112            success: true,
113            error: None,
114        },
115        Err(e) if e.kind() == ErrorKind::PermissionDenied => elevated_remove(&path_str, size),
116        Err(e) => CleanedItem {
117            path: path_str,
118            size: 0,
119            success: false,
120            error: Some(e.to_string()),
121        },
122    }
123}
124
125/// Retry deletion with elevated privileges using macOS native auth dialog.
126/// Shows the system password prompt via `osascript`.
127fn elevated_remove(path: &str, size: u64) -> CleanedItem {
128    // Escape single quotes in the path for shell safety
129    let escaped = path.replace('\'', "'\\''");
130    let script = format!("do shell script \"rm -rf '{escaped}'\" with administrator privileges");
131
132    let result = std::process::Command::new("osascript")
133        .args(["-e", &script])
134        .output();
135
136    match result {
137        Ok(output) if output.status.success() => CleanedItem {
138            path: path.to_string(),
139            size,
140            success: true,
141            error: None,
142        },
143        Ok(output) => {
144            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
145            // User cancelled the auth dialog
146            let error = if stderr.contains("User canceled") || stderr.contains("-128") {
147                "permission denied (auth cancelled)".to_string()
148            } else {
149                format!("elevated removal failed: {stderr}")
150            };
151            CleanedItem {
152                path: path.to_string(),
153                size: 0,
154                success: false,
155                error: Some(error),
156            }
157        }
158        Err(e) => CleanedItem {
159            path: path.to_string(),
160            size: 0,
161            success: false,
162            error: Some(format!("failed to request privileges: {e}")),
163        },
164    }
165}