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
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:
@@ -99,22 +99,40 @@ Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis.
|
|||||||
|
|
||||||
### Wo die Laufwerke eingebunden werden
|
### Wo die Laufwerke eingebunden werden
|
||||||
|
|
||||||
Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`
|
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:
|
standardmäßig `<mount_base_dir>/<Name des Paares>` (z. B. `/run/media/NAS`) - mehrere Paare
|
||||||
`/run/media/smart-mount`, ein einzelner, flacher Namensraum (smart-mount mountet immer als
|
stören sich also nie gegenseitig, und der Ordnername ist beim Durchsuchen sofort erkennbar
|
||||||
root, siehe oben) - `/run/media` ist auf den meisten Systemen bereits die übliche Konvention
|
(statt einer kryptischen ID). Standardwert für `mount_base_dir`: `/run/media` - auf den
|
||||||
für eingebundene Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`,
|
meisten Systemen bereits die übliche Konvention für eingebundene Wechseldatenträger/
|
||||||
muss also nie persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden
|
Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie persistieren. Der
|
||||||
bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen - `mount_base_dir` selbst
|
Name wird dabei zu einem sicheren Verzeichnisnamen bereinigt (nur alphanumerische Zeichen
|
||||||
fest mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses),
|
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
|
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
|
`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
|
Nutzer bei CIFS/WebDAV zusätzlich über die Mount-Optionen (`uid=`/`gid=`, siehe
|
||||||
"Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also
|
"Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also
|
||||||
über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer.
|
über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer.
|
||||||
|
|
||||||
Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit
|
Der Standard für `mount_base_dir` lässt sich in `config.toml` unter
|
||||||
auf einen beliebigen anderen Pfad ändern.
|
`[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+161
-5
@@ -6,6 +6,7 @@
|
|||||||
//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde).
|
//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde).
|
||||||
|
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
@@ -40,6 +41,12 @@ pub enum DriveAction {
|
|||||||
pub struct DriveArgs {
|
pub struct DriveArgs {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
name: Option<String>,
|
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)]
|
#[arg(long)]
|
||||||
owner_user: Option<String>,
|
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
|
// `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
|
// lassen, was dort ALLE bestehenden Paare durch die eine neue Konfiguration ersetzen
|
||||||
// würde. Besser früh mit einem klaren Fehler abbrechen.
|
// würde. Besser früh mit einem klaren Fehler abbrechen.
|
||||||
let settings = config::pairs::load()?.settings;
|
let cfg = config::pairs::load()?;
|
||||||
let mount_point = settings.mount_base_dir.join(&id);
|
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 {
|
let pair = DrivePair {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -313,14 +321,29 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
|
|||||||
"Cloud credentials",
|
"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 {
|
let updated = DrivePair {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name,
|
name,
|
||||||
enabled: existing.enabled,
|
enabled: existing.enabled,
|
||||||
owner_user,
|
owner_user,
|
||||||
// Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden
|
mount_point,
|
||||||
// eventuell noch aktive Mounts verwaisen.
|
|
||||||
mount_point: existing.mount_point.clone(),
|
|
||||||
local: LocalSide {
|
local: LocalSide {
|
||||||
kind: local_kind,
|
kind: local_kind,
|
||||||
address: local_address,
|
address: local_address,
|
||||||
@@ -394,6 +417,62 @@ fn resolve_field(
|
|||||||
Ok(Some(input.interact_text()?))
|
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
|
/// 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
|
/// `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
|
/// ohne extra Angabe vollen Zugriff auf sein eigenes Laufwerkspaar bekommt. `None`, wenn direkt
|
||||||
@@ -579,3 +658,80 @@ fn resolve_credentials(
|
|||||||
.interact()?;
|
.interact()?;
|
||||||
Ok((Some(username), Some(password)))
|
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
@@ -11,13 +11,15 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene
|
/// `/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
|
/// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei
|
||||||
/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie
|
/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie
|
||||||
/// persistieren müssen. Ein einzelner, flacher Namensraum reicht: smart-mount läuft
|
/// persistieren müssen. Jedes Paar bekommt direkt darunter sein eigenes, nach seinem Namen
|
||||||
/// ausschließlich als root/System-Dienst (siehe [`crate::systemd`]) und mountet dort auch
|
/// benanntes Unterverzeichnis (`/run/media/<Name>`, siehe `cli::drive::default_mount_point`) -
|
||||||
/// Paare mit gesetztem `owner_user` - die Trennung nach Linux-Nutzer passiert über die
|
/// kein zusätzlicher `smart-mount`-Zwischenordner, damit der Ordnername beim Durchsuchen von
|
||||||
/// `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über
|
/// `/run/media` sofort erkennbar ist. Die Trennung nach Linux-Nutzer passiert bei Bedarf über
|
||||||
/// unterschiedliche Mountpoint-Namensräume.
|
/// 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 {
|
fn default_mount_base_dir() -> PathBuf {
|
||||||
PathBuf::from("/run/media/smart-mount")
|
PathBuf::from("/run/media")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_log_level() -> String {
|
fn default_log_level() -> String {
|
||||||
@@ -195,9 +197,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_mount_base_dir_uses_run_media() {
|
fn default_mount_base_dir_uses_run_media() {
|
||||||
assert_eq!(
|
assert_eq!(default_mount_base_dir(), PathBuf::from("/run/media"));
|
||||||
default_mount_base_dir(),
|
|
||||||
PathBuf::from("/run/media/smart-mount")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user