Feature: Mountpoint heisst standardmaessig wie das Paar, frei ueberschreibbar
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 18s
Security Scans / Trivy & OSV-Scanner (push) Successful in 44s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m22s

Der Standard-Mountpunkt eines neuen Laufwerkspaares ist jetzt
'<mount_base_dir>/<Name des Paares>' (z. B. /run/media/NAS) statt
'/run/media/smart-mount/<uuid>' - beim Durchsuchen von /run/media ist sofort
erkennbar, worum es sich handelt, statt einer kryptischen ID. mount_base_dir
selbst ist standardmaessig /run/media (kein smart-mount-Zwischenordner mehr).

Der Name wird zu einem sicheren Verzeichnisnamen bereinigt (sanitize_dir_name)
und bei Kollision mit einem bestehenden Paar automatisch dedupliziert (-2,
-3, ...). Neuer Flag '--mount-point <pfad>' bei 'drive add'/'drive edit',
um den Mountpunkt komplett frei zu setzen (nicht-interaktiv per Flag,
interaktiv als editierbarer, vorbelegter Prompt) - unabhaengig von
mount_base_dir. 'drive edit' aendert den Mountpunkt weiterhin nur bei
expliziter Angabe (kein Prompt dafuer), mit einer Warnung, falls das Paar
gerade aktiv gemountet ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 13:36:12 +02:00
co-authored by Claude Sonnet 5
parent bd319523a7
commit e2e6c06bce
3 changed files with 198 additions and 25 deletions
+28 -10
View File
@@ -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`
(`<mount_base_dir>/<pair-id>`) - 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 `<mount_base_dir>/<Name des Paares>` (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 <pfad>`
(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.
---
+161 -5
View File
@@ -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<String>,
/// Where the pair's symlink/backing directories live. Default (on 'add', derived from the
/// name): '<mount_base_dir>/<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<PathBuf>,
#[arg(long)]
owner_user: Option<String>,
@@ -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: `<base>/<sanitierter Name>` (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<PathBuf>,
default: &Path,
non_interactive: bool,
) -> anyhow::Result<PathBuf> {
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"));
}
}
+9 -10
View File
@@ -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/<Name>`, 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"));
}
}