review pass: mount safety, abort/quit robustness, UI fixes

Engine / safety:
- verify mount points against /proc/self/mountinfo instead of path
  existence; refuse to sync into a leftover (unmounted) directory that
  would otherwise receive the library on the root filesystem
- resolve udisks block devices by label when the path is not mounted,
  and never let findmnt fall back to the containing root filesystem
- abort now SIGTERMs the whole child process group (sh -c/podkit and
  rsync children included), escalating to SIGKILL after 2s
- quitting while a sync runs aborts and waits for the engine instead of
  orphaning the child
- atomically mark the engine busy before spawning to prevent double
  syncs; persist state before publishing completion
- don't follow symlinked directories while scanning (recursion loops)
- prune empty directories recursively when removing stale files

UI:
- help overlay was clipped (hardcoded height): size it from content
- speed/ETA were pushed onto a line the progress panel clipped; show
  them right-aligned on the bytes row
- leaving log follow with u/d now scrolls from the bottom instead of
  jumping to the top of the log
- reload config with r (label updated), refresh mounts on completion so
  last-sync ages are current
- fix width underflows on narrow terminals; terminal-aware sidebar
  scrolling

Tests: mountinfo unescape, process-group abort, and a TestBackend render
smoke test covering all tabs, small terminals and the help overlay.
This commit is contained in:
Sebastian
2026-09-10 19:35:03 +02:00
parent 5b1e8b742b
commit f566162c29
11 changed files with 470 additions and 138 deletions
+55 -1
View File
@@ -1,5 +1,52 @@
use std::path::Path;
/// Whether `path` is currently a mount point.
///
/// Scanning `/proc/self/mountinfo` is used instead of `path.exists()` so a
/// leftover mount-point directory (e.g. `/media/sebastian/IPOD` after an
/// unclean eject) is not mistaken for a mounted device. Writing a device sync
/// into an unmounted directory would silently fill the root filesystem.
pub fn is_mounted(path: &Path) -> bool {
let Some(target) = path.to_str() else {
return false;
};
let target = target.trim_end_matches('/');
#[cfg(target_os = "linux")]
if let Ok(text) = std::fs::read_to_string("/proc/self/mountinfo") {
return text.lines().any(|line| {
let Some(field) = line.split(' ').nth(4) else {
return false;
};
unescape_octal(field).trim_end_matches('/') == target
});
}
// Fallback for platforms without /proc: best effort only.
path.exists()
}
/// Undo the octal escaping (`\040` etc.) used by /proc mount tables.
fn unescape_octal(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
let octal = bytes[i] == b'\\'
&& i + 3 < bytes.len()
&& bytes[i + 1..=i + 3].iter().all(|b| (b'0'..=b'7').contains(b));
if octal {
let v = (bytes[i + 1] - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
out.push(v as char);
i += 4;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
/// Total and available bytes on a mounted filesystem (statvfs).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiskStats {
@@ -37,7 +84,14 @@ pub fn fmt_bytes(n: u64) -> String {
#[cfg(test)]
mod tests {
use super::fmt_bytes;
use super::{fmt_bytes, unescape_octal};
#[test]
fn unescapes_mountinfo_paths() {
assert_eq!(unescape_octal("/media/sebastian/IPOD"), "/media/sebastian/IPOD");
assert_eq!(unescape_octal("/media/My\\040Disk"), "/media/My Disk");
assert_eq!(unescape_octal("/media/back\\134slash"), "/media/back\\slash");
}
#[test]
fn formats_bytes() {