diff --git a/README.md b/README.md index 3bf8327..1947c7b 100644 --- a/README.md +++ b/README.md @@ -99,22 +99,40 @@ Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis. ### Wo die Laufwerke eingebunden werden -Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir` -(`/`) - mehrere Paare stören sich also nie gegenseitig. Standardwert: -`/run/media/smart-mount`, ein einzelner, flacher Namensraum (smart-mount mountet immer als -root, siehe oben) - `/run/media` ist auf den meisten Systemen bereits die übliche Konvention -für eingebundene Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, -muss also nie persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden -bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen - `mount_base_dir` selbst -fest mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses), +Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`, +standardmäßig `/` (z. B. `/run/media/NAS`) - mehrere Paare +stören sich also nie gegenseitig, und der Ordnername ist beim Durchsuchen sofort erkennbar +(statt einer kryptischen ID). Standardwert für `mount_base_dir`: `/run/media` - auf den +meisten Systemen bereits die übliche Konvention für eingebundene Wechseldatenträger/ +Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie persistieren. Der +Name wird dabei zu einem sicheren Verzeichnisnamen bereinigt (nur alphanumerische Zeichen +sowie `-`/`_`, alles andere wird zu `-`); kollidiert das Ergebnis mit einem bereits +bestehenden Paar (Namen müssen nicht eindeutig sein), wird `-2`, `-3`, ... angehängt. + +**Der Mountpoint lässt sich beim Anlegen frei überschreiben** - per `--mount-point ` +(nicht-interaktiv) oder im interaktiven Prompt, komplett unabhängig von `mount_base_dir`: + +```bash +sudo smart-mount drive add --mount-point /mnt/nas --non-interactive ... +``` + +Bei `drive edit` bleibt der Mountpoint standardmäßig unverändert (kein Prompt dafür) - sonst +würde ein aktiver Mount verwaisen. Nur eine explizite `--mount-point`-Angabe ändert ihn (mit +einer Warnung, falls das Paar gerade aktiv gemountet ist - dann erst `unmount`, editieren, +dann erneut `mount`). + +Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem `mount`/ +`watch`-Lauf automatisch angelegt, falls sie fehlen - der gemeinsame Elternordner +(`mount_base_dir`, bzw. bei einem komplett eigenen `--mount-point` dessen Elternordner) fest +mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses), das jeweilige Backing-Verzeichnis (siehe unten) bei gesetztem `owner_user` zusätzlich fest `chown`t und `0700` (nur der Owner). Ist für ein Paar `owner_user` gesetzt, bekommt genau dieser Nutzer bei CIFS/WebDAV zusätzlich über die Mount-Optionen (`uid=`/`gid=`, siehe "Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer. -Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit -auf einen beliebigen anderen Pfad ändern. +Der Standard für `mount_base_dir` lässt sich in `config.toml` unter +`[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern. --- diff --git a/src/cli/drive.rs b/src/cli/drive.rs index 0e5e54c..2beeede 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -6,6 +6,7 @@ //! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde). use std::net::Ipv4Addr; +use std::path::{Path, PathBuf}; use std::str::FromStr; use clap::{Args, Subcommand}; @@ -40,6 +41,12 @@ pub enum DriveAction { pub struct DriveArgs { #[arg(long)] name: Option, + /// Where the pair's symlink/backing directories live. Default (on 'add', derived from the + /// name): '/' (see 'settings.mount_base_dir'). Ignored on 'edit' + /// unless explicitly given - an existing pair's mount point stays fixed otherwise, so an + /// active mount doesn't get orphaned. + #[arg(long)] + mount_point: Option, #[arg(long)] owner_user: Option, @@ -194,8 +201,9 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { // `add_pair()` (das intern selbst frisch lädt) auf denselben kaputten Zustand treffen // lassen, was dort ALLE bestehenden Paare durch die eine neue Konfiguration ersetzen // würde. Besser früh mit einem klaren Fehler abbrechen. - let settings = config::pairs::load()?.settings; - let mount_point = settings.mount_base_dir.join(&id); + let cfg = config::pairs::load()?; + let default_mount_point = default_mount_point(&cfg.settings.mount_base_dir, &name, &cfg.pairs); + let mount_point = resolve_mount_point(args.mount_point, &default_mount_point, ni)?; let pair = DrivePair { id: id.clone(), @@ -313,14 +321,29 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { "Cloud credentials", )?; + // Anders als bei allen anderen Feldern gibt es hier bewusst KEINEN interaktiven Prompt: der + // Mountpoint (und damit die Backing-Verzeichnisse) bleibt standardmäßig unverändert, sonst + // würde ein aktiver Mount verwaisen. Nur eine explizite '--mount-point'-Angabe ändert ihn. + let mount_point = match args.mount_point { + Some(p) => { + if smart_mount::mount::target::active_side(&existing).is_some() { + println!( + "Warning: pair '{id}' appears to be actively mounted - the old backing \ + directories will be orphaned. Run 'sudo smart-mount unmount --name {id}' \ + first, then 'mount --name {id}' again after this edit." + ); + } + p + } + None => existing.mount_point.clone(), + }; + let updated = DrivePair { id: id.to_string(), name, enabled: existing.enabled, owner_user, - // Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden - // eventuell noch aktive Mounts verwaisen. - mount_point: existing.mount_point.clone(), + mount_point, local: LocalSide { kind: local_kind, address: local_address, @@ -394,6 +417,62 @@ fn resolve_field( Ok(Some(input.interact_text()?)) } +/// Wandelt `name` in einen sicheren Verzeichnisnamen um (für den Standard-Mountpoint, siehe +/// `default_mount_point`): nur ASCII-alphanumerische Zeichen sowie `-`/`_` bleiben erhalten, +/// jede Folge anderer Zeichen (Leerzeichen, `/`, Sonderzeichen, Unicode) wird zu einem einzelnen +/// `-`; führende/folgende `-` werden entfernt. Ein leeres Ergebnis (z. B. bei einem rein aus +/// Sonderzeichen bestehenden Namen) fällt auf `"pair"` zurück, damit der Mountpoint nie +/// leer/unbrauchbar wird. +fn sanitize_dir_name(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for c in name.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + out.push(c); + } else if !out.ends_with('-') { + out.push('-'); + } + } + match out.trim_matches('-') { + "" => "pair".to_string(), + trimmed => trimmed.to_string(), + } +} + +/// Standard-Mountpoint für ein neues Paar: `/` (siehe +/// `sanitize_dir_name`). Namen sind - anders als `id` - nicht zwingend eindeutig (siehe +/// `config::pairs::find_pair`); kollidiert der Standard mit dem Mountpoint eines bestehenden +/// Paares, wird `-2`, `-3`, ... angehängt, bis er eindeutig ist. +fn default_mount_point(base: &Path, name: &str, existing: &[DrivePair]) -> PathBuf { + let sanitized = sanitize_dir_name(name); + let mut candidate = base.join(&sanitized); + let mut suffix = 2; + while existing.iter().any(|p| p.mount_point == candidate) { + candidate = base.join(format!("{sanitized}-{suffix}")); + suffix += 1; + } + candidate +} + +/// Löst den Mountpoint auf: Flag > (interaktiv: Prompt vorbelegt mit `default`, editierbar) > +/// (nicht-interaktiv: `default` ungefragt übernehmen). +fn resolve_mount_point( + flag: Option, + default: &Path, + non_interactive: bool, +) -> anyhow::Result { + if let Some(p) = flag { + return Ok(p); + } + if non_interactive { + return Ok(default.to_path_buf()); + } + let input: String = Input::new() + .with_prompt("Mount point") + .default(default.display().to_string()) + .interact_text()?; + Ok(PathBuf::from(input)) +} + /// Der Nutzer, in dessen Namen `sudo` aufgerufen wurde (falls so aufgerufen) - Standardwert für /// `owner_user` beim Anlegen eines neuen Paares (siehe `add`), damit der einrichtende Nutzer /// ohne extra Angabe vollen Zugriff auf sein eigenes Laufwerkspaar bekommt. `None`, wenn direkt @@ -579,3 +658,80 @@ fn resolve_credentials( .interact()?; Ok((Some(username), Some(password))) } + +#[cfg(test)] +mod tests { + use super::*; + use smart_mount::config::LocalAddress; + use std::net::Ipv4Addr; + + fn sample_pair(mount_point: &str) -> DrivePair { + DrivePair { + id: "pair-1".into(), + name: "Test".into(), + enabled: true, + owner_user: None, + mount_point: PathBuf::from(mount_point), + local: LocalSide { + kind: MountKind::Nfs, + address: LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)), + share: "share".into(), + username: None, + extra_options: vec![], + }, + cloud: CloudSide { + kind: MountKind::Nfs, + host_or_url: "cloud.example.com".into(), + share: "share".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn sanitize_dir_name_keeps_simple_names_unchanged() { + assert_eq!(sanitize_dir_name("NAS"), "NAS"); + assert_eq!(sanitize_dir_name("my-nas_2"), "my-nas_2"); + } + + #[test] + fn sanitize_dir_name_collapses_runs_of_special_characters_to_one_dash() { + assert_eq!(sanitize_dir_name("My NAS / Drive"), "My-NAS-Drive"); + } + + #[test] + fn sanitize_dir_name_trims_leading_and_trailing_dashes() { + assert_eq!(sanitize_dir_name(" NAS!!"), "NAS"); + assert_eq!(sanitize_dir_name("/etc/passwd"), "etc-passwd"); + } + + #[test] + fn sanitize_dir_name_falls_back_to_pair_for_only_special_characters() { + assert_eq!(sanitize_dir_name("!!!"), "pair"); + assert_eq!(sanitize_dir_name(""), "pair"); + } + + #[test] + fn default_mount_point_uses_base_dir_and_sanitized_name() { + let point = default_mount_point(Path::new("/run/media"), "NAS", &[]); + assert_eq!(point, PathBuf::from("/run/media/NAS")); + } + + #[test] + fn default_mount_point_dedupes_against_existing_pairs() { + let existing = vec![ + sample_pair("/run/media/NAS"), + sample_pair("/run/media/NAS-2"), + ]; + let point = default_mount_point(Path::new("/run/media"), "NAS", &existing); + assert_eq!(point, PathBuf::from("/run/media/NAS-3")); + } + + #[test] + fn default_mount_point_is_unaffected_by_unrelated_existing_pairs() { + let existing = vec![sample_pair("/run/media/OtherPair")]; + let point = default_mount_point(Path::new("/run/media"), "NAS", &existing); + assert_eq!(point, PathBuf::from("/run/media/NAS")); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index 5bccafd..eb01acf 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -11,13 +11,15 @@ use serde::{Deserialize, Serialize}; /// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene /// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei /// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie -/// persistieren müssen. Ein einzelner, flacher Namensraum reicht: smart-mount läuft -/// ausschließlich als root/System-Dienst (siehe [`crate::systemd`]) und mountet dort auch -/// Paare mit gesetztem `owner_user` - die Trennung nach Linux-Nutzer passiert über die -/// `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über -/// unterschiedliche Mountpoint-Namensräume. +/// persistieren müssen. Jedes Paar bekommt direkt darunter sein eigenes, nach seinem Namen +/// benanntes Unterverzeichnis (`/run/media/`, siehe `cli::drive::default_mount_point`) - +/// kein zusätzlicher `smart-mount`-Zwischenordner, damit der Ordnername beim Durchsuchen von +/// `/run/media` sofort erkennbar ist. Die Trennung nach Linux-Nutzer passiert bei Bedarf über +/// die `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über +/// unterschiedliche Mountpoint-Namensräume - smart-mount läuft ausschließlich als +/// root/System-Dienst (siehe [`crate::systemd`]). fn default_mount_base_dir() -> PathBuf { - PathBuf::from("/run/media/smart-mount") + PathBuf::from("/run/media") } fn default_log_level() -> String { @@ -195,9 +197,6 @@ mod tests { #[test] fn default_mount_base_dir_uses_run_media() { - assert_eq!( - default_mount_base_dir(), - PathBuf::from("/run/media/smart-mount") - ); + assert_eq!(default_mount_base_dir(), PathBuf::from("/run/media")); } }