The Import button went into the library header unconditionally, so an Android user got a page that opens, finds nothing, and cannot be pressed — worse than no page at all, because it reads as broken rather than as absent. `dr_plat::imports_supported()` answers the question the interface actually has, which is not "did we find a volume". An empty list on Linux means plug one in; false here means it cannot be done on this device however hard the user tries. It is false on Android for two reasons that both have to be fixed before it changes: there is no mount table to read and no path to type, and nothing implements WritableStorage except LocalStorage. The button is hidden rather than disabled. The buttons beside it come and go with the selection — unavailable now, available in a moment — where this one never will be here, and a permanently disabled control teaches the reader that the row lies. What this gates is the interface, not the engine. dr-ingest takes storage traits and never a path, and cross-compiles to aarch64-linux-android today; it should need no changes when a SAF implementation lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
369 lines
14 KiB
Rust
369 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::{Path, PathBuf};
|
|
|
|
/// 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.
|
|
#[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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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");
|
|
}
|
|
}
|