1use std::collections::HashMap;
38use std::io::Write;
39use std::path::PathBuf;
40use std::sync::mpsc::Receiver;
41
42use crate::daemon::paths::device_dir_name;
43use crate::daemon::supervisor::DeviceIdent;
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct DeviceFacts {
52 pub serial: Option<String>,
54 pub vendor_id: u16,
56 pub product_id: u16,
58 pub location_id: u64,
60 pub manufacturer: Option<String>,
62 pub product: Option<String>,
64 pub speed: Option<String>,
66 pub match_reason: Option<String>,
68 pub label: String,
70}
71
72impl DeviceFacts {
73 fn usable_serial(&self) -> Option<&str> {
75 self.serial
76 .as_deref()
77 .map(str::trim)
78 .filter(|s| !s.is_empty())
79 }
80
81 #[must_use]
86 pub fn key(&self) -> String {
87 device_dir_name(
88 self.usable_serial(),
89 self.vendor_id,
90 self.product_id,
91 self.location_id,
92 )
93 }
94
95 #[must_use]
102 pub fn ident(&self) -> DeviceIdent {
103 DeviceIdent {
104 key: self.key(),
105 label: self.headline(),
106 serial: self.usable_serial().map(str::to_string),
107 }
108 }
109
110 fn headline(&self) -> String {
113 if self.label.is_empty() {
114 format!("MTP device {}", self.key())
115 } else {
116 self.label.clone()
117 }
118 }
119}
120
121#[derive(Debug, Clone)]
123pub enum DryRunCommand {
124 Arrived(DeviceFacts),
126 Left(DeviceFacts),
128 Stop(String),
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum DepartureVerdict {
135 Matched(PathBuf),
137 WouldLeak(Vec<PathBuf>),
140 NothingToUnmount,
143}
144
145#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147pub struct Tally {
148 pub arrivals: usize,
150 pub departures: usize,
152 pub matched: usize,
154 pub unmatched: usize,
156}
157
158pub struct DryRun {
160 mount_root: PathBuf,
161 open: HashMap<String, PathBuf>,
163 arrived_keys: Vec<String>,
165 tally: Tally,
166 events: usize,
167 out: Box<dyn Write + Send>,
168}
169
170const FIELD: usize = 16;
172
173impl DryRun {
174 #[must_use]
176 pub fn new(mount_root: PathBuf) -> Self {
177 Self::with_output(mount_root, Box::new(std::io::stdout()))
178 }
179
180 #[must_use]
182 pub fn with_output(mount_root: PathBuf, out: Box<dyn Write + Send>) -> Self {
183 Self {
184 mount_root,
185 open: HashMap::new(),
186 arrived_keys: Vec::new(),
187 tally: Tally::default(),
188 events: 0,
189 out,
190 }
191 }
192
193 #[must_use]
195 pub fn tally(&self) -> Tally {
196 self.tally
197 }
198
199 #[must_use]
201 pub fn open_keys(&self) -> Vec<String> {
202 let mut keys: Vec<String> = self.open.keys().cloned().collect();
203 keys.sort();
204 keys
205 }
206
207 pub fn run(mut self, commands: Receiver<DryRunCommand>) {
213 self.banner();
214 let reason = loop {
215 match commands.recv() {
216 Ok(DryRunCommand::Arrived(facts)) => self.arrived(&facts),
217 Ok(DryRunCommand::Left(facts)) => {
218 self.left(&facts);
219 }
220 Ok(DryRunCommand::Stop(reason)) => break reason,
221 Err(_) => break "the device watch stopped".to_string(),
222 }
223 };
224 self.summary(&reason);
225 }
226
227 pub fn banner(&mut self) {
229 let root = self.mount_root.display().to_string();
230 self.line("");
231 self.line("=== mtp-mountd --dry-run: nothing will be mounted ===");
232 self.line(&format!("Mount root it would use: {root}"));
233 self.line("Plug a device in, wait for it to appear, then unplug it.");
234 self.line("Every departure should say MATCHES. Stop with Ctrl-C.");
235 self.line("");
236 }
237
238 pub fn arrived(&mut self, facts: &DeviceFacts) {
240 let key = facts.key();
241 let path = self.mount_root.join(&key);
242
243 self.tally.arrivals += 1;
244 self.event_header("PLUGGED IN", facts);
245 self.facts(facts, true);
246 self.field("mount key", &key);
247 self.field("would mount at", &path.display().to_string());
248
249 if let Some(existing) = self.open.get(&key) {
250 let existing = existing.display().to_string();
251 self.line(&format!(
252 " Note: {key} is already down as arrived (at {existing}). The daemon \
253 would keep the first mount and ignore this one."
254 ));
255 } else {
256 self.open.insert(key.clone(), path);
257 }
258 if !self.arrived_keys.contains(&key) {
259 self.arrived_keys.push(key);
260 }
261 self.running_total();
262 }
263
264 pub fn left(&mut self, facts: &DeviceFacts) -> DepartureVerdict {
267 let key = facts.key();
268
269 self.tally.departures += 1;
270 self.event_header("UNPLUGGED", facts);
271 self.facts(facts, false);
272 self.field("mount key", &key);
273
274 let verdict = match self.open.remove(&key) {
275 Some(path) => DepartureVerdict::Matched(path),
276 None if self.open.is_empty() => DepartureVerdict::NothingToUnmount,
277 None => {
278 let mut leaked: Vec<PathBuf> = self.open.values().cloned().collect();
279 leaked.sort();
280 DepartureVerdict::WouldLeak(leaked)
281 }
282 };
283
284 match &verdict {
285 DepartureVerdict::Matched(path) => {
286 self.tally.matched += 1;
287 self.line(&format!(
288 ">>> MATCHES the arrival: the daemon would unmount {} <<<",
289 path.display()
290 ));
291 }
292 DepartureVerdict::WouldLeak(leaked) => {
293 self.tally.unmatched += 1;
294 self.line("!!!");
295 self.line(&format!(
296 "!!! NO MATCH - nothing arrived with the key {key}, so this departure",
297 ));
298 self.line("!!! would take nothing down. These mounts would leak:");
299 for path in leaked {
300 self.line(&format!("!!! {}", path.display()));
301 }
302 self.line(&format!(
303 "!!! Keys seen arriving: {}",
304 self.arrived_keys.join(", ")
305 ));
306 self.line("!!! This is the bug --dry-run looks for. Please report it.");
307 self.line("!!!");
308 }
309 DepartureVerdict::NothingToUnmount => {
310 self.tally.unmatched += 1;
311 self.line(&format!(
312 "--- NO MATCH for {key}, but nothing is mounted, so nothing leaks."
313 ));
314 self.line(
315 "--- A departure with no arrival behind it: the device left twice, or it \
316 went away before the watch ever reported it.",
317 );
318 }
319 }
320
321 self.running_total();
322 verdict
323 }
324
325 pub fn summary(&mut self, reason: &str) {
327 let Tally {
328 arrivals,
329 departures,
330 matched,
331 unmatched,
332 } = self.tally;
333 let still_open = self.open_keys();
334
335 self.line("");
336 self.line(&format!("=== Dry run over: {reason} ==="));
337 self.line(&format!(" Arrivals: {arrivals}"));
338 self.line(&format!(" Departures: {departures}"));
339 self.line(&format!(" Matched: {matched}"));
340 self.line(&format!(" Unmatched: {unmatched}"));
341 if still_open.is_empty() {
342 self.line(" Would be mounted now: nothing");
343 } else {
344 self.line(&format!(
345 " Would be mounted now: {}",
346 still_open.join(", ")
347 ));
348 }
349 self.line("");
350 if unmatched > 0 {
351 self.line(
352 "PROBLEM: a departure didn't match its arrival, so the daemon would leak a \
353 mount for this device. The fields above show which one disagreed.",
354 );
355 } else if departures > 0 {
356 self.line(
357 "All good: every departure matched an arrival, so the daemon takes the right \
358 mount down for this device.",
359 );
360 } else if arrivals > 0 {
361 self.line(
362 "No departures seen. Unplug the device while this is running to check the \
363 half that matters.",
364 );
365 } else {
366 self.line(
367 "No devices seen at all. Check the phone is unlocked and set to \"File \
368 Transfer\", and try RUST_LOG=debug.",
369 );
370 }
371 self.line("");
372 let _ = self.out.flush();
373 }
374
375 fn event_header(&mut self, what: &str, facts: &DeviceFacts) {
376 self.events += 1;
377 let n = self.events;
378 let headline = facts.headline();
379 self.line("------------------------------------------------------------");
380 self.line(&format!("#{n} {what:<10} {headline}"));
382 }
383
384 fn facts(&mut self, facts: &DeviceFacts, full: bool) {
386 let unreported = "(not reported)".to_string();
387 self.field(
388 "serial",
389 &facts.serial.clone().unwrap_or_else(|| unreported.clone()),
390 );
391 self.field(
392 "vendor:product",
393 &format!("{:04x}:{:04x}", facts.vendor_id, facts.product_id),
394 );
395 self.field("location id", &facts.location_id.to_string());
396 self.field(
397 "manufacturer",
398 &facts
399 .manufacturer
400 .clone()
401 .unwrap_or_else(|| unreported.clone()),
402 );
403 self.field(
404 "product",
405 &facts.product.clone().unwrap_or_else(|| unreported.clone()),
406 );
407 if full {
408 self.field(
409 "usb speed",
410 &facts.speed.clone().unwrap_or_else(|| unreported.clone()),
411 );
412 self.field(
413 "matched by",
414 &facts.match_reason.clone().unwrap_or(unreported),
415 );
416 }
417 }
418
419 fn running_total(&mut self) {
420 let Tally {
421 arrivals,
422 departures,
423 matched,
424 unmatched,
425 } = self.tally;
426 let open = self.open.len();
427 self.line(&format!(
428 " So far: {arrivals} arrived, {departures} left, {matched} matched, \
429 {unmatched} unmatched, {open} would be mounted."
430 ));
431 }
432
433 fn field(&mut self, name: &str, value: &str) {
434 self.line(&format!(" {name:<FIELD$}{value}"));
435 }
436
437 fn line(&mut self, text: &str) {
439 let _ = writeln!(self.out, "{text}");
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use std::sync::mpsc::channel;
447
448 fn pixel() -> DeviceFacts {
449 DeviceFacts {
450 serial: Some("2A31FDH200ABC".into()),
451 vendor_id: 0x18d1,
452 product_id: 0x4ee1,
453 location_id: 3,
454 manufacturer: Some("Google".into()),
455 product: Some("Pixel 9 Pro XL".into()),
456 speed: Some("SuperPlus".into()),
457 match_reason: Some("standard_class".into()),
458 label: "Google Pixel 9 Pro XL (serial: 2A31FDH200ABC, location: 00000003)".into(),
459 }
460 }
461
462 #[test]
463 fn a_device_with_no_serial_falls_back_to_the_usb_address() {
464 let facts = DeviceFacts {
465 serial: None,
466 vendor_id: 0x04e8,
467 product_id: 0x6860,
468 location_id: 42,
469 ..Default::default()
470 };
471 assert_eq!(facts.key(), "usb-04e8-6860-42");
472 assert_eq!(facts.ident().serial, None);
473 }
474
475 #[test]
476 fn a_blank_serial_is_no_serial() {
477 let facts = DeviceFacts {
478 serial: Some(" ".into()),
479 vendor_id: 1,
480 product_id: 2,
481 location_id: 3,
482 ..Default::default()
483 };
484 assert_eq!(facts.key(), "usb-0001-0002-3");
485 }
486
487 #[test]
488 fn the_ident_carries_the_key_the_supervisor_would_mount_under() {
489 let ident = pixel().ident();
490 assert_eq!(ident.key, "2A31FDH200ABC");
491 assert_eq!(ident.serial.as_deref(), Some("2A31FDH200ABC"));
492 assert!(ident.label.contains("Pixel 9 Pro XL"));
493 }
494
495 #[test]
496 fn a_second_arrival_of_the_same_key_is_called_out() {
497 let mut dry = DryRun::with_output(PathBuf::from("/run/mtp"), Box::new(Vec::new()));
498 dry.arrived(&pixel());
499 dry.arrived(&pixel());
500 assert_eq!(dry.open_keys(), vec!["2A31FDH200ABC".to_string()]);
501 assert_eq!(dry.tally().arrivals, 2);
502 }
503
504 #[test]
505 fn the_loop_stops_when_every_sender_is_gone() {
506 let (tx, rx) = channel();
507 tx.send(DryRunCommand::Arrived(pixel())).unwrap();
508 drop(tx);
509 let dry = DryRun::with_output(PathBuf::from("/run/mtp"), Box::new(Vec::new()));
510 dry.run(rx);
511 }
512}