//! TRACES: FR-CAT-10 | NFR-PORT-1 //! Finding the card. //! //! FR-CAT-10 asks for removable-volume insertion to be detected "where the //! platform permits", which is a careful phrase and this module is why. There //! is no portable answer: Linux has a mount table and a sysfs flag, Android //! has neither and hands out a document tree the user picked (ARCH §6.9). //! So this reports what it can and returns an empty list where it cannot, //! and every caller must still offer the user a way to say where the card is. //! //! # This is the one place besides `grant` that names a path //! //! `storage` explains why nothing above it takes a `Path`: a library location //! is a [`crate::storage::LocalStorage::grant`] away from being a `RootId`, //! and Android has no path to give. Volume discovery sits on the same side of //! that line — it produces exactly what `grant` consumes, the folder that is //! about to become a root. It is discovery of a path rather than use of one. //! //! # Why a heuristic rather than a device manager //! //! Talking to udisks2 over D-Bus would be authoritative and would add a D-Bus //! dependency, a service that may not be running, and a permission dialog on //! some desktops — for a question the filesystem can answer directly. The //! mount table plus the sysfs `removable` flag covers USB card readers, //! phones mounted as storage, and external drives. What it does not cover is //! an internal SD reader that reports itself fixed, which is why a `DCIM` //! directory is reported alongside and weighted more heavily than the flag: //! a volume with `DCIM` on it is a camera card whatever sysfs believes. use std::path::PathBuf; // Only the mount-table reader below borrows a path; `Volume` owns one. #[cfg(target_os = "linux")] use std::path::Path; /// Somewhere a card might be. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Volume { /// What to call it in a list. The mount point's last component, which is /// what desktops name after the filesystem label. pub label: String, /// Where it is mounted. Hand this to /// [`LocalStorage::grant`](crate::storage::LocalStorage::grant). pub path: PathBuf, /// Whether the kernel calls the underlying device removable. pub removable: bool, /// Whether it holds a `DCIM` directory — the strongest signal there is /// that this is a camera card rather than a backup drive. pub has_dcim: bool, } impl Volume { /// Whether to show this one first. /// /// `DCIM` outranks the kernel flag: an internal SD reader often reports /// its device as fixed, and the user with a card in it does not care what /// sysfs thinks. pub fn is_likely_card(&self) -> bool { self.has_dcim || self.removable } } /// TRACES: FR-CAT-10 | NFR-PORT-1 /// Whether an import can reach a card on this platform at all. /// /// **Not the same question as "did [`volumes`] find anything".** An empty list /// on Linux means no card is plugged in, and the user can plug one in or type /// where it is mounted. `false` here means the operation cannot be performed /// however hard the user tries, and the interface should not offer it. /// /// It is `false` on Android, for two reasons that both have to be fixed before /// it can change: /// /// - There is no mount table to read and no path to type. Storage is reached /// through a tree the user granted, and a removable volume appears there or /// not at all (ARCH §6.9). /// - Nothing implements [`WritableStorage`](crate::WritableStorage) except /// [`LocalStorage`](crate::LocalStorage), so there is no destination to write /// into even once a source is named. /// /// The engine above this is already portable — it takes storage traits and /// never a path — so what this gates is the *interface*, and it stops being /// `false` when a SAF implementation lands rather than when the importer is /// rewritten. pub const fn imports_supported() -> bool { cfg!(target_os = "linux") } /// Every mounted volume that might hold photographs. /// /// Ordered with the likely cards first and, within each group, by label, so /// the list does not reorder itself between two calls a second apart. /// Returns empty where the platform does not permit the question — which is /// not an error and must not be presented as one. pub fn volumes() -> Vec { let mut found = platform_volumes(); found.sort_by(|a, b| { b.is_likely_card() .cmp(&a.is_likely_card()) .then_with(|| a.label.cmp(&b.label)) }); found } #[cfg(target_os = "linux")] fn platform_volumes() -> Vec { let table = match std::fs::read_to_string("/proc/mounts") { Ok(t) => t, Err(e) => { log::debug!("no mount table: {e}"); return Vec::new(); } }; parse_mounts(&table) .into_iter() .filter(is_candidate) .map(|m| { let removable = device_is_removable(&m.device).unwrap_or(false) // A desktop that automounts under /run/media or /media has // already decided this is removable, and it had udisks2 to ask. || under_media_dir(&m.mount_point); Volume { label: label_for(&m.mount_point), has_dcim: m.mount_point.join("DCIM").is_dir(), removable, path: m.mount_point, } }) .collect() } /// Everywhere else: no answer, and saying so is the honest result. /// /// On Android the question is not merely unanswerable but wrong — storage is /// reached through a tree the user granted, and a card appears there or not at /// all (ARCH §6.9, FR-PLAT-AND-1). #[cfg(not(target_os = "linux"))] fn platform_volumes() -> Vec { Vec::new() } /// One line of the mount table. #[cfg(target_os = "linux")] #[derive(Debug, Clone, PartialEq, Eq)] struct Mount { device: String, mount_point: PathBuf, fs_type: String, } /// Parse `/proc/mounts`. /// /// Split out from the reading so it can be tested against real tables without /// a card plugged in — which is the only way this file is testable at all. #[cfg(target_os = "linux")] fn parse_mounts(table: &str) -> Vec { table .lines() .filter_map(|line| { let mut fields = line.split_whitespace(); let device = fields.next()?; let mount_point = fields.next()?; let fs_type = fields.next()?; Some(Mount { device: unescape(device), mount_point: PathBuf::from(unescape(mount_point)), fs_type: fs_type.to_string(), }) }) .collect() } /// Undo the octal escaping the kernel applies to spaces and tabs. /// /// A card is very often labelled with a space in it — `EOS DIGITAL` is what /// Canon writes — and mounted at `/run/media/duncan/EOS\040DIGITAL`. Without /// this the path does not exist and the card is invisible. #[cfg(target_os = "linux")] fn unescape(field: &str) -> String { let mut out = String::with_capacity(field.len()); let bytes = field.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'\\' && i + 3 < bytes.len() { let digits = &field[i + 1..i + 4]; if let Ok(c) = u8::from_str_radix(digits, 8) { out.push(c as char); i += 4; continue; } } out.push(bytes[i] as char); i += 1; } out } /// Whether a mount could hold a photograph library at all. /// /// Excludes the pseudo-filesystems, which is most of the table, and anything /// not backed by a block device. A card reader always is. #[cfg(target_os = "linux")] fn is_candidate(m: &Mount) -> bool { const FILESYSTEMS: &[&str] = &[ // What cameras format cards as, in practice: FAT32 below 32 GB, // exFAT above it, which is why an SDXC card is always exfat. "vfat", "exfat", "ntfs", "ntfs3", "fuseblk", // And what an external drive carrying an archive is. "ext2", "ext3", "ext4", "btrfs", "xfs", "f2fs", "hfsplus", "apfs", ]; m.device.starts_with("/dev/") && FILESYSTEMS.contains(&m.fs_type.as_str()) } /// Whether the desktop automounted this, which means it already decided. #[cfg(target_os = "linux")] fn under_media_dir(mount_point: &Path) -> bool { let s = mount_point.to_string_lossy(); s.starts_with("/run/media/") || s.starts_with("/media/") || s.starts_with("/mnt/media/") } /// Ask sysfs whether the device behind a partition is removable. /// /// `/dev/sdb1` is a partition; the flag lives on its whole-disk parent, /// `/sys/block/sdb/removable`. Trailing digits are stripped to get there, /// except on `mmcblk0p1` and `nvme0n1p1`, whose parents keep their digits and /// lose only the `pN` suffix — strip naively and `mmcblk0p1` becomes /// `mmcblk`, which does not exist, and every SD card in an internal reader /// reports itself fixed. #[cfg(target_os = "linux")] fn device_is_removable(device: &str) -> Option { let name = device.strip_prefix("/dev/")?; let disk = whole_disk(name); let flag = std::fs::read_to_string(format!("/sys/block/{disk}/removable")).ok()?; Some(flag.trim() == "1") } /// The whole-disk name a partition belongs to. #[cfg(target_os = "linux")] fn whole_disk(partition: &str) -> String { // `mmcblk0p1` -> `mmcblk0`, `nvme0n1p3` -> `nvme0n1`: these spell the // partition with a `p`, and the disk name legitimately ends in a digit. if partition.starts_with("mmcblk") || partition.starts_with("nvme") { if let Some((disk, tail)) = partition.rsplit_once('p') { if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) { return disk.to_string(); } } return partition.to_string(); } // `sdb1` -> `sdb`. A SCSI-style disk name never ends in a digit. partition .trim_end_matches(|c: char| c.is_ascii_digit()) .to_string() } /// What to call a volume in a list. #[cfg(target_os = "linux")] fn label_for(mount_point: &Path) -> String { mount_point .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| mount_point.to_string_lossy().to_string()) } // Reading `/proc/mounts` and naming block devices is what these test, so // they are as Linux-bound as the code above. #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; const TABLE: &str = "\ proc /proc proc rw,nosuid 0 0 sys /sys sysfs rw,nosuid 0 0 /dev/nvme0n1p2 / btrfs rw,noatime 0 0 /dev/nvme0n1p1 /boot vfat rw,relatime 0 0 /dev/sdb1 /run/media/duncan/EOS\\040DIGITAL exfat rw,nosuid 0 0 tmpfs /run/user/1000 tmpfs rw,nosuid 0 0 "; #[test] fn a_mount_table_yields_its_block_devices() { let mounts = parse_mounts(TABLE); assert_eq!(mounts.len(), 6); let candidates: Vec<_> = mounts.iter().filter(|m| is_candidate(m)).collect(); // Three block-backed filesystems; the pseudo ones are gone. assert_eq!(candidates.len(), 3); assert!(candidates.iter().all(|m| m.device.starts_with("/dev/"))); } #[test] fn a_card_labelled_with_a_space_is_not_lost() { // Canon writes `EOS DIGITAL`, the kernel escapes the space, and a path // that keeps the escape does not exist. let mounts = parse_mounts(TABLE); let card = mounts .iter() .find(|m| m.device == "/dev/sdb1") .expect("the card"); assert_eq!( card.mount_point, PathBuf::from("/run/media/duncan/EOS DIGITAL") ); assert_eq!(label_for(&card.mount_point), "EOS DIGITAL"); } #[test] fn an_automounted_volume_is_treated_as_removable() { // The desktop had udisks2 to ask and already decided. assert!(under_media_dir(Path::new("/run/media/duncan/EOS DIGITAL"))); assert!(under_media_dir(Path::new("/media/usb0"))); assert!(!under_media_dir(Path::new("/home/duncan/Photos"))); // Not a prefix match on the word: `/media-server` is not automounted. assert!(!under_media_dir(Path::new("/mediaserver/x"))); } #[test] fn a_partition_resolves_to_the_disk_that_carries_the_flag() { assert_eq!(whole_disk("sdb1"), "sdb"); assert_eq!(whole_disk("sda12"), "sda"); // The naive strip turns these into `mmcblk` and `nvme`, neither of // which exists — and every SD card in an internal reader then reports // itself fixed. assert_eq!(whole_disk("mmcblk0p1"), "mmcblk0"); assert_eq!(whole_disk("nvme0n1p3"), "nvme0n1"); // A whole disk named directly is already the answer. assert_eq!(whole_disk("mmcblk0"), "mmcblk0"); assert_eq!(whole_disk("sdb"), "sdb"); } #[test] fn a_camera_card_outranks_a_backup_drive() { let card = Volume { label: "EOS DIGITAL".into(), path: "/run/media/duncan/EOS DIGITAL".into(), removable: false, has_dcim: true, }; let drive = Volume { label: "archive".into(), path: "/run/media/duncan/archive".into(), removable: true, has_dcim: false, }; let fixed = Volume { label: "boot".into(), path: "/boot".into(), removable: false, has_dcim: false, }; // An internal reader reports its device fixed, and the user with a // card in it does not care what sysfs thinks. assert!(card.is_likely_card()); assert!(drive.is_likely_card()); assert!(!fixed.is_likely_card()); } #[test] fn a_platform_that_cannot_import_says_so_separately_from_finding_nothing() { // The two are different claims: an empty list means "plug one in", // `false` here means "this cannot be done here". An interface that // conflated them would offer a page that can never be used. assert_eq!(imports_supported(), cfg!(target_os = "linux")); if !imports_supported() { assert!(volumes().is_empty()); } } #[test] fn asking_where_there_is_no_answer_is_not_an_error() { // The call must work on a machine with no card, in CI, and on a // platform that cannot answer at all — an empty list, never a panic. let _ = volumes(); } #[test] fn an_octal_escape_that_is_not_one_is_left_alone() { // A backslash near the end of a field must not read past it. assert_eq!(unescape("/mnt/odd\\"), "/mnt/odd\\"); assert_eq!(unescape("/mnt/a\\04"), "/mnt/a\\04"); assert_eq!(unescape("/mnt/a\\040b"), "/mnt/a b"); } }