1use std::path::{Path, PathBuf};
10
11use crate::error::MountError;
12use crate::spool::CacheConvention;
13
14pub const RUNTIME_SUBDIR: &str = "mtp";
16
17const MAX_DIR_NAME: usize = 64;
22
23pub 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
82pub 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
96pub 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 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}