Skip to main content

mtp_mount/daemon/
paths.rs

1//! Where the daemon puts its mounts, and what it calls each one.
2//!
3//! Mounts go under one root directory, one subdirectory per device, so a file
4//! manager or a script can watch a single predictable path and see devices come
5//! and go. `$XDG_RUNTIME_DIR/mtp` is that root: it's per-user, it's a tmpfs the
6//! session owns, and the OS clears it at logout, so a daemon that dies without
7//! cleaning up can't leave anything behind past the session.
8
9use std::path::{Path, PathBuf};
10
11use crate::error::MountError;
12use crate::spool::CacheConvention;
13
14/// Subdirectory of `$XDG_RUNTIME_DIR` that holds the mounts.
15pub const RUNTIME_SUBDIR: &str = "mtp";
16
17/// Longest device directory name the daemon will produce.
18///
19/// Serial numbers are short in practice; the cap is there so a device that
20/// reports something absurd can't push the mount path past `PATH_MAX`.
21const MAX_DIR_NAME: usize = 64;
22
23/// Resolve the directory that holds all the mounts.
24///
25/// Pure: every input is a parameter, so the fallback chain is testable without
26/// touching the real environment. Empty env values count as unset, per the XDG
27/// spec.
28///
29/// - `override_dir`: the `--mount-root` flag, wins over everything.
30/// - `xdg_runtime_dir`: `$XDG_RUNTIME_DIR`, the intended home for these.
31/// - `xdg_cache_home`, `home`, `convention`: the fallback, same convention as
32///   the spool dir (see [`crate::spool`]).
33///
34/// **Why the cache dir as the fallback, not `/tmp`.** `$XDG_RUNTIME_DIR` is
35/// unset outside a logind session (a plain SSH login, a container, macOS). A
36/// world-writable `/tmp/mtp` would be a trap: another user could pre-create the
37/// path. The user's own cache directory is private, already resolved elsewhere
38/// in this crate, and stable across reboots, which costs one stale-mount sweep
39/// at startup that the daemon does anyway.
40pub fn resolve_mount_root(
41    override_dir: Option<&Path>,
42    xdg_runtime_dir: Option<&str>,
43    xdg_cache_home: Option<&str>,
44    home: Option<&str>,
45    convention: CacheConvention,
46) -> Result<PathBuf, MountError> {
47    if let Some(dir) = override_dir {
48        return Ok(dir.to_path_buf());
49    }
50
51    fn non_empty(v: Option<&str>) -> Option<&str> {
52        v.filter(|s| !s.is_empty())
53    }
54
55    if let Some(runtime) = non_empty(xdg_runtime_dir) {
56        return Ok(Path::new(runtime).join(RUNTIME_SUBDIR));
57    }
58
59    let home = non_empty(home);
60    let base = match convention {
61        CacheConvention::MacOs => home
62            .map(|h| Path::new(h).join("Library").join("Caches"))
63            .ok_or_else(|| no_root_error("neither $XDG_RUNTIME_DIR nor $HOME is set"))?,
64        CacheConvention::Xdg => match non_empty(xdg_cache_home) {
65            Some(cache) => PathBuf::from(cache),
66            None => home.map(|h| Path::new(h).join(".cache")).ok_or_else(|| {
67                no_root_error("none of $XDG_RUNTIME_DIR, $XDG_CACHE_HOME, or $HOME is set")
68            })?,
69        },
70    };
71
72    Ok(base.join("mtp-mount").join("mounts"))
73}
74
75fn no_root_error(what: &str) -> MountError {
76    MountError::Other(format!(
77        "can't find a directory to mount devices under: {what}. \
78         Pass --mount-root to say where mounts should appear."
79    ))
80}
81
82/// Resolve the mount root from the live environment.
83pub fn mount_root_from_env(override_dir: Option<&Path>) -> Result<PathBuf, MountError> {
84    let runtime = std::env::var("XDG_RUNTIME_DIR").ok();
85    let cache = std::env::var("XDG_CACHE_HOME").ok();
86    let home = std::env::var("HOME").ok();
87    resolve_mount_root(
88        override_dir,
89        runtime.as_deref(),
90        cache.as_deref(),
91        home.as_deref(),
92        CacheConvention::current(),
93    )
94}
95
96/// The directory name a device gets under the mount root.
97///
98/// The serial number when the device reports one, because that's what people
99/// recognize and what stays the same across ports and reboots. Devices that
100/// report no serial (plenty of cameras, some cheap players) fall back to
101/// `usb-<vendor>-<product>-<location>`, which is deterministic for as long as
102/// the device stays in the same port: unplug it and move it, and it comes back
103/// under a different name. That's the honest answer, since without a serial
104/// there's nothing else that survives a replug.
105///
106/// The name doubles as the device's identity for the daemon, so two devices
107/// that report the *same* serial collide. That's rare and comes from broken
108/// firmware; the supervisor logs it and leaves the first one mounted rather
109/// than mounting the second over it.
110///
111/// Anything outside `[A-Za-z0-9._-]` becomes `_`, and a name that's only dots
112/// falls back to the USB form: a serial is device-controlled input, and it ends
113/// up as a path component.
114pub fn device_dir_name(
115    serial: Option<&str>,
116    vendor_id: u16,
117    product_id: u16,
118    location_id: u64,
119) -> String {
120    let usb_form = || format!("usb-{vendor_id:04x}-{product_id:04x}-{location_id}");
121
122    let Some(serial) = serial.map(str::trim).filter(|s| !s.is_empty()) else {
123        return usb_form();
124    };
125
126    let sanitized: String = serial
127        .chars()
128        .take(MAX_DIR_NAME)
129        .map(|c| {
130            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
131                c
132            } else {
133                '_'
134            }
135        })
136        .collect();
137
138    if sanitized.chars().all(|c| c == '.') {
139        return usb_form();
140    }
141    sanitized
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn runtime_dir_wins_when_set() {
150        let root = resolve_mount_root(
151            None,
152            Some("/run/user/1000"),
153            Some("/cache"),
154            Some("/home/dave"),
155            CacheConvention::Xdg,
156        )
157        .unwrap();
158        assert_eq!(root, PathBuf::from("/run/user/1000/mtp"));
159    }
160
161    #[test]
162    fn override_wins_over_the_runtime_dir() {
163        let root = resolve_mount_root(
164            Some(Path::new("/mnt/phones")),
165            Some("/run/user/1000"),
166            None,
167            None,
168            CacheConvention::Xdg,
169        )
170        .unwrap();
171        assert_eq!(root, PathBuf::from("/mnt/phones"));
172    }
173
174    #[test]
175    fn no_runtime_dir_falls_back_to_the_cache_dir_never_tmp() {
176        let root = resolve_mount_root(
177            None,
178            None,
179            Some("/cache"),
180            Some("/home/dave"),
181            CacheConvention::Xdg,
182        )
183        .unwrap();
184        assert_eq!(root, PathBuf::from("/cache/mtp-mount/mounts"));
185
186        let root = resolve_mount_root(
187            None,
188            Some(""),
189            None,
190            Some("/home/dave"),
191            CacheConvention::Xdg,
192        )
193        .unwrap();
194        assert_eq!(root, PathBuf::from("/home/dave/.cache/mtp-mount/mounts"));
195
196        let root = resolve_mount_root(
197            None,
198            None,
199            Some("/cache"),
200            Some("/Users/dave"),
201            CacheConvention::MacOs,
202        )
203        .unwrap();
204        assert_eq!(
205            root,
206            PathBuf::from("/Users/dave/Library/Caches/mtp-mount/mounts")
207        );
208    }
209
210    #[test]
211    fn nothing_to_go_on_names_the_flag() {
212        let err = resolve_mount_root(None, None, None, None, CacheConvention::Xdg)
213            .unwrap_err()
214            .to_string();
215        assert!(err.contains("--mount-root"), "{err}");
216    }
217
218    #[test]
219    fn serial_names_the_directory() {
220        assert_eq!(
221            device_dir_name(Some("2A31FDH200ABC"), 0x18d1, 0x4ee1, 3),
222            "2A31FDH200ABC"
223        );
224        assert_eq!(
225            device_dir_name(Some("  ABC123  "), 0x18d1, 0x4ee1, 3),
226            "ABC123"
227        );
228    }
229
230    #[test]
231    fn no_serial_falls_back_to_the_usb_address() {
232        assert_eq!(device_dir_name(None, 0x18d1, 0x4ee1, 3), "usb-18d1-4ee1-3");
233        assert_eq!(
234            device_dir_name(Some("   "), 0x04e8, 0x6860, 42),
235            "usb-04e8-6860-42"
236        );
237    }
238
239    #[test]
240    fn a_serial_can_never_escape_the_mount_root() {
241        // A device-controlled string ends up as a path component, so anything
242        // that could climb out of the root has to be neutered.
243        assert_eq!(device_dir_name(Some("../../etc"), 0x1, 0x2, 3), ".._.._etc");
244        assert_eq!(device_dir_name(Some("a/b"), 0x1, 0x2, 3), "a_b");
245        assert_eq!(device_dir_name(Some(".."), 0x1, 0x2, 3), "usb-0001-0002-3");
246        assert_eq!(device_dir_name(Some("."), 0x1, 0x2, 3), "usb-0001-0002-3");
247    }
248
249    #[test]
250    fn an_absurd_serial_is_truncated() {
251        let name = device_dir_name(Some(&"x".repeat(500)), 0x1, 0x2, 3);
252        assert_eq!(name.len(), MAX_DIR_NAME);
253    }
254}