use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use crate::daemon::paths::device_dir_name;
use crate::daemon::supervisor::DeviceIdent;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeviceFacts {
pub serial: Option<String>,
pub vendor_id: u16,
pub product_id: u16,
pub location_id: u64,
pub manufacturer: Option<String>,
pub product: Option<String>,
pub speed: Option<String>,
pub match_reason: Option<String>,
pub label: String,
}
impl DeviceFacts {
fn usable_serial(&self) -> Option<&str> {
self.serial
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
}
#[must_use]
pub fn key(&self) -> String {
device_dir_name(
self.usable_serial(),
self.vendor_id,
self.product_id,
self.location_id,
)
}
#[must_use]
pub fn ident(&self) -> DeviceIdent {
DeviceIdent {
key: self.key(),
label: self.headline(),
serial: self.usable_serial().map(str::to_string),
}
}
fn headline(&self) -> String {
if self.label.is_empty() {
format!("MTP device {}", self.key())
} else {
self.label.clone()
}
}
}
#[derive(Debug, Clone)]
pub enum DryRunCommand {
Arrived(DeviceFacts),
Left(DeviceFacts),
Stop(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DepartureVerdict {
Matched(PathBuf),
WouldLeak(Vec<PathBuf>),
NothingToUnmount,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Tally {
pub arrivals: usize,
pub departures: usize,
pub matched: usize,
pub unmatched: usize,
}
pub struct DryRun {
mount_root: PathBuf,
open: HashMap<String, PathBuf>,
arrived_keys: Vec<String>,
tally: Tally,
events: usize,
out: Box<dyn Write + Send>,
}
const FIELD: usize = 16;
impl DryRun {
#[must_use]
pub fn new(mount_root: PathBuf) -> Self {
Self::with_output(mount_root, Box::new(std::io::stdout()))
}
#[must_use]
pub fn with_output(mount_root: PathBuf, out: Box<dyn Write + Send>) -> Self {
Self {
mount_root,
open: HashMap::new(),
arrived_keys: Vec::new(),
tally: Tally::default(),
events: 0,
out,
}
}
#[must_use]
pub fn tally(&self) -> Tally {
self.tally
}
#[must_use]
pub fn open_keys(&self) -> Vec<String> {
let mut keys: Vec<String> = self.open.keys().cloned().collect();
keys.sort();
keys
}
pub fn run(mut self, commands: Receiver<DryRunCommand>) {
self.banner();
let reason = loop {
match commands.recv() {
Ok(DryRunCommand::Arrived(facts)) => self.arrived(&facts),
Ok(DryRunCommand::Left(facts)) => {
self.left(&facts);
}
Ok(DryRunCommand::Stop(reason)) => break reason,
Err(_) => break "the device watch stopped".to_string(),
}
};
self.summary(&reason);
}
pub fn banner(&mut self) {
let root = self.mount_root.display().to_string();
self.line("");
self.line("=== mtp-mountd --dry-run: nothing will be mounted ===");
self.line(&format!("Mount root it would use: {root}"));
self.line("Plug a device in, wait for it to appear, then unplug it.");
self.line("Every departure should say MATCHES. Stop with Ctrl-C.");
self.line("");
}
pub fn arrived(&mut self, facts: &DeviceFacts) {
let key = facts.key();
let path = self.mount_root.join(&key);
self.tally.arrivals += 1;
self.event_header("PLUGGED IN", facts);
self.facts(facts, true);
self.field("mount key", &key);
self.field("would mount at", &path.display().to_string());
if let Some(existing) = self.open.get(&key) {
let existing = existing.display().to_string();
self.line(&format!(
" Note: {key} is already down as arrived (at {existing}). The daemon \
would keep the first mount and ignore this one."
));
} else {
self.open.insert(key.clone(), path);
}
if !self.arrived_keys.contains(&key) {
self.arrived_keys.push(key);
}
self.running_total();
}
pub fn left(&mut self, facts: &DeviceFacts) -> DepartureVerdict {
let key = facts.key();
self.tally.departures += 1;
self.event_header("UNPLUGGED", facts);
self.facts(facts, false);
self.field("mount key", &key);
let verdict = match self.open.remove(&key) {
Some(path) => DepartureVerdict::Matched(path),
None if self.open.is_empty() => DepartureVerdict::NothingToUnmount,
None => {
let mut leaked: Vec<PathBuf> = self.open.values().cloned().collect();
leaked.sort();
DepartureVerdict::WouldLeak(leaked)
}
};
match &verdict {
DepartureVerdict::Matched(path) => {
self.tally.matched += 1;
self.line(&format!(
">>> MATCHES the arrival: the daemon would unmount {} <<<",
path.display()
));
}
DepartureVerdict::WouldLeak(leaked) => {
self.tally.unmatched += 1;
self.line("!!!");
self.line(&format!(
"!!! NO MATCH - nothing arrived with the key {key}, so this departure",
));
self.line("!!! would take nothing down. These mounts would leak:");
for path in leaked {
self.line(&format!("!!! {}", path.display()));
}
self.line(&format!(
"!!! Keys seen arriving: {}",
self.arrived_keys.join(", ")
));
self.line("!!! This is the bug --dry-run looks for. Please report it.");
self.line("!!!");
}
DepartureVerdict::NothingToUnmount => {
self.tally.unmatched += 1;
self.line(&format!(
"--- NO MATCH for {key}, but nothing is mounted, so nothing leaks."
));
self.line(
"--- A departure with no arrival behind it: the device left twice, or it \
went away before the watch ever reported it.",
);
}
}
self.running_total();
verdict
}
pub fn summary(&mut self, reason: &str) {
let Tally {
arrivals,
departures,
matched,
unmatched,
} = self.tally;
let still_open = self.open_keys();
self.line("");
self.line(&format!("=== Dry run over: {reason} ==="));
self.line(&format!(" Arrivals: {arrivals}"));
self.line(&format!(" Departures: {departures}"));
self.line(&format!(" Matched: {matched}"));
self.line(&format!(" Unmatched: {unmatched}"));
if still_open.is_empty() {
self.line(" Would be mounted now: nothing");
} else {
self.line(&format!(
" Would be mounted now: {}",
still_open.join(", ")
));
}
self.line("");
if unmatched > 0 {
self.line(
"PROBLEM: a departure didn't match its arrival, so the daemon would leak a \
mount for this device. The fields above show which one disagreed.",
);
} else if departures > 0 {
self.line(
"All good: every departure matched an arrival, so the daemon takes the right \
mount down for this device.",
);
} else if arrivals > 0 {
self.line(
"No departures seen. Unplug the device while this is running to check the \
half that matters.",
);
} else {
self.line(
"No devices seen at all. Check the phone is unlocked and set to \"File \
Transfer\", and try RUST_LOG=debug.",
);
}
self.line("");
let _ = self.out.flush();
}
fn event_header(&mut self, what: &str, facts: &DeviceFacts) {
self.events += 1;
let n = self.events;
let headline = facts.headline();
self.line("------------------------------------------------------------");
self.line(&format!("#{n} {what:<10} {headline}"));
}
fn facts(&mut self, facts: &DeviceFacts, full: bool) {
let unreported = "(not reported)".to_string();
self.field(
"serial",
&facts.serial.clone().unwrap_or_else(|| unreported.clone()),
);
self.field(
"vendor:product",
&format!("{:04x}:{:04x}", facts.vendor_id, facts.product_id),
);
self.field("location id", &facts.location_id.to_string());
self.field(
"manufacturer",
&facts
.manufacturer
.clone()
.unwrap_or_else(|| unreported.clone()),
);
self.field(
"product",
&facts.product.clone().unwrap_or_else(|| unreported.clone()),
);
if full {
self.field(
"usb speed",
&facts.speed.clone().unwrap_or_else(|| unreported.clone()),
);
self.field(
"matched by",
&facts.match_reason.clone().unwrap_or(unreported),
);
}
}
fn running_total(&mut self) {
let Tally {
arrivals,
departures,
matched,
unmatched,
} = self.tally;
let open = self.open.len();
self.line(&format!(
" So far: {arrivals} arrived, {departures} left, {matched} matched, \
{unmatched} unmatched, {open} would be mounted."
));
}
fn field(&mut self, name: &str, value: &str) {
self.line(&format!(" {name:<FIELD$}{value}"));
}
fn line(&mut self, text: &str) {
let _ = writeln!(self.out, "{text}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc::channel;
fn pixel() -> DeviceFacts {
DeviceFacts {
serial: Some("2A31FDH200ABC".into()),
vendor_id: 0x18d1,
product_id: 0x4ee1,
location_id: 3,
manufacturer: Some("Google".into()),
product: Some("Pixel 9 Pro XL".into()),
speed: Some("SuperPlus".into()),
match_reason: Some("standard_class".into()),
label: "Google Pixel 9 Pro XL (serial: 2A31FDH200ABC, location: 00000003)".into(),
}
}
#[test]
fn a_device_with_no_serial_falls_back_to_the_usb_address() {
let facts = DeviceFacts {
serial: None,
vendor_id: 0x04e8,
product_id: 0x6860,
location_id: 42,
..Default::default()
};
assert_eq!(facts.key(), "usb-04e8-6860-42");
assert_eq!(facts.ident().serial, None);
}
#[test]
fn a_blank_serial_is_no_serial() {
let facts = DeviceFacts {
serial: Some(" ".into()),
vendor_id: 1,
product_id: 2,
location_id: 3,
..Default::default()
};
assert_eq!(facts.key(), "usb-0001-0002-3");
}
#[test]
fn the_ident_carries_the_key_the_supervisor_would_mount_under() {
let ident = pixel().ident();
assert_eq!(ident.key, "2A31FDH200ABC");
assert_eq!(ident.serial.as_deref(), Some("2A31FDH200ABC"));
assert!(ident.label.contains("Pixel 9 Pro XL"));
}
#[test]
fn a_second_arrival_of_the_same_key_is_called_out() {
let mut dry = DryRun::with_output(PathBuf::from("/run/mtp"), Box::new(Vec::new()));
dry.arrived(&pixel());
dry.arrived(&pixel());
assert_eq!(dry.open_keys(), vec!["2A31FDH200ABC".to_string()]);
assert_eq!(dry.tally().arrivals, 2);
}
#[test]
fn the_loop_stops_when_every_sender_is_gone() {
let (tx, rx) = channel();
tx.send(DryRunCommand::Arrived(pixel())).unwrap();
drop(tx);
let dry = DryRun::with_output(PathBuf::from("/run/mtp"), Box::new(Vec::new()));
dry.run(rx);
}
}