`platform_volumes` has been gated to Linux since it was written, but the eight items it is built from were not, so every Android build compiled a `/proc/mounts` parser it could never call and then printed eight dead-code warnings about it. Real warnings hide in that kind of noise. Gated per item rather than moved into a module, because the file already draws the line that way one function above and two patterns for one idea is worse than a repeated attribute. The tests go with them. They parse a mount table and assert on names like `mmcblk0p1`, so they are as Linux-bound as the code they exercise, and `Path` turns out to be too -- only the reader borrows one, where `Volume` owns its own. Android now builds dr-plat with no warnings at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
382 lines
14 KiB
Rust
382 lines
14 KiB
Rust
//! 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 | FR-PLAT-AND-1 | 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<Volume> {
|
|
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<Volume> {
|
|
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<Volume> {
|
|
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<Mount> {
|
|
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<bool> {
|
|
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");
|
|
}
|
|
}
|