Skip to main content

ntfs_mac_core/
deps.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 kodephp contributors
3
4//! Dependency discovery: ntfs-3g, macFUSE / FUSE-T, diskutil, hdiutil,
5//! rsync, mkntfs/newfs_ntfs, ntfsfix, fsck_ntfs.
6//!
7//! Everything is optional at compile time — this module only reports
8//! what is actually present so the CLI can give actionable hints.
9
10use std::fmt;
11use std::path::PathBuf;
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{Error, Result};
17use crate::runner;
18
19/// Hard ceiling for one read-only system probe.
20///
21/// `sw_vers` normally answers in milliseconds, so five seconds is generous
22/// while still guaranteeing that [`report`] can never hang. Before this
23/// constant the probe ran through [`runner::RunOptions::default`], whose
24/// `timeout` is `None` — that sent `runner::run` down the unbounded
25/// `child.wait()` branch, so a wedged `launchd` turned a dependency check
26/// into an eternal spinner in the GUI.
27const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
28
29/// Which FUSE driver the user should install on macOS.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum FuseDriver {
33    /// `brew install --cask macfuse` (Intel + Apple Silicon, dext-based).
34    Macfuse,
35    /// `brew install --cask fuse-t` (Apple Silicon native, FUSE-T).
36    FuseT,
37    /// Let ntfs-3g decide.
38    #[default]
39    Auto,
40}
41
42impl From<&str> for FuseDriver {
43    fn from(s: &str) -> Self {
44        match s.to_ascii_lowercase().as_str() {
45            "macfuse" => Self::Macfuse,
46            "fuse-t" | "fuset" => Self::FuseT,
47            _ => Self::Auto,
48        }
49    }
50}
51
52impl FuseDriver {
53    /// Homebrew install hint.
54    pub fn brew_install_command(&self) -> String {
55        match self {
56            Self::Macfuse => "brew install --cask macfuse".into(),
57            Self::FuseT => "brew install --cask fuse-t".into(),
58            Self::Auto => "brew install --cask macfuse  # or fuse-t on Apple Silicon".into(),
59        }
60    }
61}
62
63/// Report for a single dependency.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DepStatus {
66    /// Display name.
67    pub name: String,
68    /// Whether it is present.
69    pub present: bool,
70    /// Location on disk, when present.
71    pub path: Option<PathBuf>,
72    /// Suggested fix command, when absent.
73    pub install_hint: Option<String>,
74}
75
76impl DepStatus {
77    fn missing(name: &str, hint: impl Into<String>) -> Self {
78        Self {
79            name: name.to_string(),
80            present: false,
81            path: None,
82            install_hint: Some(hint.into()),
83        }
84    }
85}
86
87/// Report for all dependencies.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DepReport {
90    /// Every probed dependency.
91    pub deps: Vec<DepStatus>,
92    /// Overall readiness: `true` only if every dep is present.
93    pub ready: bool,
94    /// Architecture detection (`x86_64` or `aarch64`).
95    pub arch: String,
96    /// macOS version string from `sw_vers -productVersion`.
97    pub macos_version: Option<String>,
98}
99
100impl fmt::Display for DepReport {
101    /// Human-readable summary. Used by `ntfs-mac doctor`.
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        writeln!(
104            f,
105            "Platform: macOS {} on {}",
106            self.macos_version.as_deref().unwrap_or("unknown"),
107            self.arch
108        )?;
109        writeln!(f, "\nDependencies:")?;
110        for d in &self.deps {
111            let icon = if d.present { "✓" } else { "✗" };
112            let loc = d
113                .path
114                .as_ref()
115                .map(|p| format!("  ({})", p.display()))
116                .unwrap_or_default();
117            writeln!(
118                f,
119                "  {} {:<20} {}",
120                icon,
121                d.name,
122                if d.present {
123                    loc
124                } else {
125                    format!(
126                        "missing — {}",
127                        d.install_hint.as_deref().unwrap_or("no hint")
128                    )
129                }
130            )?;
131        }
132        writeln!(f, "\nReady: {}\n", self.ready)?;
133        Ok(())
134    }
135}
136
137/// Probe all known dependencies. Does not fail — missing deps are
138/// reported, not raised. Only `diskutil` / `hdiutil` (missing = fatal
139/// system state) can produce an [`Error`].
140pub fn report() -> Result<DepReport> {
141    let arch = std::env::consts::ARCH.to_string();
142    let macos = shell_value("sw_vers", &["-productVersion"]);
143
144    let mut deps: Vec<DepStatus> = Vec::new();
145
146    // Core macOS tools — always present; missing here means the OS is
147    // misconfigured.
148    for (name, hint) in [
149        ("diskutil", "System tool — should be at /usr/sbin/diskutil"),
150        ("hdiutil", "System tool — should be at /usr/bin/hdiutil"),
151    ] {
152        deps.push(match runner::which(name) {
153            Ok(p) => DepStatus {
154                name: name.to_string(),
155                present: true,
156                path: Some(p),
157                install_hint: None,
158            },
159            Err(_) => DepStatus::missing(name, hint),
160        });
161    }
162
163    // ntfs-3g and its sub-tools.
164    for (name, hint) in [
165        (
166            "ntfs-3g",
167            "brew install ntfs-3g   (may also require `brew install --cask macfuse`)",
168        ),
169        ("newfs_ntfs", "ships with `brew install ntfs-3g`"),
170        (
171            "mkntfs",
172            "ships with `brew install ntfs-3g` (alias of newfs_ntfs)",
173        ),
174        ("ntfsfix", "ships with `brew install ntfs-3g`"),
175        ("fsck_ntfs", "ships with `brew install ntfs-3g`"),
176    ] {
177        deps.push(match runner::which(name) {
178            Ok(p) => DepStatus {
179                name: name.to_string(),
180                present: true,
181                path: Some(p),
182                install_hint: None,
183            },
184            Err(_) => DepStatus::missing(name, hint),
185        });
186    }
187
188    // rsync is highly recommended for `copy` (progress + rsync semantics).
189    deps.push(match runner::which("rsync") {
190        Ok(p) => DepStatus {
191            name: "rsync".to_string(),
192            present: true,
193            path: Some(p),
194            install_hint: None,
195        },
196        Err(_) => DepStatus::missing("rsync", "brew install rsync"),
197    });
198
199    // FUSE driver: macFUSE or FUSE-T. Presence is inferred from the
200    // filesystem driver directory.
201    let macfuse_path = PathBuf::from("/Library/Filesystems/macfuse.fs/Contents/Resources/ntfs");
202    let fuset_path = PathBuf::from("/Library/Filesystems/fuset.fs/Contents/Resources/ntfs");
203    let fuse_present = macfuse_path.exists() || fuset_path.exists();
204    deps.push(if fuse_present {
205        DepStatus {
206            name: "fuse-driver".to_string(),
207            present: true,
208            path: Some(if macfuse_path.exists() {
209                macfuse_path
210            } else {
211                fuset_path
212            }),
213            install_hint: None,
214        }
215    } else {
216        DepStatus::missing(
217            "fuse-driver",
218            "brew install --cask macfuse   (Intel + Apple Silicon)   OR   brew install --cask fuse-t   (Apple Silicon)",
219        )
220    });
221
222    let ready = deps.iter().all(|d| d.present);
223
224    Ok(DepReport {
225        deps,
226        ready,
227        arch,
228        macos_version: macos,
229    })
230}
231
232/// Small helper to capture the stdout of a read-only system probe.
233///
234/// Always bounded: see [`PROBE_TIMEOUT`]. A timeout degrades to `None`
235/// (`macos_version` simply stays unset) rather than blocking the caller.
236fn shell_value(cmd: &str, args: &[&str]) -> Option<String> {
237    use crate::runner::{RunOptions, run};
238    let opts = RunOptions {
239        timeout: Some(PROBE_TIMEOUT),
240        ..Default::default()
241    };
242    match run(cmd, args, &opts) {
243        Ok(out) if out.success() => Some(out.stdout.trim().to_string()),
244        _ => None,
245    }
246}
247
248/// Convenience: return [`Error::MissingDependency`] if the report is
249/// not ready. Used by CLI commands that need the full stack.
250pub fn require_ready() -> Result<()> {
251    let report = report()?;
252    if report.ready {
253        Ok(())
254    } else {
255        Err(Error::MissingDependency {
256            binary: "ntfs-mac dependencies".into(),
257            detail: report
258                .deps
259                .iter()
260                .filter(|d| !d.present)
261                .map(|d| d.name.clone())
262                .collect::<Vec<_>>()
263                .join(", "),
264            hint: Some(
265                "Run `ntfs-mac doctor` for details, or `./scripts/install.sh` to install.".into(),
266            ),
267            io: None,
268        })
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn fuse_driver_from_str_roundtrip() {
278        assert_eq!(FuseDriver::from("macfuse"), FuseDriver::Macfuse);
279        assert_eq!(FuseDriver::from("fuse-t"), FuseDriver::FuseT);
280        assert_eq!(FuseDriver::from("FUSE-T"), FuseDriver::FuseT);
281        assert_eq!(FuseDriver::from("auto"), FuseDriver::Auto);
282        assert_eq!(FuseDriver::from("??"), FuseDriver::Auto);
283    }
284
285    #[test]
286    fn dep_report_renders_without_panic() {
287        // The test machine may or may not have the deps; we only
288        // assert the rendering path is exercised.
289        let report = DepReport {
290            deps: vec![DepStatus::missing("ntfs-3g", "brew install ntfs-3g")],
291            ready: false,
292            arch: "aarch64".into(),
293            macos_version: Some("26.6.2".into()),
294        };
295        let text = report.to_string();
296        assert!(text.contains("ntfs-3g"));
297        assert!(text.contains("aarch64"));
298    }
299
300    #[test]
301    fn probe_timeout_is_bounded_for_the_ui() {
302        // Regression guard for the "永远检测中" defect: a probe that blocks
303        // this long would be unnoticeable to a user, but anything above a
304        // few seconds turns the window into a dead spinner.
305        assert!(
306            PROBE_TIMEOUT <= Duration::from_secs(10),
307            "a probe longer than 10s will look hung in the GUI"
308        );
309    }
310
311    #[test]
312    fn shell_value_degrades_gracefully() {
313        // A missing binary must return `None`, never panic and never block:
314        // this is the failure mode the GUI depends on to keep rendering.
315        assert_eq!(
316            shell_value("definitely-not-a-real-binary-ntfs-mac", &[]),
317            None
318        );
319    }
320}