Skip to main content

mtp_mount/daemon/
dryrun.rs

1//! `--dry-run`: watch real devices, mount nothing, and say what would happen.
2//!
3//! # What it's for
4//!
5//! The hotplug path is the one part of the daemon no test can reach: USB can't
6//! be simulated, so [`crate::daemon::usb::ident_of`] never sees a real
7//! [`MtpDeviceInfo`](mtp_rs::mtp::MtpDeviceInfo) until someone plugs a phone in.
8//! The risk that hides there is the mount key: it's derived from what the watch
9//! reports, and if an arrival and its matching departure derive it *differently*,
10//! no departure ever matches a mounted device and the daemon leaks mount points
11//! for as long as it runs. Every other bug in the daemon is visible; that one is
12//! silent.
13//!
14//! So `--dry-run` answers exactly that question against real hardware, on a
15//! machine that may have no FUSE at all: plug the phone in, pull it out, and read
16//! whether the two keys agreed.
17//!
18//! # The seam
19//!
20//! Same shape as the supervisor's (see [`crate::daemon::supervisor`]): the
21//! reporter's whole input is a channel, [`crate::daemon::usb::spawn_dry_run_watch`]
22//! is the only thing that knows about USB, and a test injects
23//! [`DryRunCommand`]s to exercise the matching logic and the output without a
24//! cable.
25//!
26//! The commands carry [`DeviceFacts`] rather than the supervisor's
27//! [`DeviceIdent`], because a dry run has to show its work: the raw fields the
28//! key came from are the evidence a person needs to see, while a mount only ever
29//! needs the key. `DeviceFacts` is also plain owned data, so tests can build one,
30//! which they can't do with `MtpDeviceInfo` (`#[non_exhaustive]`).
31//!
32//! # What it must not do
33//!
34//! Nothing on the filesystem: no mount, no mount root, no mount point, no stale
35//! sweep, and the device is never opened. It prints the paths it *would* use.
36
37use 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/// Everything the USB watch reported about one device, as reported.
46///
47/// The fields are what [`key`](Self::key) is derived from plus the strings that
48/// let a person recognize the device in a scrolling log. `Default` is there so
49/// tests can state only the fields they care about.
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct DeviceFacts {
52    /// Serial number, exactly as reported (untrimmed).
53    pub serial: Option<String>,
54    /// USB vendor ID.
55    pub vendor_id: u16,
56    /// USB product ID.
57    pub product_id: u16,
58    /// USB topology position: which port the device is in.
59    pub location_id: u64,
60    /// Manufacturer string from the USB descriptor.
61    pub manufacturer: Option<String>,
62    /// Product string from the USB descriptor.
63    pub product: Option<String>,
64    /// Negotiated USB link speed, when the OS reports one.
65    pub speed: Option<String>,
66    /// Why `mtp-rs` classified this device as MTP. Arrivals only, in practice.
67    pub match_reason: Option<String>,
68    /// What the daemon calls the device in its log (`MtpDeviceInfo::display()`).
69    pub label: String,
70}
71
72impl DeviceFacts {
73    /// The serial the key derivation actually uses: trimmed, and `None` when blank.
74    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    /// The mount key: directory name and device identity in one.
82    ///
83    /// This is the value the whole dry run is about. See
84    /// [`device_dir_name`] for the serial-then-USB-address rule.
85    #[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    /// What the supervisor would be handed for this device.
96    ///
97    /// The daemon's real path goes through here too
98    /// ([`crate::daemon::usb::ident_of`] is this function over an
99    /// `MtpDeviceInfo`), so a dry run and a live mount can't derive keys
100    /// differently.
101    #[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    /// The one-line name for the log, falling back to the key for a device that
111    /// reports no strings at all.
112    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/// What drives the reporter. The USB watch sends these; so do tests.
122#[derive(Debug, Clone)]
123pub enum DryRunCommand {
124    /// A device was plugged in, or was already there when watching started.
125    Arrived(DeviceFacts),
126    /// A device went away.
127    Left(DeviceFacts),
128    /// Print the summary and return from [`DryRun::run`].
129    Stop(String),
130}
131
132/// What a departure turned out to be: the answer the dry run exists for.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum DepartureVerdict {
135    /// The departure's key is one that arrived, so the right mount comes down.
136    Matched(PathBuf),
137    /// The key matched nothing that arrived, and mounts are open: those stay
138    /// mounted forever. This is the failure `--dry-run` is looking for.
139    WouldLeak(Vec<PathBuf>),
140    /// The key matched nothing, but nothing was mounted either, so there's
141    /// nothing to leak: a departure before any arrival, or a repeat departure.
142    NothingToUnmount,
143}
144
145/// Running totals, so a person can plug in and out a few times and glance.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147pub struct Tally {
148    /// Arrivals seen.
149    pub arrivals: usize,
150    /// Departures seen.
151    pub departures: usize,
152    /// Departures that matched an arrival.
153    pub matched: usize,
154    /// Departures that didn't.
155    pub unmatched: usize,
156}
157
158/// Reports what the daemon would do, and mounts nothing.
159pub struct DryRun {
160    mount_root: PathBuf,
161    /// Keys that arrived and haven't left: what would be mounted right now.
162    open: HashMap<String, PathBuf>,
163    /// Every key seen arriving this session, for the no-match message.
164    arrived_keys: Vec<String>,
165    tally: Tally,
166    events: usize,
167    out: Box<dyn Write + Send>,
168}
169
170/// Width of the label column in the fact list.
171const FIELD: usize = 16;
172
173impl DryRun {
174    /// A reporter that prints to stdout.
175    #[must_use]
176    pub fn new(mount_root: PathBuf) -> Self {
177        Self::with_output(mount_root, Box::new(std::io::stdout()))
178    }
179
180    /// A reporter that prints wherever you tell it. Tests capture the output.
181    #[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    /// Totals so far.
194    #[must_use]
195    pub fn tally(&self) -> Tally {
196        self.tally
197    }
198
199    /// Keys that arrived and haven't left, sorted. Used by tests.
200    #[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    /// Report events until [`DryRunCommand::Stop`] arrives or every sender is
208    /// dropped, then print the summary.
209    ///
210    /// Blocks the calling thread, like [`crate::daemon::supervisor::Supervisor::run`],
211    /// and for the same reason: the daemon keeps its runtime for the watch.
212    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    /// Header: what this mode does and what to do with it.
228    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    /// Report an arrival.
239    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    /// Report a departure, and say whether it matched. Returns the verdict so
265    /// tests can assert on it without parsing the text.
266    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    /// The closing report: the thing to read after a few plug cycles.
326    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        // Padded so the two kinds of block line up when a log is scrolling.
381        self.line(&format!("#{n}  {what:<10}  {headline}"));
382    }
383
384    /// The raw fields the key came from. `full` adds the arrival-only ones.
385    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    /// One line out. A closed stdout isn't worth stopping a dry run over.
438    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}