Author SHA1 Message Date
DragonSlayer_14 705ecd8142 Merge pull request 'Merge dev in testing: Automatischer Aufruf von sudo' (#4) from dev into testing
Unit-Tests / Unit-Tests (pull_request) Skipped
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 11s
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 14s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 25s
Security Scans / Trivy & OSV-Scanner (push) Successful in 41s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 42s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Successful in 12m0s
TruffleHog Secret Scan / TruffleHog (push) Successful in 10s
Reviewed-on: #4
2026-09-20 14:24:48 +00:00
Gitea-Bot 9c34fc93cb Chore: Erhöht Patch-Version auf 2.0.1 für Promotion nach testing
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 29s
Security Scans / Trivy & OSV-Scanner (push) Successful in 55s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 55s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 26s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m39s
Unit-Tests / Unit-Tests (pull_request) Successful in 3m20s
2026-09-20 14:19:22 +00:00
DragonSlayer_14andClaude Sonnet 5 cd9a016cf8 Feature: require_root startet sich bei fehlenden Root-Rechten automatisch per sudo neu
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 15s
Security Scans / Trivy & OSV-Scanner (push) Successful in 24s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 33s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 10s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 17s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Successful in 12s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 35s
Unit-Tests / Unit-Tests (pull_request) Successful in 4m6s
Statt nur mit einer Fehlermeldung abzubrechen, ersetzt sich der Prozess
für root-pflichtige Commands (mount/unmount/watch/service crontab/
drive add/edit/remove) jetzt transparent über sudo-ctdra::run_as_root()
via execve mit sudo neu; der Fehlerpfad greift nur noch, wenn sudo
selbst nicht gefunden werden kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:15:42 +02:00
DragonSlayer_14andClaude Sonnet 5 d88dcac7a0 Fix: Entfernt nmap als eigene Abhängigkeit, mac2ip verlangt es bereits selbst
nmap wird von smart-mount nie direkt aufgerufen, sondern nur intern von
mac2ip für den MAC->IP-Scan verwendet. Da das mac2ip-Paket nmap bereits
selbst als Abhängigkeit deklariert (siehe dessen Depends), war die
zusätzliche Nennung in den deb-/rpm-/arch-Paketmetadaten sowie der
nmap-Check in `doctor` redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:10:02 +02:00
DragonSlayer_14 7f5c8db6ce Merge pull request 'Merge dev in testing: Ref: Entfernt vollständige 32-Bit-Unterstützung (i686)' (#3) from dev into testing
Unit-Tests / Unit-Tests (pull_request) Skipped
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 12s
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 13s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 27s
Security Scans / Trivy & OSV-Scanner (push) Successful in 44s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 45s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Successful in 12m11s
TruffleHog Secret Scan / TruffleHog (push) Successful in 10s
Reviewed-on: #3
2026-09-20 12:20:30 +00:00
DragonSlayer_14andClaude Sonnet 5 09cb728f30 Ref: Entfernt vollständige 32-Bit-Unterstützung (i686)
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 17s
Security Scans / Trivy & OSV-Scanner (push) Successful in 26s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 35s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 9s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Skipped
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 19s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 28s
Unit-Tests / Unit-Tests (pull_request) Successful in 1m52s
Baut, paketiert und dokumentiert nur noch x86_64 und aarch64;
i686-Cross-Compilation-Toolchain, -Targets und -Paketierungsschritte
in den Gitea-Workflows sowie die i686-Linker-Konfiguration entfallen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:14:38 +02:00
DragonSlayer_14 2f33e2202d Merge pull request 'Merge dev in testing: Neue Version 2.0.0 mit kompletter Überarbeitung' (#1) from dev into testing
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 10s
Unit-Tests / Unit-Tests (pull_request) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 20s
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 15s
Security Scans / Trivy & OSV-Scanner (push) Successful in 36s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 24s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 34s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Failing after 13m0s
Reviewed-on: #1
2026-09-20 11:44:44 +00:00
DragonSlayer_14 f71f43a7c0 Ref: Entfernt nicht verwendete Crate program-ctdra.
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 18s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 28s
Security Scans / Trivy & OSV-Scanner (push) Successful in 45s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 14s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 51s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Successful in 10s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m23s
Unit-Tests / Unit-Tests (pull_request) Successful in 2m33s
2026-09-20 13:40:48 +02:00
DragonSlayer_14andClaude Sonnet 5 e2e6c06bce 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>
2026-09-20 13:36:12 +02:00
DragonSlayer_14andClaude Sonnet 5 bd319523a7 Fix: Mount-Verzeichnisse bekommen explizite Rechte statt sich auf Umask zu verlassen
mount_base_dir (von allen Paaren gemeinsam genutzt) sowie die versteckten
Backing-Verzeichnisse eines Paares wurden bisher nur per create_dir_all()
angelegt, ohne eigenes chmod/chown - die tatsaechlichen Zugriffsrechte hingen
also allein vom Umask des root-Prozesses ab statt von einer bewussten
Entscheidung.

- activate_symlink() setzt den gemeinsamen Elternordner (mount_base_dir) jetzt
  hart auf 0755, unabhaengig vom Umask.
- reconcile::mount_side() chownt das jeweilige Backing-Verzeichnis eines
  Paares mit gesetztem owner_user auf diesen Nutzer und setzt 0700 (neue
  Funktion target::ensure_owner_only_dir) - insbesondere fuer NFS relevant,
  wo es (anders als CIFS/WebDAV) keine uid=/gid=-Mount-Option gibt, die den
  Zugriff nach dem Mounten sonst regeln wuerde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:34:06 +02:00
DragonSlayer_14andClaude Sonnet 5 f0c0a69b9c Feature: Eingerichtetes Laufwerkspaar gehoert automatisch dem einrichtenden sudo-Nutzer
'smart-mount drive add' setzt 'owner_user' jetzt standardmaessig auf den
Nutzer, in dessen Namen 'sudo' aufgerufen wurde ($SUDO_USER) - wer ein Paar
einrichtet, bekommt also ohne weiteres Zutun vollen Zugriff darauf (uid/gid).
Ueberschreibbar per '--owner-user <name>' (nicht-interaktiv) oder im
interaktiven Prompt, der jetzt auch den konkreten Nutzernamen nennt und dort
abgelehnt oder auf einen anderen Namen geaendert werden kann. 'drive edit'
bleibt unveraendert (Default bleibt der bisherige Owner).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:32:45 +02:00
DragonSlayer_14andClaude Sonnet 5 c6526e2130 Refactor: Systemd-Dienst laeuft nur noch systemweit, Paketierung uebernimmt Installation
SmartMount richtet sich beim Installieren des .deb/.rpm/.pkg.tar.zst-Pakets
automatisch als System-systemd-Dienst ein (statische Units unter
packaging/systemd/, aktiviert/deaktiviert ueber postinst/postrm bzw. die
generate-rpm-/Arch-Paketierungs-Skriptlets) und entfernt sich beim
Deinstallieren wieder - 'smart-mount service install/uninstall' faellt aus
der CLI, 'smart-mount service crontab' bleibt als manueller Fallback fuer
Systeme ohne systemd.

Der bisherige User-Scope (MountContext::User, unprivilegiertes Mounten ueber
/etc/fstab, 'setup fstab', systemd --user) faellt komplett weg: smart-mount
mountet jetzt jedes konfigurierte Paar immer direkt als root, unabhaengig
davon, ob/fuer welchen Nutzer 'owner_user' gesetzt ist. Damit einher gehen:

- Eine einzige, systemweite Konfiguration (/etc/smart-mount/config.toml)
  statt Root-/Nutzerpfad-Umschaltung - 'drive add/edit/remove' sowie
  'mount'/'unmount'/'watch' verlangen deshalb jetzt explizit Root.
- Der Master-Schluessel fuer die Zugangsdaten-Verschluesselung kommt nur noch
  aus der Schluesseldatei (kein OS-Keyring mehr moeglich, da nie unprivilegiert
  ausgefuehrt) - die 'keyring'-Abhaengigkeit entfaellt.
- Der Cron-Fallback schreibt nur noch /etc/cron.d/smart-mount (immer root),
  da eine persoenliche Nutzer-Crontab ohnehin nie mounten koennte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:32:02 +02:00
Gitea-Bot 17c1f6d2ba Style: Automatische Formatierung & Clippy-Fixes
Security Scans / Trivy & OSV-Scanner (push) Successful in 28s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 18s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 36s
2026-09-19 19:54:19 +00:00
36 changed files with 769 additions and 2569 deletions
-3
View File
@@ -10,6 +10,3 @@ git-fetch-with-cli = true
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.i686-unknown-linux-gnu]
linker = "i686-linux-gnu-gcc"
+2 -7
View File
@@ -64,7 +64,7 @@ jobs:
- name: Install Cross-Compilation Toolchains (apt)
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-i686-linux-gnu
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
@@ -75,12 +75,11 @@ jobs:
with:
path: |
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/i686-unknown-linux-gnu
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
- name: Add Rust Cross-Compilation Targets
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
run: rustup target add aarch64-unknown-linux-gnu i686-unknown-linux-gnu
run: rustup target add aarch64-unknown-linux-gnu
- name: PATH um Cargo-bin-Verzeichnis ergänzen
run: |
@@ -111,7 +110,6 @@ jobs:
run: |
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target aarch64-unknown-linux-gnu
cargo build --release --target i686-unknown-linux-gnu
- name: Determine Build Number
id: build_num
@@ -130,20 +128,17 @@ jobs:
run: |
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
cargo deb --target i686-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
- name: Build Fedora / RPM Packages (.rpm)
run: |
mkdir -p target/generate-rpm
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
cargo generate-rpm --target i686-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
- name: Build Arch Linux Packages (.pkg.tar.zst)
run: |
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
python3 scripts/package-arch.py --target i686-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
- name: Publish Packages to Gitea Package Registry
env:
+2 -7
View File
@@ -64,7 +64,7 @@ jobs:
- name: Install Cross-Compilation Toolchains (apt)
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-i686-linux-gnu
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
@@ -75,12 +75,11 @@ jobs:
with:
path: |
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/i686-unknown-linux-gnu
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
- name: Add Rust Cross-Compilation Targets
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
run: rustup target add aarch64-unknown-linux-gnu i686-unknown-linux-gnu
run: rustup target add aarch64-unknown-linux-gnu
- name: PATH um Cargo-bin-Verzeichnis ergänzen
run: |
@@ -111,7 +110,6 @@ jobs:
run: |
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target aarch64-unknown-linux-gnu
cargo build --release --target i686-unknown-linux-gnu
- name: Determine Build Number
id: build_num
@@ -130,20 +128,17 @@ jobs:
run: |
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
cargo deb --target i686-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
- name: Build Fedora / RPM Packages (.rpm)
run: |
mkdir -p target/generate-rpm
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
cargo generate-rpm --target i686-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
- name: Build Arch Linux Packages (.pkg.tar.zst)
run: |
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
python3 scripts/package-arch.py --target i686-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
- name: Publish Packages to Gitea Package Registry
env:
+16
View File
@@ -3,6 +3,22 @@
<component name="JsonSchemaMappingsProjectConfiguration">
<state>
<map>
<entry key="Cargo Config">
<value>
<SchemaInfo>
<option name="name" value="Cargo Config" />
<option name="relativePathToSchema" value="https://www.schemastore.org/cargo-config.json" />
<option name="applicationDefined" value="true" />
<option name="patterns">
<list>
<Item>
<option name="path" value=".cargo/config.toml" />
</Item>
</list>
</option>
</SchemaInfo>
</value>
</entry>
<entry key="GitHub Workflow">
<value>
<SchemaInfo>
+2 -2
View File
@@ -7,7 +7,7 @@ Dieses Dokument dient als technischer Leitfaden und Kontextdokument für KI-Codi
## 1. Projektübersicht & Philosophie
Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und CLI-Tools mit Fokus auf:
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`, `i686`).
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`).
- Native Paketierung für Debian (`.deb`), Fedora/RHEL (`.rpm`) und Arch Linux (`.pkg.tar.zst`) sowie Docker-Container-Images.
- Vollständig automatisierte CI/CD-Pipelines via Gitea Actions (kompatibel mit Forgejo / GitHub Actions).
- Automatisierte Sicherheits-Scans (Schwachstellen, Secrets) und Dependency-Updates.
@@ -15,7 +15,7 @@ Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und C
### Standards & Tech-Stack
- **Sprache**: Rust (Edition 2024), Python 3 (für Hilfsskripte in `scripts/`).
- **Rust Toolchain**: Stable.
- **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `i686-unknown-linux-gnu`).
- **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`).
- **Container**: Docker-Images werden zusätzlich zu den nativen Paketen gebaut und in die Gitea Container Registry veröffentlicht.
- **Sicherheits-Tooling**: Trivy, OSV-Scanner, TruffleHog (Secret-Scanning), Renovate (Dependency-Updates), Qodana (statische Codeanalyse).
- **Lizenz**: GPL-3.0-or-later (sofern nicht im abgeleiteten Projekt anders definiert).
Generated
+1 -634
View File
@@ -178,17 +178,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
dependencies = [
"keyring-core",
"log",
"security-framework",
]
[[package]]
name = "arc-swap"
version = "1.9.2"
@@ -224,143 +213,6 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.4",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.4",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.4",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -436,28 +288,6 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "branches"
version = "0.4.6"
@@ -505,15 +335,6 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "cc"
version = "1.4.6"
@@ -714,22 +535,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc 0.2.189",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -860,18 +665,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -895,33 +688,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "env_filter"
version = "2.0.0"
@@ -969,26 +735,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -1030,36 +776,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -1073,7 +789,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
@@ -1203,24 +918,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.12"
@@ -1453,7 +1150,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"hybrid-array",
]
@@ -1518,27 +1214,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyring"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]]
name = "keyring-core"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
dependencies = [
"log",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1755,20 +1430,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -1779,15 +1440,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -1803,27 +1455,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1860,16 +1491,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "owo-colors"
version = "3.5.0"
@@ -1885,12 +1506,6 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1926,17 +1541,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -2010,15 +1614,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -2307,48 +1902,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
dependencies = [
"aes 0.9.3",
"cbc",
"futures-util",
"getrandom 0.4.3",
"hkdf",
"hybrid-array",
"num",
"once_cell",
"serde",
"sha2",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc 0.2.189",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc 0.2.189",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -2398,17 +1951,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -2424,17 +1966,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -2521,7 +2052,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "smart-mount"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"aes-gcm 0.11.1",
"anyhow",
@@ -2530,9 +2061,7 @@ dependencies = [
"config-ctdra",
"dialoguer",
"getrandom 0.4.3",
"keyring",
"logger-ctdra",
"program-ctdra",
"serde",
"serde_json",
"sudo-ctdra",
@@ -2803,18 +2332,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
@@ -3111,17 +2628,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "uncased"
version = "0.9.10"
@@ -3195,7 +2701,6 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
@@ -3325,19 +2830,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3449,9 +2941,6 @@ name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
@@ -3503,87 +2992,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc 0.2.189",
"ordered-stream",
"rustix 1.1.4",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus-secret-service-keyring-store"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
dependencies = [
"keyring-core",
"secret-service",
"zbus",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zerocopy"
version = "0.8.57"
@@ -3671,44 +3079,3 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.5",
"winnow 1.0.4",
]
+32 -10
View File
@@ -1,6 +1,6 @@
[package]
name = "smart-mount"
version = "2.0.0"
version = "2.0.1"
edition = "2024"
authors = ['DragonSlayer_14']
readme = "README.md"
@@ -11,7 +11,6 @@ description = "Bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein
[dependencies]
config-ctdra = { version = "1.0.6", registry = "gitea" }
logger-ctdra = { version = "1.0.5", registry = "gitea" }
program-ctdra = { version = "1.0.1", registry = "gitea" }
sudo-ctdra = { version = "1.0.1", registry = "gitea" }
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4"
@@ -26,7 +25,6 @@ serde_json = "1"
thiserror = "2"
anyhow = "1"
aes-gcm = "0.11.1"
keyring = "4.2.0"
dialoguer = "0.12.0"
uuid = { version = "1", features = ["v4"] }
# Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms
@@ -48,22 +46,28 @@ maintainer = "DragonSlayer_14"
copyright = "2026 DragonSlayer_14"
section = "net"
priority = "optional"
# mac2ip und nmap sind harte Laufzeitabhängigkeiten (MAC->IP-Auflösung); davfs2/cifs-utils/
# nfs-common werden bewusst NICHT hart verlangt, da smart-mount pro konfiguriertem Laufwerk
# nur das jeweils benötigte Mount-Backend zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
depends = "$auto, mac2ip, nmap"
# mac2ip ist eine harte Laufzeitabhängigkeit (MAC->IP-Auflösung;
# davfs2/cifs-utils/nfs-common werden bewusst NICHT hart verlangt,
# da smart-mount pro konfiguriertem Laufwerk nur das jeweils benötigte Mount-Backend
# zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
depends = "$auto, mac2ip"
recommends = "davfs2, cifs-utils, nfs-common"
extended-description = """\
smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud-Laufwerk
ein: ist das lokale Laufwerk erreichbar, wird es gemountet, sonst automatisch das
Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf
zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank
gespeichert; smart-mount kann als root/System-Dienst und als Nutzer-Dienst laufen.\
gespeichert; smart-mount richtet sich beim Installieren automatisch als System-systemd-Dienst
ein und mountet konfigurierte Laufwerkspaare für die jeweils zugeordneten lokalen Nutzer.\
"""
maintainer-scripts = "packaging/deb"
assets = [
["target/release/smart-mount", "usr/bin/smart-mount", "755"],
["README.md", "usr/share/doc/smart-mount/README.md", "644"],
["LICENSE", "usr/share/doc/smart-mount/copyright", "644"],
["packaging/systemd/smart-mount-mount.service", "usr/lib/systemd/system/smart-mount-mount.service", "644"],
["packaging/systemd/smart-mount-watch.service", "usr/lib/systemd/system/smart-mount-watch.service", "644"],
["packaging/systemd/smart-mount-watch.timer", "usr/lib/systemd/system/smart-mount-watch.timer", "644"],
]
[package.metadata.generate-rpm]
@@ -71,14 +75,32 @@ assets = [
{ source = "target/release/smart-mount", dest = "/usr/bin/smart-mount", mode = "755" },
{ source = "README.md", dest = "/usr/share/doc/smart-mount/README.md", mode = "644", doc = true },
{ source = "LICENSE", dest = "/usr/share/licenses/smart-mount/LICENSE", mode = "644", license = true },
{ source = "packaging/systemd/smart-mount-mount.service", dest = "/usr/lib/systemd/system/smart-mount-mount.service", mode = "644" },
{ source = "packaging/systemd/smart-mount-watch.service", dest = "/usr/lib/systemd/system/smart-mount-watch.service", mode = "644" },
{ source = "packaging/systemd/smart-mount-watch.timer", dest = "/usr/lib/systemd/system/smart-mount-watch.timer", mode = "644" },
]
requires = { "mac2ip" = "*", "nmap" = "*" }
requires = { "mac2ip" = "*" }
suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" }
# postinst/postrm-Äquivalente: richten den paketierten systemd-Dienst automatisch ein/entfernen
# (Gegenstück zu packaging/deb/postinst+postrm). $1 in %postun: Anzahl verbleibender Versionen
# nach diesem Schritt - 0 nur bei vollständiger Deinstallation, nicht bei einem Upgrade.
post_install_script = """
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true
fi
"""
post_uninstall_script = """
if [ "$1" = "0" ] && command -v systemctl >/dev/null 2>&1; then
systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true
systemctl daemon-reload || true
fi
"""
[package.metadata.arch]
pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben
arch = "x86_64"
depends = ["gcc-libs", "glibc", "mac2ip", "nmap"]
depends = ["gcc-libs", "glibc", "mac2ip"]
optdepends = [
"davfs2: WebDAV-Laufwerke einbinden",
"cifs-utils: SMB/CIFS-Laufwerke einbinden",
+126 -114
View File
@@ -14,8 +14,9 @@ werden (z. B. über ein separates Sync-Tool).
## Voraussetzungen
- **[`mac2ip`](https://gitea.creative-dragonslayer.de/Linuxapps/Mac2Ip)** und **`nmap`**: hart
benötigt, sofern lokale Laufwerke per MAC-Adresse adressiert werden (Auflösung MAC → IP).
- **[`mac2ip`](https://gitea.creative-dragonslayer.de/Linuxapps/Mac2Ip)**: hart benötigt, sofern
lokale Laufwerke per MAC-Adresse adressiert werden (Auflösung MAC → IP). `mac2ip` verlangt
`nmap` bereits selbst als eigene Paketabhängigkeit.
- **`timeout`** (GNU coreutils): auf praktisch jedem Linux-System bereits vorhanden (Basis-
Systempaket). Wird verwendet, um `mount`/`umount`-Aufrufe zeitlich zu begrenzen - siehe
"Verhalten bei nicht mehr erreichbarem Server" unten.
@@ -24,28 +25,31 @@ werden (z. B. über ein separates Sync-Tool).
- `davfs2` für WebDAV-Laufwerke
- `cifs-utils` für SMB/CIFS-Laufwerke
- `nfs-common` (Debian/Ubuntu) bzw. `nfs-utils` (Fedora/Arch) für NFS-Laufwerke
- Für MAC-basierte lokale Laufwerke im **Nutzerkontext**: `mac2ip` löst über `ip neigh` auf,
ohne dafür Root-Rechte zu benötigen - nur der letzte Fallback-Schritt (ein aktiver
`nmap`-Scan, falls das Zielgerät nicht in der ARP-Nachbartabelle steht) braucht
passwortlosen `sudo`-Zugriff auf `nmap`. Ohne das schlägt die Auflösung in diesem Fall sauber
fehl (kein Absturz) - entweder passwortlosen `sudo` für `nmap` einrichten, oder das
entsprechende Laufwerkspaar im System-/root-Kontext betreiben.
- `systemd`: smart-mount richtet sich beim Installieren des Pakets automatisch als
System-Dienst ein (siehe "Automatischer Start beim Systemstart" unten). Ohne systemd bleibt
`smart-mount service crontab` als manueller Fallback.
---
## CLI-Nutzung
smart-mount läuft ausschließlich als root/System-Dienst - es gibt nur eine einzige,
systemweite Konfiguration. `drive add`/`edit`/`remove` sowie `mount`/`unmount`/`watch`
verlangen daher explizit Root-Rechte (`sudo`), auch für ein Laufwerkspaar, das nur für einen
bestimmten Nutzer gedacht ist (siehe `owner_user` unten). Rein lesende Befehle (`drive list`,
`status`, `doctor`) brauchen kein `sudo`.
```bash
# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Kontext, Mount-Typ, Adresse,
# Freigabe und ggf. Zugangsdaten ab)
smart-mount drive add
# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Mount-Typ, Adresse, Freigabe und ggf.
# Zugangsdaten ab)
sudo smart-mount drive add
# Nicht-interaktiv per Flags (für Skripte/Automatisierung) - fehlende Pflichtfelder sind
# dann ein Fehler statt eines (ohne Terminal ohnehin unmöglichen) Prompts. Jedes
# *-password-stdin-Flag liest genau eine Zeile von stdin (bei mehreren im selben Aufruf
# entsprechend mehrere Zeilen, eine pro Flag in Reihenfolge):
smart-mount drive add --non-interactive \
--name NAS --context system \
sudo smart-mount drive add --non-interactive \
--name NAS \
--local-kind smb --local-ip 192.168.1.50 --local-share share --local-username nasuser --local-password-stdin \
--cloud-kind webdav --cloud-host https://cloud.example.com/dav --cloud-share / --cloud-username clouduser --cloud-password cloud-pw-direkt \
<<< "lokales-passwort"
@@ -55,35 +59,33 @@ smart-mount drive add --non-interactive \
# Bestehendes Laufwerkspaar bearbeiten - nur angegebene Felder ändern sich, alles andere
# (inkl. gespeichertem Passwort) bleibt unangetastet. Ohne Flags: interaktiver Durchlauf,
# vorbelegt mit den aktuellen Werten (inkl. Rückfrage, ob das Passwort geändert werden soll).
smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99
smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort"
sudo smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99
sudo smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort"
# Konfigurierte Laufwerkspaare auflisten / entfernen (--json für Skripte)
smart-mount drive list
smart-mount drive list --json
smart-mount drive remove <id>
sudo smart-mount drive remove <id>
# Ein einzelnes Paar oder alle einbinden/aushängen
smart-mount mount --name <id>
smart-mount mount --all
smart-mount unmount --all
sudo smart-mount mount --name <id>
sudo smart-mount mount --all
sudo smart-mount unmount --all
# Aktuellen Status (Mount-Zustand + Erreichbarkeit beider Seiten) anzeigen (--json für Skripte)
smart-mount status
smart-mount status --json
# Ein Reconcile-Durchlauf (lokal/Cloud-Umschaltung) - für systemd-Timer/Cron gedacht
smart-mount watch
sudo smart-mount watch
# systemd-Units installieren (System- bzw. Nutzerkontext)
sudo smart-mount service install --system
smart-mount service install --user
# Cron-Fallback für Systeme ohne (genutzten) systemd - siehe "Automatischer Start beim
# Systemstart" unten
sudo smart-mount service crontab
sudo smart-mount service crontab --remove
# Einmaliges root-Setup, damit Nutzer-Kontext-Paare unprivilegiert (un)gemountet werden können
sudo smart-mount setup fstab
# Voraussetzungen prüfen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) - deckt
# gebündelt ab, was man sonst erst einzeln beim Mount-Fehlschlag entdecken würde
# Voraussetzungen prüfen (Binaries, Scheduler) - deckt gebündelt ab, was man sonst erst
# einzeln beim Mount-Fehlschlag entdecken würde
smart-mount doctor
smart-mount doctor --json
@@ -92,74 +94,95 @@ smart-mount completions bash > /etc/bash_completion.d/smart-mount
smart-mount completions zsh > "${fpath[1]}/_smart-mount"
```
Die Konfiguration liegt unter `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw.
`/etc/smart-mount/config.toml` (root/System-Kontext) - automatisch aufgelöst je nachdem, ob
`smart-mount` mit Root-Rechten läuft. Die verschlüsselte Zugangsdaten-Datenbank
(`smart-mount.db`) liegt im selben Verzeichnis.
Die Konfiguration liegt immer unter `/etc/smart-mount/config.toml`, unabhängig davon, ob
`smart-mount` selbst mit oder ohne Root-Rechte aufgerufen wird (siehe oben). Die verschlüsselte
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
für `mount_base_dir`: `/run/media/smart-mount` im System-Kontext (root, ein gemeinsamer,
keinem Nutzer zugeordneter Namensraum), `/run/media/<Nutzer>/smart-mount` im Nutzerkontext -
`/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 - eigenes Anlegen ist nicht nötig.
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.
**Achtung bei Nutzer-Kontext-Paaren:** `/run/media` gehört standardmäßig `root:root` mit Modus
`0755` - ein normaler Nutzer kann dort also nicht einmal sein eigenes Unterverzeichnis
anlegen. `sudo smart-mount setup fstab` übernimmt das einmalig (legt `/run/media/<Nutzer>`
sowie `/run/media/<Nutzer>/smart-mount` an und macht den Nutzer zum Besitzer) - ohne diesen
Schritt schlägt das automatische Anlegen für Nutzer-Kontext-Paare fehl.
**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`:
Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit
auf einen beliebigen anderen Pfad ändern.
```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 für `mount_base_dir` lässt sich in `config.toml` unter
`[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern.
---
## Automatischer Start beim Systemstart
Das .deb/.rpm/.pkg.tar.zst-Paket richtet smart-mount beim Installieren **automatisch** als
System-systemd-Dienst ein - kein manueller Schritt nötig. Die dafür paketierten Unit-Dateien
(`packaging/systemd/` im Quellbaum, installiert nach `/usr/lib/systemd/system/`) bestehen aus:
- `smart-mount-mount.service`: ein `oneshot`-Service, der beim Boot `smart-mount mount --all`
aufruft (nach `network-online.target`).
- `smart-mount-watch.service` + `smart-mount-watch.timer`: ein Timer, der periodisch
`smart-mount watch` aufruft (Standardintervall: 120s).
Die postinst/postrm-Skripte des Pakets (bzw. das `.INSTALL`-Skriptlet bei Arch) aktivieren und
starten beide Units beim Installieren/Upgraden (`systemctl enable --now ...`) und deaktivieren
sie wieder beim Deinstallieren (`systemctl disable --now ...`) - siehe `packaging/deb/postinst`
+ `packaging/deb/postrm` bzw. `[package.metadata.generate-rpm]`/`scripts/package-arch.py` in
`Cargo.toml`. Welche Laufwerkspaare dabei gemountet werden (und für welchen Nutzer, siehe
`owner_user` unten) steuert ausschließlich `/etc/smart-mount/config.toml` - nicht die
Unit-Dateien selbst.
**Anderes Watch-Intervall als der Standard (120s):** `settings.watch_interval_secs` in
`config.toml` steuert nur den Cron-Fallback (s. u.) - der paketierte Timer hat ein fest
eingebautes Intervall. Zum Anpassen:
```bash
sudo smart-mount setup fstab # einmalig, nur nötig bei Nutzer-Kontext-Paaren
sudo smart-mount service install --system # für System-Kontext-Paare
smart-mount service install --user # für die eigenen Nutzer-Kontext-Paare
sudo systemctl edit smart-mount-watch.timer
# [Timer]
# OnUnitActiveSec=60s
```
`service install` wählt automatisch den passenden Mechanismus: ist `systemctl` vorhanden,
werden systemd-Units installiert (ein `oneshot`-Service für den initialen Mount beim Boot
sowie ein Timer, der periodisch `smart-mount watch` aufruft - Intervall:
`settings.watch_interval_secs`, Standard 120s). Ist kein systemd vorhanden, wird automatisch
auf Cron ausgewichen - als root wird `/etc/cron.d/smart-mount` geschrieben, als normaler
Nutzer die eigene, persönliche Crontab über `crontab -l`/`crontab -` aktualisiert (ein
verwalteter Block lässt dabei bereits vorhandene, unabhängige Cron-Einträge unangetastet und
verhindert Duplikate bei wiederholten Aufrufen). Ist weder systemd noch Cron vorhanden, werden
stattdessen die beiden äquivalenten Zeilen zum manuellen Eintragen ausgegeben:
### Cron-Fallback (Systeme ohne systemd)
```bash
sudo smart-mount service crontab # einrichten
sudo smart-mount service crontab --remove # wieder entfernen
```
Erfordert Root (wie `mount`/`watch` - Mounten läuft immer als root, siehe oben) und schreibt
`/etc/cron.d/smart-mount` - das Intervall folgt `settings.watch_interval_secs`. Ist
`/etc/cron.d` nicht vorhanden, werden stattdessen die beiden äquivalenten Zeilen zum manuellen
Eintragen ausgegeben:
```cron
@reboot smart-mount mount --all
*/2 * * * * smart-mount watch
```
`smart-mount service crontab` erzwingt gezielt den Cron-Weg (z. B. um systemd bewusst zu
umgehen), mit identischem Verhalten wie der automatische Fallback von `install`.
```bash
sudo smart-mount service uninstall --system
smart-mount service uninstall --user
```
räumt alles wieder auf, was `install`/`crontab`/`setup fstab` eingerichtet haben - systemd-
Units (falls vorhanden), den Cron-Eintrag (falls vorhanden) und - nur bei `--system`, da
`setup fstab` root-weit für alle Nutzer-Kontext-Paare gilt - den verwalteten `/etc/fstab`-
Block. Jeder Teil wird unabhängig geprüft: fehlt etwas (z. B. weil nur Cron statt systemd
installiert war), wird das ohne Fehler übersprungen; die Ausgabe listet, was tatsächlich
entfernt wurde. Backing-Verzeichnisse, gemountete Daten und Gruppenmitgliedschaften (z. B. in
der `davfs2`-Gruppe) werden dabei bewusst **nicht** angerührt - dafür gibt es keine
automatische Umkehrung, da das ungewollte Nebenwirkungen haben könnte (siehe
Architekturentscheidungen unten).
---
## Architekturentscheidungen
@@ -189,39 +212,26 @@ Architekturentscheidungen unten).
beim Umschalten zuerst die neue Seite gemountet und der Symlink umgebogen wird, bevor die
alte Seite (mit eben diesem tolerierten Timeout) ausgehängt wird, blockiert ein
hängender Alt-Mount den sichtbaren Wechsel ohnehin nicht.
- **Privilegienmodell für Nutzer-Mounts**: `sudo smart-mount setup fstab` schreibt einmalig
`/etc/fstab`-Einträge mit `user,exec,noauto` sowie nötige Gruppenmitgliedschaften (z. B.
`davfs2`-Gruppe). Jede Seite (lokal/Cloud) bekommt dabei ihr **eigenes, eindeutiges**
verstecktes Backing-Verzeichnis (nicht denselben Mountpoint für beide) - das entspricht
exakt dem einzigen in `man 8 mount` ("Non-superuser mounts") dokumentierten Fall, statt sich
auf unspezifiziertes Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der
konfigurierte, sichtbare Mountpoint selbst ist ein Symlink, den SmartMount zur Laufzeit
zwischen den beiden Backing-Verzeichnissen umschaltet. `exec` wird explizit gesetzt, weil die
`user`-Option laut `man 8 mount` sonst für jedes Dateisystem automatisch `noexec` erzwingt -
ohne das könnten auf einem Nutzer-Kontext-Laufwerk liegende Skripte nicht ausgeführt werden.
- **Privilegienmodell**: SmartMount läuft ausschließlich als root/System-Dienst - es gibt
keinen unprivilegierten Mount-Weg mehr (früher: `/etc/fstab`-Einträge mit `user,noauto`, über
die ein einzelner Nutzer unprivilegiert selbst mounten konnte). Root mountet direkt
(`mount -t <typ> <quelle> <ziel> -o <optionen>`) für jedes konfigurierte Paar, unabhängig
davon, ob/für welchen Nutzer `owner_user` gesetzt ist - siehe `owner_user` unten für die
Zugriffssteuerung. Das vereinfacht das Rechtemodell erheblich und schließt nebenbei die
frühere strukturelle CIFS-Lücke (jeder unprivilegiert mount-berechtigte Nutzer konnte zuvor
jedes CIFS-Nutzer-Kontext-Paar mounten, nicht nur sein eigenes) - Zugangsdaten sind jetzt nur
noch root zugänglich.
- **Voller Zugriff für einen bestimmten Nutzer (`owner_user`)**: Bei CIFS/WebDAV (keine
nativen Unix-Rechte) setzt SmartMount automatisch `uid=`/`gid=`/`file_mode=0700`/
`dir_mode=0700`, sobald ein Paar ein `owner_user` hat (Pflicht bei Nutzer-Kontext-Paaren,
optional bei System-Kontext). Das ist nicht nur für vollen Zugriff (inkl. Skript-Ausführung)
nötig, sondern bei WebDAV auch sicherheitsrelevant: `mount.davfs` erlaubt einem
unprivilegierten Nutzer das Mounten einer `user`-Zeile laut `man mount.davfs` nur, wenn
`uid=` auf ihn selbst zeigt - ohne das dürfte jedes Mitglied der Gruppe `davfs2` jedes
konfigurierte Paar mounten, nicht nur sein eigenes (`setup fstab` verweigert daher Paare ohne
`owner_user`).
**Bekannte, bewusst nicht behobene Lücke bei CIFS im Nutzerkontext:** `man mount.cifs`
bestätigt, dass `uid=`/`gid=` dort **ausschließlich** die simulierte Datei-Ownership nach dem
Mount betreffen - anders als bei davfs2 gibt es **keinen** Mechanismus, der das Mount-*Recht*
einer `user`-fstab-Zeile auf eine bestimmte Person einschränkt. Jeder lokale Nutzer, der
unprivilegiert mounten darf, kann daher aktuell jedes konfigurierte CIFS-Nutzer-Kontext-Paar
mounten (nicht nur sein eigenes) und dabei dessen gespeicherte Zugangsdaten für die Dauer des
Mounts mitbenutzen. Das ist eine strukturelle Grenze von `mount(8)`/`mount.cifs`, keine Lücke,
die sich über Mount-Optionen schließen ließe. **Konsequenz:** CIFS-Nutzer-Kontext-Paare nur
auf Einzelnutzer-Maschinen oder unter sich gegenseitig bereits vertrauenden lokalen Nutzern
einsetzen. davfs2 ist von diesem Problem nicht betroffen (siehe oben); bei NFS gibt es keine
clientseitige `uid=`/`gid=`-Option, Zugriff bestimmt dort ausschließlich der Server über die
tatsächlichen Datei-Eigentümer/-Rechte des Exports - wer die Freigabe mounten kann, sieht
dadurch nicht automatisch fremde Daten.
`dir_mode=0700`, sobald ein Paar ein `owner_user` hat - optional, ohne `owner_user` gehört
der Mount root. `smart-mount drive add` setzt `owner_user` standardmäßig auf den Nutzer, der
`sudo` aufgerufen hat (`$SUDO_USER`) - wer ein Paar einrichtet, bekommt also automatisch
vollen Zugriff darauf, sofern nicht per `--owner-user` (nicht-interaktiv) oder im
interaktiven Prompt bewusst ein anderer Nutzer angegeben bzw. abgelehnt wird. Bei NFS gibt es
keine clientseitige `uid=`/`gid=`-Option, Zugriff bestimmt dort ausschließlich der Server über
die tatsächlichen Datei-Eigentümer/-Rechte des Exports - `owner_user` wirkt sich hier nur auf
das lokale Backing-Verzeichnis selbst aus (`chown`+`0700`, s. o.), nicht auf das, was der
Server tatsächlich exportiert.
- **davfs2-Konfiguration**: SmartMount setzt in `davfs2.conf` automatisch `gui_optimize 1`
(bündelt PROPFIND-Anfragen, wichtig für grafische Dateimanager) sowie `buf_size 16384`
(deutlich über dem Standard von 16 KiB) - Letzteres behebt ein bekanntes Praxisproblem, bei
@@ -230,10 +240,9 @@ Architekturentscheidungen unten).
kleinem `buf_size` stillschweigend abgeschnitten).
- **Zugangsdaten-Verschlüsselung**: Turso hat aktuell keine produktionsreife eingebaute
Verschlüsselung, daher verschlüsselt SmartMount Passwörter selbst (AES-256-GCM) vor der
Ablage. Der Master-Schlüssel wird bevorzugt im OS-Keyring (GNOME Keyring/KWallet über
secret-service) abgelegt; ist keins verfügbar (typisch für den root/System-Dienst sowie
Headless-Systeme), wird automatisch auf eine Schlüsseldatei (`chmod 600`, neben der
Konfiguration) zurückgegriffen.
Ablage. Der Master-Schlüssel liegt in einer Schlüsseldatei (`chmod 600`, neben der
Konfiguration, also `/etc/smart-mount/master.key`) - kein OS-Keyring, da SmartMount
ausschließlich als root/System-Dienst läuft, für den es kein Nutzer-Keyring gibt.
- **`mac2ip`-Integration**: SmartMount ruft `mac2ip --json --auto-trust-networks <mac>` auf.
`--auto-trust-networks` lässt mac2ip die eigene "nmap-Scan in diesem Netzwerk erlauben?"-
Rückfrage automatisch bejahen und dauerhaft in seiner eigenen Cache-Datenbank merken - das
@@ -249,6 +258,10 @@ Architekturentscheidungen unten).
│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry)
├── .gitea/
│ └── workflows/ # CI/CD-Pipelines (Build, Tests, Security-Scans, Releases)
├── packaging/
│ ├── systemd/ # Statische systemd-Unit-Dateien (paketiert nach
│ │ # /usr/lib/systemd/system/)
│ └── deb/ # postinst/postrm für das .deb-Paket
├── scripts/
│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer
│ ├── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen
@@ -263,8 +276,7 @@ Architekturentscheidungen unten).
│ ├── mount/ # WebDAV/SMB/NFS-Backends, dynamischer Dispatch
│ ├── network/ # Erreichbarkeit, mac2ip-Integration
│ ├── reconcile/ # Watchdog-Entscheidungslogik (lokal/Cloud-Umschaltung)
── systemd/ # systemd-Unit-Generierung/-Installation
│ └── fstab/ # Einmaliges root-Setup für Nutzer-Mounts
── systemd/ # Cron-Fallback (Systeme ohne systemd)
├── tests/ # Cross-Modul-Integrationstests
├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch)
├── LICENSE # Lizenzdatei (GPL-3.0-or-later)
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Richtet smart-mount beim Installieren/Upgraden des Pakets automatisch als
# System-systemd-Dienst ein (Gegenstueck: postrm). Laeuft nur beim eigentlichen
# "configure"-Schritt (siehe Debian Policy Manual, Abschnitt 6.5), nicht bei
# "abort-upgrade"/"abort-remove" etc.
set -e
if [ "$1" = "configure" ] && command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true
fi
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Entfernt den beim Installieren eingerichteten systemd-Dienst wieder (Gegenstueck:
# postinst). Nur bei tatsaechlicher Entfernung ("remove"/"purge"), nicht bei einem
# Upgrade (dort ersetzt dpkg die Unit-Dateien einfach durch die neue Version, ohne den
# laufenden Dienst zwischenzeitlich zu deaktivieren).
set -e
if { [ "$1" = "remove" ] || [ "$1" = "purge" ]; } && command -v systemctl >/dev/null 2>&1; then
systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true
systemctl daemon-reload || true
fi
exit 0
@@ -0,0 +1,11 @@
[Unit]
Description=smart-mount: mount configured drive pairs at boot
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/smart-mount mount --all
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,6 @@
[Unit]
Description=smart-mount: check reachability and switch local/cloud if needed
[Service]
Type=oneshot
ExecStart=/usr/bin/smart-mount watch
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=smart-mount: periodic reconciling
[Timer]
OnBootSec=1min
OnUnitActiveSec=120s
Persistent=true
Unit=smart-mount-watch.service
[Install]
WantedBy=timers.target
+69 -7
View File
@@ -17,10 +17,6 @@ TARGET_ARCH_MAP = {
"aarch64-unknown-linux-musl": "aarch64",
"aarch64": "aarch64",
"arm64": "aarch64",
"i686-unknown-linux-gnu": "i686",
"i686-unknown-linux-musl": "i686",
"i686": "i686",
"i386": "i686",
}
@@ -45,6 +41,52 @@ def resolve_pkgrel(version=None, default="1"):
return str(default)
def collect_systemd_units(systemd_src_dir="packaging/systemd"):
"""Findet die im Projekt paketierten systemd-Unit-Dateien (falls vorhanden) und
unterscheidet dabei generisch - ohne einen Anwendungsnamen zu kennen - zwischen direkt
aktivierbaren Units (die einen '[Install]'-Abschnitt haben, z. B. Timer oder ein beim Boot
laufender oneshot-Service) und rein abhängigen Units (z. B. ein nur von einem Timer
ausgelöster Service ohne eigenen '[Install]'-Abschnitt, siehe `man systemd.unit`)."""
if not os.path.isdir(systemd_src_dir):
return [], []
unit_files = sorted(
f for f in os.listdir(systemd_src_dir) if f.endswith((".service", ".timer", ".socket"))
)
installable = []
for f in unit_files:
with open(os.path.join(systemd_src_dir, f)) as fh:
if "[Install]" in fh.read():
installable.append(f)
return unit_files, installable
def build_install_scriptlet(installable, all_units):
"""Erzeugt den Inhalt einer Arch-'.INSTALL'-Datei (siehe `man PKGBUILD`, Abschnitt
'install'), die den paketierten systemd-Dienst beim Installieren aktiviert/startet und beim
Entfernen wieder deaktiviert/stoppt - rein generisch anhand der tatsächlich gefundenen
Unit-Dateien, ohne Anwendungsnamen hart zu codieren."""
installable_str = " ".join(installable)
all_units_str = " ".join(all_units)
return f"""post_install() {{
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable --now {installable_str} >/dev/null 2>&1 || true
}}
post_upgrade() {{
systemctl daemon-reload >/dev/null 2>&1 || true
}}
pre_remove() {{
systemctl disable --now {all_units_str} >/dev/null 2>&1 || true
}}
post_remove() {{
systemctl daemon-reload >/dev/null 2>&1 || true
}}
"""
def build_package(target_triple=None, target_arch=None, pkgrel=None):
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]))
pkg = metadata["packages"][0]
@@ -102,6 +144,17 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None):
if os.path.exists("README.md"):
subprocess.run(["install", "-m", "644", "README.md", f"{doc_dir}/README.md"], check=True)
systemd_src_dir = "packaging/systemd"
all_units, installable_units = collect_systemd_units(systemd_src_dir)
if all_units:
unit_dir = os.path.join(build_dir, "usr/lib/systemd/system")
os.makedirs(unit_dir, exist_ok=True)
for unit in all_units:
subprocess.run(
["install", "-m", "644", os.path.join(systemd_src_dir, unit), os.path.join(unit_dir, unit)],
check=True,
)
installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0]
builddate = str(int(time.time()))
@@ -121,21 +174,30 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None):
pkginfo_lines.append(f"depend = {dep}")
for optdep in optdepends:
pkginfo_lines.append(f"optdepend = {optdep}")
if all_units:
pkginfo_lines.append(f"install = {name}.install")
pkginfo_lines.append("makepkgopt = strip\n")
with open(os.path.join(build_dir, ".PKGINFO"), "w") as f:
f.write("\n".join(pkginfo_lines))
tar_members = [".PKGINFO", "usr"]
if all_units:
install_script_name = f"{name}.install"
with open(os.path.join(build_dir, install_script_name), "w") as f:
f.write(build_install_scriptlet(installable_units, all_units))
tar_members.append(install_script_name)
os.makedirs("target/arch", exist_ok=True)
output_file = os.path.abspath(f"target/arch/{name}-{version}-{pkgrel}-{arch}.pkg.tar.zst")
subprocess.run(["tar", "--zstd", "-cf", output_file, ".PKGINFO", "usr"], cwd=build_dir, check=True)
subprocess.run(["tar", "--zstd", "-cf", output_file, *tar_members], cwd=build_dir, check=True)
print(f"Arch-Paket erfolgreich erstellt: {output_file}")
def main():
parser = argparse.ArgumentParser(description="Erstellt Arch Linux-Pakete (.pkg.tar.zst)")
parser.add_argument("--target", help="Rust Target-Triple (z.B. x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, i686-unknown-linux-gnu)")
parser.add_argument("--arch", help="Architektur (z.B. x86_64, aarch64, i686)")
parser.add_argument("--target", help="Rust Target-Triple (z.B. x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu)")
parser.add_argument("--arch", help="Architektur (z.B. x86_64, aarch64)")
parser.add_argument("--pkgrel", help="Release-/Build-Nummer (z.B. 1, 2, ...)")
args = parser.parse_args()
+193 -80
View File
@@ -6,13 +6,14 @@
//! 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};
use dialoguer::{Confirm, Input, Password, Select};
use smart_mount::config::{
self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountContext, MountKind,
self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountKind,
};
use smart_mount::db::credentials::{Credential, CredentialStore, Side};
@@ -40,8 +41,12 @@ pub enum DriveAction {
pub struct DriveArgs {
#[arg(long)]
name: Option<String>,
#[arg(long, value_enum)]
context: Option<MountContext>,
/// 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>,
@@ -108,10 +113,9 @@ async fn list(json: bool) -> anyhow::Result<()> {
}
for pair in &cfg.pairs {
println!(
"{} \"{}\" [{:?}] lokal={} cloud={} -> {}",
"{} \"{}\" lokal={} cloud={} -> {}",
pair.id,
pair.name,
pair.context,
pair.local.kind.as_str(),
pair.cloud.kind.as_str(),
pair.mount_point.display()
@@ -121,6 +125,8 @@ async fn list(json: bool) -> anyhow::Result<()> {
}
async fn remove(id: &str) -> anyhow::Result<()> {
crate::cli::require_root("drive remove")?;
// Vor dem Entfernen aus der Config nachschlagen, damit wir hinterher noch wissen, welche
// Mount-Typen/Adressen betroffen sind - nötig, um die passenden Klartext-Zugangsdaten
// (davfs2 secrets, .cred-Datei) aufzuräumen, siehe smart_mount::mount::cleanup_credentials.
@@ -148,11 +154,14 @@ async fn remove(id: &str) -> anyhow::Result<()> {
}
async fn add(args: DriveArgs) -> anyhow::Result<()> {
crate::cli::require_root("drive add")?;
let ni = args.non_interactive;
let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required");
let context = resolve_context(args.context, None, ni)?;
let owner_user = resolve_owner_user(args.owner_user, context, None, ni)?;
// Der Nutzer, der das Paar einrichtet, bekommt standardmäßig vollen Zugriff darauf (siehe
// `invoking_user`) - überschreibbar per '--owner-user' oder (interaktiv) durch Ablehnen/
// einen anderen Namen im Prompt.
let owner_user = resolve_owner_user(args.owner_user, invoking_user().as_deref(), ni)?;
let local_kind = resolve_kind(args.local_kind, None, "Local mount type", ni)?;
let local_address = resolve_local_address(args.local_ip, args.local_mac, None, ni)?;
@@ -192,14 +201,14 @@ 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(),
name,
enabled: true,
context,
owner_user,
mount_point,
local: LocalSide {
@@ -233,22 +242,18 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> {
}
println!("Drive pair '{id}' created.");
if context == MountContext::User {
println!("Note: for user-context pairs, 'sudo smart-mount setup fstab' must be run once.");
}
Ok(())
}
async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
crate::cli::require_root("drive edit")?;
let cfg = config::pairs::load()?;
let existing = config::pairs::find_pair(&cfg, id)?;
let ni = args.non_interactive;
let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)?
.expect("required");
let context = resolve_context(args.context, Some(existing.context), ni)?;
let owner_user =
resolve_owner_user(args.owner_user, context, existing.owner_user.as_deref(), ni)?;
let owner_user = resolve_owner_user(args.owner_user, existing.owner_user.as_deref(), ni)?;
let local_kind = resolve_kind(
args.local_kind,
@@ -316,15 +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,
context,
owner_user,
// Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden
// eventuell noch aktive Mounts/fstab-Einträge verwaisen.
mount_point: existing.mount_point.clone(),
mount_point,
local: LocalSide {
kind: local_kind,
address: local_address,
@@ -398,90 +417,107 @@ fn resolve_field(
Ok(Some(input.interact_text()?))
}
fn resolve_context(
flag: Option<MountContext>,
current: Option<MountContext>,
non_interactive: bool,
) -> anyhow::Result<MountContext> {
if let Some(c) = flag {
return Ok(c);
/// 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('-');
}
if let Some(c) = current
&& non_interactive
{
return Ok(c);
}
if non_interactive {
anyhow::bail!(
"Field 'context' is missing - specify it via '--context system|user' in non-interactive mode."
);
match out.trim_matches('-') {
"" => "pair".to_string(),
trimmed => trimmed.to_string(),
}
let default_idx = if current == Some(MountContext::System) {
0
} else {
1
};
let idx = Select::new()
.with_prompt("Context")
.items(["System (root)", "User"])
.default(default_idx)
.interact()?;
Ok(if idx == 0 {
MountContext::System
} else {
MountContext::User
})
}
/// 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
/// als root angemeldet (kein "einrichtender Nutzer" außer root selbst) oder `SUDO_USER` leer/
/// nicht gesetzt ist.
fn invoking_user() -> Option<String> {
std::env::var("SUDO_USER").ok().filter(|u| !u.is_empty())
}
/// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff
/// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership - der
/// Mount selbst läuft immer als root (siehe [`smart_mount::systemd`]). `default_owner` ist der
/// vorbelegte/vorgeschlagene Wert (bei `add`: der einrichtende Nutzer, siehe `invoking_user`;
/// bei `edit`: der bisherige `owner_user`) - immer überschreibbar per Flag, im interaktiven
/// Prompt auch durch Ablehnen oder einen anderen Namen.
fn resolve_owner_user(
flag: Option<String>,
context: MountContext,
current: Option<&str>,
default_owner: Option<&str>,
non_interactive: bool,
) -> anyhow::Result<Option<String>> {
if let Some(v) = flag {
return Ok(Some(v));
}
match context {
MountContext::User => {
// Pflicht: wird auch für 'setup fstab' (Gruppenmitgliedschaft, Verzeichnis-Owner)
// und für die uid=/gid=-Zugriffsrechte benötigt.
if non_interactive {
return current.map(str::to_string).map(Some).ok_or_else(|| {
anyhow::anyhow!("Field 'owner_user' is required for context 'user'.")
});
}
let default_user = current
.map(str::to_string)
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
Ok(Some(
Input::new()
.with_prompt("Linux username (owner)")
.default(default_user)
.interact_text()?,
))
}
MountContext::System => {
// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff
// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership.
if non_interactive {
return Ok(current.map(str::to_string));
return Ok(default_owner.map(str::to_string));
}
let prompt = match default_owner {
Some(user) => format!(
"Should '{user}' get full access to this drive (uid/gid, including script execution)?"
),
None => "Should a specific user get full access to this drive (uid/gid, including script execution)?".to_string(),
};
let want_owner = Confirm::new()
.with_prompt("Should a specific user get full access to this drive (uid/gid, including script execution)?")
.default(current.is_some())
.with_prompt(prompt)
.default(default_owner.is_some())
.interact()?;
if !want_owner {
return Ok(None);
}
let mut input = Input::<String>::new().with_prompt("Linux username");
if let Some(c) = current {
if let Some(c) = default_owner {
input = input.default(c.to_string());
}
Ok(Some(input.interact_text()?))
}
}
}
fn resolve_kind(
flag: Option<MountKind>,
@@ -622,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"));
}
}
+20 -9
View File
@@ -4,13 +4,28 @@ pub mod doctor;
pub mod drive;
pub mod mount_cmd;
pub mod service;
pub mod setup;
pub mod status;
pub mod watch;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
/// Startet den Prozess bei Bedarf transparent über `sudo` neu, falls nicht bereits als root
/// aufgerufen. Mounten läuft ausschließlich als root/System-Dienst (siehe
/// [`smart_mount::systemd`]) - es gibt seit dem Wegfall des Nutzerkontexts keine
/// unprivilegierte Mount-Variante mehr, für die ein Rechte-Check hier zu früh käme.
///
/// `run_as_root()` ersetzt den aktuellen Prozess per `execve` und kehrt bei Erfolg nie zurück
/// (siehe sudo-ctdra); der `io::Error`-Rückgabewert ist ausschließlich der Fehlerfall (z. B.
/// `sudo` nicht gefunden).
pub(crate) fn require_root(context: &str) -> anyhow::Result<()> {
if !sudo_ctdra::is_run_as_root() {
let err = sudo_ctdra::run_as_root();
anyhow::bail!("'{context}' requires root privileges: could not re-exec via sudo: {err}");
}
Ok(())
}
#[derive(Parser)]
#[command(
name = "smart-mount",
@@ -55,17 +70,14 @@ pub enum Commands {
},
/// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron.
Watch,
/// Install/remove systemd units.
/// Sets up (or removes) the cron fallback for systems without (or not using) systemd -
/// the systemd service itself is installed/removed automatically by the .deb/.rpm/
/// .pkg.tar.zst package, not via this CLI.
Service {
#[command(subcommand)]
action: service::ServiceAction,
},
/// One-time root setup for unprivileged user mounts.
Setup {
#[command(subcommand)]
action: setup::SetupAction,
},
/// Checks prerequisites (binaries, group membership, fstab setup, scheduler).
/// Checks prerequisites (binaries, scheduler).
Doctor {
/// Output as JSON instead of text - for scripts.
#[arg(long)]
@@ -85,7 +97,6 @@ pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
Commands::Status { name, json } => status::run(name, json).await,
Commands::Watch => watch::run().await,
Commands::Service { action } => service::run(action),
Commands::Setup { action } => setup::run(action),
Commands::Doctor { json } => doctor::run(json).await,
Commands::Completions { shell } => {
clap_complete::generate(
+4 -19
View File
@@ -1,6 +1,6 @@
//! `smart-mount mount` / `smart-mount unmount`.
use smart_mount::config::{self, DrivePair, MountContext};
use smart_mount::config::{self, DrivePair};
use smart_mount::db::credentials::CredentialStore;
use smart_mount::reconcile;
@@ -15,27 +15,11 @@ fn select_pairs(
if !all {
anyhow::bail!("Please specify '--name <id>' or '--all'.");
}
let pairs = if sudo_ctdra::is_run_as_root() {
cfg.pairs
.iter()
.filter(|p| p.context == MountContext::System)
.cloned()
.collect()
} else {
let user = std::env::var("USER").unwrap_or_default();
cfg.pairs
.iter()
.filter(|p| {
p.context == MountContext::User && p.owner_user.as_deref() == Some(user.as_str())
})
.cloned()
.collect()
};
Ok(pairs)
Ok(cfg.pairs.clone())
}
pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
crate::cli::require_root("mount")?;
let cfg = config::pairs::load()?;
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
if pairs.is_empty() {
@@ -52,6 +36,7 @@ pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
}
pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()> {
crate::cli::require_root("unmount")?;
let cfg = config::pairs::load()?;
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
if pairs.is_empty() {
+28 -126
View File
@@ -1,152 +1,54 @@
//! `smart-mount service install|uninstall --system|--user`.
//! `smart-mount service crontab [--remove]`.
use clap::{Args, Subcommand};
use clap::Subcommand;
use smart_mount::config;
use smart_mount::fstab;
use smart_mount::systemd::{self, Scope};
use smart_mount::systemd;
#[derive(Subcommand)]
pub enum ServiceAction {
/// Sets up periodic execution: systemd if available, otherwise falls back to cron
/// automatically (see `crontab`).
Install(ScopeArgs),
/// Removes everything that `install`/`crontab`/`setup fstab` have set up - systemd
/// units, cron entry, and (only for `--system`) the managed `/etc/fstab` block.
/// Missing parts are skipped, not treated as an error.
Uninstall(ScopeArgs),
/// Sets up periodic execution via cron (alternative to `install` for systems without
/// systemd) - system or user context is chosen automatically based on the current
/// privileges (root -> `/etc/cron.d/smart-mount`, otherwise personal crontab). If no
/// cron mechanism is present, the lines for manual entry are printed instead.
Crontab,
}
#[derive(Args)]
pub struct ScopeArgs {
#[arg(long, conflicts_with = "user")]
system: bool,
#[arg(long, conflicts_with = "system")]
user: bool,
}
impl ScopeArgs {
fn scope(&self) -> anyhow::Result<Scope> {
match (self.system, self.user) {
(true, false) => Ok(Scope::System),
(false, true) => Ok(Scope::User),
_ => anyhow::bail!("Please specify exactly one of '--system' or '--user'."),
}
}
/// Sets up (or, with '--remove', tears down) periodic execution via
/// '/etc/cron.d/smart-mount' - a manual fallback for systems that don't use the packaged
/// systemd service (see 'packaging/systemd/' in the source tree). Requires root, same as
/// 'mount'/'watch' - mounting always runs as root.
Crontab {
/// Removes a previously installed cron entry instead of installing one.
#[arg(long)]
remove: bool,
},
}
pub fn run(action: ServiceAction) -> anyhow::Result<()> {
crate::cli::require_root("service crontab")?;
match action {
ServiceAction::Install(args) => {
let scope = args.scope()?;
if scope == Scope::System && !sudo_ctdra::is_run_as_root() {
anyhow::bail!(
"'service install --system' requires root privileges (re-run with sudo)."
);
ServiceAction::Crontab { remove: false } => install(),
ServiceAction::Crontab { remove: true } => uninstall(),
}
}
fn install() -> anyhow::Result<()> {
let cfg = config::pairs::load()?;
let interval = cfg.settings.watch_interval_secs;
if systemd::is_available() {
systemd::install(scope, interval)?;
println!("systemd units installed and enabled ({scope:?}).");
} else {
println!("systemd not found - setting up cron instead.");
match systemd::install_cron(scope, interval)? {
match systemd::install_cron(interval)? {
systemd::CronInstallOutcome::SystemFile(path) => {
println!("Cron entry written: {}", path.display());
}
systemd::CronInstallOutcome::UserCrontab => {
println!("Personal crontab updated (see 'crontab -l').");
}
systemd::CronInstallOutcome::Unavailable => {
println!(
"Neither systemd nor cron found - here are the lines for manual entry:"
);
print!("{}", systemd::crontab_equivalent(interval));
}
}
}
Ok(())
}
ServiceAction::Uninstall(args) => {
let scope = args.scope()?;
if scope == Scope::System && !sudo_ctdra::is_run_as_root() {
anyhow::bail!(
"'service uninstall --system' requires root privileges (re-run with sudo)."
);
}
let mut removed = Vec::new();
if systemd::is_available() {
match systemd::uninstall(scope)? {
systemd::SystemdUninstallOutcome::Removed => removed.push("systemd units"),
systemd::SystemdUninstallOutcome::NotPresent => {}
}
}
match systemd::uninstall_cron(scope)? {
systemd::CronUninstallOutcome::Removed => removed.push("cron entry"),
systemd::CronUninstallOutcome::NotPresent => {}
}
// fstab-Einträge sind unabhängig vom --system/--user-Scope des Aufrufers immer
// root-weit (setup() betrifft alle User-Kontext-Paare) - nur bei --system mit
// aufräumen, damit ein `--user`-Uninstall nicht versehentlich Root-Konfiguration
// anfasst, die ein anderer Nutzer noch braucht.
if scope == Scope::System {
match fstab::teardown()? {
fstab::FstabTeardownOutcome::Removed => removed.push("fstab entries"),
fstab::FstabTeardownOutcome::NotPresent => {}
}
}
if removed.is_empty() {
println!("Nothing to remove - nothing was installed ({scope:?}).");
} else {
println!("Removed ({scope:?}): {}", removed.join(", "));
}
Ok(())
}
ServiceAction::Crontab => {
let cfg = config::pairs::load()?;
let interval = cfg.settings.watch_interval_secs;
// Scope folgt automatisch den aktuellen Rechten, wie bei `mount --all` -
// root pflegt den systemweiten Cron-Eintrag, ein normaler Nutzer seine eigene
// Crontab. Anders als bei `install`/`uninstall` gibt es hier bewusst keine
// expliziten `--system`/`--user`-Flags, weil die Wahl ohnehin durch die Rechte
// vorgegeben ist (root kann nicht "versehentlich" die falsche Crontab treffen).
let scope = if sudo_ctdra::is_run_as_root() {
Scope::System
} else {
Scope::User
};
match systemd::install_cron(scope, interval)? {
systemd::CronInstallOutcome::SystemFile(path) => {
println!("Cron entry written: {}", path.display());
}
systemd::CronInstallOutcome::UserCrontab => {
println!("Personal crontab updated (see 'crontab -l').");
}
systemd::CronInstallOutcome::Unavailable => {
println!(
"No cron mechanism found ({}) - here are the lines for manual entry:",
if scope == Scope::System {
"/etc/cron.d is missing"
} else {
"'crontab' not in PATH"
}
"No cron mechanism found ('/etc/cron.d' is missing) - here are the lines for manual entry:"
);
print!("{}", systemd::crontab_equivalent(interval));
}
}
Ok(())
}
fn uninstall() -> anyhow::Result<()> {
match systemd::uninstall_cron()? {
systemd::CronUninstallOutcome::Removed => println!("Cron entry removed."),
systemd::CronUninstallOutcome::NotPresent => {
println!("Nothing to remove - no cron entry was installed.")
}
}
Ok(())
}
-21
View File
@@ -1,21 +0,0 @@
//! `smart-mount setup fstab`.
use clap::Subcommand;
use smart_mount::fstab;
#[derive(Subcommand)]
pub enum SetupAction {
/// One-time root setup: `/etc/fstab` entries + group membership for
/// unprivileged user mounts.
Fstab,
}
pub fn run(action: SetupAction) -> anyhow::Result<()> {
match action {
SetupAction::Fstab => {
fstab::setup()?;
println!("fstab setup complete.");
Ok(())
}
}
}
+1
View File
@@ -7,6 +7,7 @@ use smart_mount::reconcile::{self, Action};
use crate::cli::mount_cmd::print_outcome;
pub async fn run() -> anyhow::Result<()> {
crate::cli::require_root("watch")?;
let cfg = config::pairs::load()?;
let creds = CredentialStore::open().await?;
let outcomes = reconcile::watch_once(&cfg, &creds).await;
+12 -4
View File
@@ -4,12 +4,20 @@ pub mod pairs;
pub mod schema;
pub use schema::{
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountContext,
MountKind,
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountKind,
};
/// Initialisiert den Konfigurationsdateinamen bei `config-ctdra`. Muss vor dem ersten
/// `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter Zustand).
/// Initialisiert `config-ctdra`: Dateiname und - unabhängig von den Rechten des aufrufenden
/// Prozesses - immer der System-Pfad (`/etc/<programmname>/config.toml`). Es gibt nur noch
/// eine einzige, systemweite Konfiguration (smart-mount läuft ausschließlich als root/
/// System-Dienst, siehe [`crate::systemd`]) statt einer je nach Aufrufer wechselnden
/// Root-/Nutzerpfad-Auflösung - `drive add`/`edit`/`remove` verlangen deshalb explizit Root
/// (siehe `cli::require_root`), auch wenn sie nicht direkt mounten.
///
/// Muss vor dem ersten `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter
/// Zustand).
pub fn init() {
config_ctdra::set_config_name("config");
let program = config_ctdra::get_program_name();
config_ctdra::set_custom_dir(std::path::PathBuf::from("/etc").join(program));
}
+19 -60
View File
@@ -11,39 +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. Root-/System-Kontext-Paare landen flach unter `/run/media/smart-mount`
/// (ein systemweiter Dienst, keinem einzelnen Nutzer zugeordnet); Nutzer-Kontext-Paare unter
/// `/run/media/<Nutzer>/smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich
/// nicht denselben Namensraum teilen.
/// 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 {
if sudo_ctdra::is_run_as_root() {
PathBuf::from("/run/media/smart-mount")
} else {
// Absichtlich KEIN Fallback auf einen festen Platzhalter wie "user": unter `systemd
// --user` (wo $USER nicht immer gesetzt ist) würden dann zwei verschiedene reale
// Nutzer denselben Mountpoint-Namensraum `/run/media/user/smart-mount` teilen - genau
// die Kollision, die die Aufteilung nach Nutzername eigentlich verhindern soll. `id
// -un` liest den Nutzernamen stattdessen direkt vom Kernel.
let run_id = |flag: &str| {
std::process::Command::new("id")
.arg(flag)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
};
let user = std::env::var("USER")
.ok()
.filter(|u| !u.is_empty())
.or_else(|| run_id("-un"))
// Letzter Ausweg: die numerische UID ist immer verfügbar und auf jedem System
// eindeutig, anders als ein fest codierter Platzhalter-String wie "user", der bei
// mehreren betroffenen Nutzern denselben Namensraum kollidieren ließe.
.or_else(|| run_id("-u").map(|uid| format!("uid-{uid}")))
.unwrap_or_else(|| "user".to_string());
PathBuf::from("/run/media").join(user).join("smart-mount")
}
PathBuf::from("/run/media")
}
fn default_log_level() -> String {
@@ -58,9 +34,8 @@ fn default_mac2ip_binary() -> String {
"mac2ip".to_string()
}
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` unter
/// `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. `/etc/smart-mount/config.toml`
/// (Root-Kontext).
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` immer unter
/// `/etc/smart-mount/config.toml` (siehe [`crate::config::init`]).
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct AppConfig {
#[serde(default)]
@@ -76,8 +51,10 @@ pub struct GlobalSettings {
pub mount_base_dir: PathBuf,
#[serde(default = "default_log_level")]
pub log_level: String,
/// Periode, mit der `smart-mount watch` über den generierten systemd-Timer bzw. die
/// dokumentierte Crontab-Zeile ausgeführt werden soll.
/// Periode, mit der `smart-mount watch` über den Cron-Fallback (`smart-mount service
/// crontab`, siehe [`crate::systemd`]) ausgeführt werden soll. Der paketierte systemd-Timer
/// hat ein fest eingebautes Intervall (siehe `packaging/systemd/smart-mount-watch.timer`)
/// und liest dieses Feld nicht.
#[serde(default = "default_watch_interval_secs")]
pub watch_interval_secs: u64,
/// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad).
@@ -101,14 +78,14 @@ impl Default for GlobalSettings {
/// eingebunden - siehe [`crate::reconcile`].
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DrivePair {
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten, Mount-Unterverzeichnis,
/// systemd-Unit-Namen und fstab-Einträge.
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten sowie das Mount-Unterverzeichnis.
pub id: String,
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
pub context: MountContext,
/// Pflicht bei `context == User`: der Linux-Benutzername, dem dieses Paar gehört.
/// Optional: der Linux-Benutzername, der vollen Zugriff (uid/gid) auf dieses Paar
/// bekommen soll (siehe [`crate::mount::target::build_target`]). Ohne `owner_user` gehört
/// der Mount root.
#[serde(default)]
pub owner_user: Option<String>,
pub mount_point: PathBuf,
@@ -120,16 +97,6 @@ fn default_true() -> bool {
true
}
/// Ob ein Laufwerkspaar systemweit (root, `/etc/fstab`+systemd-System-Service) oder als
/// einzelner Nutzer (`systemd --user`, unprivilegiert über `setup fstab`) eingebunden wird.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[value(rename_all = "lowercase")]
pub enum MountContext {
System,
User,
}
/// Die lokale (LAN-)Seite eines Laufwerkspaars.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LocalSide {
@@ -198,7 +165,6 @@ mod tests {
id: "pair-1".into(),
name: "NAS".into(),
enabled: true,
context: MountContext::User,
owner_user: Some("dragon".into()),
mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"),
local: LocalSide {
@@ -223,7 +189,6 @@ mod tests {
assert_eq!(round_tripped.pairs.len(), 1);
assert_eq!(round_tripped.pairs[0].id, "pair-1");
assert_eq!(round_tripped.pairs[0].context, MountContext::User);
match &round_tripped.pairs[0].local.address {
LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"),
LocalAddress::Ip(_) => panic!("expected Mac variant"),
@@ -232,12 +197,6 @@ mod tests {
#[test]
fn default_mount_base_dir_uses_run_media() {
// Tests laufen nie als root, daher greift hier immer der Nutzerkontext-Zweig.
let dir = default_mount_base_dir();
assert!(dir.starts_with("/run/media"));
assert!(dir.ends_with("smart-mount"));
if let Ok(user) = std::env::var("USER") {
assert!(dir.to_string_lossy().contains(&user));
}
assert_eq!(default_mount_base_dir(), PathBuf::from("/run/media"));
}
}
+5 -125
View File
@@ -1,11 +1,8 @@
//! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung.
//!
//! Reihenfolge (wie mit dem Nutzer abgestimmt):
//! - Root-/System-Kontext: **immer** die Schlüsseldatei (kein Nutzer-Keyring im
//! Systemdienst-Kontext verfügbar).
//! - Nutzerkontext: zuerst das OS-Keyring (GNOME Keyring/KWallet über secret-service)
//! versuchen, bei Nichtverfügbarkeit (z. B. Headless-Server, kein D-Bus-Secret-Service)
//! transparent auf die Schlüsseldatei zurückfallen.
//! Immer die Schlüsseldatei: SmartMount läuft ausschließlich als root/System-Dienst (siehe
//! [`crate::systemd`]), für den es kein Nutzer-Keyring (GNOME Keyring/KWallet über
//! secret-service) gibt.
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
@@ -13,64 +10,13 @@ use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
const KEYRING_SERVICE: &str = "smart-mount";
const KEYRING_USERNAME: &str = "master-key";
const KEY_FILE_NAME: &str = "master.key";
const KEY_BACKEND_MARKER_NAME: &str = "master.key.backend";
const KEY_LEN: usize = 32;
/// Ermittelt (und erzeugt bei Bedarf) den 256-Bit-Master-Schlüssel für die
/// Zugangsdaten-Verschlüsselung, siehe Modul-Dokumentation für die Fallback-Reihenfolge.
///
/// Welcher Backend (Keyring oder Schlüsseldatei) für einen Nutzer verwendet wird, wird beim
/// ersten Aufruf in einer Marker-Datei festgehalten und danach immer wieder verwendet. Ohne
/// diese Festlegung würde eine vorübergehend nicht erreichbare Keyring (z. B. `systemd --user`
/// ohne D-Bus-Secret-Service) sonst bei jedem Aufruf transparent einen *neuen* Datei-Schlüssel
/// erzeugen und damit zuvor unter dem Keyring-Schlüssel verschlüsselte Zugangsdaten unwiderruflich
/// unlesbar machen.
/// Zugangsdaten-Verschlüsselung.
pub fn resolve_master_key() -> Result<[u8; 32]> {
if sudo_ctdra::is_run_as_root() {
return file_key::load_or_create(&key_file_path());
}
let marker_path = key_backend_marker_path();
match fs::read_to_string(&marker_path) {
Ok(backend) => match backend.trim() {
"keyring" => keyring_key::load_or_create()
.map_err(|reason| Error::Crypto(format!("OS keyring not available ({reason})"))),
_ => file_key::load_or_create(&key_file_path()),
},
Err(_) => match keyring_key::load_or_create() {
Ok(key) => {
write_key_backend_marker(&marker_path, "keyring");
Ok(key)
}
Err(reason) => {
logger_ctdra::warn(
"crypto",
&format!("OS keyring not available ({reason}), using key file"),
);
let key = file_key::load_or_create(&key_file_path())?;
write_key_backend_marker(&marker_path, "file");
Ok(key)
}
},
}
}
fn write_key_backend_marker(marker_path: &Path, backend: &str) {
if let Some(dir) = marker_path.parent() {
let _ = fs::create_dir_all(dir);
}
if let Err(e) = fs::write(marker_path, backend) {
logger_ctdra::warn(
"crypto",
&format!(
"could not persist key backend marker '{}': {e}",
marker_path.display()
),
);
}
file_key::load_or_create(&key_file_path())
}
fn key_file_path() -> PathBuf {
@@ -81,14 +27,6 @@ fn key_file_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME))
}
fn key_backend_marker_path() -> PathBuf {
let config_path = config_ctdra::get_config_path();
config_path
.parent()
.map(|dir| dir.join(KEY_BACKEND_MARKER_NAME))
.unwrap_or_else(|| PathBuf::from(KEY_BACKEND_MARKER_NAME))
}
mod file_key {
use super::*;
@@ -150,61 +88,3 @@ mod file_key {
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))
}
}
mod keyring_key {
use super::*;
pub fn load_or_create() -> std::result::Result<[u8; 32], String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME)
.map_err(|e| format!("Could not create keyring entry: {e}"))?;
match entry.get_password() {
Ok(hex_key) => decode(&hex_key),
Err(keyring::Error::NoEntry) => {
let key = generate()?;
if let Err(e) = entry.set_password(&encode(&key)) {
// A concurrent first run may have already created the entry; use its key
// instead of failing outright.
return match entry.get_password() {
Ok(hex_key) => decode(&hex_key),
Err(_) => Err(format!("Could not store key in keyring: {e}")),
};
}
// Re-read the entry: a concurrent writer may have overwritten ours after our
// own `set_password` succeeded. Using whichever key ultimately "won" ensures
// both processes agree on the same key instead of one silently using a key
// that was never actually persisted.
match entry.get_password() {
Ok(hex_key) => decode(&hex_key),
Err(_) => Ok(key),
}
}
Err(e) => Err(format!("Keyring access failed: {e}")),
}
}
fn generate() -> std::result::Result<[u8; 32], String> {
let mut key = [0u8; 32];
::getrandom::fill(&mut key).map_err(|e| format!("Random number generator failed: {e}"))?;
Ok(key)
}
fn encode(key: &[u8; 32]) -> String {
key.iter().map(|b| format!("{b:02x}")).collect()
}
fn decode(hex_key: &str) -> std::result::Result<[u8; 32], String> {
if hex_key.len() != 64 {
return Err(format!(
"unexpected key length in keyring ({} instead of 64 hex characters)",
hex_key.len()
));
}
let mut key = [0u8; 32];
for (i, chunk) in hex_key.as_bytes().chunks(2).enumerate() {
let byte_str = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
key[i] = u8::from_str_radix(byte_str, 16).map_err(|e| e.to_string())?;
}
Ok(key)
}
}
+10 -125
View File
@@ -1,11 +1,10 @@
//! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung
//! angesammelten Voraussetzungen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler)
//! gebündelt an einer Stelle, statt sie einzeln erst beim Mount-Fehlschlag zu entdecken.
//! angesammelten Voraussetzungen (Binaries, Scheduler) gebündelt an einer Stelle, statt sie
//! einzeln erst beim Mount-Fehlschlag zu entdecken.
use std::collections::HashSet;
use std::process::Command;
use crate::config::{AppConfig, DrivePair, LocalAddress, MountContext, MountKind};
use crate::config::{AppConfig, LocalAddress, MountKind};
use crate::mount;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -65,24 +64,6 @@ pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
&cfg.settings.mac2ip_binary,
"install mac2ip (private tool, see README)",
));
results.push(check_binary("nmap", "nmap", "install package 'nmap'"));
}
if cfg.pairs.iter().any(|p| p.context == MountContext::User) {
results.push(check_fstab_setup(cfg));
if used_kinds.contains(&MountKind::WebDav) {
results.push(check_davfs2_group_membership(cfg));
}
}
for pair in &cfg.pairs {
if pair.context == MountContext::User && pair.owner_user.is_none() {
results.push(fail(
format!("Pair '{}': owner_user", pair.name),
"context 'user', but no owner_user set - 'setup fstab' will reject this."
.to_string(),
));
}
}
results
@@ -141,12 +122,12 @@ fn check_scheduler() -> CheckResult {
if systemd {
ok(
"Scheduler",
"systemd found - 'smart-mount service install' uses systemd timers",
"systemd found - the packaged .deb/.rpm/.pkg.tar.zst installs/enables the smart-mount systemd service automatically",
)
} else if cron_d || crontab {
warn(
"Scheduler",
"no systemd, but cron found - 'smart-mount service install' automatically falls back to cron",
"no systemd, but cron found - run 'smart-mount service crontab' to set up the periodic 'watch' call",
)
} else {
fail(
@@ -178,89 +159,17 @@ fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult {
}
}
fn check_fstab_setup(cfg: &AppConfig) -> CheckResult {
let existing = std::fs::read_to_string("/etc/fstab").unwrap_or_default();
if existing.contains("# BEGIN smart-mount managed block") {
ok("setup fstab", "managed block found in /etc/fstab")
} else {
let count = cfg
.pairs
.iter()
.filter(|p| p.context == MountContext::User)
.count();
fail(
"setup fstab",
format!(
"no managed block found in /etc/fstab, but {count} user-context pair(s) configured - run 'sudo smart-mount setup fstab'"
),
)
}
}
fn check_davfs2_group_membership(cfg: &AppConfig) -> CheckResult {
let owners: HashSet<&str> = cfg
.pairs
.iter()
.filter(|p| {
p.context == MountContext::User
&& (p.local.kind == MountKind::WebDav || p.cloud.kind == MountKind::WebDav)
})
.filter_map(|p: &DrivePair| p.owner_user.as_deref())
.collect();
if owners.is_empty() {
return ok(
"davfs2 group membership",
"no WebDAV user-context pairs with owner_user - nothing to check",
);
}
let mut missing = Vec::new();
for owner in &owners {
let output = Command::new("id").args(["-nG", owner]).output();
let is_member = output
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.any(|g| g == "davfs2")
})
.unwrap_or(false);
if !is_member {
missing.push(*owner);
}
}
if missing.is_empty() {
ok(
"davfs2 group membership",
format!(
"all affected users ({}) are members of the 'davfs2' group",
owners.len()
),
)
} else {
warn(
"davfs2 group membership",
format!(
"users without 'davfs2' group: {} - run 'sudo smart-mount setup fstab' (log out and back in afterwards if needed)",
missing.join(", ")
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CloudSide, GlobalSettings, LocalSide};
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide};
use std::net::Ipv4Addr;
fn sample_pair(context: MountContext, owner_user: Option<&str>, mac: bool) -> DrivePair {
fn sample_pair(owner_user: Option<&str>, mac: bool) -> DrivePair {
DrivePair {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
context,
owner_user: owner_user.map(str::to_string),
mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide {
@@ -288,7 +197,7 @@ mod tests {
fn used_mount_kinds_collects_both_sides_across_pairs() {
let cfg = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::System, None, false)],
pairs: vec![sample_pair(None, false)],
};
let kinds = used_mount_kinds(&cfg);
assert!(kinds.contains(&MountKind::Nfs));
@@ -300,40 +209,16 @@ mod tests {
fn uses_mac_addressing_detects_mac_pairs() {
let with_mac = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::System, None, true)],
pairs: vec![sample_pair(None, true)],
};
let without_mac = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::System, None, false)],
pairs: vec![sample_pair(None, false)],
};
assert!(uses_mac_addressing(&with_mac));
assert!(!uses_mac_addressing(&without_mac));
}
#[test]
fn flags_user_context_pair_without_owner_user() {
let cfg = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::User, None, false)],
};
let results = run_checks(&cfg);
assert!(
results
.iter()
.any(|r| r.status == CheckStatus::Fail && r.label.contains("owner_user"))
);
}
#[test]
fn does_not_flag_owner_user_when_present() {
let cfg = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::User, Some("dragon"), false)],
};
let results = run_checks(&cfg);
assert!(!results.iter().any(|r| r.label.contains("owner_user")));
}
#[test]
fn empty_config_still_runs_global_checks_without_panicking() {
let cfg = AppConfig::default();
-3
View File
@@ -39,9 +39,6 @@ pub enum Error {
#[error("mac2ip resolution failed for MAC {mac}: {reason}")]
Mac2Ip { mac: String, reason: String },
#[error("No root context: {0}")]
RequiresRoot(&'static str),
#[error("{0}")]
Other(String),
}
-683
View File
@@ -1,683 +0,0 @@
//! Einmaliges root-Setup, das unprivilegierten `User`-Kontext-Paaren erlaubt, sich selbst
//! (unprivilegiert) zu mounten/unmounten.
//!
//! Kernidee: pro Paar werden **zwei** `/etc/fstab`-Zeilen geschrieben - je eine pro Seite,
//! auf das jeweils eindeutige Backing-Verzeichnis dieser Seite (siehe
//! [`crate::mount::target::backing_dir`]), nicht auf einen gemeinsamen Mountpoint. Damit
//! entspricht jede Zeile exakt dem einzigen in `man 8 mount` ("Non-superuser mounts")
//! dokumentierten Fall - genau eine fstab-Zeile pro Ziel - statt sich auf unspezifiziertes
//! Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der sichtbare `pair.mount_point`
//! selbst erscheint dadurch gar nicht in `/etc/fstab` - er ist ein Symlink, den smart-mount
//! zur Laufzeit zwischen den beiden Backing-Verzeichnissen umschaltet (siehe
//! [`crate::reconcile`]).
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{AppConfig, DrivePair, GlobalSettings, MountContext, MountKind};
use crate::db::credentials::Side;
use crate::error::{Error, Result};
use crate::mount::smb;
use crate::mount::target::{self, backing_dir};
const BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
const END_MARKER: &str = "# END smart-mount managed block";
const FSTAB_PATH: &str = "/etc/fstab";
/// Führt das einmalige root-Setup für alle `User`-Kontext-Paare aus: fstab-Block
/// regenerieren, Gruppenmitgliedschaft sicherstellen, Mountpoints anlegen.
///
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root - das ist der
/// einzige Befehl in smart-mount, der das tut (alle anderen root-Aktionen verlangen
/// explizit, bereits als root aufgerufen zu werden).
pub fn setup() -> Result<()> {
if !sudo_ctdra::is_run_as_root() {
let err = sudo_ctdra::run_as_root();
return Err(Error::Other(format!(
"Restart with root privileges failed: {err}"
)));
}
let sudo_user = std::env::var("SUDO_USER").ok().filter(|s| !s.is_empty());
if let Some(sudo_user) = &sudo_user
&& config_ctdra::get_custom_path().is_none()
{
let user_config = user_config_path(sudo_user);
config_ctdra::set_custom_path(&user_config);
}
let cfg = crate::config::pairs::load()?;
let user_pairs: Vec<&DrivePair> = cfg
.pairs
.iter()
.filter(|p| p.context == MountContext::User)
.collect();
if user_pairs.is_empty() {
logger_ctdra::info("fstab", "No user-context pairs configured - nothing to do.");
return Ok(());
}
validate_user_pairs_have_owner(&user_pairs)?;
for pair in &user_pairs {
ensure_backing_dirs(pair)?;
ensure_group_membership(pair)?;
}
// Wessen zuvor installierte, jetzt aber nicht mehr konfigurierte Zeilen beim
// Zusammenführen (siehe `merge_managed_block`) entfernt werden dürfen: bei einem
// `sudo`-Aufruf im Namen eines bestimmten Nutzers ausschließlich dessen eigene Zeilen,
// sonst die Menge der in der (dann root-eigenen) Konfiguration genannten `owner_user`.
// Zeilen ANDERER Nutzer bleiben immer unangetastet - andernfalls würde ein zweiter Nutzer,
// der `setup fstab` für sein eigenes Konto ausführt, die vom ersten Nutzer installierten
// Zeilen löschen, weil sich beide denselben verwalteten Block in `/etc/fstab` teilen.
let owner_scope: Vec<String> = match &sudo_user {
Some(u) => vec![u.clone()],
None => user_pairs
.iter()
.filter_map(|p| p.owner_user.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect(),
};
write_managed_block(&user_pairs, &cfg.settings, &owner_scope)?;
logger_ctdra::info(
"fstab",
"Done. Affected users may need to log out and back in for new group memberships to \
take effect. For MAC-based local drives in user context: set up passwordless sudo \
access to 'nmap' if resolution does not already succeed via the ARP neighbor table \
(see README).",
);
Ok(())
}
/// Ergebnis von [`teardown`].
pub enum FstabTeardownOutcome {
/// Der verwaltete Block wurde gefunden und entfernt.
Removed,
/// Kein von smart-mount verwalteter Block vorhanden - nichts zu tun.
NotPresent,
}
/// Gegenstück zu [`setup`]: entfernt den von smart-mount verwalteten Block wieder aus
/// `/etc/fstab` (Backup wie bei `setup` nach `/etc/fstab.smart-mount.bak`). Rührt bewusst
/// **keine** Backing-Verzeichnisse, gemounteten Daten oder Gruppenmitgliedschaften an - nur
/// die fstab-Zeilen selbst, da das Löschen von Verzeichnissen/Cache-Daten oder das Entfernen
/// aus einer Gruppe ungewollte Nebenwirkungen haben könnte (die Gruppe könnte z. B. auch
/// unabhängig von smart-mount genutzt werden).
///
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root (wie `setup`).
pub fn teardown() -> Result<FstabTeardownOutcome> {
if !sudo_ctdra::is_run_as_root() {
let err = sudo_ctdra::run_as_root();
return Err(Error::Other(format!(
"Restart with root privileges failed: {err}"
)));
}
let fstab_path = PathBuf::from(FSTAB_PATH);
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
if !existing.contains(BEGIN_MARKER) {
return Ok(FstabTeardownOutcome::NotPresent);
}
backup(&fstab_path, &existing)?;
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
write_atomic(&fstab_path, &without_block)?;
Ok(FstabTeardownOutcome::Removed)
}
/// Ohne `owner_user` würde die fstab-Zeile ohne `uid=`/`gid=` geschrieben - bei davfs2 heißt
/// das laut `man mount.davfs` ("uid=user"/"gid=group"): JEDES Mitglied der Gruppe 'davfs2'
/// dürfte dieses Paar mounten, nicht nur der vorgesehene Besitzer. Lieber hart fehlschlagen,
/// bevor eine unsichere Zeile geschrieben wird, als das still zuzulassen.
fn validate_user_pairs_have_owner(pairs: &[&DrivePair]) -> Result<()> {
for pair in pairs {
if pair.owner_user.is_none() {
return Err(Error::Other(format!(
"Drive pair '{}' has context 'user', but no owner_user set. \
Without owner_user, mount access cannot be restricted to a specific \
user - please set owner_user in the configuration \
(e.g. via 'smart-mount drive add').",
pair.id
)));
}
}
Ok(())
}
/// Legt beide Backing-Verzeichnisse an (nicht `pair.mount_point` selbst - das bleibt ein
/// Symlink, siehe Moduldoku) und macht `owner_user` zum Besitzer beider.
fn ensure_backing_dirs(pair: &DrivePair) -> Result<()> {
for side in [Side::Local, Side::Cloud] {
create_dir_all_owned(&backing_dir(pair, side), pair.owner_user.as_deref())?;
}
// Der Elternordner des sichtbaren Mountpoints (z. B. `/run/media/<Nutzer>/smart-mount`)
// muss dem Nutzer ebenfalls gehören - dort legt `activate_symlink` bei JEDEM `mount`/
// `watch`-Lauf den Symlink an/ersetzt ihn, und das läuft (anders als dieses einmalige
// Setup) unprivilegiert als der Nutzer selbst. `/run/media` ist standardmäßig `root:root
// 0755` - ohne diesen Schritt könnte der Nutzer dort nicht einmal ein eigenes
// Unterverzeichnis anlegen.
if let Some(parent) = pair.mount_point.parent() {
create_dir_all_owned(parent, pair.owner_user.as_deref())?;
}
Ok(())
}
/// Wie `std::fs::create_dir_all`, macht aber zusätzlich `owner` zum Besitzer aller dabei
/// **neu angelegten** Verzeichnisse - nicht bereits vorhandener Elternverzeichnisse (z. B.
/// `/run/media` selbst, das root-eigen bleiben muss). Läuft von `dir` aus rückwärts nach
/// oben, bis der erste bereits existierende Vorfahre gefunden ist.
fn create_dir_all_owned(dir: &std::path::Path, owner: Option<&str>) -> Result<()> {
let Some(owner) = owner else {
return std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e));
};
let mut newly_created = Vec::new();
let mut current = dir;
while !current.exists() {
newly_created.push(current.to_path_buf());
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
// Von oben nach unten chownen (Eltern vor Kindern) - rein kosmetisch, jeder Aufruf ist
// unabhängig, aber so bleibt die Reihenfolge nachvollziehbar.
for path in newly_created.iter().rev() {
let status = Command::new("chown")
.arg(format!("{owner}:{owner}"))
.arg(path)
.status()
.map_err(|e| Error::Other(format!("could not run chown: {e}")))?;
if !status.success() {
return Err(Error::Other(format!(
"chown failed for '{}'",
path.display()
)));
}
}
Ok(())
}
fn ensure_group_membership(pair: &DrivePair) -> Result<()> {
let Some(owner) = &pair.owner_user else {
return Ok(());
};
if pair.local.kind == MountKind::WebDav || pair.cloud.kind == MountKind::WebDav {
let status = Command::new("usermod")
.args(["-aG", "davfs2", owner])
.status()
.map_err(|e| Error::Other(format!("could not run usermod: {e}")))?;
if !status.success() {
logger_ctdra::warn(
"fstab",
&format!(
"Could not add '{owner}' to group 'davfs2' - does the group exist (package 'davfs2' installed)?"
),
);
}
}
Ok(())
}
fn write_managed_block(
pairs: &[&DrivePair],
settings: &GlobalSettings,
owner_scope: &[String],
) -> Result<()> {
let fstab_path = PathBuf::from(FSTAB_PATH);
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
backup(&fstab_path, &existing)?;
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
let current_block =
crate::util::extract_managed_block(&existing, BEGIN_MARKER, END_MARKER).unwrap_or_default();
let new_block = merge_managed_block(&current_block, pairs, settings, owner_scope);
let new_contents = format!(
"{}\n{}\n{}\n{}\n",
without_block.trim_end(),
BEGIN_MARKER,
new_block.trim_end(),
END_MARKER
);
write_atomic(&fstab_path, &new_contents)
}
fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> {
let backup_path = PathBuf::from(format!("{FSTAB_PATH}.smart-mount.bak"));
std::fs::write(&backup_path, contents).map_err(|e| Error::io(&backup_path, e))?;
let _ = fstab_path; // nur zur Doku der Herkunft von `contents`.
Ok(())
}
/// Schreibt `contents` atomar (Temp-Datei im selben Verzeichnis + `rename`) statt per direktem
/// Trunkieren-und-Schreiben - ein Absturz oder ein volles Dateisystem mitten im Schreiben
/// könnte `/etc/fstab` sonst in einem leeren/halb geschriebenen Zustand zurücklassen, was den
/// nächsten Boot verhindern kann.
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
let tmp_path = PathBuf::from(format!("{}.smart-mount-tmp", path.display()));
std::fs::write(&tmp_path, contents).map_err(|e| Error::io(&tmp_path, e))?;
std::fs::rename(&tmp_path, path).map_err(|e| Error::io(path, e))
}
/// Tag-Kommentar, der an jede von smart-mount geschriebene fstab-Zeile angehängt wird
/// (`man 5 fstab`: ein `#` leitet einen bis zum Zeilenende reichenden Kommentar ein, auch nach
/// den 6 regulären Feldern - das stört `mount(8)` nicht). Erlaubt, beim nächsten `setup fstab`
/// zeilenweise zu erkennen, zu welchem Paar/welcher Seite/welchem Besitzer eine bestehende
/// Zeile gehört, statt den kompletten Block bei jedem Lauf zu ersetzen (siehe
/// [`merge_managed_block`]).
fn line_tag(pair: &DrivePair, side: Side) -> String {
format!(
"smart-mount pair={} side={} owner={}",
pair.id,
side.as_str(),
pair.owner_user.as_deref().unwrap_or("-")
)
}
fn tagged_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
let line = fstab_line(pair, side, settings)?;
Ok(format!("{line} # {}", line_tag(pair, side)))
}
/// Liest `(pair_id, side, owner)` aus dem von [`line_tag`] angehängten Kommentar einer
/// bestehenden fstab-Zeile, falls vorhanden.
fn parse_tag(line: &str) -> Option<(String, &'static str, String)> {
let marker = "# smart-mount ";
let idx = line.find(marker)?;
let rest = &line[idx + marker.len()..];
let mut pair_id = None;
let mut side = None;
let mut owner = None;
for token in rest.split_whitespace() {
if let Some(v) = token.strip_prefix("pair=") {
pair_id = Some(v.to_string());
} else if let Some(v) = token.strip_prefix("side=") {
side = match v {
"local" => Some("local"),
"cloud" => Some("cloud"),
_ => None,
};
} else if let Some(v) = token.strip_prefix("owner=") {
owner = Some(v.to_string());
}
}
Some((pair_id?, side?, owner.unwrap_or_else(|| "-".to_string())))
}
/// Führt den bestehenden verwalteten Block mit den frisch berechneten Zeilen für `pairs`
/// zusammen, statt ihn komplett zu ersetzen:
/// - eine bestehende Zeile, die zu einem der aktuell verarbeiteten Paare gehört, wird durch die
/// frische Version ersetzt (oder, falls deren Neuberechnung fehlschlägt, z. B. weil ein
/// MAC-adressiertes lokales Gerät gerade offline ist, unverändert beibehalten statt
/// ersatzlos gelöscht - siehe [`fstab_line`]/[`tagged_line`]);
/// - eine Zeile eines inzwischen aus der Konfiguration entfernten Paares DESSELBEN Nutzers
/// (`owner_scope`) wird entfernt;
/// - jede andere Zeile (insbesondere die eines ANDEREN Nutzers) bleibt unangetastet.
///
/// Ohne diese Unterscheidung würde ein zweiter Nutzer, der `setup fstab` für sein eigenes Konto
/// ausführt, versehentlich die vom ersten Nutzer installierten Zeilen löschen, da beide
/// denselben verwalteten Block in `/etc/fstab` teilen. Ebenso würde ein einzelnes Paar, dessen
/// Neuberechnung gerade fehlschlägt, sonst den gesamten Block-Rebuild für alle anderen,
/// gesunden Paare verhindern.
fn merge_managed_block(
current_block: &str,
pairs: &[&DrivePair],
settings: &GlobalSettings,
owner_scope: &[String],
) -> String {
let current_pair_ids: HashSet<&str> = pairs.iter().map(|p| p.id.as_str()).collect();
let mut new_lines = Vec::new();
let mut replaced: HashSet<(String, &'static str)> = HashSet::new();
for pair in pairs {
for side in [Side::Local, Side::Cloud] {
match tagged_line(pair, side, settings) {
Ok(line) => {
replaced.insert((pair.id.clone(), side.as_str()));
new_lines.push(line);
}
Err(e) => {
logger_ctdra::warn(
"fstab",
&format!(
"could not compute fstab entry for pair '{}' ({}): {e} - leaving \
any existing entry for it untouched",
pair.id,
side.as_str()
),
);
}
}
}
}
let mut kept: Vec<String> = current_block
.lines()
.filter(|line| match parse_tag(line) {
Some((pair_id, side, _)) if replaced.contains(&(pair_id.clone(), side)) => false,
Some((pair_id, _, owner))
if !current_pair_ids.contains(pair_id.as_str())
&& owner_scope.iter().any(|o| o == &owner) =>
{
false
}
_ => true,
})
.map(str::to_string)
.collect();
kept.extend(new_lines);
kept.join("\n")
}
fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
// Wiederverwendet dieselbe Options-Berechnung wie der tatsächliche Mount-Aufruf
// (`mount::target::build_target`, inkl. `apply_owner_permissions`) - insbesondere die
// dort injizierten uid=/gid= sind hier nicht optional: laut `man mount.davfs`
// ("uid=user"/"gid=group") darf ein unprivilegierter Nutzer eine Zeile nur mounten, wenn
// uid= auf ihn selbst zeigt und er Mitglied der in gid= genannten Gruppe ist. Ohne diese
// Optionen dürfte JEDES Mitglied der Gruppe 'davfs2' JEDES konfigurierte Paar mounten,
// nicht nur der vorgesehene Besitzer (`setup()` verweigert daher bereits vorab Paare ohne
// `owner_user`).
//
// Für CIFS ist per `man mount.cifs` BESTÄTIGT, dass dieselbe Beschränkung NICHT existiert:
// uid=/gid= betreffen dort ausschließlich die simulierte Datei-Ownership nach dem Mount,
// nicht das Mount-*Recht* selbst - mount(8)/mount.cifs bieten keinen Mechanismus, eine
// 'user'-fstab-Zeile auf eine bestimmte Person einzuschränken. Für CIFS-Nutzer-Kontext-
// Paare bleibt das eine bewusst akzeptierte, strukturelle Lücke (siehe README) statt einer
// über Mount-Optionen behebbaren - die Optionen werden trotzdem gesetzt, da korrekte
// Ownership unabhängig davon nötig ist.
let target = target::build_target(pair, settings, side)?;
let kind = target::side_kind(pair, side);
let (fstype, mut extra_opts) = match kind {
MountKind::WebDav => ("davfs", String::new()),
MountKind::Smb => {
let creds = smb::credentials_path(&pair.id, side);
("cifs", format!(",credentials={}", creds.display()))
}
MountKind::Nfs => ("nfs", String::new()),
};
for opt in &target.options {
extra_opts.push_str(&format!(",{opt}"));
}
// Jede Seite bekommt ihr eigenes, eindeutiges Backing-Verzeichnis als Ziel - siehe
// Moduldoku. `pair.mount_point` selbst taucht bewusst NICHT in fstab auf.
//
// WICHTIG: die `user`-Option impliziert laut `man 8 mount` ("Non-superuser mounts") für
// JEDES Dateisystem `noexec,nosuid,nodev`, sofern nicht direkt im selben Optionslisten-
// Eintrag überschrieben. Ohne das explizite `exec` hier könnten auf einem User-Kontext-
// Laufwerk liegende Skripte NICHT ausgeführt werden. `nosuid`/`nodev` bleiben bewusst
// implizit (sinnvolle Absicherung, dafür gab es keine Anforderung).
Ok(format!(
"{source} {mount_point} {fstype} user,exec,noauto{extra_opts} 0 0",
source = target.source,
mount_point = target.mount_point.display()
))
}
fn user_config_path(username: &str) -> PathBuf {
let program_name = config_ctdra::get_program_name();
let config_name = config_ctdra::get_config_name();
let file_name = if config_name.ends_with(".toml") {
config_name
} else {
format!("{config_name}.toml")
};
user_home_dir(username)
.map(|h| h.join(".config").join(&program_name).join(&file_name))
.unwrap_or_else(|| {
PathBuf::from(format!(
"/home/{username}/.config/{program_name}/{file_name}"
))
})
}
fn user_home_dir(username: &str) -> Option<PathBuf> {
if let Ok(output) = Command::new("getent").args(["passwd", username]).output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
let fields: Vec<&str> = stdout.trim().split(':').collect();
if fields.len() >= 6 && !fields[5].is_empty() {
return Some(PathBuf::from(fields[5]));
}
}
if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
for line in passwd.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 6 && fields[0] == username && !fields[5].is_empty() {
return Some(PathBuf::from(fields[5]));
}
}
}
// Weder `getent` noch `/etc/passwd` konnten den Nutzer auflösen - das reine Erraten von
// `/home/<user>` kann bei einem abweichenden Home-Verzeichnis (oder falsch geschriebenem
// Nutzernamen) dazu führen, dass `setup()` anschließend die Konfigurationsdatei am
// falschen Pfad lädt und stillschweigend "nichts zu tun" meldet. Warnen statt schweigen.
logger_ctdra::warn(
"fstab",
&format!(
"could not resolve home directory for user '{username}' via getent/passwd - \
guessing '/home/{username}'"
),
);
Some(PathBuf::from(format!("/home/{username}")))
}
/// Zeigt an, dass diese Konfiguration bereits ein einmaliges `setup fstab` benötigt hat.
pub fn requires_setup(cfg: &AppConfig) -> bool {
cfg.pairs.iter().any(|p| p.context == MountContext::User)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn sample_pair() -> DrivePair {
// Nutzt den tatsächlich ausführenden Testnutzer statt eines hartkodierten Namens,
// da `fstab_line` jetzt `id -u`/`id -g` für `owner_user` aufruft (siehe
// `mount::target::apply_owner_permissions`) - ein fester Name wäre auf anderen
// Maschinen/CI nicht garantiert vorhanden.
let user = std::env::var("USER").expect("USER env var set in test environment");
DrivePair {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
context: MountContext::User,
owner_user: Some(user.clone()),
mount_point: "/home/dragon/smart-mount/pair-1".into(),
local: crate::config::LocalSide {
kind: MountKind::Smb,
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)),
share: "share".into(),
username: Some("nasuser".into()),
extra_options: vec![],
},
cloud: crate::config::CloudSide {
kind: MountKind::Smb,
host_or_url: "cloud.example.com".into(),
share: "share".into(),
username: Some(user),
extra_options: vec![],
},
}
}
#[test]
fn validate_user_pairs_have_owner_rejects_missing_owner() {
let mut pair = sample_pair();
pair.owner_user = None;
let err = validate_user_pairs_have_owner(&[&pair]).unwrap_err();
assert!(err.to_string().contains("owner_user"));
}
#[test]
fn validate_user_pairs_have_owner_accepts_pair_with_owner() {
let pair = sample_pair();
assert!(validate_user_pairs_have_owner(&[&pair]).is_ok());
}
#[test]
fn renders_two_lines_per_pair_each_with_its_own_unique_target() {
let pair = sample_pair();
let block = merge_managed_block("", &[&pair], &GlobalSettings::default(), &[]);
let lines: Vec<&str> = block.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("user,exec,noauto"));
assert!(lines[0].contains("uid="));
assert!(lines[0].contains("gid="));
// Der sichtbare pair.mount_point selbst darf in KEINER Zeile als Ziel auftauchen -
// das ist der Symlink, den smart-mount zur Laufzeit umschaltet, kein fstab-Ziel.
let visible = pair.mount_point.display().to_string();
let target_tokens: Vec<&str> = [lines[0], lines[1]]
.iter()
.map(|l| l.split_whitespace().nth(1).unwrap())
.collect();
assert!(!target_tokens.contains(&visible.as_str()));
// Jede Zeile hat ein eigenes, eindeutiges Ziel (Backing-Verzeichnis) - keine zwei
// Zeilen mit demselben Mountpoint, auf dessen Disambiguierung sich mount(8) laut
// `man 8 mount` nicht verlassen ließe.
let target_of = |line: &str| line.split_whitespace().nth(1).unwrap().to_string();
assert_ne!(target_of(lines[0]), target_of(lines[1]));
assert_eq!(
target_of(lines[0]),
backing_dir(&pair, Side::Local).display().to_string()
);
assert_eq!(
target_of(lines[1]),
backing_dir(&pair, Side::Cloud).display().to_string()
);
}
#[test]
fn merge_managed_block_preserves_lines_belonging_to_other_users() {
let pair = sample_pair();
let other_users_line = "//other/share /backing/other cifs user,exec,noauto 0 0 \
# smart-mount pair=other-pair side=local owner=someone-else";
let block = merge_managed_block(
other_users_line,
&[&pair],
&GlobalSettings::default(),
&[pair.owner_user.clone().unwrap()],
);
assert!(
block.contains(other_users_line),
"a run scoped to one user must not touch another user's fstab lines"
);
assert!(block.contains("pair=pair-1"));
}
#[test]
fn merge_managed_block_drops_stale_lines_for_a_removed_pair_of_the_same_owner() {
let pair = sample_pair();
let owner = pair.owner_user.clone().unwrap();
let stale_line = format!(
"//old/share /backing/old cifs user,exec,noauto 0 0 \
# smart-mount pair=deleted-pair side=local owner={owner}"
);
// `deleted-pair` is no longer part of `pairs`, so its line should be dropped since it
// belongs to the same owner this run is scoped to - but only then.
let block =
merge_managed_block(&stale_line, &[&pair], &GlobalSettings::default(), &[owner]);
assert!(!block.contains("deleted-pair"));
assert!(block.contains("pair=pair-1"));
}
#[test]
fn merge_managed_block_keeps_stale_lines_of_a_different_owner() {
let pair = sample_pair();
let stale_line = "//old/share /backing/old cifs user,exec,noauto 0 0 \
# smart-mount pair=deleted-pair side=local owner=someone-else";
// `owner_scope` only covers `pair.owner_user`, not `someone-else` - the stale line must
// survive even though its pair is absent from `pairs`.
let block = merge_managed_block(
stale_line,
&[&pair],
&GlobalSettings::default(),
&[pair.owner_user.clone().unwrap()],
);
assert!(block.contains("deleted-pair"));
}
#[test]
fn strip_managed_block_removes_only_the_marked_section() {
let contents = "/dev/sda1 / ext4 defaults 0 1\n# BEGIN smart-mount managed block\nfoo\n# END smart-mount managed block\n";
let stripped = crate::util::strip_managed_block(contents, BEGIN_MARKER, END_MARKER);
assert!(stripped.contains("/dev/sda1"));
assert!(!stripped.contains("foo"));
}
#[test]
fn create_dir_all_owned_creates_multi_level_path_and_chowns_new_dirs() {
// `chown` zu einem ANDEREN Nutzer bräuchte Root - hier wird bewusst auf den eigenen
// Nutzer "umgechownt" (funktioniert unprivilegiert, ist ein No-op auf die tatsächliche
// Ownership, prüft aber, dass der `chown`-Aufruf pro neu angelegtem Verzeichnis
// fehlerfrei durchläuft und die Verzeichnisstruktur korrekt entsteht).
let user = std::env::var("USER").expect("USER env var set in test environment");
let base = tempfile::tempdir().expect("tempdir");
let target = base.path().join("a").join("b").join("c");
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
assert!(target.is_dir());
assert!(base.path().join("a").is_dir());
}
#[test]
fn create_dir_all_owned_does_not_touch_already_existing_ancestors() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let base = tempfile::tempdir().expect("tempdir");
let target = base.path().join("existing").join("new-child");
std::fs::create_dir_all(base.path().join("existing")).expect("pre-create ancestor");
// Darf nicht versuchen, `base.path()` selbst zu chownen (das existierte schon vorher) -
// nur `existing/new-child`. Schlägt fehl, falls die Funktion stattdessen versucht,
// einen nicht existierenden Nutzer für einen bereits vorhandenen Ordner zu setzen o. Ä.
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
assert!(target.is_dir());
}
#[test]
fn create_dir_all_owned_without_owner_just_creates_directories() {
let base = tempfile::tempdir().expect("tempdir");
let target = base.path().join("x").join("y");
create_dir_all_owned(&target, None).expect("create_dir_all_owned");
assert!(target.is_dir());
}
#[test]
fn user_config_path_resolves_for_user() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let path = user_config_path(&user);
let s = path.to_str().unwrap();
assert!(s.contains(&format!("/home/{user}/.config/")));
assert!(s.ends_with("/config.toml"));
}
}
-2
View File
@@ -6,11 +6,9 @@ pub mod crypto;
pub mod db;
pub mod doctor;
pub mod error;
pub mod fstab;
pub mod mount;
pub mod network;
pub mod reconcile;
pub mod systemd;
pub(crate) mod util;
pub use error::{Error, Result};
+6 -19
View File
@@ -18,21 +18,9 @@ use crate::config::{DrivePair, GlobalSettings, MountKind};
use crate::db::credentials::{Credential, Side};
use crate::error::Result;
/// Wie ein Mount-Aufruf ausgeführt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountInvocation {
/// Root, volle `-o`-Optionen: `mount -t <type> <source> <target> -o <opts>`.
Direct,
/// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount <target>` (nur das
/// Ziel, genau der in `man 8 mount` dokumentierte Fall `mount /cd`). Da jede Seite ihr
/// eigenes, eindeutiges Backing-Verzeichnis hat (siehe [`target::backing_dir`]), gibt es
/// dabei nie mehr als eine passende fstab-Zeile. Voraussetzung: `smart-mount setup fstab`
/// wurde für dieses Paar bereits ausgeführt.
ViaFstab,
}
/// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein-
/// bzw. auszuhängen.
/// bzw. auszuhängen. Der Mount-Aufruf ist immer `mount -t <type> <source> <target> -o <opts>`
/// als root (smart-mount läuft ausschließlich als root/System-Dienst, siehe [`crate::systemd`]).
pub struct MountTarget {
pub pair_id: String,
/// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile
@@ -41,12 +29,11 @@ pub struct MountTarget {
pub mount_point: PathBuf,
/// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`.
pub source: String,
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`), nur bei
/// `MountInvocation::Direct` verwendet - mit Kommas verbindbar für `mount -o`.
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`) - mit Kommas
/// verbindbar für `mount -o`.
pub options: Vec<String>,
pub invocation: MountInvocation,
/// Für `MountContext::User`-Paare: der Linux-Benutzername, dem Zugangsdaten-/Secrets-
/// Dateien gehören sollen.
/// Für Paare mit gesetztem `owner_user`: der Linux-Benutzername, dem Zugangsdaten-/
/// Secrets-Dateien gehören sollen.
pub owner_user: Option<String>,
}
+1 -10
View File
@@ -5,9 +5,7 @@ use std::process::Command;
use crate::db::credentials::Credential;
use crate::error::{Error, Result};
use crate::mount::{
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
};
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
pub struct NfsBackend;
@@ -34,8 +32,6 @@ impl MountBackend for NfsBackend {
fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount");
match target.invocation {
MountInvocation::Direct => {
cmd.arg("-t")
.arg("nfs")
.arg(&target.source)
@@ -43,11 +39,6 @@ impl MountBackend for NfsBackend {
if !target.options.is_empty() {
cmd.arg("-o").arg(target.options.join(","));
}
}
MountInvocation::ViaFstab => {
cmd.arg(&target.mount_point);
}
}
run_tolerating_already_done(cmd, "nfs mount", false)
}
+3 -12
View File
@@ -7,9 +7,7 @@ use std::process::Command;
use crate::db::credentials::Credential;
use crate::error::{Error, Result};
use crate::mount::{
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
};
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
pub struct SmbBackend;
@@ -38,8 +36,6 @@ impl MountBackend for SmbBackend {
fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount");
match target.invocation {
MountInvocation::Direct => {
cmd.arg("-t")
.arg("cifs")
.arg(&target.source)
@@ -50,11 +46,6 @@ impl MountBackend for SmbBackend {
credentials_path(&target.pair_id, target.side).display()
));
cmd.arg("-o").arg(opts.join(","));
}
MountInvocation::ViaFstab => {
cmd.arg(&target.mount_point);
}
}
run_tolerating_already_done(cmd, "cifs mount", false)
}
@@ -65,8 +56,8 @@ impl MountBackend for SmbBackend {
}
}
/// Stabiler Pfad (nicht ein Tempfile!), da bei `MountInvocation::ViaFstab` die fstab-Zeile
/// (von `setup fstab` einmalig geschrieben) exakt auf diesen `credentials=`-Pfad verweist.
/// Stabiler Pfad (nicht ein Tempfile!) - `prepare()` schreibt hierhin, `mount()` referenziert
/// denselben Pfad über `-o credentials=...`.
pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf {
let base = config_ctdra::get_config_path()
.parent()
+2 -1
View File
@@ -103,7 +103,8 @@ mod tests {
#[test]
fn matches_a_mount_point_containing_a_space_escaped_by_the_kernel() {
let mountinfo = "43 36 0:26 / /mnt/my\\040drive rw,relatime shared:2 - cifs //server/share rw";
let mountinfo =
"43 36 0:26 / /mnt/my\\040drive rw,relatime shared:2 - cifs //server/share rw";
let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/my drive"));
assert_eq!(source.as_deref(), Some("//server/share"));
}
+62 -8
View File
@@ -15,12 +15,13 @@
//! kurz während eines Umschaltens (siehe [`crate::reconcile`]) mehr als ein Backing-Verzeichnis
//! gemountet - im Ruhezustand immer nur eines, wie ursprünglich vorgesehen.
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountContext, MountKind};
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountKind};
use crate::db::credentials::Side;
use crate::error::{Error, Result};
use crate::mount::{self, MountInvocation, MountTarget};
use crate::mount::{self, MountTarget};
use crate::network::address;
/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird
@@ -60,10 +61,6 @@ pub fn build_target_with_cached_local_ip(
side: Side,
cached_local_ip: Option<std::net::Ipv4Addr>,
) -> Result<MountTarget> {
let invocation = match pair.context {
MountContext::System => MountInvocation::Direct,
MountContext::User => MountInvocation::ViaFstab,
};
let kind = side_kind(pair, side);
let (source, mut options) = match side {
@@ -88,7 +85,6 @@ pub fn build_target_with_cached_local_ip(
mount_point: backing_dir(pair, side),
source,
options,
invocation,
owner_user: pair.owner_user.clone(),
})
}
@@ -164,6 +160,21 @@ fn resolve_uid_gid(username: &str) -> Result<(u32, u32)> {
Ok((run_id(username, "-u")?, run_id(username, "-g")?))
}
/// Macht `owner` (Nutzer + dessen primäre Gruppe) zum alleinigen Besitzer von `dir` (`0700`).
/// Für das Backing-Verzeichnis eines Paares mit gesetztem `owner_user` - unabhängig vom
/// Mount-Typ, also auch für NFS, wo es (anders als bei CIFS/WebDAV) keine `uid=`/`gid=`-
/// Mount-Option gibt, die die nach dem Mounten sichtbaren Rechte übersteuern könnte: das
/// Verzeichnis selbst gehört so wenigstens `owner` - reicht z. B. aus, um es zu betreten, bevor/
/// falls etwas gemountet ist, ersetzt aber nicht die serverseitige NFS-Export-Rechtevergabe
/// (siehe README, Abschnitt "Voller Zugriff für einen bestimmten Nutzer").
pub(crate) fn ensure_owner_only_dir(dir: &Path, owner: &str) -> Result<()> {
let (uid, gid) = resolve_uid_gid(owner)?;
std::os::unix::fs::chown(dir, Some(uid), Some(gid)).map_err(|e| Error::io(dir, e))?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(|e| Error::io(dir, e))?;
Ok(())
}
fn run_id(username: &str, flag: &str) -> Result<u32> {
let output = std::process::Command::new("id")
.arg(flag)
@@ -268,6 +279,13 @@ pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> {
if let Some(parent) = link_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
// `mount_base_dir` ist von allen Paaren (mit ggf. unterschiedlichem `owner_user`)
// gemeinsam genutzt - hart auf `0755` setzen statt sich auf das Umask des root-Prozesses
// zu verlassen, damit jeder lokale Nutzer bis zum eigentlichen Mountpoint durchqueren
// kann (die eigentliche Zugriffsbeschränkung passiert weiter unten je Paar, siehe
// `ensure_owner_only_dir`/die `uid=`/`gid=`-Mount-Optionen).
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755))
.map_err(|e| Error::io(parent, e))?;
}
let target = backing_dir(pair, side);
@@ -296,7 +314,6 @@ mod tests {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
context: MountContext::System,
owner_user: None,
mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide {
@@ -472,4 +489,41 @@ mod tests {
let err = activate_symlink(&pair, Side::Local).unwrap_err();
assert!(err.to_string().contains("real directory"));
}
#[test]
fn ensure_owner_only_dir_sets_mode_0700() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let dir = tempfile::tempdir().expect("tempdir");
ensure_owner_only_dir(dir.path(), &user).expect("ensure_owner_only_dir");
let mode = std::fs::metadata(dir.path())
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn ensure_owner_only_dir_fails_clearly_for_unknown_user() {
let dir = tempfile::tempdir().expect("tempdir");
let err = ensure_owner_only_dir(dir.path(), "no-such-user-xyz").unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn activate_symlink_sets_shared_parent_dir_to_0755() {
let dir = tempfile::tempdir().expect("tempdir");
let parent = dir.path().join("mount-base");
let mut pair = sample_pair();
pair.mount_point = parent.join("pair-1");
activate_symlink(&pair, Side::Local).expect("activate local");
let mode = std::fs::metadata(&parent)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755);
}
}
+7 -25
View File
@@ -7,9 +7,7 @@ use std::process::Command;
use crate::db::credentials::Credential;
use crate::error::{Error, Result};
use crate::mount::{
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
};
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
pub struct WebDavBackend;
@@ -60,8 +58,6 @@ impl MountBackend for WebDavBackend {
fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount");
match target.invocation {
MountInvocation::Direct => {
cmd.arg("-t")
.arg("davfs")
.arg(&target.source)
@@ -69,11 +65,6 @@ impl MountBackend for WebDavBackend {
if !target.options.is_empty() {
cmd.arg("-o").arg(target.options.join(","));
}
}
MountInvocation::ViaFstab => {
cmd.arg(&target.mount_point);
}
}
run_tolerating_already_done(cmd, "davfs2 mount", false)
}
@@ -84,26 +75,14 @@ impl MountBackend for WebDavBackend {
}
}
/// Mounten läuft ausschließlich als root/System-Dienst (siehe [`crate::systemd`]), daher immer
/// der System-Pfad - kein Nutzerkontext-Zweig mehr nötig.
fn davfs2_conf_path() -> PathBuf {
if sudo_ctdra::is_run_as_root() {
PathBuf::from("/etc/davfs2/davfs2.conf")
} else {
home_dir().join(".davfs2/davfs2.conf")
}
}
pub(crate) fn davfs2_secrets_path() -> PathBuf {
if sudo_ctdra::is_run_as_root() {
PathBuf::from("/etc/davfs2/secrets")
} else {
home_dir().join(".davfs2/secrets")
}
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
/// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen
@@ -382,7 +361,10 @@ mod tests {
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.contains("pass1-updated"));
assert!(!contents.contains("pass1\n") && !contents.ends_with("pass1"));
assert!(contents.contains("pass2"), "unrelated prefix-matching URL's entry must survive");
assert!(
contents.contains("pass2"),
"unrelated prefix-matching URL's entry must survive"
);
}
#[test]
+7 -6
View File
@@ -282,6 +282,12 @@ async fn mount_side(
target::build_target_with_cached_local_ip(pair, settings, side, cached_local_ip)?;
std::fs::create_dir_all(&mount_target.mount_point)
.map_err(|e| crate::error::Error::io(&mount_target.mount_point, e))?;
// Backing-Verzeichnis auf `owner_user` chownen (0700) - relevant vor allem für NFS, wo es
// (anders als CIFS/WebDAV) keine `uid=`/`gid=`-Mount-Option gibt, die den Zugriff nach dem
// Mounten sonst regeln würde (siehe `target::ensure_owner_only_dir`).
if let Some(owner) = &pair.owner_user {
target::ensure_owner_only_dir(&mount_target.mount_point, owner)?;
}
let backend = mount::backend_for(target::side_kind(pair, side));
backend.check_available()?;
@@ -354,10 +360,6 @@ async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -
source: String::new(),
mount_point: target::backing_dir(pair, side),
options: vec![],
invocation: match pair.context {
crate::config::MountContext::System => crate::mount::MountInvocation::Direct,
crate::config::MountContext::User => crate::mount::MountInvocation::ViaFstab,
},
owner_user: pair.owner_user.clone(),
}
});
@@ -367,7 +369,7 @@ async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CloudSide, MountContext, MountKind};
use crate::config::{CloudSide, MountKind};
use std::net::Ipv4Addr;
fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair {
@@ -375,7 +377,6 @@ mod tests {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
context: MountContext::System,
owner_user: None,
mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide {
+35 -294
View File
@@ -1,30 +1,23 @@
//! Generiert/installiert systemd-Units (System- und User-Kontext) sowie das Crontab-Äquivalent
//! für Systeme ohne systemd.
//! Cron-Fallback (`smart-mount service crontab`) für Systeme ohne (oder ohne genutzten)
//! systemd.
//!
//! Die systemd-Units rufen ausschließlich einfache `smart-mount`-Subcommands auf, damit
//! dieselben Zeilen 1:1 als Crontab-Einträge funktionieren.
//! Die eigentliche systemd-Einrichtung passiert NICHT mehr zur Laufzeit über dieses Modul,
//! sondern über die paketierten, statischen Unit-Dateien (siehe `packaging/systemd/` im
//! Quellbaum) sowie die postinst/postrm-Skripte der .deb/.rpm/.pkg.tar.zst-Pakete - smart-mount
//! richtet sich beim Installieren des Pakets automatisch als System-systemd-Dienst ein und
//! entfernt sich beim Deinstallieren wieder. Dieses Modul bleibt für Systeme ohne systemd (oder
//! zum bewussten Umgehen von systemd) als manueller Cron-Weg bestehen - immer systemweit
//! (`/etc/cron.d/smart-mount`), da Mounten ohnehin immer Root-Rechte braucht (siehe
//! [`crate::cli::require_root`]); eine persönliche Nutzer-Crontab liefe ins Leere.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::error::{Error, Result};
const MOUNT_SERVICE: &str = "smart-mount-mount.service";
const WATCH_SERVICE: &str = "smart-mount-watch.service";
const WATCH_TIMER: &str = "smart-mount-watch.timer";
const CRON_D_PATH: &str = "/etc/cron.d/smart-mount";
const CRON_BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
const CRON_END_MARKER: &str = "# END smart-mount managed block";
/// System (root) oder User-Kontext für die Unit-Installation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
System,
User,
}
fn binary_path() -> String {
std::env::current_exe()
.ok()
@@ -32,141 +25,15 @@ fn binary_path() -> String {
.unwrap_or_else(|| "/usr/bin/smart-mount".to_string())
}
fn mount_service_unit(scope: Scope) -> String {
let wanted_by = match scope {
Scope::System => "multi-user.target",
Scope::User => "default.target",
};
// `network-online.target` ist ein Ziel des System-Managers - unter `systemctl --user` gibt
// es dafür keine sinnvolle Entsprechung (die Unit existiert dort nicht bzw. wird nie
// erreicht), die Ordnungsabhängigkeit wäre also für User-Scope-Units wirkungslos statt
// schlicht harmlos. Nur im System-Kontext gesetzt.
let network_wait = match scope {
Scope::System => "After=network-online.target\nWants=network-online.target\n",
Scope::User => "",
};
format!(
"[Unit]\nDescription=smart-mount: mount configured drive pairs at boot\n{network_wait}\n[Service]\nType=oneshot\nExecStart={} mount --all\n\n[Install]\nWantedBy={wanted_by}\n",
binary_path()
)
}
fn watch_service_unit() -> String {
format!(
"[Unit]\nDescription=smart-mount: check reachability and switch local/cloud if needed\n\n[Service]\nType=oneshot\nExecStart={} watch\n",
binary_path()
)
}
fn watch_timer_unit(interval_secs: u64) -> String {
format!(
"[Unit]\nDescription=smart-mount: periodic reconciling\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec={interval_secs}s\nPersistent=true\nUnit={WATCH_SERVICE}\n\n[Install]\nWantedBy=timers.target\n"
)
}
fn unit_dir(scope: Scope) -> Result<PathBuf> {
match scope {
Scope::System => {
if !sudo_ctdra::is_run_as_root() {
return Err(Error::RequiresRoot("service install --system"));
}
Ok(PathBuf::from("/etc/systemd/system"))
}
Scope::User => {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or(Error::Other("HOME not set".to_string()))?;
Ok(home.join(".config/systemd/user"))
}
}
}
fn systemctl(scope: Scope, args: &[&str]) -> Result<()> {
let mut cmd = Command::new("systemctl");
if scope == Scope::User {
cmd.arg("--user");
}
cmd.args(args);
let output = cmd
.output()
.map_err(|e| Error::Other(format!("could not run systemctl: {e}")))?;
if !output.status.success() {
return Err(Error::Other(format!(
"systemctl {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
/// Schreibt die Unit-Dateien, lädt systemd neu und aktiviert Mount- und Watch-Timer-Unit.
pub fn install(scope: Scope, watch_interval_secs: u64) -> Result<()> {
let dir = unit_dir(scope)?;
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
std::fs::write(dir.join(MOUNT_SERVICE), mount_service_unit(scope))
.map_err(|e| Error::io(&dir, e))?;
std::fs::write(dir.join(WATCH_SERVICE), watch_service_unit())
.map_err(|e| Error::io(&dir, e))?;
std::fs::write(dir.join(WATCH_TIMER), watch_timer_unit(watch_interval_secs))
.map_err(|e| Error::io(&dir, e))?;
systemctl(scope, &["daemon-reload"])?;
systemctl(scope, &["enable", "--now", MOUNT_SERVICE, WATCH_TIMER])?;
if scope == Scope::User {
logger_ctdra::info(
"systemd",
"For boot-time operation without an active login session: run 'loginctl enable-linger <user>'. \
Note: in that case the OS keyring may not yet be available at boot - \
smart-mount then automatically falls back to the key file.",
);
}
Ok(())
}
/// Ob `systemctl` auf diesem System überhaupt vorhanden ist - Voraussetzung, bevor
/// [`install`]/[`uninstall`] sinnvoll aufgerufen werden können.
/// Ob `systemctl` auf diesem System vorhanden ist - rein informativ für `doctor`; die
/// eigentliche systemd-Einrichtung läuft über die Paketierung (siehe Moduldoku).
pub fn is_available() -> bool {
crate::mount::binary_available("systemctl")
}
/// Ergebnis von [`uninstall`].
pub enum SystemdUninstallOutcome {
/// Mindestens eine Unit-Datei war vorhanden und wurde entfernt.
Removed,
/// Keine der Unit-Dateien war vorhanden - nichts zu tun.
NotPresent,
}
/// Deaktiviert und entfernt die Unit-Dateien, falls vorhanden.
pub fn uninstall(scope: Scope) -> Result<SystemdUninstallOutcome> {
let dir = unit_dir(scope)?;
let units = [MOUNT_SERVICE, WATCH_SERVICE, WATCH_TIMER];
if !units.iter().any(|unit| dir.join(unit).exists()) {
return Ok(SystemdUninstallOutcome::NotPresent);
}
let _ = systemctl(scope, &["disable", "--now", MOUNT_SERVICE, WATCH_TIMER]);
for unit in units {
let path = dir.join(unit);
if path.exists() {
std::fs::remove_file(&path).map_err(|e| Error::io(&path, e))?;
}
}
systemctl(scope, &["daemon-reload"])?;
Ok(SystemdUninstallOutcome::Removed)
}
/// Erzeugt die Crontab-Äquivalente zu den generierten Units, für Systeme ohne systemd.
/// `watch_interval_secs` ist derselbe Wert wie `settings.watch_interval_secs`, der auch die
/// `OnUnitActiveSec`-Periode des systemd-Timers steuert - beide Wege sollen dieselbe Kadenz
/// ergeben, statt dass die Crontab-Variante einen unabhängigen, fest eingebauten Wert hat.
/// Erzeugt die Crontab-Äquivalente zu den paketierten systemd-Units, für Systeme ohne
/// (genutzten) systemd. `watch_interval_secs` ist derselbe Wert wie
/// `settings.watch_interval_secs`.
pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
let bin = binary_path();
let schedule = cron_schedule_for_interval(watch_interval_secs);
@@ -177,50 +44,22 @@ pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
pub enum CronInstallOutcome {
/// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`).
SystemFile(PathBuf),
/// Persönliche Crontab des aufrufenden Nutzers aktualisiert.
UserCrontab,
/// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer
/// sollte stattdessen [`crontab_equivalent`] anzeigen.
Unavailable,
}
/// Richtet die periodische Ausführung direkt über Cron ein (Alternative zu [`install`] für
/// Systeme ohne systemd), sofern ein Cron-Mechanismus gefunden wird - sonst [`CronInstallOutcome::Unavailable`]
/// statt eines Fehlers, der Aufrufer zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung.
///
/// `Scope::System` schreibt `/etc/cron.d/smart-mount` (Standard-Konvention für
/// paketverwaltete Cron-Einträge, läuft als root; erfordert Root-Rechte, kein
/// Self-Elevate - analog zu `install(Scope::System, ...)`). `Scope::User` aktualisiert die
/// persönliche Crontab des aufrufenden Nutzers über `crontab -l`/`crontab -`, mit demselben
/// verwalteten-Block-Muster wie `fstab::setup` für `/etc/fstab` - bestehende, unabhängige
/// Cron-Einträge bleiben unangetastet.
pub fn install_cron(scope: Scope, watch_interval_secs: u64) -> Result<CronInstallOutcome> {
match scope {
Scope::System => install_cron_system(watch_interval_secs),
Scope::User => install_cron_user(watch_interval_secs),
}
}
fn managed_cron_block(watch_interval_secs: u64, user_field: Option<&str>) -> String {
let bin = binary_path();
let schedule = cron_schedule_for_interval(watch_interval_secs);
let user_prefix = user_field.map(|u| format!("{u} ")).unwrap_or_default();
format!(
"{CRON_BEGIN_MARKER}\n@reboot {user_prefix}{bin} mount --all\n{schedule} {user_prefix}{bin} watch\n{CRON_END_MARKER}\n"
)
}
fn install_cron_system(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
if !sudo_ctdra::is_run_as_root() {
return Err(Error::RequiresRoot("service crontab (system context)"));
}
/// Richtet die periodische Ausführung direkt über `/etc/cron.d/smart-mount` ein - manueller
/// Fallback für Systeme ohne (genutzten) systemd, sofern `/etc/cron.d` existiert - sonst
/// [`CronInstallOutcome::Unavailable`] statt eines Fehlers, der Aufrufer zeigt dann
/// [`crontab_equivalent`] zur manuellen Einrichtung. Erfordert Root (siehe Moduldoku) - der
/// Aufrufer (`cli::service`) prüft das bereits vorab.
pub fn install_cron(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
if !Path::new("/etc/cron.d").is_dir() {
return Ok(CronInstallOutcome::Unavailable);
}
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root,
// passend dazu, dass System-Kontext-Paare auch sonst als root gemountet werden.
let contents = managed_cron_block(watch_interval_secs, Some("root"));
let contents = managed_cron_block(watch_interval_secs);
std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?;
#[cfg(unix)]
{
@@ -231,19 +70,14 @@ fn install_cron_system(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH)))
}
fn install_cron_user(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
if !crate::mount::binary_available("crontab") {
return Ok(CronInstallOutcome::Unavailable);
}
let existing = read_current_user_crontab();
let without_block =
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER);
let block = managed_cron_block(watch_interval_secs, None);
let new_contents = format!("{}\n{block}", without_block.trim_end());
write_user_crontab(&new_contents)?;
Ok(CronInstallOutcome::UserCrontab)
fn managed_cron_block(watch_interval_secs: u64) -> String {
let bin = binary_path();
let schedule = cron_schedule_for_interval(watch_interval_secs);
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root,
// passend dazu, dass Mounts auch sonst immer als root laufen.
format!(
"{CRON_BEGIN_MARKER}\n@reboot root {bin} mount --all\n{schedule} root {bin} watch\n{CRON_END_MARKER}\n"
)
}
/// Ergebnis von [`uninstall_cron`].
@@ -255,22 +89,8 @@ pub enum CronUninstallOutcome {
}
/// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten
/// Cron-Eintrag wieder, sofern vorhanden. `Scope::User` rührt dabei - wie `install_cron` -
/// nur den von smart-mount verwalteten Block in der persönlichen Crontab an, keine
/// unabhängigen, bereits vorhandenen Einträge.
pub fn uninstall_cron(scope: Scope) -> Result<CronUninstallOutcome> {
match scope {
Scope::System => uninstall_cron_system(),
Scope::User => uninstall_cron_user(),
}
}
fn uninstall_cron_system() -> Result<CronUninstallOutcome> {
if !sudo_ctdra::is_run_as_root() {
return Err(Error::RequiresRoot(
"service uninstall (system context, cron)",
));
}
/// Cron-Eintrag wieder, sofern vorhanden.
pub fn uninstall_cron() -> Result<CronUninstallOutcome> {
let path = Path::new(CRON_D_PATH);
if !path.exists() {
return Ok(CronUninstallOutcome::NotPresent);
@@ -279,53 +99,6 @@ fn uninstall_cron_system() -> Result<CronUninstallOutcome> {
Ok(CronUninstallOutcome::Removed)
}
fn uninstall_cron_user() -> Result<CronUninstallOutcome> {
if !crate::mount::binary_available("crontab") {
return Ok(CronUninstallOutcome::NotPresent);
}
let existing = read_current_user_crontab();
if !existing.contains(CRON_BEGIN_MARKER) {
return Ok(CronUninstallOutcome::NotPresent);
}
let without_block =
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER);
write_user_crontab(without_block.trim_end())?;
Ok(CronUninstallOutcome::Removed)
}
/// `crontab -l` meldet für einen Nutzer ohne bestehende Crontab einen Fehler ("no crontab for
/// ...") - das ist der Normalfall bei der ersten Einrichtung, kein echter Fehler.
fn read_current_user_crontab() -> String {
Command::new("crontab")
.arg("-l")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
}
fn write_user_crontab(contents: &str) -> Result<()> {
let mut child = Command::new("crontab")
.arg("-")
.stdin(Stdio::piped())
.spawn()
.map_err(|e| Error::Other(format!("could not start 'crontab': {e}")))?;
child
.stdin
.take()
.ok_or_else(|| Error::Other("stdin of 'crontab -' not available".to_string()))?
.write_all(contents.as_bytes())
.map_err(|e| Error::Other(format!("writing to 'crontab -' failed: {e}")))?;
let status = child
.wait()
.map_err(|e| Error::Other(format!("'crontab -' failed: {e}")))?;
if !status.success() {
return Err(Error::Other("'crontab -' reported an error".to_string()));
}
Ok(())
}
/// Rechnet ein Sekunden-Intervall in einen `*/N`-artigen Cron-Ausdruck um. Crons Granularität
/// ist Minuten (keine Sekunden) - es wird auf die nächste Minute gerundet, mindestens 1
/// (Cron kann nicht häufiger als minütlich auslösen). Ab 60 Minuten wird auf Stunden
@@ -350,44 +123,12 @@ mod tests {
use super::*;
#[test]
fn mount_service_unit_system_scope_uses_multi_user_target() {
let unit = mount_service_unit(Scope::System);
assert!(unit.contains("WantedBy=multi-user.target"));
}
#[test]
fn mount_service_unit_user_scope_uses_default_target() {
let unit = mount_service_unit(Scope::User);
assert!(unit.contains("WantedBy=default.target"));
assert!(!unit.contains("WantedBy=multi-user.target"));
}
#[test]
fn mount_service_unit_system_scope_waits_for_network_online() {
let unit = mount_service_unit(Scope::System);
assert!(unit.contains("network-online.target"));
}
#[test]
fn mount_service_unit_user_scope_does_not_reference_network_online_target() {
let unit = mount_service_unit(Scope::User);
assert!(!unit.contains("network-online.target"));
}
#[test]
fn managed_cron_block_for_user_crontab_has_no_user_field() {
let block = managed_cron_block(120, None);
fn managed_cron_block_includes_root_user_field_and_both_lines() {
let block = managed_cron_block(120);
assert!(block.starts_with(CRON_BEGIN_MARKER));
assert!(block.trim_end().ends_with(CRON_END_MARKER));
assert!(block.contains("@reboot") && !block.contains("@reboot root"));
assert!(block.contains("mount --all"));
assert!(block.contains("*/2 * * * *"));
}
#[test]
fn managed_cron_block_for_system_cron_d_includes_user_field() {
let block = managed_cron_block(120, Some("root"));
assert!(block.contains("@reboot root "));
assert!(block.contains("mount --all"));
assert!(block.contains("*/2 * * * * root "));
}
-103
View File
@@ -1,103 +0,0 @@
//! Kleine, modulübergreifend geteilte Hilfsfunktionen.
/// Entfernt einen durch `begin_marker`/`end_marker` abgegrenzten Abschnitt aus `contents`
/// (Marker-Zeilen selbst eingeschlossen). Für das "verwalteter Block"-Muster, mit dem
/// smart-mount eigene Zeilen in einer fremden Datei (`/etc/fstab`, Crontab) aktualisiert,
/// ohne bestehende, unabhängige Einträge anzurühren - siehe [`crate::fstab`] und
/// [`crate::systemd`].
pub(crate) fn strip_managed_block(contents: &str, begin_marker: &str, end_marker: &str) -> String {
let mut out = String::new();
let mut inside = false;
for line in contents.lines() {
if line.trim() == begin_marker {
inside = true;
continue;
}
if line.trim() == end_marker {
inside = false;
continue;
}
if !inside {
out.push_str(line);
out.push('\n');
}
}
out
}
/// Gegenstück zu [`strip_managed_block`]: gibt nur den Inhalt *innerhalb* des Blocks zurück
/// (ohne die Marker-Zeilen selbst), oder `None`, falls kein solcher Block vorhanden ist. Für
/// ein zeilenweises Zusammenführen (statt komplettem Ersetzen) des verwalteten Blocks.
pub(crate) fn extract_managed_block(
contents: &str,
begin_marker: &str,
end_marker: &str,
) -> Option<String> {
let mut out = String::new();
let mut inside = false;
let mut found = false;
for line in contents.lines() {
if line.trim() == begin_marker {
inside = true;
found = true;
continue;
}
if line.trim() == end_marker {
inside = false;
continue;
}
if inside {
out.push_str(line);
out.push('\n');
}
}
found.then_some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removes_only_the_marked_section() {
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
let stripped = strip_managed_block(contents, "# BEGIN test", "# END test");
assert_eq!(stripped, "line1\nline2\n");
}
#[test]
fn is_a_noop_when_markers_are_absent() {
let contents = "line1\nline2\n";
assert_eq!(
strip_managed_block(contents, "# BEGIN test", "# END test"),
contents
);
}
#[test]
fn handles_content_before_the_first_marker_and_no_trailing_content() {
let contents = "keep-me\n# BEGIN x\ndrop-me\n# END x\n";
assert_eq!(
strip_managed_block(contents, "# BEGIN x", "# END x"),
"keep-me\n"
);
}
#[test]
fn extract_managed_block_returns_only_the_interior() {
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
assert_eq!(
extract_managed_block(contents, "# BEGIN test", "# END test"),
Some("foo\nbar\n".to_string())
);
}
#[test]
fn extract_managed_block_is_none_when_markers_are_absent() {
let contents = "line1\nline2\n";
assert_eq!(
extract_managed_block(contents, "# BEGIN test", "# END test"),
None
);
}
}