mount: fix iPod automount (lsblk flags + udisks suffixed paths)

Two bugs kept the connected iPod from being auto-mounted:

- `lsblk -o PATH,LABEL -n -l -P` is invalid (--pairs and --list are
  mutually exclusive), so device lookup by label always failed with
  "no block device found" — a regression from the earlier parse fix.
  Drop -l.
- udisks mounted the iPod volume at /media/sebastian/IPOD1 because the
  configured directory /media/sebastian/IPOD already exists, so even a
  resolved device failed with AlreadyMounted. The configured mount point
  is now treated as a hint: mounted_path_for() resolves an exact mount or
  the lowest-numbered udisks-suffixed sibling (IPOD → IPOD1) from
  /proc/self/mountinfo, and the sync destination, storage stats, settings
  display and fatsort resolution all use the real mount.

Also harden fatsort: resolve_fatsort_target now refuses to run when the
path is not a mount point, so findmnt can never fall back to the root
filesystem's block device.

Verified on the connected iPod: mounted_path_for → IPOD1,
resolve_block_device → /dev/sdb2, mount_device → "already mounted at
/media/sebastian/IPOD1", UI shows ● mounted with 9.7 GiB free · 92% and
mount point ✓ /media/sebastian/IPOD1.

Bump to 0.3.3.
This commit is contained in:
Sebastian
2026-09-10 20:20:40 +02:00
parent 9366186245
commit 23508fdc89
8 changed files with 198 additions and 39 deletions
Generated
+1 -1
View File
@@ -253,7 +253,7 @@ dependencies = [
[[package]] [[package]]
name = "dap-tui" name = "dap-tui"
version = "0.3.2" version = "0.3.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dap-tui" name = "dap-tui"
version = "0.3.2" version = "0.3.3"
edition = "2021" edition = "2021"
description = "Device sync for portable players — TUI edition (ratatui)" description = "Device sync for portable players — TUI edition (ratatui)"
license = "MIT" license = "MIT"
+6 -3
View File
@@ -107,9 +107,12 @@ mounts the target first (resolved by mount-point label via `lsblk`). Only the
mlove `fatsort` step needs root (raw block device). mlove `fatsort` step needs root (raw block device).
Mount status and sync targets are validated against the kernel mount table Mount status and sync targets are validated against the kernel mount table
(`/proc/self/mountinfo`), not just directory existence: if the configured (`/proc/self/mountinfo`), not just directory existence. The configured
`mount_point` exists but is not actually mounted, `dap-tui` refuses to sync to `mount_point` is a hint: if udisks mounted the volume at a suffixed sibling
it rather than writing the library onto the root filesystem. because the configured directory already existed (e.g. `IPOD` → `IPOD1`),
dap-tui follows the actual mount and uses it as the sync destination. If the
path exists but is not a mount at all, syncing into it is refused rather than
writing the library onto the root filesystem.
## Development ## Development
+3 -1
View File
@@ -122,12 +122,14 @@ pub struct Config {
impl Config { impl Config {
/// Refresh `Device::mounted` from the kernel mount table (no subprocess). /// Refresh `Device::mounted` from the kernel mount table (no subprocess).
/// A device counts as mounted when its volume is mounted at the configured
/// path or at a udisks-suffixed sibling (e.g. `IPOD` → `IPOD1`).
pub fn refresh_mounts(&mut self) { pub fn refresh_mounts(&mut self) {
for d in self.devices.iter_mut() { for d in self.devices.iter_mut() {
d.mounted = d d.mounted = d
.mount_point .mount_point
.as_deref() .as_deref()
.map(crate::disk::is_mounted) .map(|p| crate::disk::mounted_path_for(p).is_some())
.unwrap_or(false); .unwrap_or(false);
} }
} }
+76 -2
View File
@@ -1,4 +1,4 @@
use std::path::Path; use std::path::{Path, PathBuf};
/// Whether `path` is currently a mount point. /// Whether `path` is currently a mount point.
/// ///
@@ -26,6 +26,56 @@ pub fn is_mounted(path: &Path) -> bool {
path.exists() path.exists()
} }
/// Mount points currently listed in the kernel mount table.
fn mount_points() -> Vec<PathBuf> {
#[cfg(target_os = "linux")]
if let Ok(text) = std::fs::read_to_string("/proc/self/mountinfo") {
return text
.lines()
.filter_map(|line| line.split(' ').nth(4))
.map(|field| PathBuf::from(unescape_octal(field)))
.collect();
}
Vec::new()
}
/// Where the volume configured at `path` is actually mounted.
///
/// udisks mounts a volume at `/media/$USER/$LABEL`; if that directory already
/// exists (e.g. a leftover mount dir after an unclean eject) it appends a
/// number, so `/media/sebastian/IPOD` becomes `/media/sebastian/IPOD1`. This
/// resolves the exact path or the lowest-numbered suffixed sibling, so the
/// configured path is treated as a hint rather than a hard requirement.
pub fn mounted_path_for(path: &Path) -> Option<PathBuf> {
if is_mounted(path) {
return Some(path.to_path_buf());
}
let parent = path.parent()?;
let base = path.file_name()?.to_string_lossy().to_string();
mount_points()
.into_iter()
.filter_map(|mp| mount_suffix_rank(parent, &base, &mp).map(|rank| (rank, mp)))
.min_by_key(|(rank, _)| *rank)
.map(|(_, mp)| mp)
}
/// Rank of a mount point relative to a configured path: 0 for an exact match,
/// `n` for `name{n}` (udisks' uniquifying suffix), `None` otherwise.
fn mount_suffix_rank(parent: &Path, base: &str, candidate: &Path) -> Option<u32> {
if candidate.parent() != Some(parent) {
return None;
}
let name = candidate.file_name()?.to_string_lossy();
let rest = name.strip_prefix(base)?;
if rest.is_empty() {
return Some(0);
}
if rest.chars().all(|c| c.is_ascii_digit()) {
return rest.parse().ok();
}
None
}
/// Undo the octal escaping (`\040` etc.) used by /proc mount tables. /// Undo the octal escaping (`\040` etc.) used by /proc mount tables.
fn unescape_octal(s: &str) -> String { fn unescape_octal(s: &str) -> String {
let bytes = s.as_bytes(); let bytes = s.as_bytes();
@@ -84,7 +134,31 @@ pub fn fmt_bytes(n: u64) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{fmt_bytes, unescape_octal}; use super::{fmt_bytes, mounted_path_for, mount_suffix_rank, unescape_octal};
#[test]
fn ranks_udisks_suffixed_mounts() {
let parent = std::path::Path::new("/media/sebastian");
let rank = |c: &str| mount_suffix_rank(parent, "IPOD", std::path::Path::new(c));
assert_eq!(rank("/media/sebastian/IPOD"), Some(0));
assert_eq!(rank("/media/sebastian/IPOD1"), Some(1));
assert_eq!(rank("/media/sebastian/IPOD12"), Some(12));
// non-numeric suffixes and other directories must not match
assert_eq!(rank("/media/sebastian/IPODX"), None);
assert_eq!(rank("/media/sebastian/IPOD-2"), None);
assert_eq!(rank("/media/other/IPOD1"), None);
assert_eq!(rank("/media/sebastian/IPOD1/extra"), None);
}
#[test]
#[cfg(target_os = "linux")]
fn resolves_exact_mounts() {
// `/` is always mounted; mounted_path_for must return it unchanged.
assert_eq!(
mounted_path_for(std::path::Path::new("/")),
Some(std::path::PathBuf::from("/"))
);
}
#[test] #[test]
fn unescapes_mountinfo_paths() { fn unescapes_mountinfo_paths() {
+42 -20
View File
@@ -542,29 +542,43 @@ fn scan_music_dir(base: &Path) -> HashMap<String, (u64, i64)> {
} }
fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> { fn run_rsync_device(config: &Config, device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
let mount = device let configured = device
.mount_point .mount_point
.as_ref() .as_ref()
.ok_or_else(|| "mount_point is not set for this device".to_string())?; .ok_or_else(|| "mount_point is not set for this device".to_string())?;
if !crate::disk::is_mounted(mount) { // Trust the kernel mount table over the configured path: udisks may have
ctx.log( // mounted the volume at a suffixed path (e.g. /media/.../IPOD1) because the
LogLevel::Info, // configured directory already existed.
format!("device not mounted at {} — trying udisksctl…", mount.display()), let mount = match crate::disk::mounted_path_for(configured) {
); Some(p) => p,
match crate::udisks::mount_device(mount) { None => {
Ok(msg) => ctx.log(LogLevel::Info, format!("auto-mounted: {msg}")), ctx.log(
Err(e) => { LogLevel::Info,
return Err(format!( format!(
"device not mounted at {} — connect & mount it (m) and retry. {e}", "device not mounted at {} — trying udisksctl…",
mount.display() configured.display()
)); ),
);
match crate::udisks::mount_device(configured) {
Ok(msg) => ctx.log(LogLevel::Info, format!("auto-mounted: {msg}")),
Err(e) => {
return Err(format!(
"device not mounted at {} — connect & mount it (m) and retry. {e}",
configured.display()
));
}
} }
crate::disk::mounted_path_for(configured).ok_or_else(|| {
format!(
"auto-mount of {} did not yield a mount point",
configured.display()
)
})?
} }
} };
// A leftover (empty) mount-point directory must not be mistaken for a // Paranoia: never write into a directory that is not actually a mount.
// mounted device — rsync would otherwise happily fill the root filesystem. if !crate::disk::is_mounted(&mount) {
if !crate::disk::is_mounted(mount) {
return Err(format!( return Err(format!(
"{} exists but is not a mount point — refusing to sync. Connect the device and press 'm'.", "{} exists but is not a mount point — refusing to sync. Connect the device and press 'm'.",
mount.display() mount.display()
@@ -732,15 +746,17 @@ fn capture(cmd: &str, args: &[&str]) -> Result<(i32, String), String> {
} }
fn finalize_fat(device: &Device, ctx: &WorkerCtx) -> Result<(), String> { fn finalize_fat(device: &Device, ctx: &WorkerCtx) -> Result<(), String> {
let mount = device let configured = device
.mount_point .mount_point
.as_ref() .as_ref()
.ok_or_else(|| "mount_point is required to run fatsort".to_string())?; .ok_or_else(|| "mount_point is required to run fatsort".to_string())?;
// Use the actual mount point in case udisks moved it to a suffixed path.
let mount = crate::disk::mounted_path_for(configured).unwrap_or_else(|| configured.clone());
let target = resolve_fatsort_target(mount)?; let target = resolve_fatsort_target(&mount)?;
ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display())); ctx.log(LogLevel::Info, format!("Unmounting {mount_display}", mount_display = mount.display()));
match crate::udisks::unmount_device(mount) { match crate::udisks::unmount_device(&mount) {
Ok(msg) => ctx.log(LogLevel::Info, format!("unmounted: {msg}")), Ok(msg) => ctx.log(LogLevel::Info, format!("unmounted: {msg}")),
Err(e) => { Err(e) => {
// fall back to sudo for setups without udisks // fall back to sudo for setups without udisks
@@ -771,6 +787,12 @@ fn resolve_fatsort_target(mount: &Path) -> Result<String, String> {
if !mount.is_dir() { if !mount.is_dir() {
return Ok(mount.display().to_string()); return Ok(mount.display().to_string());
} }
// Never let findmnt fall back to the parent filesystem (e.g. the root
// disk) when the directory is not actually a mount point — fatsort on the
// wrong block device would corrupt it.
if !crate::disk::is_mounted(mount) {
return Err(format!("{} is not a mount point", mount.display()));
}
let (code, out) = capture( let (code, out) = capture(
"findmnt", "findmnt",
&["-n", "-o", "SOURCE", "-T", mount.to_str().unwrap_or("")], &["-n", "-o", "SOURCE", "-T", mount.to_str().unwrap_or("")],
+56 -6
View File
@@ -19,11 +19,10 @@ fn run(cmd: &str, args: &[&str]) -> (bool, String) {
} }
fn resolve_block_device(path: &Path) -> Option<String> { fn resolve_block_device(path: &Path) -> Option<String> {
// Only trust findmnt when the path really is a mount point; otherwise // If the volume is already mounted (possibly at a udisks-suffixed path
// findmnt happily reports the containing filesystem (e.g. /), which would // like IPOD1), ask findmnt for its source device.
// make us "mount" the root device. if let Some(mp) = crate::disk::mounted_path_for(path) {
if crate::disk::is_mounted(path) { let (ok, out) = run("findmnt", &["-n", "-o", "SOURCE", "-T", mp.to_str()?]);
let (ok, out) = run("findmnt", &["-n", "-o", "SOURCE", "-T", path.to_str()?]);
if ok { if ok {
let src = out.trim(); let src = out.trim();
if src.starts_with("/dev/") { if src.starts_with("/dev/") {
@@ -31,8 +30,10 @@ fn resolve_block_device(path: &Path) -> Option<String> {
} }
} }
} }
// Not mounted: match a partition label to the configured mount point's
// basename (e.g. `/media/sebastian/IPOD` → label `IPOD`).
let wanted = path.file_name()?.to_string_lossy().to_lowercase(); let wanted = path.file_name()?.to_string_lossy().to_lowercase();
let (ok, out) = run("lsblk", &["-o", "PATH,LABEL", "-n", "-l", "-P"]); let (ok, out) = run("lsblk", &["-o", "PATH,LABEL", "-n", "-P"]);
if !ok { if !ok {
return None; return None;
} }
@@ -50,6 +51,16 @@ fn resolve_block_device(path: &Path) -> Option<String> {
None None
} }
/// Current mount target of a block device, if any.
fn mounted_target(dev: &str) -> Option<std::path::PathBuf> {
let (ok, out) = run("findmnt", &["-n", "-o", "TARGET", "-S", dev]);
if ok && !out.trim().is_empty() {
Some(std::path::PathBuf::from(out.trim()))
} else {
None
}
}
/// Extract a `KEY="value"` field from `lsblk -P` output. /// Extract a `KEY="value"` field from `lsblk -P` output.
fn field(line: &str, key: &str) -> Option<String> { fn field(line: &str, key: &str) -> Option<String> {
let needle = format!("{key}=\""); let needle = format!("{key}=\"");
@@ -60,13 +71,25 @@ fn field(line: &str, key: &str) -> Option<String> {
} }
pub fn mount_device(path: &Path) -> Result<String, String> { pub fn mount_device(path: &Path) -> Result<String, String> {
// Already mounted, possibly at a udisks-suffixed path.
if let Some(mp) = crate::disk::mounted_path_for(path) {
return Ok(format!("already mounted at {}", mp.display()));
}
let Some(dev) = resolve_block_device(path) else { let Some(dev) = resolve_block_device(path) else {
return Err(format!("no block device found for {}", path.display())); return Err(format!("no block device found for {}", path.display()));
}; };
if let Some(mp) = mounted_target(&dev) {
return Ok(format!("already mounted at {}", mp.display()));
}
let (ok, detail) = run("udisksctl", &["mount", "-b", dev.as_str()]); let (ok, detail) = run("udisksctl", &["mount", "-b", dev.as_str()]);
if ok { if ok {
Ok(detail) Ok(detail)
} else { } else {
// A concurrent/late mount makes AlreadyMounted spurious: report
// success when the kernel now has the device mounted.
if let Some(mp) = mounted_target(&dev) {
return Ok(format!("mounted at {}", mp.display()));
}
Err(format!("udisksctl mount {dev} failed: {detail}")) Err(format!("udisksctl mount {dev} failed: {detail}"))
} }
} }
@@ -82,3 +105,30 @@ pub fn unmount_device(path: &Path) -> Result<String, String> {
Err(format!("udisksctl unmount {dev} failed: {detail}")) Err(format!("udisksctl unmount {dev} failed: {detail}"))
} }
} }
#[cfg(test)]
mod tests {
use super::field;
#[test]
fn parses_lsblk_pairs() {
assert_eq!(
field(r#"PATH="/dev/sdb2" LABEL="IPOD""#, "PATH").as_deref(),
Some("/dev/sdb2")
);
assert_eq!(
field(r#"PATH="/dev/sdb2" LABEL="IPOD""#, "LABEL").as_deref(),
Some("IPOD")
);
assert_eq!(
field(r#"PATH="/dev/sdb1" LABEL="""#, "LABEL").as_deref(),
Some("")
);
assert_eq!(
field(r#"LABEL="My Disk" PATH="/dev/sdc1""#, "LABEL").as_deref(),
Some("My Disk")
);
assert_eq!(field(r#"PATH="/dev/sdc1""#, "LABEL"), None);
}
}
+13 -5
View File
@@ -147,11 +147,14 @@ fn draw_device_card(f: &mut Frame, area: Rect, app: &App, device: &crate::config
line("last sync", last, last_style), line("last sync", last, last_style),
]; ];
// Mounted storage usage. The compact value ("276.8 GiB free · 39%") fits // Mounted storage usage. udisks may mount the volume at a suffixed path
// the card width, unlike the old "459.5 GiB total · 276.8 GiB free" which // (IPOD → IPOD1), so report stats for wherever it actually is mounted.
// was truncated mid-number. let actual_mount = device
.mount_point
.as_deref()
.and_then(crate::disk::mounted_path_for);
let storage = if device.mounted { let storage = if device.mounted {
device.mount_point.as_ref().and_then(|p| disk_stats(p)) actual_mount.as_deref().and_then(disk_stats)
} else { } else {
None None
}; };
@@ -232,7 +235,12 @@ fn draw_settings_card(f: &mut Frame, area: Rect, device: &crate::config::Device)
Style::default().fg(theme::FG), Style::default().fg(theme::FG),
)); ));
match device.mount_point.as_deref() { match device.mount_point.as_deref() {
Some(p) => lines.push(path_kv(l_mount, p, value_w, lw)), Some(p) => {
// Show where it is actually mounted when udisks used a suffixed
// path (e.g. the configured IPOD dir existed, so it chose IPOD1).
let shown = crate::disk::mounted_path_for(p).unwrap_or_else(|| p.to_path_buf());
lines.push(path_kv(l_mount, &shown, value_w, lw));
}
None => lines.push(line(l_mount, "—".to_string(), Style::default().fg(theme::DIM))), None => lines.push(line(l_mount, "—".to_string(), Style::default().fg(theme::DIM))),
} }
lines.push(line( lines.push(line(