Skip to main content

hard_sync_core/
drive.rs

1use std::path::{Path, PathBuf};
2
3use sysinfo::Disks;
4
5use crate::config::DriveId;
6
7// ── Public types ──────────────────────────────────────────────────────────────
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct ConnectedDrive {
11    pub name: String,
12    pub mount_point: PathBuf,
13    pub is_removable: bool,
14    pub total_space: u64,
15    pub available_space: u64,
16}
17
18// ── Public API ────────────────────────────────────────────────────────────────
19
20/// List all currently connected drives on the system.
21pub fn list_connected_drives() -> Vec<ConnectedDrive> {
22    let disks = Disks::new_with_refreshed_list();
23    disks
24        .iter()
25        .map(|d| ConnectedDrive {
26            name: d.name().to_string_lossy().to_string(),
27            mount_point: d.mount_point().to_path_buf(),
28            is_removable: d.is_removable(),
29            total_space: d.total_space(),
30            available_space: d.available_space(),
31        })
32        .collect()
33}
34
35/// Returns true if both paths live on the same physical drive / mount point.
36/// Used on init to decide whether drive_id should be stored.
37pub fn same_drive(a: &Path, b: &Path) -> bool {
38    let mount_a = find_mount_point(a);
39    let mount_b = find_mount_point(b);
40    match (mount_a, mount_b) {
41        (Some(ma), Some(mb)) => ma == mb,
42        // Fallback: compare root prefixes (drive letter on Windows, "/" on Linux)
43        _ => root_of(a) == root_of(b),
44    }
45}
46
47/// Given a path, detect the drive it lives on and return its DriveId.
48/// Returns None if the drive cannot be identified (e.g. network share).
49pub fn get_drive_id(path: &Path) -> Option<DriveId> {
50    let disks = Disks::new_with_refreshed_list();
51    let canonical = strip_unc_prefix(&path.canonicalize().ok()?);
52
53    let disk = disks
54        .iter()
55        .filter(|d| canonical.starts_with(d.mount_point()))
56        .max_by_key(|d| d.mount_point().as_os_str().len())?;
57
58    let raw_name = disk.name().to_string_lossy().to_string();
59    let label = if raw_name.is_empty() { None } else { Some(raw_name) };
60    let uuid = get_volume_uuid(disk.mount_point());
61
62    // If we can't identify the drive at all, don't store a DriveId
63    if label.is_none() && uuid.is_none() {
64        return None;
65    }
66
67    Some(DriveId { label, uuid })
68}
69
70/// Poll currently mounted drives and return the mount point of the first drive
71/// that matches the stored DriveId (by label OR uuid).
72/// Called in watch mode for cross-drive pairs.
73pub fn find_mounted_drive(id: &DriveId) -> Option<PathBuf> {
74    let disks = Disks::new_with_refreshed_list();
75
76    for disk in disks.iter() {
77        let name = disk.name().to_string_lossy().to_string();
78        let disk_uuid = get_volume_uuid(disk.mount_point());
79
80        let label_match = id
81            .label
82            .as_ref()
83            .map(|l| !l.is_empty() && l == &name)
84            .unwrap_or(false);
85
86        let uuid_match = id
87            .uuid
88            .as_ref()
89            .zip(disk_uuid.as_ref())
90            .map(|(a, b)| a == b)
91            .unwrap_or(false);
92
93        if label_match || uuid_match {
94            return Some(disk.mount_point().to_path_buf());
95        }
96    }
97
98    None
99}
100
101// ── Internal helpers ──────────────────────────────────────────────────────────
102
103fn find_mount_point(path: &Path) -> Option<PathBuf> {
104    let canonical = path.canonicalize().ok()?;
105    // On Windows, canonicalize() prepends \\?\ which breaks starts_with()
106    // comparisons against plain drive-letter paths returned by sysinfo.
107    let canonical = strip_unc_prefix(&canonical);
108    let disks = Disks::new_with_refreshed_list();
109    disks
110        .iter()
111        .filter(|d| canonical.starts_with(d.mount_point()))
112        .max_by_key(|d| d.mount_point().as_os_str().len())
113        .map(|d| d.mount_point().to_path_buf())
114}
115
116/// Strip the Windows extended-length path prefix `\\?\` if present.
117/// On other platforms this is a no-op.
118fn strip_unc_prefix(path: &Path) -> PathBuf {
119    #[cfg(target_os = "windows")]
120    {
121        let s = path.to_string_lossy();
122        if let Some(rest) = s.strip_prefix(r"\\?\") {
123            return PathBuf::from(rest);
124        }
125    }
126    path.to_path_buf()
127}
128
129fn root_of(path: &Path) -> Option<std::path::Component<'_>> {
130    path.components().next()
131}
132
133// ── OS-specific UUID extraction ───────────────────────────────────────────────
134//
135// v1: best-effort UUID detection per platform.
136// Label-based matching is the primary mechanism and works on all platforms.
137// UUID adds disambiguation when two drives have the same label.
138//
139// Windows: volume GUID via GetVolumeNameForVolumeMountPointW
140// Linux:   symlink resolution in /dev/disk/by-uuid/
141// Other:   None (label-only matching)
142
143#[cfg(target_os = "windows")]
144fn get_volume_uuid(mount_point: &Path) -> Option<String> {
145    use std::ffi::OsString;
146    use std::os::windows::ffi::{OsStrExt, OsStringExt};
147
148    let mut mount_wide: Vec<u16> = mount_point.as_os_str().encode_wide().collect();
149    // Must end with backslash
150    if mount_wide.last() != Some(&(b'\\' as u16)) {
151        mount_wide.push(b'\\' as u16);
152    }
153    mount_wide.push(0); // null terminator
154
155    let mut guid_buf = vec![0u16; 64];
156
157    let ok = unsafe {
158        windows_sys::Win32::Storage::FileSystem::GetVolumeNameForVolumeMountPointW(
159            mount_wide.as_ptr(),
160            guid_buf.as_mut_ptr(),
161            guid_buf.len() as u32,
162        )
163    };
164
165    if ok == 0 {
166        return None;
167    }
168
169    let end = guid_buf.iter().position(|&c| c == 0).unwrap_or(guid_buf.len());
170    let raw = OsString::from_wide(&guid_buf[..end])
171        .to_string_lossy()
172        .to_string();
173
174    // Format: \\?\Volume{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}\
175    // Extract the GUID portion only
176    let guid = raw
177        .trim_start_matches(r"\\?\Volume{")
178        .trim_end_matches(r"}\")
179        .to_string();
180
181    if guid.is_empty() || guid == raw {
182        None
183    } else {
184        Some(guid)
185    }
186}
187
188#[cfg(target_os = "linux")]
189fn get_volume_uuid(mount_point: &Path) -> Option<String> {
190    let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
191    let mount_str = mount_point.to_string_lossy();
192
193    // Find the device path for this mount point
194    let device = mounts.lines().find_map(|line| {
195        let mut parts = line.split_whitespace();
196        let dev = parts.next()?;
197        let mp = parts.next()?;
198        if mp == mount_str.as_ref() {
199            Some(dev.to_string())
200        } else {
201            None
202        }
203    })?;
204
205    // Resolve UUID symlinks in /dev/disk/by-uuid/
206    let by_uuid = Path::new("/dev/disk/by-uuid");
207    if !by_uuid.exists() {
208        return None;
209    }
210
211    for entry in std::fs::read_dir(by_uuid).ok()?.flatten() {
212        if let Ok(link) = std::fs::read_link(entry.path()) {
213            let resolved = by_uuid.join(&link);
214            if let Ok(canonical) = resolved.canonicalize() {
215                if canonical == Path::new(&device) {
216                    return Some(entry.file_name().to_string_lossy().to_string());
217                }
218            }
219        }
220    }
221
222    None
223}
224
225#[cfg(not(any(target_os = "windows", target_os = "linux")))]
226fn get_volume_uuid(_mount_point: &Path) -> Option<String> {
227    None
228}
229
230// ── Tests ─────────────────────────────────────────────────────────────────────
231
232#[cfg(test)]
233mod tests {
234    use super::strip_unc_prefix;
235    use std::path::{Path, PathBuf};
236
237    #[test]
238    #[cfg(target_os = "windows")]
239    fn strip_unc_removes_windows_prefix() {
240        assert_eq!(
241            strip_unc_prefix(Path::new(r"\\?\C:\foo\bar")),
242            PathBuf::from(r"C:\foo\bar")
243        );
244        assert_eq!(
245            strip_unc_prefix(Path::new(r"\\?\E:\hard-sync-target")),
246            PathBuf::from(r"E:\hard-sync-target")
247        );
248    }
249
250    #[test]
251    #[cfg(target_os = "windows")]
252    fn strip_unc_is_noop_for_plain_paths() {
253        assert_eq!(
254            strip_unc_prefix(Path::new(r"C:\foo\bar")),
255            PathBuf::from(r"C:\foo\bar")
256        );
257    }
258
259    #[test]
260    #[cfg(not(target_os = "windows"))]
261    fn strip_unc_is_noop_on_non_windows() {
262        assert_eq!(
263            strip_unc_prefix(Path::new("/home/user/foo")),
264            PathBuf::from("/home/user/foo")
265        );
266    }
267}