Skip to main content

phemios_core/
volumes.rs

1//! Read-only mounted destinations; discovery never mounts, probes by writing, or copies files.
2use serde_json::{Value, json};
3use std::io::Read;
4#[cfg(any(target_os = "macos", windows))]
5use std::process::{Command, Stdio};
6#[cfg(any(target_os = "macos", windows))]
7use std::time::{Duration, Instant};
8#[cfg(any(target_os = "macos", test))]
9#[path = "volumes_ipod.rs"]
10mod ipod;
11#[cfg(target_os = "macos")]
12#[path = "volumes_macos.rs"]
13mod macos;
14#[cfg(any(target_os = "macos", test))]
15#[path = "volumes_plist.rs"]
16mod plist;
17/// An OS mount candidate, not a certified music-player model or conversion profile.
18#[derive(Debug, PartialEq, Eq)]
19pub struct Volume {
20    /// Display name derived from the mount path.
21    pub name: String,
22    /// OS-mounted absolute root.
23    pub path: String,
24    /// OS filesystem label.
25    pub format: String,
26    /// Mounted volume, removable drive or network share.
27    pub kind: String,
28    /// Optional model established by exact OS USB-to-volume association.
29    pub model: Option<&'static str>,
30    /// Local outline icon; generic drive when the model is unknown.
31    pub icon: &'static str,
32    /// Known podkit compatibility; unknown is not a positive compatibility claim.
33    pub ipod_supported: Option<bool>,
34    /// The native OS can safely eject this mounted device.
35    pub ejectable: bool,
36    #[cfg(target_os = "macos")]
37    device: Option<String>,
38}
39impl Volume {
40    /// Only public destination facts cross IPC.
41    pub fn json(&self) -> Value {
42        json!({"name":self.name,"path":self.path,"format":self.format,"kind":self.kind,"model":self.model,"icon":self.icon,"ipodSupported":self.ipod_supported,"ejectable":self.ejectable})
43    }
44    /// Revalidate OS identity and a caller's captured mount before requesting non-forced ejection.
45    /// Unsupported platforms fail explicitly; no arbitrary command or browser-provided path is admitted.
46    pub fn eject(&self, confirm: impl Fn() -> Result<(), String>) -> Result<(), String> {
47        #[cfg(target_os = "macos")]
48        {
49            macos::eject(self, confirm)
50        }
51        #[cfg(not(target_os = "macos"))]
52        {
53            let _ = confirm;
54            Err("Éjectez ce lecteur depuis votre système d’exploitation.".into())
55        }
56    }
57}
58fn volume(path: &str, format: &str, kind: &str) -> Volume {
59    Volume {
60        name: std::path::Path::new(path)
61            .file_name()
62            .and_then(|name| name.to_str())
63            .unwrap_or(path)
64            .to_owned(),
65        path: path.into(),
66        format: format.into(),
67        kind: kind.into(),
68        model: None,
69        icon: "drive",
70        ipod_supported: None,
71        ejectable: false,
72        #[cfg(target_os = "macos")]
73        device: None,
74    }
75}
76#[cfg(any(target_os = "macos", target_os = "linux", test))]
77fn unescape(path: &str) -> String {
78    path.replace("\\040", " ")
79        .replace("\\011", "\t")
80        .replace("\\012", "\n")
81        .replace("\\134", "\\")
82}
83#[cfg(any(target_os = "macos", target_os = "linux", test))]
84fn parse_mounts(text: &str, mac: bool) -> Vec<Volume> {
85    let mut rows = Vec::new();
86    for line in text.lines() {
87        let parsed = if mac {
88            line.split_once(" on ")
89                .and_then(|(_, tail)| tail.rsplit_once(" ("))
90                .map(|(path, flags)| {
91                    (
92                        path,
93                        flags.split(',').next().unwrap_or("").trim_end_matches(')'),
94                    )
95                })
96        } else {
97            line.split_once(" - ").and_then(|(fields, tail)| {
98                Some((
99                    fields.split_whitespace().nth(4)?,
100                    tail.split_whitespace().next()?,
101                ))
102            })
103        };
104        let Some((path, format)) = parsed else {
105            continue;
106        };
107        let path = unescape(path);
108        let network = matches!(
109            format,
110            "smbfs" | "cifs" | "nfs" | "nfs4" | "sshfs" | "fuse.sshfs"
111        );
112        let media = if mac {
113            path.starts_with("/Volumes/")
114        } else {
115            ["/media/", "/run/media/", "/mnt/"]
116                .iter()
117                .any(|root| path.starts_with(root))
118        };
119        if path != "/"
120            && path.starts_with('/')
121            && (media || network)
122            && !rows.iter().any(|row: &Volume| row.path == path)
123        {
124            rows.push(volume(
125                &path,
126                format,
127                if network { "network" } else { "volume" },
128            ));
129        }
130    }
131    rows
132}
133#[cfg(any(windows, test))]
134fn parse_windows(bytes: &[u8]) -> Result<Vec<Volume>, String> {
135    let rows: Vec<Value> =
136        serde_json::from_slice(bytes).map_err(|_| "Détection des lecteurs illisible.")?;
137    rows.into_iter()
138        .map(|row| {
139            let path = row["path"].as_str().ok_or("Lecteur invalide.")?;
140            let format = row["format"].as_str().ok_or("Lecteur invalide.")?;
141            let kind = row["kind"].as_str().ok_or("Lecteur invalide.")?;
142            if path.len() != 3
143                || !path.as_bytes()[0].is_ascii_alphabetic()
144                || &path[1..] != ":\\"
145                || !matches!(kind, "removable" | "network" | "volume")
146            {
147                return Err("Lecteur invalide.".into());
148            }
149            Ok(volume(path, format, kind))
150        })
151        .collect()
152}
153#[cfg(any(target_os = "macos", windows))]
154fn output(program: &str, args: &[&str], timeout: Duration) -> Result<Vec<u8>, String> {
155    let mut child = Command::new(program)
156        .args(args)
157        .stdout(Stdio::piped())
158        .stderr(Stdio::null())
159        .spawn()
160        .map_err(|_| "Détection des lecteurs indisponible.")?;
161    let stdout = child
162        .stdout
163        .take()
164        .ok_or("Détection des lecteurs indisponible.")?;
165    let reader = std::thread::spawn(move || {
166        let mut bytes = Vec::new();
167        stdout.take(262145).read_to_end(&mut bytes).map(|_| bytes)
168    });
169    let deadline = Instant::now() + timeout;
170    let status = loop {
171        if let Some(status) = child
172            .try_wait()
173            .map_err(|_| "Détection des lecteurs indisponible.")?
174        {
175            break status;
176        }
177        if Instant::now() >= deadline {
178            let _ = child.kill();
179            let _ = child.wait();
180            return Err("La détection des lecteurs a expiré.".into());
181        }
182        std::thread::sleep(Duration::from_millis(10));
183    };
184    let bytes = reader
185        .join()
186        .map_err(|_| "Détection des lecteurs interrompue.")?
187        .map_err(|_| "Détection des lecteurs illisible.")?;
188    if !status.success() || bytes.len() > 262144 {
189        return Err("Détection des lecteurs illisible.".into());
190    }
191    Ok(bytes)
192}
193/// Discover current mounted candidates through fixed OS commands, with no client-supplied paths or arguments.
194pub fn discover() -> Result<Vec<Volume>, String> {
195    #[cfg(target_os = "macos")]
196    {
197        let deadline = Instant::now() + Duration::from_secs(3);
198        let bytes = output("/sbin/mount", &[], Duration::from_secs(3))?;
199        let candidates = parse_mounts(
200            std::str::from_utf8(&bytes).map_err(|_| "Détection des lecteurs illisible.")?,
201            true,
202        );
203        let usb = macos::usb_tree();
204        let mut rows = Vec::new();
205        for mut row in candidates {
206            if row.kind == "network" {
207                rows.push(row);
208                continue;
209            }
210            let remaining = deadline.saturating_duration_since(Instant::now());
211            if remaining.is_zero() {
212                return Err("La détection des lecteurs a expiré.".into());
213            }
214            if macos::decorate(&mut row, usb.as_ref(), remaining)? {
215                rows.push(row);
216            }
217        }
218        Ok(rows)
219    }
220    #[cfg(target_os = "linux")]
221    {
222        let file = std::fs::File::open("/proc/self/mountinfo")
223            .map_err(|_| "Détection des lecteurs indisponible.")?;
224        let mut text = String::new();
225        file.take(262145)
226            .read_to_string(&mut text)
227            .map_err(|_| "Détection des lecteurs illisible.")?;
228        if text.len() > 262144 {
229            return Err("Détection des lecteurs illisible.".into());
230        }
231        Ok(parse_mounts(&text, false))
232    }
233    #[cfg(windows)]
234    {
235        let bytes = output(
236            "powershell.exe",
237            &[
238                "-NoProfile",
239                "-NonInteractive",
240                "-Command",
241                "ConvertTo-Json -Compress -InputObject @(Get-CimInstance Win32_LogicalDisk | Where-Object { $_.DriveType -in 2,4 -or ($_.DriveType -eq 3 -and $_.DeviceID -ne $env:SystemDrive) } | ForEach-Object { @{ path=($_.DeviceID+'\\'); kind=$(if($_.DriveType -eq 4){'network'}elseif($_.DriveType -eq 2){'removable'}else{'volume'}); format=[string]$_.FileSystem } })",
242            ],
243            Duration::from_secs(3),
244        )?;
245        parse_windows(&bytes)
246    }
247    #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
248    {
249        Err("Détection des lecteurs indisponible sur cette plateforme.".into())
250    }
251}
252#[cfg(test)]
253#[path = "volumes_tests.rs"]
254mod tests;