Skip to main content

atomcode_core/uninstall/
scan.rs

1//! Filesystem scan that classifies real on-disk paths into uninstall groups.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::Result;
7
8use super::paths::uninstall_manifest;
9use super::{Group, Item};
10
11#[derive(Debug)]
12pub struct Plan {
13    pub items: Vec<Item>,
14    pub binary_path: PathBuf,
15    pub atomcode_dir: PathBuf,
16}
17
18/// Walk the filesystem and produce a Plan. Missing paths are silently skipped.
19/// Order: Group::Binary first, then Credentials, then State.
20pub fn scan(binary_path: &Path, atomcode_dir: &Path) -> Result<Plan> {
21    let mut items = Vec::new();
22
23    // ---- Group::Binary ----
24    if binary_path.exists() {
25        items.push(item(Group::Binary, binary_path.to_path_buf(), "binary")?);
26    }
27    if let Some(dir) = binary_path.parent() {
28        // Self-update backup uses extension `.bak` appended (atomcode.bak / atomcode.exe.bak).
29        let bak_name = {
30            let mut s = binary_path.file_name().unwrap_or_default().to_os_string();
31            s.push(".bak");
32            s
33        };
34        let p = dir.join(&bak_name);
35        if p.exists() {
36            items.push(item(Group::Binary, p, "self-update backup")?);
37        }
38
39        for (name, note) in [
40            (".atomcode.rolling", "self-update rename slot"),
41            (".atomcode.download", "self-update partial download"),
42            (".atomcode.writable-probe", "self-update probe leftover"),
43        ] {
44            let p = dir.join(name);
45            if p.exists() {
46                items.push(item(Group::Binary, p, note)?);
47            }
48        }
49    }
50
51    // ---- Group::Credentials ----
52    let m = uninstall_manifest();
53    for fname in m.credential_files {
54        let p = atomcode_dir.join(fname);
55        if p.exists() {
56            items.push(item(Group::Credentials, p, fname)?);
57        }
58    }
59
60    // ---- Group::State ----
61    for fname in m.state_files {
62        let p = atomcode_dir.join(fname);
63        if p.exists() {
64            items.push(item(Group::State, p, fname)?);
65        }
66    }
67    for dname in m.state_dirs {
68        let p = atomcode_dir.join(dname);
69        if p.exists() {
70            items.push(item(Group::State, p, dname)?);
71        }
72    }
73    if atomcode_dir.exists() {
74        for entry in fs::read_dir(atomcode_dir)? {
75            let entry = entry?;
76            let name = entry.file_name();
77            let name_str = name.to_string_lossy();
78            for prefix in m.state_prefixes {
79                if name_str.starts_with(prefix) {
80                    items.push(item(Group::State, entry.path(), "notice marker")?);
81                    break;
82                }
83            }
84        }
85    }
86
87    Ok(Plan {
88        items,
89        binary_path: binary_path.to_path_buf(),
90        atomcode_dir: atomcode_dir.to_path_buf(),
91    })
92}
93
94fn item(group: Group, path: PathBuf, note: &'static str) -> Result<Item> {
95    let size = if path.is_dir() {
96        dir_size(&path).unwrap_or(0)
97    } else {
98        fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
99    };
100    let needs_privilege = needs_privilege_to_remove(&path);
101    Ok(Item {
102        group,
103        path,
104        size_bytes: size,
105        note,
106        needs_privilege,
107    })
108}
109
110fn dir_size(p: &Path) -> Result<u64> {
111    let mut total = 0u64;
112    for entry in fs::read_dir(p)? {
113        let entry = entry?;
114        let md = entry.metadata()?;
115        if md.is_dir() {
116            total = total.saturating_add(dir_size(&entry.path()).unwrap_or(0));
117        } else {
118            total = total.saturating_add(md.len());
119        }
120    }
121    Ok(total)
122}
123
124#[cfg(unix)]
125fn needs_privilege_to_remove(p: &Path) -> bool {
126    use std::os::unix::ffi::OsStrExt;
127    let parent = match p.parent() {
128        Some(parent) => parent,
129        None => return false,
130    };
131    let c_path = match std::ffi::CString::new(parent.as_os_str().as_bytes()) {
132        Ok(s) => s,
133        Err(_) => return true, // path contains an interior NUL — treat conservatively
134    };
135    // SAFETY: access(2) reads from c_path, which is a valid CString; no allocations.
136    unsafe { libc::access(c_path.as_ptr(), libc::W_OK) != 0 }
137}
138
139#[cfg(not(unix))]
140fn needs_privilege_to_remove(_p: &Path) -> bool {
141    false
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::fs;
148    use tempfile::TempDir;
149
150    fn make_fake_install(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
151        let bin_dir = tmp.path().join("bin");
152        fs::create_dir(&bin_dir).unwrap();
153        let exe = bin_dir.join("atomcode");
154        fs::write(&exe, b"\x7fELF......").unwrap();
155        // self-update artifacts
156        fs::write(bin_dir.join("atomcode.bak"), b"old").unwrap();
157        fs::write(bin_dir.join(".atomcode.rolling"), b"r").unwrap();
158
159        let data = tmp.path().join(".atomcode");
160        fs::create_dir(&data).unwrap();
161        fs::write(data.join("auth.toml"), b"k=1").unwrap();
162        fs::write(data.join("config.toml"), b"x=1").unwrap();
163        fs::write(data.join("history"), b"hi").unwrap();
164        fs::create_dir(data.join("plugins")).unwrap();
165        fs::write(data.join("plugins/.gitkeep"), b"").unwrap();
166        fs::create_dir(data.join("staged")).unwrap();
167        (exe, data)
168    }
169
170    #[test]
171    fn scan_finds_binary_and_artifacts() {
172        let tmp = TempDir::new().unwrap();
173        let (exe, data) = make_fake_install(&tmp);
174        let plan = scan(&exe, &data).unwrap();
175        let bin_paths: Vec<_> = plan
176            .items
177            .iter()
178            .filter(|i| i.group == Group::Binary)
179            .map(|i| i.path.clone())
180            .collect();
181        assert!(bin_paths.contains(&exe));
182        assert!(bin_paths.contains(&exe.with_file_name("atomcode.bak")));
183        assert!(bin_paths.contains(&exe.with_file_name(".atomcode.rolling")));
184    }
185
186    #[test]
187    fn scan_classifies_credentials_and_state() {
188        let tmp = TempDir::new().unwrap();
189        let (exe, data) = make_fake_install(&tmp);
190        let plan = scan(&exe, &data).unwrap();
191        let creds: Vec<_> = plan
192            .items
193            .iter()
194            .filter(|i| i.group == Group::Credentials)
195            .map(|i| i.path.clone())
196            .collect();
197        assert!(creds.contains(&data.join("auth.toml")));
198        assert!(creds.contains(&data.join("config.toml")));
199
200        let state: Vec<_> = plan
201            .items
202            .iter()
203            .filter(|i| i.group == Group::State)
204            .map(|i| i.path.clone())
205            .collect();
206        assert!(state.contains(&data.join("history")));
207        assert!(state.contains(&data.join("plugins")));
208        assert!(state.contains(&data.join("staged")));
209    }
210
211    #[test]
212    fn scan_skips_missing_files_silently() {
213        let tmp = TempDir::new().unwrap();
214        let exe = tmp.path().join("atomcode");
215        std::fs::write(&exe, b"x").unwrap();
216        let data = tmp.path().join("nonexistent");
217        let plan = scan(&exe, &data).unwrap();
218        // Only binary present (no .bak, no .rolling, no data dir).
219        assert_eq!(
220            plan.items
221                .iter()
222                .filter(|i| i.group == Group::Binary)
223                .count(),
224            1
225        );
226        assert_eq!(
227            plan.items
228                .iter()
229                .filter(|i| i.group != Group::Binary)
230                .count(),
231            0
232        );
233    }
234
235    #[test]
236    #[cfg(unix)]
237    fn needs_privilege_false_for_user_writable_tempdir() {
238        let tmp = TempDir::new().unwrap();
239        let p = tmp.path().join("file");
240        std::fs::write(&p, b"x").unwrap();
241        // Tempdir is user-writable, so we don't need privilege.
242        assert!(!needs_privilege_to_remove(&p));
243    }
244
245    #[test]
246    #[cfg(unix)]
247    fn needs_privilege_true_for_path_with_no_parent() {
248        // A path with no parent (e.g. just "/") returns false (no rm needed).
249        let p = std::path::Path::new("/");
250        // We don't expect needs_privilege to crash here.
251        let _ = needs_privilege_to_remove(p);
252    }
253
254    #[test]
255    fn scan_returns_items_in_group_order() {
256        let tmp = TempDir::new().unwrap();
257        let (exe, data) = make_fake_install(&tmp);
258        let plan = scan(&exe, &data).unwrap();
259        let groups: Vec<_> = plan.items.iter().map(|i| i.group).collect();
260        let first_cred = groups.iter().position(|g| *g == Group::Credentials);
261        let first_state = groups.iter().position(|g| *g == Group::State);
262        let last_bin = groups.iter().rposition(|g| *g == Group::Binary);
263        if let (Some(lb), Some(fc)) = (last_bin, first_cred) {
264            assert!(lb < fc);
265        }
266        if let (Some(fc), Some(fs)) = (first_cred, first_state) {
267            assert!(fc < fs);
268        }
269    }
270}