From cd290f7f5b163ad49546fd45ec714b52f54d104c Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 00:49:57 +0200 Subject: [PATCH 01/28] =?UTF-8?q?Chore:=20F=C3=BCgt=20CI/Tooling-Vorlagen?= =?UTF-8?q?=20aus=20dem=20pers=C3=B6nlichen=20Rust-Projekt-Template=20hinz?= =?UTF-8?q?u?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt die alten, projektspezifischen Metadaten (Lizenzjahr/-inhaber, .gitignore) durch die Standardvorlage: Gitea-Workflows für CI/Security/ Renovate, Qodana-Konfiguration, Packaging-Skripte sowie IDE-/Cargo- Registry-Einstellungen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FZ3VzCWgbQRMyFEEKPvnZz --- .cargo/config.toml | 15 + .gitea/workflows/code-quality.yaml | 54 +++ .gitea/workflows/main.yaml | 254 ++++++++++++++ .gitea/workflows/renovate.yaml | 27 ++ .gitea/workflows/security-scan.yaml | 95 ++++++ .gitea/workflows/testing-to-main-pr.yaml | 111 +++++++ .gitea/workflows/testing.yaml | 254 ++++++++++++++ .gitea/workflows/trufflehog-scan.yaml | 59 ++++ .gitea/workflows/unit-tests.yaml | 38 +++ .gitea/workflows/version-bump.yaml | 84 +++++ .gitignore | 402 +---------------------- .idea/SmartMount.iml | 1 + .idea/jsonSchemas.xml | 49 +++ AGENTS.md | 138 ++++++++ LICENSE | 4 +- qodana.yaml | 50 +++ renovate.json | 101 ++++++ scripts/get-build-number.py | 178 ++++++++++ scripts/package-arch.py | 146 ++++++++ scripts/report-security-issue.py | 312 ++++++++++++++++++ src/config.rs | 178 ---------- src/filesystem/credentials.rs | 111 ------- src/filesystem/mod.rs | 3 - src/filesystem/mount.rs | 213 ------------ src/filesystem/mounted.rs | 167 ---------- src/log.rs | 241 -------------- src/network/mod.rs | 2 - src/network/network_interface.rs | 192 ----------- src/network/utils.rs | 391 ---------------------- src/program.rs | 14 - src/sudo.rs | 62 ---- 31 files changed, 1969 insertions(+), 1977 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .gitea/workflows/code-quality.yaml create mode 100644 .gitea/workflows/main.yaml create mode 100644 .gitea/workflows/renovate.yaml create mode 100644 .gitea/workflows/security-scan.yaml create mode 100644 .gitea/workflows/testing-to-main-pr.yaml create mode 100644 .gitea/workflows/testing.yaml create mode 100644 .gitea/workflows/trufflehog-scan.yaml create mode 100644 .gitea/workflows/unit-tests.yaml create mode 100644 .gitea/workflows/version-bump.yaml create mode 100644 .idea/jsonSchemas.xml create mode 100644 AGENTS.md create mode 100644 qodana.yaml create mode 100644 renovate.json create mode 100755 scripts/get-build-number.py create mode 100755 scripts/package-arch.py create mode 100644 scripts/report-security-issue.py delete mode 100644 src/config.rs delete mode 100644 src/filesystem/credentials.rs delete mode 100644 src/filesystem/mod.rs delete mode 100644 src/filesystem/mount.rs delete mode 100644 src/filesystem/mounted.rs delete mode 100644 src/log.rs delete mode 100644 src/network/mod.rs delete mode 100644 src/network/network_interface.rs delete mode 100644 src/network/utils.rs delete mode 100644 src/program.rs delete mode 100644 src/sudo.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..c7356c5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,15 @@ +[registry] +default = "gitea" + +[registries.gitea] +index = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" # Sparse index +# index = "https://gitea.creative-dragonslayer.de/Rust-Crates/_cargo-index.git" # Git + +[net] +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" diff --git a/.gitea/workflows/code-quality.yaml b/.gitea/workflows/code-quality.yaml new file mode 100644 index 0000000..8c35d11 --- /dev/null +++ b/.gitea/workflows/code-quality.yaml @@ -0,0 +1,54 @@ +name: Code Quality (Auto-Format & Clippy-Fix) + +on: + push: + branches: + - dev + workflow_dispatch: + +jobs: + fix: + name: Formatierung & Clippy automatisch beheben + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + with: + ref: ${{ gitea.ref_name || github.ref_name }} + token: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + + - name: Install Rust Toolchain + uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + components: clippy, rustfmt + cache: false + + - name: Cache Cargo-Abhängigkeiten & Build-Artefakte + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Formatierung automatisch beheben + run: cargo fmt + + - name: Clippy-Fixes automatisch anwenden + run: cargo clippy --fix --allow-dirty --allow-staged --all-targets + + - name: Änderungen committen & pushen + run: | + if [ -n "$(git status --porcelain)" ]; then + git config user.name "Gitea-Bot" + git config user.email "no-reply@creativedragonslayer.de" + git add -A + git commit -m "Style: Automatische Formatierung & Clippy-Fixes" + git push origin HEAD:${{ gitea.ref_name || github.ref_name }} + else + echo "Keine Formatierungs- oder Clippy-Änderungen." + fi diff --git a/.gitea/workflows/main.yaml b/.gitea/workflows/main.yaml new file mode 100644 index 0000000..f201502 --- /dev/null +++ b/.gitea/workflows/main.yaml @@ -0,0 +1,254 @@ +name: Main Release & Publish + +on: + push: + branches: + - main + +jobs: + detect-changes: + name: Erkenne relevante Code-Änderungen + runs-on: ubuntu-latest + outputs: + code_changed: ${{ steps.filter.outputs.code }} + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Prüfe auf Änderungen am Programmcode + uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + code: + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'scripts/**' + - '.cargo/**' + - '.gitea/workflows/main.yaml' + + release-and-publish: + name: Build, Publish Packages (Stable) & Create Release + needs: detect-changes + if: needs.detect-changes.outputs.code_changed == 'true' + runs-on: ubuntu-latest + env: + CARGO_BINSTALL_VERSION: "1.23.0" + CARGO_DEB_VERSION: "3.8.0" + CARGO_GENERATE_RPM_VERSION: "0.21.0" + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Install Rust Toolchain + uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + cache: false + + - name: Cache Cargo-Abhängigkeiten & Build-Artefakte + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Alte Paketierungs-Ausgaben aus dem Cache entfernen + run: rm -rf target/debian target/generate-rpm target/arch + + - name: Install Cross-Compilation Toolchains (apt) + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu gcc-i686-linux-gnu + + - name: Ermittle Rust-Version für Rustup-Target-Cache-Key + run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV" + + - name: Cache Rustup Cross-Compilation-Targets + id: cache-rustup-targets + uses: actions/cache@v6 + 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 + + - name: PATH um Cargo-bin-Verzeichnis ergänzen + run: | + mkdir -p ~/.cargo/bin + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Cache Packaging-Tools (cargo-binstall, cargo-deb, cargo-generate-rpm) + id: cache-packaging-tools + uses: actions/cache@v6 + with: + path: | + ~/.cargo/bin/cargo-binstall + ~/.cargo/bin/cargo-deb + ~/.cargo/bin/cargo-generate-rpm + key: packaging-tools-${{ runner.os }}-${{ env.CARGO_BINSTALL_VERSION }}-${{ env.CARGO_DEB_VERSION }}-${{ env.CARGO_GENERATE_RPM_VERSION }} + + - name: Install Packaging Tools (Prebuilt Binaries) + if: steps.cache-packaging-tools.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" | tar -xz -C ~/.cargo/bin + ~/.cargo/bin/cargo-binstall -y --no-symlinks "cargo-deb@${CARGO_DEB_VERSION}" "cargo-generate-rpm@${CARGO_GENERATE_RPM_VERSION}" + + - name: Run Tests + run: | + cargo test + + - name: Build Release Binaries + 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 + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + run: | + BUILD_NUM=$(python3 scripts/get-build-number.py) + echo "build_number=${BUILD_NUM}" >> $GITHUB_OUTPUT + echo "BUILD_NUMBER=${BUILD_NUM}" >> $GITHUB_ENV + echo "Ermittelte Build-Nummer: ${BUILD_NUM}" + + - name: Build Debian Packages (.deb) + 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: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + run: | + echo "Veröffentliche Debian-Paket (Distribution: stable, Component: main)..." + for deb in target/debian/*.deb; do + [ -f "$deb" ] || continue + curl -f -s -S -X PUT \ + -H "Authorization: token ${TOKEN}" \ + --upload-file "$deb" \ + "${GITEA_URL}/api/packages/${REPO_OWNER}/debian/pool/stable/main/upload" + done + + echo "Veröffentliche Fedora/RPM-Paket (Gruppe: stable)..." + for rpm in target/generate-rpm/*.rpm; do + [ -f "$rpm" ] || continue + curl -f -s -S -X PUT \ + -H "Authorization: token ${TOKEN}" \ + --upload-file "$rpm" \ + "${GITEA_URL}/api/packages/${REPO_OWNER}/rpm/stable/upload" + done + + echo "Veröffentliche Arch Linux-Paket (Repository: stable)..." + for pkg in target/arch/*.pkg.tar.zst; do + [ -f "$pkg" ] || continue + curl -f -s -S -X PUT \ + -H "Authorization: token ${TOKEN}" \ + --upload-file "$pkg" \ + "${GITEA_URL}/api/packages/${REPO_OWNER}/arch/stable" + done + + - name: Create Gitea Release and Upload Assets + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + run: | + VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)" + TAG_NAME="v${VERSION}" + RELEASE_TITLE="Release ${TAG_NAME}" + RELEASE_NOTES="Automatisches Release für ${REPO_NAME} ${VERSION}." + + echo "Erstelle oder hole Release für Tag ${TAG_NAME} in ${REPO}..." + + GET_RESP=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG_NAME}") + HTTP_CODE=$(echo "$GET_RESP" | tail -n1) + BODY=$(echo "$GET_RESP" | sed '$d') + + RELEASE_ID="" + if [ "$HTTP_CODE" -eq 200 ]; then + RELEASE_ID=$(echo "$BODY" | jq -r '.id // empty' 2>/dev/null || echo "$BODY" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2) + echo "Bestehendes Release gefunden (ID: ${RELEASE_ID})." + else + echo "Erstelle neues Release ${TAG_NAME}..." + CREATE_PAYLOAD=$(cat </dev/null || echo "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2) + echo "Neues Release erstellt (ID: ${RELEASE_ID})." + fi + + if [ -z "$RELEASE_ID" ]; then + echo "Fehler: Release-ID konnte nicht ermittelt werden!" + exit 1 + fi + + EXISTING_ASSETS_JSON=$(curl -s \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" || echo "[]") + + for file in target/debian/*.deb target/generate-rpm/*.rpm target/arch/*.pkg.tar.zst; do + [ -f "$file" ] || continue + filename="$(basename "$file")" + echo "Lade Release-Asset hoch: $filename" + + ASSET_ID=$(echo "$EXISTING_ASSETS_JSON" | jq -r --arg name "$filename" '.[]? | select(.name == $name) | .id' 2>/dev/null | head -n1 || true) + if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then + echo "Lösche altes Asset mit ID ${ASSET_ID}..." + curl -s -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" || true + fi + + curl -f -s -S -X POST \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${file}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" + echo "Asset ${filename} erfolgreich hochgeladen." + done diff --git a/.gitea/workflows/renovate.yaml b/.gitea/workflows/renovate.yaml new file mode 100644 index 0000000..f9f6456 --- /dev/null +++ b/.gitea/workflows/renovate.yaml @@ -0,0 +1,27 @@ +name: Renovate + +on: + schedule: + - cron: "0 * * * *" + workflow_dispatch: + +jobs: + renovate: + name: Dependency-Updates prüfen & Pull Requests erstellen + runs-on: ubuntu-latest + container: ghcr.io/renovatebot/renovate:44.82.0 + steps: + - name: Renovate ausführen + run: renovate + env: + RENOVATE_PLATFORM: gitea + RENOVATE_ENDPOINT: ${{ gitea.server_url || github.server_url }}/api/v1/ + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + RENOVATE_REPOSITORIES: ${{ gitea.repository || github.repository }} + RENOVATE_AUTODISCOVER: "false" + RENOVATE_ALLOW_CUSTOM_CRATE_REGISTRIES: "true" + RENOVATE_GIT_AUTHOR: "Renovate Bot " + RENOVATE_HOST_RULES: >- + [{"hostType":"cargo","matchHost":"${{ gitea.server_url || github.server_url }}","token":"${{ secrets.RENOVATE_TOKEN }}"}] + GITHUB_COM_TOKEN: ${{ secrets.GH_RENOVATE_TOKEN }} + LOG_LEVEL: info diff --git a/.gitea/workflows/security-scan.yaml b/.gitea/workflows/security-scan.yaml new file mode 100644 index 0000000..75df4b3 --- /dev/null +++ b/.gitea/workflows/security-scan.yaml @@ -0,0 +1,95 @@ +name: Security Scans + +on: + push: + branches: + - main + - testing + - dev + pull_request: + schedule: + - cron: "0 5 * * 1" + workflow_dispatch: + +jobs: + security-scan: + name: Trivy & OSV-Scanner + runs-on: ubuntu-latest + env: + TRIVY_VERSION: "0.74.0" + OSV_SCANNER_VERSION: "2.5.1" + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Lokales bin-Verzeichnis zum PATH hinzufügen + run: | + mkdir -p "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Cache Trivy-Binary + id: cache-trivy + uses: actions/cache@v6 + with: + path: ~/.local/bin/trivy + key: trivy-bin-${{ runner.os }}-${{ env.TRIVY_VERSION }} + + - name: Install Trivy + if: steps.cache-trivy.outputs.cache-hit != 'true' + run: | + curl -fsSL -o trivy.tar.gz \ + "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" + tar -xzf trivy.tar.gz trivy + chmod +x trivy + mv trivy "$HOME/.local/bin/trivy" + rm -f trivy.tar.gz + + - name: Ermittle Cache-Datum für Trivy-DB + run: echo "CACHE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV" + + - name: Cache Trivy-Schwachstellen-Datenbank + uses: actions/cache@v6 + with: + path: ~/.cache/trivy + key: trivy-db-${{ runner.os }}-${{ env.CACHE_DATE }} + restore-keys: | + trivy-db-${{ runner.os }}- + + - name: Run Trivy Scanner + run: | + trivy fs \ + --scanners vuln,secret,misconfig \ + --severity CRITICAL,HIGH \ + --format json \ + --output trivy-results.json \ + --exit-code 0 \ + . + + - name: Cache OSV-Scanner-Binary + id: cache-osv-scanner + uses: actions/cache@v6 + with: + path: ~/.local/bin/osv-scanner + key: osv-scanner-bin-${{ runner.os }}-${{ env.OSV_SCANNER_VERSION }} + + - name: Install OSV-Scanner + if: steps.cache-osv-scanner.outputs.cache-hit != 'true' + run: | + curl -fsSL -o "$HOME/.local/bin/osv-scanner" \ + "https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64" + chmod +x "$HOME/.local/bin/osv-scanner" + + - name: Run OSV-Scanner + run: | + set +e + osv-scanner scan source --recursive --format json --output-file osv-results.json . + echo "OSV_EXIT=$?" >> "$GITHUB_ENV" + + - name: Ergebnisse & Gitea-Issue erstellen/aktualisieren + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + TOKEN: ${{ secrets.SECURITY_TOKEN }} + RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }} + run: | + python3 scripts/report-security-issue.py trivy-results.json osv-results.json diff --git a/.gitea/workflows/testing-to-main-pr.yaml b/.gitea/workflows/testing-to-main-pr.yaml new file mode 100644 index 0000000..f26a71d --- /dev/null +++ b/.gitea/workflows/testing-to-main-pr.yaml @@ -0,0 +1,111 @@ +name: Auto-PR (Testing → Main) + +on: + push: + branches: + - testing + +jobs: + create-pr: + name: Erstelle automatisch PR von testing nach main + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Prüfe auf bereits offenen PR nach main + id: check_pr + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + run: | + OPEN_PRS=$(curl -s -H "Authorization: token ${TOKEN}" "${GITEA_URL}/api/v1/repos/${REPO}/pulls?state=open&limit=50") + EXISTS=$(echo "$OPEN_PRS" | jq -r '[.[] | select(.base.ref == "main" and .head.ref == "testing")] | length') + echo "Bereits offene testing→main PRs: ${EXISTS}" + echo "exists=${EXISTS}" >> "$GITHUB_OUTPUT" + + - name: Ermittle Versionen auf main & testing + id: versions + if: steps.check_pr.outputs.exists == '0' + run: | + git fetch origin main + MAIN_VERSION="$(git show origin/main:Cargo.toml | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)" + TESTING_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)" + echo "Version auf main: ${MAIN_VERSION} / Version auf testing: ${TESTING_VERSION}" + echo "main_version=${MAIN_VERSION}" >> "$GITHUB_OUTPUT" + echo "testing_version=${TESTING_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Ermittle geänderte Kategorien (main...testing) + id: categories + if: steps.check_pr.outputs.exists == '0' && steps.versions.outputs.main_version == steps.versions.outputs.testing_version + run: | + CHANGED_FILES="$(git diff --name-only origin/main...HEAD)" + echo "Geänderte Dateien main...testing:" + echo "$CHANGED_FILES" + + WORKFLOWS="false" + CONFIG="false" + DOCS="false" + + if echo "$CHANGED_FILES" | grep -q '^\.gitea/workflows/'; then + WORKFLOWS="true" + fi + if echo "$CHANGED_FILES" | grep -qE '^(renovate\.json|qodana\.yaml|Cargo\.toml|Cargo\.lock|\.cargo/)'; then + CONFIG="true" + fi + if echo "$CHANGED_FILES" | grep -qE '(^|/)[^/]+\.md$|^LICENSE$'; then + DOCS="true" + fi + + echo "workflows=${WORKFLOWS}" >> "$GITHUB_OUTPUT" + echo "config=${CONFIG}" >> "$GITHUB_OUTPUT" + echo "docs=${DOCS}" >> "$GITHUB_OUTPUT" + + - name: Bestimme PR-Titel + id: title + if: steps.check_pr.outputs.exists == '0' + run: | + if [ "${{ steps.versions.outputs.main_version }}" != "${{ steps.versions.outputs.testing_version }}" ]; then + TITLE="Merge testing in main: Release ${{ steps.versions.outputs.testing_version }}" + else + PARTS=() + [ "${{ steps.categories.outputs.workflows }}" = "true" ] && PARTS+=("Workflows") + [ "${{ steps.categories.outputs.config }}" = "true" ] && PARTS+=("Konfigurationen") + [ "${{ steps.categories.outputs.docs }}" = "true" ] && PARTS+=("Dokumentation") + + if [ ${#PARTS[@]} -eq 0 ]; then + TITLE="Merge testing in main" + else + JOINED="" + for PART in "${PARTS[@]}"; do + if [ -z "$JOINED" ]; then + JOINED="$PART" + else + JOINED="${JOINED} & ${PART}" + fi + done + TITLE="Merge testing in main: ${JOINED} aktualisiert" + fi + fi + echo "Ermittelter PR-Titel: ${TITLE}" + echo "title=${TITLE}" >> "$GITHUB_OUTPUT" + + - name: Erstelle PR (testing -> main) + if: steps.check_pr.outputs.exists == '0' + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + TITLE: ${{ steps.title.outputs.title }} + run: | + PAYLOAD=$(jq -n --arg title "$TITLE" --arg head "testing" --arg base "main" \ + '{title: $title, head: $head, base: $base}') + curl -f -s -S -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "${GITEA_URL}/api/v1/repos/${REPO}/pulls" + echo "PR erstellt: ${TITLE}" diff --git a/.gitea/workflows/testing.yaml b/.gitea/workflows/testing.yaml new file mode 100644 index 0000000..6bf6d29 --- /dev/null +++ b/.gitea/workflows/testing.yaml @@ -0,0 +1,254 @@ +name: Testing Build, Publish & Preview Release + +on: + push: + branches: + - testing + +jobs: + detect-changes: + name: Erkenne relevante Code-Änderungen + runs-on: ubuntu-latest + outputs: + code_changed: ${{ steps.filter.outputs.code }} + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Prüfe auf Änderungen am Programmcode + uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + code: + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'scripts/**' + - '.cargo/**' + - '.gitea/workflows/testing.yaml' + + build-and-publish: + name: Build, Publish Packages (Testing) & Create Preview Release + needs: detect-changes + if: needs.detect-changes.outputs.code_changed == 'true' + runs-on: ubuntu-latest + env: + CARGO_BINSTALL_VERSION: "1.23.0" + CARGO_DEB_VERSION: "3.8.0" + CARGO_GENERATE_RPM_VERSION: "0.21.0" + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Install Rust Toolchain + uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + cache: false + + - name: Cache Cargo-Abhängigkeiten & Build-Artefakte + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Alte Paketierungs-Ausgaben aus dem Cache entfernen + run: rm -rf target/debian target/generate-rpm target/arch + + - name: Install Cross-Compilation Toolchains (apt) + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu gcc-i686-linux-gnu + + - name: Ermittle Rust-Version für Rustup-Target-Cache-Key + run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV" + + - name: Cache Rustup Cross-Compilation-Targets + id: cache-rustup-targets + uses: actions/cache@v6 + 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 + + - name: PATH um Cargo-bin-Verzeichnis ergänzen + run: | + mkdir -p ~/.cargo/bin + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Cache Packaging-Tools (cargo-binstall, cargo-deb, cargo-generate-rpm) + id: cache-packaging-tools + uses: actions/cache@v6 + with: + path: | + ~/.cargo/bin/cargo-binstall + ~/.cargo/bin/cargo-deb + ~/.cargo/bin/cargo-generate-rpm + key: packaging-tools-${{ runner.os }}-${{ env.CARGO_BINSTALL_VERSION }}-${{ env.CARGO_DEB_VERSION }}-${{ env.CARGO_GENERATE_RPM_VERSION }} + + - name: Install Packaging Tools (Prebuilt Binaries) + if: steps.cache-packaging-tools.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" | tar -xz -C ~/.cargo/bin + ~/.cargo/bin/cargo-binstall -y --no-symlinks "cargo-deb@${CARGO_DEB_VERSION}" "cargo-generate-rpm@${CARGO_GENERATE_RPM_VERSION}" + + - name: Run Tests + run: | + cargo test + + - name: Build Release Binaries + 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 + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + run: | + BUILD_NUM=$(python3 scripts/get-build-number.py) + echo "build_number=${BUILD_NUM}" >> $GITHUB_OUTPUT + echo "BUILD_NUMBER=${BUILD_NUM}" >> $GITHUB_ENV + echo "Ermittelte Build-Nummer: ${BUILD_NUM}" + + - name: Build Debian Packages (.deb) + 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: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + run: | + echo "Veröffentliche Debian-Paket (Distribution: testing, Component: main)..." + for deb in target/debian/*.deb; do + [ -f "$deb" ] || continue + curl -f -s -S -X PUT \ + -H "Authorization: token ${TOKEN}" \ + --upload-file "$deb" \ + "${GITEA_URL}/api/packages/${REPO_OWNER}/debian/pool/testing/main/upload" + done + + echo "Veröffentliche Fedora/RPM-Paket (Gruppe: testing)..." + for rpm in target/generate-rpm/*.rpm; do + [ -f "$rpm" ] || continue + curl -f -s -S -X PUT \ + -H "Authorization: token ${TOKEN}" \ + --upload-file "$rpm" \ + "${GITEA_URL}/api/packages/${REPO_OWNER}/rpm/testing/upload" + done + + echo "Veröffentliche Arch Linux-Paket (Repository: testing)..." + for pkg in target/arch/*.pkg.tar.zst; do + [ -f "$pkg" ] || continue + curl -f -s -S -X PUT \ + -H "Authorization: token ${TOKEN}" \ + --upload-file "$pkg" \ + "${GITEA_URL}/api/packages/${REPO_OWNER}/arch/testing" + done + + - name: Create Gitea Pre-Release and Upload Assets + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }} + TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + run: | + VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)" + TAG_NAME="v${VERSION}-preview" + RELEASE_TITLE="Preview Release ${TAG_NAME}" + RELEASE_NOTES="Automatisches Preview-Release für ${REPO_NAME} ${VERSION} (Branch: Testing)." + + echo "Erstelle oder hole Preview-Release für Tag ${TAG_NAME} in ${REPO}..." + + GET_RESP=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG_NAME}") + HTTP_CODE=$(echo "$GET_RESP" | tail -n1) + BODY=$(echo "$GET_RESP" | sed '$d') + + RELEASE_ID="" + if [ "$HTTP_CODE" -eq 200 ]; then + RELEASE_ID=$(echo "$BODY" | jq -r '.id // empty' 2>/dev/null || echo "$BODY" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2) + echo "Bestehendes Release gefunden (ID: ${RELEASE_ID})." + else + echo "Erstelle neues Preview-Release ${TAG_NAME}..." + CREATE_PAYLOAD=$(cat </dev/null || echo "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2) + echo "Neues Preview-Release erstellt (ID: ${RELEASE_ID})." + fi + + if [ -z "$RELEASE_ID" ]; then + echo "Fehler: Release-ID konnte nicht ermittelt werden!" + exit 1 + fi + + EXISTING_ASSETS_JSON=$(curl -s \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" || echo "[]") + + for file in target/debian/*.deb target/generate-rpm/*.rpm target/arch/*.pkg.tar.zst; do + [ -f "$file" ] || continue + filename="$(basename "$file")" + echo "Lade Release-Asset hoch: $filename" + + ASSET_ID=$(echo "$EXISTING_ASSETS_JSON" | jq -r --arg name "$filename" '.[]? | select(.name == $name) | .id' 2>/dev/null | head -n1 || true) + if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then + echo "Lösche altes Asset mit ID ${ASSET_ID}..." + curl -s -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" || true + fi + + curl -f -s -S -X POST \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${file}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" + echo "Asset ${filename} erfolgreich hochgeladen." + done diff --git a/.gitea/workflows/trufflehog-scan.yaml b/.gitea/workflows/trufflehog-scan.yaml new file mode 100644 index 0000000..5e4fcb6 --- /dev/null +++ b/.gitea/workflows/trufflehog-scan.yaml @@ -0,0 +1,59 @@ +name: TruffleHog Secret Scan + +on: + push: + pull_request: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +jobs: + trufflehog-scan: + name: TruffleHog + runs-on: ubuntu-latest + env: + TRUFFLEHOG_VERSION: "3.97.4" + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Lokales bin-Verzeichnis zum PATH hinzufügen + run: | + mkdir -p "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Cache TruffleHog-Binary + id: cache-trufflehog + uses: actions/cache@v6 + with: + path: ~/.local/bin/trufflehog + key: trufflehog-bin-${{ runner.os }}-${{ env.TRUFFLEHOG_VERSION }} + + - name: Install TruffleHog + if: steps.cache-trufflehog.outputs.cache-hit != 'true' + run: | + curl -fsSL -o trufflehog.tar.gz \ + "https://github.com/trufflesecurity/trufflehog/releases/download/v${TRUFFLEHOG_VERSION}/trufflehog_${TRUFFLEHOG_VERSION}_linux_amd64.tar.gz" + tar -xzf trufflehog.tar.gz trufflehog + chmod +x trufflehog + mv trufflehog "$HOME/.local/bin/trufflehog" + rm trufflehog.tar.gz + + - name: Run TruffleHog Scanner + run: | + set +e + trufflehog git file://. --results=verified,unknown --fail --json > trufflehog-results.json + echo "TRUFFLEHOG_EXIT=$?" >> "$GITHUB_ENV" + + - name: Ergebnisse & Gitea-Issue erstellen/aktualisieren + env: + GITEA_URL: ${{ gitea.server_url || github.server_url }} + REPO: ${{ gitea.repository || github.repository }} + TOKEN: ${{ secrets.SECURITY_TOKEN }} + RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }} + ISSUE_TITLE: "Security-Scan: TruffleHog Secrets" + ISSUE_LABEL: "security-scan-trufflehog" + run: | + python3 scripts/report-security-issue.py "" "" trufflehog-results.json diff --git a/.gitea/workflows/unit-tests.yaml b/.gitea/workflows/unit-tests.yaml new file mode 100644 index 0000000..7703f7f --- /dev/null +++ b/.gitea/workflows/unit-tests.yaml @@ -0,0 +1,38 @@ +name: Unit-Tests + +on: + pull_request: + types: + - opened + - synchronize + - reopened + branches: + - testing + +jobs: + test: + name: Unit-Tests + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Install Rust Toolchain + uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + cache: false + + - name: Cache Cargo-Abhängigkeiten & Build-Artefakte + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: Run Tests + run: cargo test diff --git a/.gitea/workflows/version-bump.yaml b/.gitea/workflows/version-bump.yaml new file mode 100644 index 0000000..dd25604 --- /dev/null +++ b/.gitea/workflows/version-bump.yaml @@ -0,0 +1,84 @@ +name: Auto Patch-Version-Bump (Dev → Testing PR) + +on: + pull_request: + types: [opened] + branches: + - testing + +jobs: + detect-changes: + name: Erkenne relevante Änderungen im PR + runs-on: ubuntu-latest + if: ${{ (gitea.head_ref || github.head_ref) == 'dev' }} + outputs: + code_changed: ${{ steps.filter.outputs.code }} + steps: + - name: Checkout Dev-Branch (PR-Head) + uses: actions/checkout@v7 + with: + ref: ${{ gitea.head_ref || github.head_ref }} + fetch-depth: 0 + + - name: Prüfe auf Änderungen an Cargo.toml, Cargo.lock oder src/ + uses: dorny/paths-filter@v4 + id: filter + with: + base: ${{ gitea.base_ref || github.base_ref }} + filters: | + code: + - 'Cargo.toml' + - 'Cargo.lock' + - 'src/**' + + bump-version: + name: Patch-Version erhöhen & auf Dev pushen + needs: detect-changes + if: needs.detect-changes.outputs.code_changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout Dev-Branch (PR-Head) + uses: actions/checkout@v7 + with: + ref: ${{ gitea.head_ref || github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }} + + - name: Ermittle Cargo-Version auf testing & dev + id: versions + run: | + git fetch origin testing --depth=1 + TESTING_VERSION="$(git show origin/testing:Cargo.toml | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)" + DEV_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)" + echo "Version auf testing: ${TESTING_VERSION} / Version auf dev: ${DEV_VERSION}" + echo "testing_version=${TESTING_VERSION}" >> "$GITHUB_OUTPUT" + echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Patch-Version um 1 erhöhen (Cargo.toml & Cargo.lock) + if: steps.versions.outputs.testing_version == steps.versions.outputs.dev_version + run: | + VERSION="${{ steps.versions.outputs.dev_version }}" + MAJOR="$(echo "$VERSION" | cut -d. -f1)" + MINOR="$(echo "$VERSION" | cut -d. -f2)" + PATCH="$(echo "$VERSION" | cut -d. -f3)" + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + echo "Erhöhe Version: ${VERSION} -> ${NEW_VERSION}" + + sed -i "0,/^version = \"${VERSION}\"/s//version = \"${NEW_VERSION}\"/" Cargo.toml + + PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -n1)" + awk -v new="$NEW_VERSION" -v pkg="$PACKAGE_NAME" ' + found_name && /^version = "/ { + print "version = \"" new "\"" + found_name = 0 + next + } + $0 == "name = \"" pkg "\"" { found_name = 1 } + { print } + ' Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock + + git config user.name "Gitea-Bot" + git config user.email "no-reply@creativedragonslayer.de" + git add Cargo.toml Cargo.lock + git commit -m "Chore: Erhöht Patch-Version auf ${NEW_VERSION} für Promotion nach testing" + git push origin HEAD:${{ gitea.head_ref || github.head_ref }} diff --git a/.gitignore b/.gitignore index c8776f5..c5aa50f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,6 @@ debug/ target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - # These are backup files generated by rustfmt **/*.rs.bk @@ -113,400 +109,4 @@ fabric.properties # Built Visual Studio Code Extensions *.vsix -# ---> VisualStudio -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -*.code-workspace - -# Local History for Visual Studio Code - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -.idea - -# Added by cargo - -/target +.junie/plans diff --git a/.idea/SmartMount.iml b/.idea/SmartMount.iml index cf84ae4..bbe0a70 100644 --- a/.idea/SmartMount.iml +++ b/.idea/SmartMount.iml @@ -3,6 +3,7 @@ + diff --git a/.idea/jsonSchemas.xml b/.idea/jsonSchemas.xml new file mode 100644 index 0000000..bb9d296 --- /dev/null +++ b/.idea/jsonSchemas.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3b7ff31 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,138 @@ +# AGENTS.md + +Dieses Dokument dient als technischer Leitfaden und Kontextdokument für KI-Coding-Agenten (sowie Entwickler), die an diesem Repository oder daraus abgeleiteten Projekten arbeiten. + +--- + +## 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`). +- 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. + +### 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`). +- **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). + +--- + +## 2. Projektstruktur + +```text +├── .cargo/ +│ └── config.toml # Linker für Cross-Target-Kompilierung & Registry-Konfiguration +├── .gitea/ +│ └── workflows/ +│ ├── main.yaml # CI/CD: Stabile Builds, Multi-Arch-Paketierung, Docker-Image, Release & Upload +│ ├── testing.yaml # CI/CD: Preview-Builds, Docker-Image & Testing-Pakete +│ ├── unit-tests.yaml # CI: Unit-Tests für Pull Requests gegen 'testing' +│ ├── security-scan.yaml # CI: Trivy & OSV-Scanner (Schwachstellen/Misconfig/Secrets) +│ ├── trufflehog-scan.yaml # CI: TruffleHog Secret-Scan (inkl. Git-Historie) +│ └── renovate.yaml # CI: Wöchentlicher Renovate-Lauf für Dependency-Updates +├── scripts/ +│ ├── get-build-number.py # Ermittelt automatisch die nächste Revisions-/Build-Nummer +│ ├── package-arch.py # Erzeugt native Arch Linux .pkg.tar.zst Pakete +│ └── report-security-issue.py # Meldet Scan-Ergebnisse (Trivy/OSV/TruffleHog) als Gitea-Issue +├── src/ +│ └── main.rs # Einstiegspunkt der Anwendung +├── Cargo.toml # Projekt-Manifest & Metadaten für deb, rpm und arch +├── qodana.yaml # Konfiguration für JetBrains Qodana (statische Analyse) +├── renovate.json # Renovate-Konfiguration (Gruppierung, Versions-Pins in Workflows) +├── LICENSE # Lizenztext +├── README.md # Benutzerdokumentation & Setup-Checkliste +└── AGENTS.md # Dieses Agenten-Handbuch +``` + +> **Hinweis:** `main.yaml`/`testing.yaml` bauen zusätzlich ein Docker-Image (`docker build .` mit `--build-arg TARGET_BIN=...`). Ein `Dockerfile` ist im Template noch **nicht** enthalten und muss von abgeleiteten Projekten ergänzt werden; der aktuell hartkodierte `TARGET_BIN`-Pfad (`.../release/mirror-package`) ist ein Platzhalter aus einem Referenzprojekt und muss beim Ableiten des Templates auf den tatsächlichen Binärnamen (`Cargo.toml` → `[package] name`) angepasst werden. + +--- + +## 3. Regeln & Richtlinien für Agenten + +### 3.1 Code-Stil & Best Practices +- **Idiomatisches Rust**: Nutze moderne Sprachfeatures der Rust Edition 2024. +- **Fehlerbehandlung**: Verwende aussagekräftige Fehlertypen (z. B. mit `thiserror` oder `anyhow` für CLIs). Vermeide unnötiges `unwrap()` oder `panic!()` im Produktivcode. +- **Kommentare**: Ergänze KDoc/RustDoc-Kommentare (`///`) an öffentlichen Funktionen und Typen. Behalte die bestehende Sprachkonvention bei. +- **Template-TODOs**: Wenn neue Vorlagen-Features oder Platzhalter ergänzt werden, markiere anpassungsbedürftige Stellen eindeutig mit `// TODO:` (Rust), `# TODO:` (TOML/Python/YAML). + +### 3.2 Paketierungs-Metadaten in `Cargo.toml` +Bei Änderungen an Binärnamen, Abhängigkeiten oder Beschreibungen müssen die drei Metadaten-Blöcke in `Cargo.toml` synchron gehalten werden: +1. `[package.metadata.deb]` (für `cargo-deb`): + - `maintainer`, `copyright`, `section`, `priority`, `depends`, `extended-description`, `assets`. +2. `[package.metadata.generate-rpm]` (für `cargo-generate-rpm`): + - `requires`, `assets`. +3. `[package.metadata.arch]` (für `scripts/package-arch.py`): + - `pkgrel`, `arch`, `depends`, `optdepends`. + +Ändert sich der Binärname (`[package] name`), muss auch der `TARGET_BIN`-Build-Arg im Docker-Build-Step von `main.yaml`/`testing.yaml` sowie das (abzuleitende) `Dockerfile` angepasst werden. + +### 3.3 Skripte in `scripts/` +- **Generizität**: Die Skripte dürfen keine hardcodierten Anwendungsnamen, spezifischen Abhängigkeiten oder projektspezifischen URLs enthalten. Alle Werte müssen dynamisch aus `Cargo.toml` (via `cargo metadata` oder Dateiparsing) oder Umgebungsvariablen (`BUILD_NUMBER`, `GITEA_URL`, `REPO`, `TOKEN`) ermittelt werden. +- **Python-Kompatibilität**: Verwende Standard-Python 3 ohne externe PyPI-Abhängigkeiten (nur Standardbibliothek: `json`, `subprocess`, `urllib`, `argparse`, `os`, `re`, `tempfile`, `tarfile` etc.). +- **`get-build-number.py`**: Ermittelt die nächste Build-/Revisions-Nummer nicht mehr rein lokal, sondern dynamisch über: + 1. Gitea Releases API (Tag-/Asset-Namen), + 2. Gitea Packages API (jeweils neueste Version pro Paket-Typ: `debian`, `rpm`, `arch`), + 3. lokale `target/{debian,generate-rpm,arch}`-Verzeichnisse als Fallback. + Unterstützt CLI-Flags (`--version`, `--gitea-url`, `--repo`, `--owner`, `--token`, `--build-number`) sowie Umgebungsvariablen-Fallbacks (`BUILD_NUMBER`/`BUILD_NUM`/`PKGREL`, `GITEA_URL`, `REPO`, `REPO_OWNER`, `TOKEN`). Package-Typen (deb/rpm/arch) teilen sich eine gemeinsame Build-Nummer. +- **`report-security-issue.py`**: Fasst Funde aus Trivy-, OSV-Scanner- und TruffleHog-JSON-Reports zusammen, sortiert nach Schweregrad und pflegt darüber ein einzelnes offenes Gitea-Issue pro Scan-Typ (Kommentar-Historie statt ständig neuer Issues; Label per `ISSUE_LABEL`, Titel per `ISSUE_TITLE` konfigurierbar). Secret-Werte selbst werden nie ausgegeben. + +--- + +## 4. Workflows: Bauen, Testen & Validieren + +Agenten müssen Änderungen vor dem Abschluss validieren. + +### 4.1 Grundlegende Validierung +```bash +# Syntax- und Typprüfung +cargo check + +# Unit- & Integrationstests +cargo test + +# Release-Build prüfen +cargo build --release +``` + +### 4.2 Hilfsskripte testen +```bash +# Build-Nummern-Skript testen (rein lokal, ohne Gitea-API) +python3 scripts/get-build-number.py + +# Arch Linux Paketierung lokal testen (nach 'cargo build --release') +python3 scripts/package-arch.py --arch x86_64 --pkgrel 1 +``` + +--- + +## 5. CI/CD-Pipeline Details + +Alle Workflows liegen unter `.gitea/workflows/` und nutzen gecachte Abhängigkeiten (`actions/cache@v6` für Cargo-Registry/Build-Artefakte, Rustup-Targets und Packaging-Tools) sowie fest gepinnte Tool-Versionen über `env`-Variablen — diese werden von Renovate automatisch aktuell gehalten (siehe 5.2). + +### 5.1 Build & Release +- **`main.yaml`** (Trigger: `push` auf `main`): Baut Binaries für alle 3 Architekturen, führt `cargo test` aus, baut `.deb`, `.rpm` und `.pkg.tar.zst`, lädt sie in die Gitea Package Registry hoch, erstellt ein Gitea Release `v` und baut/veröffentlicht zusätzlich ein Docker-Image (`:latest`, `:`, `:v`, `:.`). +- **`testing.yaml`** (Trigger: `push` auf `testing`): Analog zu `main.yaml`, veröffentlicht jedoch in die `testing`-Kanäle der Paket-Registry, erstellt ein Pre-Release `v-preview` und taggt Docker-Images mit `:testing`, `:-preview`, `:-testing` etc. +- **`unit-tests.yaml`** (Trigger: `pull_request` → `testing`, bei `opened`/`synchronize`/`reopened`): Schnelle CI-Prüfung (`cargo test`) für Pull Requests, ohne Paketierung oder Veröffentlichung. + +### 5.2 Sicherheit & Qualität +- **`security-scan.yaml`** (Trigger: `push` auf `main`/`testing`/`dev`, `pull_request`, wöchentlich montags 05:00 UTC, `workflow_dispatch`): Führt Trivy (`vuln`, `secret`, `misconfig`; Schweregrad `CRITICAL`/`HIGH`) und OSV-Scanner aus und meldet Funde über `scripts/report-security-issue.py` als Gitea-Issue (Label `security-scan`). +- **`trufflehog-scan.yaml`** (Trigger: `push`, `pull_request`, wöchentlich montags 06:00 UTC, `workflow_dispatch`): Durchsucht die volle Git-Historie (`fetch-depth: 0`) nach verifizierten/unbekannten Secrets und meldet Funde in ein eigenes Issue (Label `security-scan-trufflehog`) inkl. Rotations-Anleitung. +- **`qodana.yaml`**: Konfiguration für JetBrains Qodana (statische Codeanalyse, Profil `qodana.starter`); wird über eine externe/IDE-seitige Qodana-CI-Integration ausgeführt, nicht über einen eigenen Gitea-Workflow in diesem Template. +- **`renovate.yaml`** (Trigger: wöchentlich montags 04:00 UTC, `workflow_dispatch`): Führt Renovate (Container-Image, gepinnte Version) gegen die Gitea-Plattform aus und erstellt Pull Requests für veraltete Abhängigkeiten gemäß `renovate.json`. + +### 5.3 Renovate-Konfiguration (`renovate.json`) +- Basis-Branch für PRs: `dev`. +- Gruppiert Updates nach `Gitea Actions`, `Cargo Dependencies` und `Docker-Images`. +- Custom-Regex-Manager halten die in den Workflows gepinnten Tool-Versionen aktuell: `TRIVY_VERSION`, `OSV_SCANNER_VERSION`, `TRUFFLEHOG_VERSION`, `CARGO_BINSTALL_VERSION`, `CARGO_DEB_VERSION`, `CARGO_GENERATE_RPM_VERSION`. +- Zeitplan: vor 6 Uhr montags (Europe/Berlin). + +### 5.4 Secrets +- `PACKAGE_TOKEN` (Fallback-Kette: `RELEASE_TOKEN`, `PUBLISH_TOKEN`, `API_TOKEN`, `PAT_TOKEN`, `CUSTOM_TOKEN`, `GITEA_TOKEN`, `GITHUB_TOKEN`) — API-Zugriff auf Gitea Packages, Releases und die Container Registry. +- `SECURITY_TOKEN` — wird von `security-scan.yaml` und `trufflehog-scan.yaml` für das Erstellen/Kommentieren von Gitea-Issues verwendet. +- `RENOVATE_TOKEN` — Zugriffstoken für den Renovate-Lauf (Gitea-Plattform-Endpoint & Cargo-Registry-Host-Rule). diff --git a/LICENSE b/LICENSE index a5f59c3..4fa7c9a 100644 --- a/LICENSE +++ b/LICENSE @@ -209,7 +209,7 @@ If you develop a new program, and you want it to be of the greatest possible use To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. SmartMount - Copyright (C) 2025 DragonSlayer_14 + Copyright (C) 2026 creative-dragonslayer.de This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. @@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: - SmartMount Copyright (C) 2025 DragonSlayer_14 + SmartMount Copyright (C) 2026 creative-dragonslayer.de This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..d5f217e --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,50 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# dependencyLicenses - fails the run on prohibited or unknown dependency licenses +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 +# dependencyLicenses: +# failOnProhibited: true +# failOnUnknown: false + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-:2026.2 diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..717bd4d --- /dev/null +++ b/renovate.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "timezone": "Europe/Berlin", + "schedule": ["before 6am on monday"], + "baseBranchPatterns": [ + "dev" + ], + "packageRules": [ + { + "matchFileNames": [".gitea/workflows/**"], + "groupName": "Gitea Actions", + "separateMajorMinor": false, + "separateMinorPatch": false + }, + { + "matchManagers": ["cargo"], + "groupName": "Cargo Dependencies", + "separateMajorMinor": false, + "separateMinorPatch": false + }, + { + "matchManagers": ["dockerfile", "docker-compose"], + "groupName": "Docker-Images", + "separateMajorMinor": false, + "separateMinorPatch": false + } + ], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.gitea/workflows/.+\\.ya?ml$/" + ], + "matchStrings": [ + "TRIVY_VERSION:\\s*\"(?[^\"]+)\"" + ], + "depNameTemplate": "aquasecurity/trivy", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.*)$" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.gitea/workflows/.+\\.ya?ml$/" + ], + "matchStrings": [ + "OSV_SCANNER_VERSION:\\s*\"(?[^\"]+)\"" + ], + "depNameTemplate": "google/osv-scanner", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.*)$" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.gitea/workflows/.+\\.ya?ml$/" + ], + "matchStrings": [ + "TRUFFLEHOG_VERSION:\\s*\"(?[^\"]+)\"" + ], + "depNameTemplate": "trufflesecurity/trufflehog", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.*)$" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.gitea/workflows/.+\\.ya?ml$/" + ], + "matchStrings": [ + "CARGO_BINSTALL_VERSION:\\s*\"(?[^\"]+)\"" + ], + "depNameTemplate": "cargo-bins/cargo-binstall", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.*)$" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.gitea/workflows/.+\\.ya?ml$/" + ], + "matchStrings": [ + "CARGO_DEB_VERSION:\\s*\"(?[^\"]+)\"" + ], + "depNameTemplate": "cargo-deb", + "datasourceTemplate": "crate" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.gitea/workflows/.+\\.ya?ml$/" + ], + "matchStrings": [ + "CARGO_GENERATE_RPM_VERSION:\\s*\"(?[^\"]+)\"" + ], + "depNameTemplate": "cargo-generate-rpm", + "datasourceTemplate": "crate" + } + ] +} diff --git a/scripts/get-build-number.py b/scripts/get-build-number.py new file mode 100755 index 0000000..4896194 --- /dev/null +++ b/scripts/get-build-number.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Ermittelt automatisch die nächste Build-Nummer (Revision / Release / pkgrel) +für eine gegebene Paket-Version (z. B. 1.0.2 -> wenn 1.0.2-1 existiert, wird 2 zurückgegeben). +""" +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +# Paket-Typen, unter denen dasselbe Release veroeffentlicht wird und die sich +# daher eine gemeinsame Build-Nummer teilen muessen. +PACKAGE_TYPES = ("debian", "rpm", "arch") + + +def get_cargo_pkg_info(): + try: + metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"])) + pkg = metadata["packages"][0] + name = pkg.get("name", "") + version = pkg.get("version", "0.0.0") + repository = pkg.get("repository", "") + return name, version, repository + except Exception: + # Fallback auf einfaches Parsen der Cargo.toml + name = "" + version = "0.0.0" + repository = "" + if os.path.exists("Cargo.toml"): + with open("Cargo.toml", "r") as f: + for line in f: + line = line.strip() + if line.startswith("name ="): + name = line.split("=", 1)[1].strip().strip('"\'') + elif line.startswith("version ="): + version = line.split("=", 1)[1].strip().strip('"\'') + elif line.startswith("repository ="): + repository = line.split("=", 1)[1].strip().strip('"\'') + return name, version, repository + + +def parse_repo_info(repo_url): + """Extrahiert Server-URL, Owner und Repo-Name aus einer Repository-URL.""" + if not repo_url: + return None, None, None + parsed = urllib.parse.urlparse(repo_url) + server_url = f"{parsed.scheme}://{parsed.netloc}" + path_parts = [p for p in parsed.path.strip("/").split("/") if p] + if len(path_parts) >= 2: + owner = path_parts[0] + repo_name = path_parts[1] + if repo_name.endswith(".git"): + repo_name = repo_name[:-4] + return server_url, f"{owner}/{repo_name}", owner + return server_url, None, None + + +def query_existing_build_numbers(name, version, gitea_url, repo, owner, token=None): + build_nums = set() + pattern = re.compile(rf'(?:{re.escape(name)}[_-]|^v?){re.escape(version)}-(\d+)') + + headers = {"User-Agent": f"{name}-build-resolver" if name else "rust-build-resolver"} + if token: + headers["Authorization"] = f"token {token}" + + # 1. Gitea Releases API + if gitea_url and repo: + try: + url = f"{gitea_url.rstrip('/')}/api/v1/repos/{repo}/releases?limit=50" + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=10) as resp: + releases = json.loads(resp.read().decode()) + for rel in releases: + tag = rel.get("tag_name", "") + m = pattern.search(tag) + if m: + build_nums.add(int(m.group(1))) + for asset in rel.get("assets", []): + asset_name = asset.get("name", "") + m = pattern.search(asset_name) + if m: + build_nums.add(int(m.group(1))) + except Exception as e: + sys.stderr.write(f"[Hinweis] Konnte Gitea Releases nicht abfragen: {e}\n") + + # 2. Gitea Packages API (falls Token vorhanden): pro Paket-Typ gezielt nur die + # neueste Version abfragen (ein Request, keine Pagination noetig). Das haelt + # die Abfrage schnell unabhaengig von der Historie und ist unbeeinflusst von + # Docker-Tags/anderen Paket-Typen, die unter demselben Owner haengen. + if gitea_url and owner and token and name: + for pkg_type in PACKAGE_TYPES: + try: + url = ( + f"{gitea_url.rstrip('/')}/api/v1/packages/{owner}/" + f"{pkg_type}/{urllib.parse.quote(name, safe='')}/-/latest" + ) + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=10) as resp: + pkg = json.loads(resp.read().decode()) + pkg_ver = pkg.get("version", "") + m = pattern.search(pkg_ver) + if m: + build_nums.add(int(m.group(1))) + except urllib.error.HTTPError as e: + if e.code != 404: + sys.stderr.write(f"[Hinweis] Konnte neueste {pkg_type}-Paketversion nicht abfragen: {e}\n") + except Exception as e: + sys.stderr.write(f"[Hinweis] Konnte neueste {pkg_type}-Paketversion nicht abfragen: {e}\n") + + # 3. Lokales Target-Verzeichnis prüfen + target_dirs = ["target/debian", "target/generate-rpm", "target/arch"] + for t_dir in target_dirs: + if os.path.isdir(t_dir): + for file in os.listdir(t_dir): + m = pattern.search(file) + if m: + build_nums.add(int(m.group(1))) + + return build_nums + + +def get_next_build_number(version=None, gitea_url=None, repo=None, owner=None, token=None, explicit_build_num=None): + if explicit_build_num is not None: + return int(explicit_build_num) + + env_build_num = os.environ.get("BUILD_NUMBER") or os.environ.get("BUILD_NUM") or os.environ.get("PKGREL") + if env_build_num: + try: + return int(env_build_num) + except ValueError: + pass + + pkg_name, pkg_version, pkg_repo = get_cargo_pkg_info() + if not version: + version = pkg_version + + default_server, default_repo, default_owner = parse_repo_info(pkg_repo) + + gitea_url = gitea_url or os.environ.get("GITEA_URL") or os.environ.get("GITHUB_SERVER_URL") or default_server + repo = repo or os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or default_repo + owner = owner or os.environ.get("REPO_OWNER") or os.environ.get("GITHUB_REPOSITORY_OWNER") or default_owner + token = token or os.environ.get("TOKEN") or os.environ.get("GITEA_TOKEN") or os.environ.get("PACKAGE_TOKEN") or os.environ.get("RELEASE_TOKEN") + + existing_nums = query_existing_build_numbers(pkg_name, version, gitea_url, repo, owner, token) + if existing_nums: + return max(existing_nums) + 1 + return 1 + + +def main(): + parser = argparse.ArgumentParser(description="Ermittelt die nächste Build-Nummer für die Paketierung.") + parser.add_argument("--version", help="Paket-Version (Standard: aus Cargo.toml)") + parser.add_argument("--gitea-url", help="Gitea Basis-URL") + parser.add_argument("--repo", help="Repository (z. B. Owner/Repo)") + parser.add_argument("--owner", help="Repository Owner / Organisation") + parser.add_argument("--token", help="API-Token") + parser.add_argument("--build-number", type=int, help="Explizite Build-Nummer erzwingen") + + args = parser.parse_args() + + build_num = get_next_build_number( + version=args.version, + gitea_url=args.gitea_url, + repo=args.repo, + owner=args.owner, + token=args.token, + explicit_build_num=args.build_number, + ) + print(build_num) + + +if __name__ == "__main__": + main() diff --git a/scripts/package-arch.py b/scripts/package-arch.py new file mode 100755 index 0000000..d03aeee --- /dev/null +++ b/scripts/package-arch.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +import argparse +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import time + +TARGET_ARCH_MAP = { + "x86_64-unknown-linux-gnu": "x86_64", + "x86_64-unknown-linux-musl": "x86_64", + "x86_64": "x86_64", + "amd64": "x86_64", + "aarch64-unknown-linux-gnu": "aarch64", + "aarch64-unknown-linux-musl": "aarch64", + "aarch64": "aarch64", + "arm64": "aarch64", + "i686-unknown-linux-gnu": "i686", + "i686-unknown-linux-musl": "i686", + "i686": "i686", + "i386": "i686", +} + + +def resolve_pkgrel(version=None, default="1"): + # 1. Environment Variable + env_pkgrel = os.environ.get("BUILD_NUMBER") or os.environ.get("BUILD_NUM") or os.environ.get("PKGREL") + if env_pkgrel: + return str(env_pkgrel) + + # 2. get-build-number.py falls vorhanden + script_dir = os.path.dirname(os.path.abspath(__file__)) + getter_path = os.path.join(script_dir, "get-build-number.py") + if os.path.exists(getter_path): + try: + spec = importlib.util.spec_from_file_location("get_build_number", getter_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return str(module.get_next_build_number(version=version)) + except Exception as e: + sys.stderr.write(f"[Hinweis] Konnte get-build-number nicht ausführen: {e}\n") + + return str(default) + + +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] + name = pkg["name"] + version = pkg["version"] + description = pkg.get("description", "") + license_name = pkg.get("license", "") + repository = pkg.get("repository", "") + authors = pkg.get("authors", []) + author = authors[0] if authors else "Unknown" + + arch_meta = pkg.get("metadata", {}).get("arch", {}) + default_pkgrel = arch_meta.get("pkgrel", "1") + if not pkgrel: + pkgrel = resolve_pkgrel(version=version, default=default_pkgrel) + default_arch = arch_meta.get("arch", "x86_64") + + if target_arch: + arch = TARGET_ARCH_MAP.get(target_arch, target_arch) + elif target_triple: + arch = TARGET_ARCH_MAP.get(target_triple, default_arch) + else: + arch = default_arch + + candidate_paths = [] + if target_triple: + candidate_paths.append(f"target/{target_triple}/release/{name}") + candidate_paths.append(f"target/release/{name}") + + bin_path = None + for p in candidate_paths: + if os.path.exists(p): + bin_path = p + break + + if not bin_path: + raise FileNotFoundError( + f"Keine kompilierte Binary für {name} gefunden. Gesuchte Pfade: {candidate_paths}" + ) + + depends = arch_meta.get("depends", ["gcc-libs", "glibc"]) + optdepends = arch_meta.get("optdepends", []) + + with tempfile.TemporaryDirectory() as build_dir: + bin_dir = os.path.join(build_dir, "usr/bin") + doc_dir = os.path.join(build_dir, f"usr/share/doc/{name}") + lic_dir = os.path.join(build_dir, f"usr/share/licenses/{name}") + os.makedirs(bin_dir, exist_ok=True) + os.makedirs(doc_dir, exist_ok=True) + os.makedirs(lic_dir, exist_ok=True) + + subprocess.run(["install", "-m", "755", bin_path, f"{bin_dir}/{name}"], check=True) + if os.path.exists("LICENSE"): + subprocess.run(["install", "-m", "644", "LICENSE", f"{lic_dir}/LICENSE"], check=True) + if os.path.exists("README.md"): + subprocess.run(["install", "-m", "644", "README.md", f"{doc_dir}/README.md"], check=True) + + installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0] + builddate = str(int(time.time())) + + pkginfo_lines = [ + f"pkgname = {name}", + f"pkgbase = {name}", + f"pkgver = {version}-{pkgrel}", + f"pkgdesc = {description}", + f"url = {repository}", + f"builddate = {builddate}", + f"packager = {author}", + f"size = {installed_size}", + f"arch = {arch}", + f"license = {license_name}", + ] + for dep in depends: + pkginfo_lines.append(f"depend = {dep}") + for optdep in optdepends: + pkginfo_lines.append(f"optdepend = {optdep}") + pkginfo_lines.append("makepkgopt = strip\n") + + with open(os.path.join(build_dir, ".PKGINFO"), "w") as f: + f.write("\n".join(pkginfo_lines)) + + 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) + 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("--pkgrel", help="Release-/Build-Nummer (z.B. 1, 2, ...)") + args = parser.parse_args() + + build_package(target_triple=args.target, target_arch=args.arch, pkgrel=args.pkgrel) + + +if __name__ == "__main__": + main() diff --git a/scripts/report-security-issue.py b/scripts/report-security-issue.py new file mode 100644 index 0000000..1518b86 --- /dev/null +++ b/scripts/report-security-issue.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Erstellt oder kommentiert ein Gitea-Issue mit den Ergebnissen der Security-Scans. + +Sucht ein offenes Issue mit dem Label ISSUE_LABEL (Standard: "security-scan"). +Existiert eines, wird der aktuelle Scan-Stand als neuer Kommentar angehängt +(die Historie bleibt erhalten). Existiert keines (z.B. weil das letzte +geschlossen wurde), wird ein neues Issue erstellt. Gibt es keine Funde mehr, +wird ein offenes Issue nur kommentiert, nicht geschlossen. + +Titel und Label lassen sich per Umgebungsvariable ISSUE_TITLE / ISSUE_LABEL +überschreiben, damit z.B. TruffleHog-Funde in ein eigenes Issue laufen statt +in das gemeinsame Trivy/OSV-Issue. +""" + +import json +import os +import sys +import urllib.error +import urllib.request + +LABEL_NAME = os.environ.get("ISSUE_LABEL", "security-scan") +LABEL_COLOR = "#b60205" +ISSUE_TITLE = os.environ.get("ISSUE_TITLE", "Security-Scan: Offene Schwachstellen") + +SEVERITY_ORDER = { + "VERIFIED": -1, + "CRITICAL": 0, + "HIGH": 1, + "MEDIUM": 2, + "LOW": 3, + "UNKNOWN": 4, + "UNVERIFIED": 6, +} + + +def api(method, path, token, gitea_url, data=None): + url = f"{gitea_url}/api/v1{path}" + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"token {token}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + raw = resp.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + print(f"Gitea API Fehler ({method} {path}): {e.code} {e.read().decode()}", file=sys.stderr) + raise + + +def make_finding(source, id, severity, package, installed="-", fixed="-", target="-"): + return { + "source": source, + "id": id, + "severity": severity, + "package": package, + "installed": installed, + "fixed": fixed, + "target": target, + } + + +def cvss_score_to_severity(score): + try: + score = float(score) + except (TypeError, ValueError): + return "UNKNOWN" + if score >= 9.0: + return "CRITICAL" + if score >= 7.0: + return "HIGH" + if score >= 4.0: + return "MEDIUM" + if score > 0.0: + return "LOW" + return "UNKNOWN" + + +def load_trivy(path): + findings = [] + if not path or not os.path.isfile(path): + return findings + with open(path) as f: + data = json.load(f) + for result in data.get("Results", []) or []: + target = result.get("Target", "?") + for vuln in result.get("Vulnerabilities", []) or []: + findings.append(make_finding( + "Trivy", + vuln.get("VulnerabilityID", "?"), + vuln.get("Severity", "UNKNOWN"), + vuln.get("PkgName", "?"), + installed=vuln.get("InstalledVersion", "?"), + fixed=vuln.get("FixedVersion") or "-", + target=target, + )) + for misc in result.get("Misconfigurations", []) or []: + findings.append(make_finding( + "Trivy (Misconfig)", + misc.get("ID", "?"), + misc.get("Severity", "UNKNOWN"), + misc.get("Title", "?"), + target=target, + )) + for secret in result.get("Secrets", []) or []: + findings.append(make_finding( + "Trivy (Secret)", + secret.get("RuleID", "?"), + secret.get("Severity", "UNKNOWN"), + secret.get("Title", "?"), + target=target, + )) + return findings + + +def load_osv(path): + findings = [] + if not path or not os.path.isfile(path): + return findings + with open(path) as f: + data = json.load(f) + for result in data.get("results", []) or []: + source = (result.get("source") or {}).get("path", "?") + for pkg in result.get("packages", []) or []: + info = pkg.get("package", {}) + pkg_name = f"{info.get('name', '?')} ({info.get('ecosystem', '?')})" + severity_by_id = {} + for group in pkg.get("groups", []) or []: + label = cvss_score_to_severity(group.get("max_severity")) + for vuln_id in group.get("ids", []) or []: + severity_by_id[vuln_id] = label + for vuln in pkg.get("vulnerabilities", []) or []: + vuln_id = vuln.get("id", "?") + findings.append(make_finding( + "OSV-Scanner", + vuln_id, + severity_by_id.get(vuln_id, "UNKNOWN"), + pkg_name, + installed=info.get("version", "?"), + target=source, + )) + return findings + + +def load_trufflehog(path): + findings = [] + if not path or not os.path.isfile(path): + return findings + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + git_meta = ((entry.get("SourceMetadata") or {}).get("Data") or {}).get("Git") or {} + findings.append(make_finding( + "TruffleHog", + entry.get("DetectorName", "?"), + "VERIFIED" if entry.get("Verified") else "UNVERIFIED", + git_meta.get("file", "?"), + target=git_meta.get("commit", "-"), + )) + return findings + + +def sort_findings(findings): + return sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["id"])) + + +def print_summary(findings): + if not findings: + print("Keine Funde.") + return + widths = { + key: max(len(key), *(len(str(f[key])) for f in findings)) + for key in ("source", "id", "severity", "package", "installed", "fixed", "target") + } + header = ("source", "id", "severity", "package", "installed", "fixed", "target") + row_fmt = " ".join(f"{{:{widths[k]}}}" for k in header) + print(row_fmt.format(*header)) + print(row_fmt.format(*("-" * widths[k] for k in header))) + for f in findings: + print(row_fmt.format(*(str(f[k]) for k in header))) + + +TRUFFLEHOG_GUIDANCE = """### Vorgehen bei gefundenen Secrets + +1. **Sofort rotieren/widerrufen**: Das betroffene Secret (Token, Passwort, Schlüssel) beim jeweiligen Dienst ungültig machen und durch ein neues ersetzen. Ein einmal committetes Secret gilt als kompromittiert, auch wenn es später aus der Historie entfernt wird. +2. **Ursache beheben**: Neues Secret nur noch über Umgebungsvariablen/Secrets-Store einbinden, nicht erneut hart codieren. +3. **Historie bereinigen (optional, manuell, erst nach Schritt 1)**: Mit `git filter-repo` oder BFG Repo-Cleaner den Commit-Inhalt entfernen, danach `git push --force` in Absprache mit allen Mitwirkenden – bestehende Clones/Forks werden dadurch ungültig. +4. **Issue schließen**, sobald rotiert wurde. TruffleHog findet das alte Secret ggf. weiterhin in der Historie – nach der Rotation ist das unkritisch. + +> Der Wert des Secrets selbst wird hier bewusst nicht ausgegeben, auch nicht gekürzt – nur Detector, Datei und Commit. Fund lässt sich über "Ziel" (Commit-Hash) und "Paket" (Dateipfad) lokalisieren.""" + + +def escape_md_cell(value): + return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ") + + +def build_report(findings, run_url): + lines = [ + "Automatisch erstellt vom Security-Scan-Workflow.", + f"Lauf: {run_url}" if run_url else "", + "", + "| Quelle | ID | Schweregrad | Paket | Installiert | Fix | Ziel |", + "|---|---|---|---|---|---|---|", + ] + for f in findings: + cells = (f["source"], f["id"], f["severity"], f["package"], f["installed"], f["fixed"], f["target"]) + lines.append("| " + " | ".join(escape_md_cell(c) for c in cells) + " |") + if any(f["source"] == "TruffleHog" for f in findings): + lines.append("") + lines.append(TRUFFLEHOG_GUIDANCE) + return "\n".join(lines) + + +def ensure_label(token, gitea_url, repo): + page = 1 + while True: + labels = api("GET", f"/repos/{repo}/labels?limit=50&page={page}", token, gitea_url) or [] + for label in labels: + if label.get("name") == LABEL_NAME: + return label["id"] + if len(labels) < 50: + break + page += 1 + created = api("POST", f"/repos/{repo}/labels", token, gitea_url, { + "name": LABEL_NAME, + "color": LABEL_COLOR, + "description": "Automatisch verwaltet vom Security-Scan-Workflow", + }) + return created["id"] + + +def find_open_issue(token, gitea_url, repo): + issues = api( + "GET", + f"/repos/{repo}/issues?state=open&type=issues&labels={LABEL_NAME}", + token, + gitea_url, + ) or [] + for issue in issues: + if issue.get("title") == ISSUE_TITLE: + return issue + return None + + +def main(): + trivy_path = sys.argv[1] if len(sys.argv) > 1 else None + osv_path = sys.argv[2] if len(sys.argv) > 2 else None + trufflehog_path = sys.argv[3] if len(sys.argv) > 3 else None + + gitea_url = os.environ.get("GITEA_URL", "").strip().rstrip("/") + repo = os.environ.get("REPO", "").strip() + token = os.environ.get("TOKEN", "").strip() + run_url = os.environ.get("RUN_URL", "") + osv_exit = int(os.environ.get("OSV_EXIT", "0")) + trufflehog_exit = int(os.environ.get("TRUFFLEHOG_EXIT", "0")) + + findings = sort_findings(load_trivy(trivy_path) + load_osv(osv_path) + load_trufflehog(trufflehog_path)) + print_summary(findings) + + if not token or not gitea_url or not repo: + missing = [name for name, val in [("TOKEN", token), ("GITEA_URL", gitea_url), ("REPO", repo)] if not val] + print(f"{', '.join(missing)} nicht gesetzt oder leer – überspringe Gitea-Issue-Synchronisation.") + else: + open_issue = find_open_issue(token, gitea_url, repo) + + if findings: + report = build_report(findings, run_url) + if open_issue: + print(f"Kommentiere bestehendes Issue #{open_issue['number']} mit {len(findings)} Fund(en).") + api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {"body": report}) + else: + label_id = ensure_label(token, gitea_url, repo) + print(f"Erstelle neues Issue mit {len(findings)} Fund(en).") + api("POST", f"/repos/{repo}/issues", token, gitea_url, { + "title": ISSUE_TITLE, + "body": report, + "labels": [label_id], + }) + elif open_issue: + print(f"Keine aktuellen Funde mehr. Kommentiere Issue #{open_issue['number']}.") + api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, { + "body": f"Aktueller Scan hat keine offenen Schwachstellen mehr gefunden.\n\n{run_url}".strip(), + }) + else: + print("Keine Funde und kein offenes Issue vorhanden.") + + osv_ok_exits = {0, 1} + trufflehog_ok_exits = {0, 183} + if osv_exit not in osv_ok_exits: + print(f"WARNUNG: osv-scanner beendete sich mit unerwartetem Exit-Code {osv_exit} - Scan evtl. unvollständig.", file=sys.stderr) + if trufflehog_exit not in trufflehog_ok_exits: + print(f"WARNUNG: trufflehog beendete sich mit unerwartetem Exit-Code {trufflehog_exit} - Scan evtl. unvollständig.", file=sys.stderr) + + has_trivy_findings = any(f["source"].startswith("Trivy") for f in findings) + if ( + has_trivy_findings + or osv_exit == 1 + or trufflehog_exit == 183 + or osv_exit not in osv_ok_exits + or trufflehog_exit not in trufflehog_ok_exits + ): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index f7a230f..0000000 --- a/src/config.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Modul für die Konfigurationsverwaltung der Anwendung. -//! -//! Dieses Modul stellt die Funktionalität zum Laden, Speichern und Verwalten -//! der Anwendungskonfiguration bereit. Die Konfiguration wird in einer Datei gespeichert -//! und beim Programmstart automatisch geladen. - -use crate::program; -use crate::sudo::is_run_as_root; -use serde::{Deserialize, Serialize}; -use std::sync::OnceLock; - -/// Hauptkonfigurationsstruktur der Anwendung. -/// -/// Enthält alle Konfigurationseinstellungen, aufgeteilt in verschiedene Bereiche. -#[derive(Serialize, Deserialize, Clone)] -pub struct AppConfig { - /// Allgemeine Einstellungen - pub general: General, - /// Lokale Einbindungseinstellungen - pub local: Local, - /// Remote-Einbindungseinstellungen - pub remote: Remote, - /// Lokaler Zwischenspeicher - pub storage: Option, -} - -impl Default for AppConfig { - fn default() -> Self { - Self { - general: General::default(), - local: Local::default(), - remote: Remote::default(), - storage: None, - } - } -} - -/// Allgemeine Konfigurationseinstellungen. -#[derive(Serialize, Deserialize, Clone)] -pub struct General { - /// Log-Level für die Anwendung - pub log_level: String, - /// Pfad zum Einhängepunkt - pub mount_point: String, -} - -impl Default for General { - fn default() -> Self { - Self { - log_level: "info".to_string(), - mount_point: get_default_mount_path(), - } - } -} - -/// Konfiguration für lokale Einbindungen. -#[derive(Serialize, Deserialize, Clone)] -pub struct Local { - /// Typ der lokalen Einbindung (z.B. "nfs") - pub mount_type: String, - /// Pfad zur lokalen Einbindung - pub mount_path: String, - /// MAC-Adresse des lokalen Geräts - pub device_mac: String, -} - -impl Default for Local { - fn default() -> Self { - Self { - mount_type: "nfs".to_string(), - mount_path: "".to_string(), - device_mac: "".to_string(), - } - } -} - -/// Konfiguration für Remote-Einbindungen. -#[derive(Serialize, Deserialize, Clone)] -pub struct Remote { - /// Typ der Remote-Einbindung (z.B. "webdav") - pub mount_type: String, - /// Pfad zur Remote-Einbindung - pub mount_path: String, - /// Benutzername für Remote-Zugriff - pub username: String, - /// Passwort für Remote-Zugriff - pub password: String, -} - -impl Default for Remote { - fn default() -> Self { - Self { - mount_type: "webdav".to_string(), - mount_path: "".to_string(), - username: "".to_string(), - password: "".to_string(), - } - } -} - -/// Zwischenspeicher für das Programm. -#[derive(Serialize, Deserialize, Clone)] -pub struct Storage { - pub device_ip: String, -} - -impl Default for Storage { - fn default() -> Self { - Self { - device_ip: "".to_string(), - } - } -} - -/// Ermittelt den Standard-Einhängepunkt basierend auf dem Betriebssystem. -fn get_default_mount_path() -> String { - #[cfg(not(any(target_os = "windows")))] - { - "/media/smart_mount".to_string() - } - #[cfg(any(target_os = "windows"))] - { - let drives = ('C'..='Z').collect::>(); - for drive in drives { - let drive_string = format!("{}:", drive); - if !std::path::Path::new(&drive_string).exists() { - return format!("{}:", drive); - } - } - "H".to_string() - } -} - -static CONFIG_NAME: &str = "config"; -static CONFIG: OnceLock = OnceLock::new(); - -/// Gibt die aktuelle Konfiguration zurück. -/// -/// Lädt die Konfiguration beim ersten Aufruf und speichert sie zwischen. -/// Nachfolgende Aufrufe geben die gespeicherte Konfiguration zurück. -pub fn get_config() -> &'static AppConfig { - CONFIG.get_or_init(|| { - load_config() - }) -} - -/// Modifiziert die aktuelle Konfiguration mit der übergebenen Mutator-Funktion. -/// -/// Die Funktion lädt die Konfiguration neu von der Festplatte, wendet die Mutator-Funktion an -/// und speichert die geänderte Konfiguration anschließend wieder. -pub fn modify_config(mutator: F) -where - F: FnOnce(&mut AppConfig), -{ - // Immer frisch von Disk laden, damit Änderungen konsistent sind - let mut cfg = load_config(); - mutator(&mut cfg); - save_config(cfg); -} - -/// Lädt die Konfiguration aus der Konfigurationsdatei. -fn load_config() -> AppConfig { - #[cfg(target_os = "linux")] - if is_run_as_root() { - return confy::load_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME)).unwrap_or_default(); - } - confy::load(&*program::program_name(), CONFIG_NAME).unwrap_or_default() -} - -/// Speichert die übergebene Konfiguration in der Konfigurationsdatei. -fn save_config(config: AppConfig) { - #[cfg(target_os = "linux")] - if is_run_as_root() { - confy::store_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME), config).unwrap(); - return; - } - confy::store(&*program::program_name(), CONFIG_NAME, config).unwrap(); -} \ No newline at end of file diff --git a/src/filesystem/credentials.rs b/src/filesystem/credentials.rs deleted file mode 100644 index e40dc35..0000000 --- a/src/filesystem/credentials.rs +++ /dev/null @@ -1,111 +0,0 @@ -use crate::config::get_config; -use crate::log::{log, LogLevel}; -use std::process::Command; - -/// Speichert die Zugangsdaten für einen WebDAV-Mount-Punkt. -/// -/// # Parameter -/// * `mount_path` - Der Netzwerkpfad zum WebDAV-Server -/// * `username` - Der Benutzername für den WebDAV-Zugriff -/// * `password` - Das Passwort für den WebDAV-Zugriff -pub fn save_credentials_webdav() { - let config = get_config(); - - #[cfg(target_os = "windows")] - { - let output = Command::new("powershell") - .args([ - "-Command", - &format!("cmdkey /delete:{}", config.remote.mount_path) - ]) - .output() - .expect("Failed to delete credentials"); - - let output = Command::new("powershell") - .args([ - "-Command", - &format!("cmdkey /add:{} /user:{} /pass:{}", - config.remote.mount_path, - config.remote.username, - config.remote.password - ) - ]) - .output() - .expect("Failed to store credentials"); - - if !output.status.success() { - log( - "credentials", - "Could not store credentials in Windows Credential Manager", - LogLevel::Error, - ); - log( - "credentials", - &String::from_utf8_lossy(&output.stderr), - LogLevel::Debug, - ); - } - } - - #[cfg(not(target_os = "windows"))] - { - if config.remote.username.is_empty() || config.remote.password.is_empty() { - log( - "credentials", - "No credentials found in config", - LogLevel::Error, - ); - return; - } - - let output = Command::new("touch") - .arg("/etc/davfs2/secrets") - .output() - .expect("Failed to create secrets file"); - - if !output.status.success() { - log( - "credentials", - "Could not create secrets file", - LogLevel::Error, - ); - return; - } - - let output = Command::new("chmod") - .arg("600") - .arg("/etc/davfs2/secrets") - .output() - .expect("Failed to set secrets file permissions"); - - if !output.status.success() { - log( - "credentials", - "Could not set secrets file permissions", - LogLevel::Error, - ); - return; - } - - let credentials = format!("{} \"{}\" \"{}\"\n", - config.general.mount_point, - config.remote.username, - config.remote.password - ); - let mut content = std::fs::read_to_string("/etc/davfs2/secrets") - .unwrap_or_default(); - - content = content.lines() - .filter(|line| !line.starts_with(&config.general.mount_point)) - .collect::>() - .join("\n"); - - if !content.is_empty() { - content.push('\n'); - } - content.push_str(&credentials); - - std::fs::write("/etc/davfs2/secrets", content) - .expect("Failed to write credentials"); - } -} \ No newline at end of file diff --git a/src/filesystem/mod.rs b/src/filesystem/mod.rs deleted file mode 100644 index 541fb16..0000000 --- a/src/filesystem/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod mount; -pub mod mounted; -pub mod credentials; diff --git a/src/filesystem/mount.rs b/src/filesystem/mount.rs deleted file mode 100644 index fcd8e98..0000000 --- a/src/filesystem/mount.rs +++ /dev/null @@ -1,213 +0,0 @@ -use crate::filesystem::mounted::{/* is_mounted, */ FS_MOUNT_GUARD}; -use crate::log::{LogLevel, log}; -use std::process::Command; -use std::sync::{Mutex, OnceLock, RwLock}; -use std::thread::sleep; -use std::time::Duration; - -static GUARD_MOUNT: OnceLock> = OnceLock::new(); -static GUARD_UNMOUNT: OnceLock> = OnceLock::new(); - -/// Führt die Einbindung eines Dateisystems durch. -/// -/// # Parameter -/// * `mount_point` - Der lokale Verzeichnispfad, an dem das Dateisystem eingebunden werden soll -/// * `network_path` - Der Netzwerkpfad zum einzubindenden Dateisystem -/// * `mount_type` - Der Mount-Typ, z.B. "nfs" oder "davfs2" -pub fn mount(mount_point: &str, network_path: &str, mount_type: &str) { - // 1) Globalen Write-Lock halten (blockiert parallele Statusabfragen) - let _fs_write = FS_MOUNT_GUARD - .get_or_init(|| RwLock::new(())) - .write() - .expect("mount rwlock poisoned"); - - // 2) Danach funktionsspezifischen Mutex sperren (konsistente Lock-Reihenfolge!) - let m = GUARD_MOUNT.get_or_init(|| Mutex::new(())); - let _lock = m.lock().expect("Mutex poisoned"); - - log( - "mount", - &*format!( - "Mounting filesystem ({}) at \"{}\" as \"{}\"", - network_path, mount_point, mount_type - ), - LogLevel::Info, - ); - - #[cfg(target_os = "windows")] - { - let output = Command::new("New-PSDrive") - .args([ - "-Name", - &mount_point[0..1], // First letter for drive letter - "-PSProvider", - "FileSystem", - "-Root", - network_path, - "-Persist", - "-Type", - mount_type, - ]) - .output() - .expect("Failed to execute mount command"); - - if !output.status.success() { - log( - "mount", - &*format!( - "Filesystem ({}) couldn't be mounted at \"{}\" as \"{}\"", - network_path, mount_point, mount_type - ), - LogLevel::Error, - ); - log( - "mount", - &*String::from_utf8_lossy(&output.stderr), - LogLevel::Debug, - ); - } - } - - #[cfg(not(target_os = "windows"))] - { - // Verzeichnis vorbereiten - let output = Command::new("mkdir") - .arg("-p") - .arg(mount_point) - .output() - .expect("Failed to create mount point directory"); - - if output.status.success() { - log("mount", "Mountpoint created successfully!", LogLevel::Info); - } else { - log( - "mount", - &*String::from_utf8_lossy(&output.stderr), - LogLevel::Debug, - ); - } - - let output = Command::new("chmod") - .arg("777") - .arg(mount_point) - .output() - .expect("Failed to set mount point permissions"); - - if output.status.success() { - log( - "mount", - "Successfully set mount point permissions.", - LogLevel::Info, - ); - } else { - log( - "mount", - &*String::from_utf8_lossy(&output.stderr), - LogLevel::Debug, - ); - } - - let output = Command::new("mount") - .arg("-t") - .arg(mount_type) - .arg(network_path) - .arg(mount_point) - .output() - .expect("Failed to execute mount command"); - - sleep(Duration::from_secs(1)); - - if output.status.success() { - log("mount", "Filesystem mounted successfully.", LogLevel::Info); - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - // Häufige „schon gemountet“/Busy-Indikatoren tolerant behandeln - let already_or_busy = stderr.contains("already mounted") - || stderr.contains("is busy") - || stderr.contains("Device or resource busy") - || stderr.contains("EBUSY"); - - if already_or_busy { - log( - "mount", - "Filesystem appears to be already mounted or mountpoint is busy. Treating as no-op.", - LogLevel::Info, - ); - log("mount", &*stderr, LogLevel::Debug); - return; - } - - log( - "mount", - &*format!( - "Filesystem ({}) couldn't be mounted at \"{}\" as \"{}\"", - network_path, mount_point, mount_type - ), - LogLevel::Error, - ); - log("mount", &*stderr, LogLevel::Debug); - } - } -} - -/// Hängt ein Dateisystem aus. -/// -/// # Parameter -/// * `mount_point` - Der lokale Verzeichnispfad, von dem das Dateisystem ausgehängt werden soll -pub fn unmount(mount_point: &str) { - // 1) Globalen Write-Lock halten (blockiert parallele Statusabfragen) - let _fs_write = FS_MOUNT_GUARD - .get_or_init(|| RwLock::new(())) - .write() - .expect("mount rwlock poisoned"); - - // 2) Danach funktionsspezifischen Mutex sperren (konsistente Lock-Reihenfolge!) - let m = GUARD_UNMOUNT.get_or_init(|| Mutex::new(())); - let _lock = m.lock().expect("Mutex poisoned"); - - #[cfg(target_os = "windows")] - { - let output = Command::new("Remove-PSDrive") - .args([ - "-Name", - &mount_point[0..1], // First letter for drive letter - "-Force", - ]) - .output() - .expect("Failed to execute unmount command"); - - if !output.status.success() { - log( - "unmount", - &*format!("Filesystem couldn't be unmounted from \"{}\"", mount_point), - LogLevel::Error, - ); - log( - "unmount", - &*String::from_utf8_lossy(&output.stderr), - LogLevel::Debug, - ); - } - } - - #[cfg(not(target_os = "windows"))] - { - let output = Command::new("umount") - .arg(mount_point) - .output() - .expect("Failed to execute unmount command"); - - if !output.status.success() { - log( - "unmount", - &*format!("Filesystem couldn't be unmounted from \"{}\"", mount_point), - LogLevel::Error, - ); - log( - "unmount", - &*String::from_utf8_lossy(&output.stderr), - LogLevel::Debug, - ); - } - } -} diff --git a/src/filesystem/mounted.rs b/src/filesystem/mounted.rs deleted file mode 100644 index a3176f4..0000000 --- a/src/filesystem/mounted.rs +++ /dev/null @@ -1,167 +0,0 @@ -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::Path; -use std::sync::{OnceLock, RwLock}; - -// Globaler RW-Lock für Mount-Operationen und Statusabfragen -pub static FS_MOUNT_GUARD: OnceLock> = OnceLock::new(); - -fn guard() -> &'static RwLock<()> { - FS_MOUNT_GUARD.get_or_init(|| RwLock::new(())) -} - -/// Überprüft, ob ein Dateisystem am angegebenen Mount-Point mit dem spezifizierten Mount-Typ eingebunden ist. -/// -/// # Parameter -/// * `mount_point` - Der Pfad, an dem das Dateisystem eingebunden sein soll -/// * `mount_type` - Der erwartete Mount-Typ des Dateisystems (z.B. nfs, smb, davfs2) -/// -/// # Rückgabewert -/// * `true` wenn das Dateisystem mit dem angegebenen Typ eingebunden ist -/// * `false` wenn das Dateisystem nicht oder mit einem anderen Typ eingebunden ist -pub fn is_mounted_as(mount_point: &str, mount_type: &str) -> bool { - // Während Statusabfragen nur Read-Lock halten - let _read_guard = guard().read().expect("mount rwlock poisoned"); - - #[cfg(target_os = "windows")] - { - use std::process::Command; - let drive_letter = &mount_point[0..1]; - let output = Command::new("powershell") - .args([ - "-Command", - &format!( - "(Get-PSDrive -Name {} -PSProvider 'FileSystem').Description", - drive_letter - ), - ]) - .output() - .expect("Failed to execute get-psdrive command"); - - if !output.status.success() { - return false; - } - - let drive_type = String::from_utf8_lossy(&output.stdout).trim().to_string(); - drive_type == mount_type - } - - #[cfg(not(target_os = "windows"))] - { - let norm_mp = normalize_mount_point(mount_point); - - for entry in read_proc_mounts() { - let (mp, fstype, _opts, _src) = entry; - if mp == norm_mp { - if type_matches(mount_type, &fstype, &_opts) { - return true; - } - } - } - - false - } -} - -/// Überprüft, ob ein Dateisystem am angegebenen Mount-Point eingebunden ist. -/// -/// # Parameter -/// * `mount_point` - Der Pfad, an dem das Dateisystem eingebunden sein soll -/// -/// # Rückgabewert -/// * `true` wenn ein Dateisystem am angegebenen Pfad eingebunden ist -/// * `false` wenn kein Dateisystem eingebunden ist -pub fn is_mounted(mount_point: &str) -> bool { - // Während Statusabfragen nur Read-Lock halten - let _read_guard = guard().read().expect("mount rwlock poisoned"); - - #[cfg(target_os = "windows")] - { - use std::process::Command; - let drive_letter = &mount_point[0..1]; - let output = Command::new("powershell") - .args([ - "-Command", - &format!( - "(Get-PSDrive -Name {} -PSProvider 'FileSystem')", - drive_letter - ), - ]) - .output() - .expect("Failed to execute get-psdrive command"); - - output.status.success() - } - - #[cfg(not(target_os = "windows"))] - { - let norm_mp = normalize_mount_point(mount_point); - read_proc_mounts() - .into_iter() - .any(|(mp, _, _, _)| mp == norm_mp) - } -} - -#[cfg(not(target_os = "windows"))] -fn read_proc_mounts() -> Vec<(String, String, String, String)> { - // Liefert Tupel: (mount_point, fstype, options, source) - let file = match File::open("/proc/mounts") { - Ok(f) => f, - Err(_) => return Vec::new(), - }; - let reader = BufReader::new(file); - let mut result = Vec::new(); - - for line in reader.lines().flatten() { - // Format /proc/mounts: - // fs_spec fs_file fs_vfstype fs_mntops fs_freq fs_passno - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 6 { - continue; - } - let fs_spec = parts[0].to_string(); - let fs_file = parts[1].to_string(); - let fs_vfstype = parts[2].to_string(); - let fs_mntops = parts[3].to_string(); - - result.push((fs_file, fs_vfstype, fs_mntops, fs_spec)); - } - - result -} - -#[cfg(not(target_os = "windows"))] -fn normalize_mount_point(p: &str) -> String { - // Entfernt redundante Slashes am Ende (außer bei "/") und canonicalized soweit möglich. - if p == "/" { - return "/".to_string(); - } - let trimmed = p.trim_end_matches('/'); - // Versuche, realpath zu bilden, falle sonst auf trimmed zurück - let path = Path::new(trimmed); - path.canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| trimmed.to_string()) -} - -#[cfg(not(target_os = "windows"))] -fn type_matches(expected: &str, actual_fstype: &str, options: &str) -> bool { - // Normalisiere erwartete Typfamilien - match expected { - // NFS kann als nfs oder nfs4 erscheinen - "nfs" | "nfs4" => actual_fstype == "nfs" || actual_fstype == "nfs4", - - // davfs/webdav erscheint häufig als fuse.davfs (oder fuse mit helper=davfs) - "webdav" | "davfs" | "davfs2" => { - actual_fstype == "fuse.davfs" - || actual_fstype == "davfs" - || (actual_fstype == "fuse" && options.contains("helper=davfs")) - } - - // CIFS/Samba Alias - "cifs" | "smb" | "smb3" => actual_fstype == "cifs" || actual_fstype == "smb3", - - // Fallback: exakter Vergleich - other => other == actual_fstype, - } -} diff --git a/src/log.rs b/src/log.rs deleted file mode 100644 index 79a2155..0000000 --- a/src/log.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! Einfaches, threadsicheres Logging-Modul. -//! -//! Merkmale: -//! - Ausgabe in Terminal (stdout/stderr) und zusätzlich in eine Logdatei im temporären Verzeichnis. -//! - Nachrichtenvorlage: `[DD.MM.YYYY HH:MM:SS.mmm][LEVEL][TAG]: ` mit Emoji-Präfix (🛑/⚠️/ℹ️/🚧). -//! - Der aktuell verwendete Schweregradfilter ist statisch (`LOG_LEVEL`) und wird zur Laufzeit nicht geändert. -//! - Terminal-Erkennung und Dateihandle werden lazily initialisiert und zwischengespeichert (`OnceLock`). -//! -//! Hinweise: -//! - Bei einem leeren `tag` wird ein leerer Tag-Abschnitt erzeugt. -//! - Die Logdatei wird unterhalb eines prozessspezifischen Ordners im System-Temp-Verzeichnis abgelegt und nach Datum -//! benannt (z. B. `log-2025-08-18.log`). Es wird im Append-Modus geschrieben. -//! -//! Beispiel (ohne Ausführung in Doctests): -//! ```rust,no_run -//! use crate::log::{log, LogLevel}; -//! -//! log("startup", "Dienst wird initialisiert …", LogLevel::Info); -//! log("db", "Verbindung unterbrochen!", LogLevel::Warn); -//! log("core", "Unerwarteter Fehler", LogLevel::Error); -//! ``` - -use std::env; -use std::fmt::{Display, Formatter}; -use std::fs::{create_dir_all, File, OpenOptions}; -use std::io::{stdout, IsTerminal, Write}; -use std::sync::{Mutex, OnceLock}; - -use crate::config; -use crate::program; -use time::{macros::format_description, OffsetDateTime}; - -/// Schweregrade für Logeinträge in aufsteigender Detailtiefe. -/// -/// Die Reihenfolge bestimmt den Filter: Nur Einträge mit `log_level <= LOG_LEVEL` werden ausgegeben. -/// -/// Anzeige (Display): -/// - `Error` -> `ERROR` -/// - `Warn` -> `WARN` -/// - `Info` -> `INFO` -/// - `Debug` -> `DEBUG` -#[derive(PartialEq, Eq, PartialOrd, Ord, Copy, Clone)] -pub enum LogLevel { - /// Kritische Fehler, gehen zusätzlich auf `stderr`. - Error = 1, - /// Wichtige Warnungen über potenzielle Probleme. - Warn = 2, - /// Allgemeine Betriebsinformationen. - Info = 3, - /// Ausführliche Diagnoseausgaben für die Entwicklung. - Debug = 4, -} - -impl Display for LogLevel { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - LogLevel::Error => write!(f, "ERROR"), - LogLevel::Warn => write!(f, "WARN"), - LogLevel::Info => write!(f, "INFO"), - LogLevel::Debug => write!(f, "DEBUG"), - } - } -} - -impl TryFrom for LogLevel { - type Error = LogLevel; - - fn try_from(value: String) -> Result { - match value.to_lowercase().as_str() { - "error" => Ok(LogLevel::Error), - "warn" => Ok(LogLevel::Warn), - "info" => Ok(LogLevel::Info), - "debug" => Ok(LogLevel::Debug), - _ => Err(LogLevel::Info) - } - } -} - -/// Zwischenspeicher für die einmalig ermittelte Terminal-Fähigkeit von `stdout`. -static IS_TERMINAL: OnceLock = OnceLock::new(); - -/// Globaler Schweregradfilter für Ausgabe. -static LOG_LEVEL: OnceLock = OnceLock::new(); - -/// Lazy-initialisiertes Handle zur Logdatei; kann `None` sein, falls das Öffnen fehlschlug. -static LOG_FILE: OnceLock>> = OnceLock::new(); - -/// Protokolliert eine Nachricht abhängig vom angegebenen Schweregrad. -/// -/// Verhalten: -/// - Wenn `log_level` größer als der globale Filter ist, wird nichts ausgegeben. -/// - Bei Terminalausgabe gehen `Error`-Meldungen nach `stderr`, alle anderen nach `stdout`. -/// - Zusätzlich wird in eine tägliche Logdatei im Temp-Verzeichnis geschrieben (wenn erfolgreich geöffnet). -/// -/// Parameter: -/// - `tag`: Kurzer Kontext (z. B. Modulname). -/// - `message`: Die eigentliche Nachricht (eine Zeile). -/// - `log_level`: Schweregrad der Nachricht. -/// -/// Thread-Sicherheit: -/// - Dateischreibzugriffe sind über `Mutex` serialisiert. -/// -/// Beispiel: -/// ```rust,no_run -/// # use crate::log::{log, LogLevel}; -/// log("http", "Server gestartet auf Port 8080", LogLevel::Info); -/// ``` -pub fn log(tag: &str, message: &str, log_level: LogLevel) { - if log_level <= get_log_level() { - let message: String = format_message(tag, message, &log_level); - - if is_terminal() { - if log_level == LogLevel::Error { - eprintln!("{}", message) - } else { - println!("{}", message) - } - } - - let file_lock = get_or_init_log_file(); - if let Ok(mut guard) = file_lock.lock() { - if let Some(f) = guard.as_mut() { - let _ = writeln!(f, "{}", message); - let _ = f.flush(); - } - } - } -} - -/// Liefert das einmalig initialisierte Log-Level für die Filterung. -/// -/// Ermittelt das konfigurierte Log-Level aus der Config-Datei. -/// Wenn ungültig oder nicht vorhanden, wird LogLevel::Info als Fallback verwendet. -/// -/// Rückgabe: -/// - `LogLevel`: Das zu verwendende Log-Level als enum-Wert. -fn get_log_level() -> LogLevel { - *LOG_LEVEL.get_or_init(|| { - let log_level = &config::get_config().general.log_level; - LogLevel::try_from(log_level.to_string()).unwrap_or(LogLevel::Info) - } - ) -} - -/// Ermittelt einmalig, ob `stdout` ein Terminal ist, und cached das Ergebnis. -/// -/// Rückgabe: -/// - `true`, wenn `stdout` ein TTY/Terminal ist. -/// - `false` andernfalls. -fn is_terminal() -> bool { - *IS_TERMINAL.get_or_init(|| stdout().is_terminal()) -} - -/// Formatiert eine Lognachricht mit Zeitstempel, Level, Tag und Emoji-Präfix. -/// -/// Format: -/// - Datum/Zeit lokal (Fallback: UTC) im Format `DD.MM.YYYY HH:MM:SS.mmm`. -/// - Level als Text (`ERROR`, `WARN`, `INFO`, `DEBUG`). -/// - Tag in eckigen Klammern; bei leerem Tag entsteht ein leeres `[]`. -/// - Emoji-Präfix pro Level: 🛑/⚠️/ℹ️/🚧. -/// -/// Beispielausgabe: -/// - `ℹ️[18.08.2025 14:23:45.012][INFO][init]: Fertig` -/// -/// Hinweis: -/// - Diese Funktion formatiert nur; sie führt keinen I/O aus. -fn format_message(tag: &str, message: &str, log_level: &LogLevel) -> String { - let mut prefix: String = String::new(); - - let now_local: OffsetDateTime = - OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc()); - let fmt = - format_description!("[day].[month].[year] [hour]:[minute]:[second].[subsecond digits:3]"); - - if tag != "" { - prefix = format!("[{}]", tag); - } - - let message = format!( - "[{}][{}][{}]: {}", - now_local.format(fmt).unwrap(), - log_level.to_string(), - prefix, - message - ); - - match log_level { - &LogLevel::Error => format!("{}{}", "🛑", message), - &LogLevel::Warn => format!("{}{}", "⚠️", message), - &LogLevel::Info => format!("{}{}", "ℹ️", message), - &LogLevel::Debug => format!("{}{}", "🚧", message), - } -} - -/// Initialisiert das Logdatei-Handle beim ersten Aufruf und liefert eine Referenz darauf. -/// -/// Rückgabe: -/// - `&'static Mutex>`: Das Mutex schützt den optionalen Dateihandler. -/// `None` bedeutet, dass das Öffnen fehlgeschlagen ist (z. B. fehlende Rechte). -fn get_or_init_log_file() -> &'static Mutex> { - LOG_FILE.get_or_init(|| { - let file = open_log_file().ok(); - Mutex::new(file) - }) -} - -/// Öffnet (und erstellt bei Bedarf) die tagesbasierte Logdatei im Temp-Verzeichnis. -/// -/// Pfadaufbau: -/// - Basis: `std::env::temp_dir()` -/// - Unterordner: `-` -/// - Datei: `log-YYYY-MM-DD.log` im Append-Modus -/// -/// Rückgabe: -/// - `Ok(File)`, wenn der Ordner erstellt/gefunden und die Datei geöffnet/angelegt werden konnte. -/// - `Err(std::io::Error)`, wenn ein I/O-Fehler auftrat. -/// -/// Fehler: -/// - Gibt I/O-Fehler unverändert weiter (z. B. beim Erstellen des Ordners oder Öffnen der Datei). -fn open_log_file() -> std::io::Result { - let program = program::program_name(); - let rand = "13692bbf-a93b-43e9-9cc6-f05f94a8cfb6"; - - let mut dir = env::temp_dir(); - dir.push(format!("{}-{}", program, rand)); - create_dir_all(&dir)?; - - let today_fmt = format_description!("[year]-[month]-[day]"); - let date_str = OffsetDateTime::now_local() - .unwrap_or(OffsetDateTime::now_utc()) - .format(today_fmt) - .unwrap_or_else(|_| "0000-00-00".to_string()); - - let file_name = format!("log-{}.log", date_str); - dir.push(file_name); - - OpenOptions::new() - .create(true) - .append(true) - .open(dir) -} diff --git a/src/network/mod.rs b/src/network/mod.rs deleted file mode 100644 index 401a650..0000000 --- a/src/network/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod network_interface; -pub mod utils; \ No newline at end of file diff --git a/src/network/network_interface.rs b/src/network/network_interface.rs deleted file mode 100644 index 24a1439..0000000 --- a/src/network/network_interface.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::log; -use crate::log::LogLevel; -use std::process::Command; - -/// Prüft, ob mindestens eine verfügbare Netzwerkschnittstelle (LAN/WLAN etc.) aktiv ist -/// und nicht nur die Loopback-Schnittstelle. -/// -/// Rückgabe: -/// - Option: Name der aktiven Netzwerkkarte oder None wenn keine gefunden -pub fn get_active_network_interface() -> Option { - #[cfg(target_os = "linux")] - { - use std::fs; - use std::path::Path; - - // Prüfe Interfaces in /sys/class/net - let sysfs = Path::new("/sys/class/net"); - if let Ok(entries) = fs::read_dir(sysfs) { - for entry in entries.flatten() { - let ifname = match entry.file_name().into_string() { - Ok(n) => n, - Err(_) => continue, - }; - if ifname == "lo" { - continue; - } - - let iface_path = entry.path(); - - // 1) operstate == "up" - let operstate = fs::read_to_string(iface_path.join("operstate")) - .unwrap_or_default() - .trim() - .to_string(); - if operstate != "up" { - continue; - } - - // 2) carrier == "1" (hat Link) - let carrier = fs::read_to_string(iface_path.join("carrier")) - .unwrap_or_default() - .trim() - .to_string(); - if carrier != "1" { - continue; - } - - // 3) Virtuell ausschließen: - // /sys/class/net//device -> Symlink zu physischer HW; - // zeigt der Pfad unterhalb von ".../virtual/..." oder existiert er gar nicht, - // dann ist das i. d. R. ein virtuelles Interface. - let device_link = iface_path.join("device"); - let is_virtual = match fs::read_link(&device_link) { - Ok(target) => target.as_os_str().to_string_lossy().contains("/virtual/"), - Err(_) => true, // kein device-Link -> in der Regel virtuell - }; - if is_virtual { - continue; - } - - // Optional: bekannte „logische" Typen via Verzeichnis-Erkennung ausschließen - // (Bridges, VLANs, Macvlan etc.) - if iface_path.join("bridge").exists() - || iface_path.join("bonding").exists() - || iface_path.join("team").exists() - || iface_path.join("vlan").exists() - || iface_path.join("macvlan").exists() - { - continue; - } - - // Wenn wir hier sind, haben wir eine aktive physische NIC - log::log("network_interface", "Found active network interface.", LogLevel::Info); - return Some(ifname); - } - } - - let output = Command::new("ip") - .args(["-o", "link", "show", "up"]) - .output(); - - if let Ok(out) = output { - if !out.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&out.stdout); - for line in stdout.lines() { - if let Some(rest) = line.splitn(2, ": ").nth(1) { - if let Some(ifname) = rest.split(':').next() { - let name = ifname.trim(); - if name != "lo" { - log::log("network_interface", "Found active network interface.", LogLevel::Debug); - return Some(name.to_string()); - } - } - } - } - } - None - } - - #[cfg(target_os = "windows")] - { - // Windows: PowerShell verwenden, um aktive Adapter zu finden (Status = Up). - // Wir ignorieren Loopback/Pseudo-Interfaces, indem wir Physical=true filtern. - let ps_cmd = r#" -$adapters = Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1 -if ($adapters) { $adapters.Name } else { 'NONE' } -"#; - let output = Command::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd]) - .output(); - - if let Ok(out) = output { - if !out.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - - if stdout != "NONE" { - log::log("network_interface", "Found active network interface.", LogLevel::Debug); - return Some(stdout); - } - } - None - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - log::log("network_interface", "OS not supported.", LogLevel::Error); - None - } -} - -/// Ermittelt die IP-Adresse (mit Netzmaske) für eine angegebene Netzwerkschnittstelle -/// -/// # Parameter -/// - `interface`: Name der Netzwerkschnittstelle -/// -/// # Rückgabe -/// - Option: IP-Adresse mit Netzmaske (z.B. "192.168.1.100/24") oder None wenn keine gefunden -pub fn get_interface_ip_address(interface: &str) -> Option { - #[cfg(target_os = "linux")] - { - let output = Command::new("ip") - .args(["addr", "show", interface]) - .output(); - - if let Ok(out) = output { - if !out.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&out.stdout); - for line in stdout.lines() { - if line.contains("inet ") { - if let Some(ip) = line.split_whitespace().nth(1) { - return Some(ip.to_string()); - } - } - } - } - None - } - - #[cfg(target_os = "windows")] - { - let ps_cmd = format!( - r#"Get-NetIPAddress -InterfaceAlias '{}' -AddressFamily IPv4 | Select-Object IPAddress,PrefixLength | ForEach-Object {{ "{0}/{1}" -f $_.IPAddress,$_.PrefixLength }}"#, - interface - ); - let output = Command::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd]) - .output(); - - if let Ok(out) = output { - if !out.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stdout.is_empty() { - return Some(stdout); - } - } - None - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - log::log("network_interface", "OS not supported.", LogLevel::Error); - None - } -} diff --git a/src/network/utils.rs b/src/network/utils.rs deleted file mode 100644 index cd25041..0000000 --- a/src/network/utils.rs +++ /dev/null @@ -1,391 +0,0 @@ -use crate::log; -use crate::log::LogLevel; -use std::net::Ipv4Addr; -use std::process::Command; - -/// Prüft, ob eine angegebene IP-Adresse oder URL erreichbar ist -/// -/// # Parameter -/// - `address`: Eine IP-Adresse oder vollständige HTTPS-URL -/// -/// # Rückgabe -/// - `bool`: True wenn die Adresse erreichbar ist, False wenn nicht -pub fn is_reachable(address: &str) -> bool { - let addr_string = if let Some((ip_str, mask_str)) = address.split_once('/') { - if ip_str.parse::().is_ok() - && mask_str.parse::().ok().filter(|m| *m <= 32).is_some() - { - let ip = ip_str.parse::().unwrap(); - let next_ip = Ipv4Addr::from(u32::from(ip).saturating_add(1)); - next_ip.to_string() - } else { - address.to_string() - } - } else { - address.to_string() - }; - let addr = addr_string.as_str(); - - #[cfg(target_os = "linux")] - { - if addr.parse::().is_ok() { - let output = Command::new("ping") - .args(["-c", "1", "-W", "2", addr]) - .output(); - - if let Ok(out) = output { - let stdout = String::from_utf8_lossy(&out.stdout); - log("network_utils", &*stdout, LogLevel::Debug); - - out.status.success() - } else { - log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error); - false - } - } else { - let output = Command::new("curl") - .args(["--head", "--silent", "--fail", addr]) - .output(); - - if let Ok(out) = output { - out.status.success() - } else { - log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error); - false - } - } - } - - #[cfg(target_os = "windows")] - { - if addr.parse::().is_ok() { - let output = Command::new("ping") - .args(["-n", "1", "-w", "2000", addr]) - .output(); - - if let Ok(out) = output { - out.status.success() - } else { - log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error); - false - } - } else { - let output = Command::new("powershell") - .args(["-Command", &format!("Invoke-WebRequest -Uri {} -Method HEAD -UseBasicParsing", addr)]) - .output(); - - if let Ok(out) = output { - out.status.success() - } else { - log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error); - false - } - } - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - log::log("network_utils", "OS not supported.", LogLevel::Error); - false - } -} - -/// Berechnet die Netzwerkadresse aus einer IP-Adresse mit Subnetzmaske -/// -/// # Parameter -/// - `address`: Eine IPv4-Adresse mit Subnetzmaske im Format "xxx.xxx.xxx.xxx/yy" -/// -/// # Rückgabe -/// - Option: Die Netzwerkadresse im gleichen Format oder None bei ungültiger Eingabe -pub fn get_network_address(address: &str) -> Option { - let parts: Vec<&str> = address.split('/').collect(); - if parts.len() != 2 { - return None; - } - - if let Ok(ip) = parts[0].parse::() { - if let Ok(mask) = parts[1].parse::() { - if mask > 32 { - return None; - } - - let ip_bits: u32 = u32::from(ip); - let mask_bits: u32 = !0u32 << (32 - mask); - let network = Ipv4Addr::from(ip_bits & mask_bits); - - return Some(format!("{}/{}", network, mask)); - } - } - - None -} -/// Ermittelt die IP-Adresse zu einer gegebenen MAC-Adresse im lokalen Netzwerk. -/// Verwendet einen nmap-Scan, um das Gerät zu finden. -/// -/// # Parameter -/// - `mac`: MAC-Adresse im Format "xx:xx:xx:xx:xx:xx" -/// - `network`: Netzwerkadresse im Format "xxx.xxx.xxx.xxx/yy" -/// -/// # Rückgabe -/// - Option: Die IP-Adresse des Geräts oder None wenn nicht gefunden -pub fn get_ip_from_mac(mac: &str, network: &str) -> Option { - #[cfg(any(target_os = "linux", target_os = "windows"))] - { - let output = Command::new("nmap") - .args(["-sn", "-n", "--system-dns", "-PR", network]) - .output(); - - match output { - Ok(out) => { - if !out.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&out.stdout); - log("network_utils", &*stdout, LogLevel::Debug); - - // MAC normalisieren: nur Hex-Zeichen, klein, ohne Trennzeichen - let normalize_mac = |m: &str| -> String { - m.chars() - .filter(|c| c.is_ascii_hexdigit()) - .flat_map(|c| c.to_lowercase()) - .collect::() - }; - let target_mac = normalize_mac(mac); - - // Beispielausgaben: - // Nmap scan report for 192.168.0.10 - // Nmap scan report for printer.lan (192.168.0.10) - // MAC Address: 00:11:22:33:44:55 (Vendor) - // - // Strategie: - // - Merke die zuletzt gesehene IP aus "Nmap scan report for ..." - // - Wenn danach eine "MAC Address:"-Zeile folgt und MAC passt -> gib IP zurück - let mut current_ip: Option = None; - - for raw_line in stdout.lines() { - let line = raw_line.trim(); - - if line.starts_with("Nmap scan report for") { - // Versuche IP zu extrahieren: - // 1) Klammerform: ... (x.x.x.x) - // 2) Sonst letztes Token als IP - let ip = if let Some(start) = line.rfind('(') { - if let Some(end) = line.rfind(')') { - let in_parens = &line[start + 1..end]; - Some(in_parens.to_string()) - } else { - None - } - } else { - line.split_whitespace() - .last() - .map(|s| s.to_string()) - }; - - // Grobe Validierung IPv4 - if let Some(ip_str) = ip { - let maybe_ip = ip_str.parse::().ok().map(|ip| ip.to_string()); - current_ip = maybe_ip; - } else { - current_ip = None; - } - } else if line.starts_with("MAC Address:") { - // Format: "MAC Address: XX:XX:XX:XX:XX:XX (Vendor)" - let mac_part = line.strip_prefix("MAC Address:").unwrap().trim(); - let mac_token = mac_part.split_whitespace().next().unwrap_or(""); - let seen_mac = normalize_mac(mac_token); - - if !seen_mac.is_empty() && seen_mac == target_mac { - if let Some(ip) = current_ip.clone() { - return Some(ip); - } - } - } - } - - None - } - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - log::log("network_utils", "nmap is not installed on the system!", LogLevel::Error); - } - None - } - } - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - log::log("network_utils", "OS not supported.", LogLevel::Error); - None - } -} - -/// Ermittelt die MAC-Adresse zu einer gegebenen IP-Adresse im lokalen Netzwerk -/// durch Auslesen der ARP-Tabelle. -/// -/// # Parameter -/// - `ip`: IP-Adresse für die die MAC-Adresse ermittelt werden soll -/// -/// # Rückgabe -/// - Option: Die MAC-Adresse des Geräts oder None wenn nicht gefunden -pub fn get_mac_from_ip(ip: &str) -> Option { - // Hilfsfunktionen zur MAC-Normalisierung - let normalize_mac = |m: &str| -> String { - m.chars() - .filter(|c| c.is_ascii_hexdigit()) - .flat_map(|c| c.to_lowercase()) - .collect::() - }; - let canonicalize_mac = |hex_no_sep: &str| -> String { - hex_no_sep - .as_bytes() - .chunks(2) - .map(std::str::from_utf8) - .filter_map(Result::ok) - .collect::>() - .join(":") - }; - - #[cfg(target_os = "linux")] - { - // 1) Ziel kurz anpingen, damit ein ARP-Eintrag entsteht - let _ = Command::new("ping") - .args(["-c", "1", "-W", "1", ip]) - .output(); - - // 2) Bis zu drei Versuche, ARP/Neigh einzulesen (kleine Wartezeit) - for _ in 0..3 { - let output = Command::new("ip") - .args(["neigh", "show", ip]) - .output(); - - if let Ok(out) = output { - if out.status.success() { - let stdout = String::from_utf8_lossy(&out.stdout); - // Beispiel: 192.168.1.1 dev eth0 lladdr 00:11:22:33:44:55 REACHABLE - for line in stdout.lines() { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.get(0).copied() == Some(ip) { - if let Some(idx) = parts.iter().position(|p| *p == "lladdr") { - if let Some(mac_tok) = parts.get(idx + 1) { - let seen_norm = normalize_mac(mac_tok); - if seen_norm.len() == 12 { - return Some(canonicalize_mac(&seen_norm)); - } - } - } - } - } - } - } - - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - None - } - - #[cfg(target_os = "windows")] - { - // 1) Ziel kurz anpingen, damit ein ARP-Eintrag entsteht - let _ = Command::new("ping") - .args(["-n", "1", "-w", "500", ip]) - .output(); - - // 2) Bis zu drei Versuche, ARP einzulesen (kleine Wartezeit) - for _ in 0..3 { - // Hinweis: `arp -a` auf Windows zeigt die gesamte Tabelle; mit IP filtert es i. d. R. auf Interface, - // daher filtern wir inhaltlich auf die Zeile mit der Ziel-IP. - let output = Command::new("arp") - .args(["-a"]) - .output(); - - if let Ok(out) = output { - if out.status.success() { - let stdout = String::from_utf8_lossy(&out.stdout); - for line in stdout.lines() { - let cols: Vec<&str> = line.split_whitespace().collect(); - // Typisch: "192.168.1.1 00-11-22-33-44-55 dynamic" - if cols.get(0).copied() == Some(ip) && cols.len() >= 2 { - let mac_colon = cols[1].replace('-', ":"); - let seen_norm = normalize_mac(&mac_colon); - if seen_norm.len() == 12 { - return Some(canonicalize_mac(&seen_norm)); - } - } - } - } - } - - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - None - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - log::log("network_utils", "OS not supported.", LogLevel::Error); - None - } -} - -/// Sendet ein Wake-on-LAN Magic Packet an eine MAC-Adresse -/// -/// # Parameter -/// - `mac`: MAC-Adresse im Format "xx:xx:xx:xx:xx:xx" -/// -/// # Funktionsweise -/// Sendet ein Magic Packet bestehend aus: -/// - 6 Bytes 0xFF (Synchronisierung) -/// - 16x die MAC-Adresse wiederholt -/// - Port 9 (Standard Wake-on-LAN Port) -pub fn wake_on_land(mac: &str) { - #[cfg(target_os = "linux")] - { - let output = Command::new("wakeonlan") - .arg(mac) - .output(); - - if let Ok(out) = output { - if !out.status.success() { - log("network_utils", "Failed to send WOL packet!", LogLevel::Error); - } - } else { - log("network_utils", "wakeonlan is not installed on the system!", LogLevel::Error); - } - } - - #[cfg(target_os = "windows")] - { - let ps_cmd = format!( - r#" - $mac="{}" - $macByteArray=[byte[]]::new(102) - 6..101 | %{{ $macByteArray[$_]=0xFF }} - $macAddr=[byte[]]::new(6) - $mac -split "[:-]" | %{{$macAddr[$i]=[Convert]::ToByte($_,16); $i++}} - 6..101 | %{{ $macByteArray[$_]=$macAddr[($_ - 6) % 6] }} - $UdpClient=New-Object System.Net.Sockets.UdpClient - $UdpClient.Connect(([System.Net.IPAddress]::Broadcast),9) - $UdpClient.Send($macByteArray,$macByteArray.Length) - "#, - mac - ); - - let output = Command::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd]) - .output(); - - if let Err(_) = output { - log::log("network_utils", "Failed to send WOL packet!", LogLevel::Error); - } - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - log::log("network_utils", "OS not supported.", LogLevel::Error); - } -} diff --git a/src/program.rs b/src/program.rs deleted file mode 100644 index 8847199..0000000 --- a/src/program.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::env; - -/// Liefert den Programmnamen (Dateistamm der aktuellen ausführbaren Datei). -/// -/// Rückgabe: -/// - Dateistamm der aktuellen Executable als `String`. -/// - Fallback `"app"`, wenn der Name nicht ermittelt werden kann. -pub(crate) fn program_name() -> String { - env::current_exe() - .ok() - .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "app".to_string()) -} diff --git a/src/sudo.rs b/src/sudo.rs deleted file mode 100644 index 0485321..0000000 --- a/src/sudo.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::env; -use std::process::Command; - -/// Prüft, ob das Programm mit Root-/Administrator-Rechten ausgeführt wird. -/// -/// # Returns -/// -/// * `true` - Das Programm läuft mit erhöhten Rechten -/// * `false` - Das Programm läuft mit normalen Benutzerrechten -pub fn is_run_as_root() -> bool { - #[cfg(any(target_os = "windows"))] - { - // Windows: Prüfen ob Admin-Rechte vorhanden - Command::new("net") - .args(&["session"]) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) - } - - #[cfg(not(any(target_os = "windows")))] - { - // Linux/Unix: Prüfen ob Root oder sudo - unsafe { libc::geteuid() == 0 } - } -} -/// Startet das Programm mit Root-/Administrator-Rechten neu. -/// -/// Diese Funktion versucht das Programm mit erhöhten Rechten neu zu starten. -/// -/// # Details -/// -/// - Unter Linux wird das Programm mit `sudo` neu gestartet -/// - Unter Windows wird der UAC-Dialog zur Rechteanfrage angezeigt -pub fn run_as_root() { - #[cfg(any(target_os = "windows"))] - { - let commandline_args: Vec = env::args().collect(); - let program_path = commandline_args[0].clone(); - - Command::new("powershell") - .args(&[ - "Start-Process", - &program_path, - "-ArgumentList", - &commandline_args[1..].join(" "), - "-Verb", - "RunAs" - ]) - .exec(); - } - - #[cfg(not(any(target_os = "windows")))] - { - use std::os::unix::process::CommandExt; - - let commandline_args: Vec = env::args().collect(); - let _output = Command::new("sudo") - .args(&commandline_args) - .exec(); // Bye bye never returns - } -} \ No newline at end of file From b64b81a5c1403b42bd766043dbbf55d01f8b2eec Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 00:50:20 +0200 Subject: [PATCH 02/28] Feat: Kompletter Rewrite von SmartMount 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt die alte Single-Paar-Implementierung durch eine modulare Architektur, die beliebig viele lokale/Cloud-Laufwerkspaare verwaltet und automatisch zwischen ihnen umschaltet: - Dynamisch dispatchte Mount-Backends für WebDAV (davfs2), SMB/CIFS und NFS, inkl. NFS-"soft"-Resilienz-Defaults gegen unbegrenztes Hängen bei nicht erreichbaren Servern. - Verschlüsselte Zugangsdaten-Ablage in einer eingebetteten Turso-DB (AES-256-GCM), Master-Key aus OS-Keyring mit Datei-Fallback. - Lokal-Adressierung per IP oder MAC (Auflösung über das externe Tool `mac2ip`). - Symlink-basiertes Umschalten zwischen zwei eindeutigen Backing- Verzeichnissen pro Paar, statt zweier fstab-Zeilen auf denselben Mountpoint (siehe `mount/target.rs` für die Begründung). - Einmaliges root-Setup (`setup fstab`) für unprivilegierte User- Kontext-Mounts inkl. automatischer Verzeichnis-Ownership unter `/run/media`. - systemd-Units (System/User) mit automatischem Cron-Fallback für Systeme ohne systemd, inkl. sauberem Uninstall aller Artefakte. - `doctor`-Diagnose, Shell-Completions, JSON-Ausgabe für Skript-Automatisierung. - Alle Terminal-Ausgaben (Logs, Fehler, Prompts, --help) auf Englisch, Code-Kommentare und Doku weiterhin auf Deutsch. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FZ3VzCWgbQRMyFEEKPvnZz --- Cargo.lock | 3714 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 89 +- src/cli/doctor.rs | 50 + src/cli/drive.rs | 607 +++++++ src/cli/mod.rs | 100 ++ src/cli/mount_cmd.rs | 82 + src/cli/service.rs | 152 ++ src/cli/setup.rs | 21 + src/cli/status.rs | 98 ++ src/cli/watch.rs | 26 + src/config/mod.rs | 15 + src/config/pairs.rs | 51 + src/config/schema.rs | 221 +++ src/crypto/key.rs | 151 ++ src/crypto/mod.rs | 84 + src/db/credentials.rs | 140 ++ src/db/mod.rs | 53 + src/doctor.rs | 344 ++++ src/error.rs | 59 + src/fstab/mod.rs | 425 +++++ src/lib.rs | 16 + src/main.rs | 347 +--- src/mount/lock.rs | 31 + src/mount/mod.rs | 257 +++ src/mount/nfs.rs | 59 + src/mount/smb.rs | 147 ++ src/mount/state.rs | 76 + src/mount/target.rs | 444 +++++ src/mount/webdav.rs | 356 ++++ src/network/address.rs | 18 + src/network/mac2ip.rs | 107 ++ src/network/mod.rs | 30 + src/reconcile/mod.rs | 221 +++ src/systemd/mod.rs | 392 +++++ src/util.rs | 56 + 35 files changed, 8701 insertions(+), 338 deletions(-) create mode 100644 Cargo.lock create mode 100644 src/cli/doctor.rs create mode 100644 src/cli/drive.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/mount_cmd.rs create mode 100644 src/cli/service.rs create mode 100644 src/cli/setup.rs create mode 100644 src/cli/status.rs create mode 100644 src/cli/watch.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/pairs.rs create mode 100644 src/config/schema.rs create mode 100644 src/crypto/key.rs create mode 100644 src/crypto/mod.rs create mode 100644 src/db/credentials.rs create mode 100644 src/db/mod.rs create mode 100644 src/doctor.rs create mode 100644 src/error.rs create mode 100644 src/fstab/mod.rs create mode 100644 src/lib.rs create mode 100644 src/mount/lock.rs create mode 100644 src/mount/mod.rs create mode 100644 src/mount/nfs.rs create mode 100644 src/mount/smb.rs create mode 100644 src/mount/state.rs create mode 100644 src/mount/target.rs create mode 100644 src/mount/webdav.rs create mode 100644 src/network/address.rs create mode 100644 src/network/mac2ip.rs create mode 100644 src/network/mod.rs create mode 100644 src/reconcile/mod.rs create mode 100644 src/systemd/mod.rs create mode 100644 src/util.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..561d0f8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3714 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aegis" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58541132f980da31e9aa99f7bdee69bc84bf1e168b9b91ef2dbe8abb7b4ce5dd" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc 0.2.189", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "antithesis_sdk" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08410fcac93669a476c006cd6c4512ac1e2b30fd117231a5d55d8a2c76599b82" +dependencies = [ + "libc 0.2.189", + "libloading", + "linkme", + "once_cell", + "rand 0.8.8", + "rustc_version_runtime", + "serde", + "serde_json", +] + +[[package]] +name = "anyhow" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "aristo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdeefa800110050103e2459a2f8dbdbd560ad046e30f1f87e24ce19c2cb8bde" +dependencies = [ + "aristo-macros", +] + +[[package]] +name = "aristo-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "assoc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cfg_block" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc 0.2.189", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ab9a1a7094014a0d0813f8e7c5a0058ad8fe6cf7475b9151364259f5a35d0f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config-ctdra" +version = "1.0.6" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "83eb23d473fce79e8234ad66baf210289b5c9e4c4a587273b4df7deceb73f416" +dependencies = [ + "confy", + "program-ctdra", + "serde", + "sudo-ctdra", +] + +[[package]] +name = "confy" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8807c397789cbe02bbdb1a27ea5f345584132808697b2a3f957c829829ee4814" +dependencies = [ + "etcetera", + "lazy_static", + "serde", + "thiserror", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc 0.2.189", + "unicode-width 0.2.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc 0.2.189", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc 0.2.189", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encode_unicode" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc 0.2.189", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc 0.2.189", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc 0.2.189", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc 0.2.189", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc 0.2.189", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags", + "cfg-if", + "libc 0.2.189", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libc" +version = "1.0.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0f24f33af482526a4e3f9b47f0abb2c6377a1713c8aa4a8106994689a4cfa5" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "linkme" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "logger-ctdra" +version = "1.0.5" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "ce612b7d943b77060fa36eab0c85782e650c4cf023e9d560bef791951ed92cad" +dependencies = [ + "program-ctdra", + "time", +] + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc 0.2.189", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc 0.2.189", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "pack1" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc 0.2.189", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pin-project-lite" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "program-ctdra" +version = "1.0.1" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "528d5771916f74bdffb77ad60ea00c0ae4bd85f9a84e920cd65730a082133d67" + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc 0.2.189", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "roaring" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc 0.2.189", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc 0.2.189", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1_smol" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "shuttle" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab17edba38d63047f46780cf7360acf7467fec2c048928689a5c1dd1c2b4e31" +dependencies = [ + "assoc", + "bitvec", + "cfg-if", + "generator", + "hex", + "owo-colors", + "rand 0.8.8", + "rand_core 0.6.4", + "rand_pcg", + "scoped-tls", + "smallvec", + "tracing", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc 0.2.189", +] + +[[package]] +name = "simsimd" +version = "6.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" +dependencies = [ + "cc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "smart-mount" +version = "2.0.0" +dependencies = [ + "aes-gcm 0.11.1", + "anyhow", + "clap", + "clap_complete", + "config-ctdra", + "dialoguer", + "getrandom 0.4.3", + "keyring", + "logger-ctdra", + "program-ctdra", + "serde", + "serde_json", + "sudo-ctdra", + "tempfile", + "thiserror", + "tokio", + "toml 1.1.6+spec-1.1.0", + "turso", + "uuid", +] + +[[package]] +name = "softaes" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sudo-ctdra" +version = "1.0.1" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "20e576be60eb2050d475d0fbe46f0d09462ba9985ab55c7833e0c2e6d116d341" +dependencies = [ + "libc 1.0.0-alpha.4", +] + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc 0.2.189", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc 0.2.189", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "turso" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9491d7a80312c5abe66a4409e4dce02065503a235453c94b9e4133877e39ffc" +dependencies = [ + "mimalloc", + "thiserror", + "tracing", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sync_sdk_kit", +] + +[[package]] +name = "turso_core" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a833cc3bf8d4e6c101c504fa470f8ab4270c2202ff2591b61b2e373b4f20d9b" +dependencies = [ + "aegis", + "aes 0.8.4", + "aes-gcm 0.10.3", + "allocator-api2", + "antithesis_sdk", + "arc-swap", + "aristo", + "bigdecimal", + "bitflags", + "branches", + "bumpalo", + "bytemuck", + "cfg_aliases", + "cfg_block", + "chrono", + "crc32c", + "crossbeam-epoch", + "crossbeam-utils", + "either", + "fallible-iterator", + "fastbloom", + "hex", + "icu_collator", + "icu_locale", + "intrusive-collections", + "io-uring", + "libc 0.2.189", + "libloading", + "libm", + "loom", + "miette", + "num-bigint", + "num-traits", + "pack1", + "parking_lot", + "pastey", + "polling", + "rand 0.9.5", + "rapidhash", + "regex", + "regex-syntax", + "roaring", + "rustc-hash 2.1.3", + "rustix 1.1.4", + "ryu", + "serde_json", + "shuttle", + "simsimd", + "smallvec", + "strum", + "strum_macros", + "tempfile", + "thiserror", + "tracing", + "tracing-subscriber", + "turso_ext", + "turso_macros", + "turso_parser", + "twox-hash", + "uncased", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "turso_ext" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7452fe676450b6840e9eb80e512d14293265221154eba66aba3845dfc0d659f" +dependencies = [ + "chrono", + "getrandom 0.4.3", + "turso_macros", +] + +[[package]] +name = "turso_macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2825eaa6b7b893693636947f988e252c1196393fa54d4b85637c70d616d92fa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_parser" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e58d029f02e442df4be0b5036ce520b49c553505d9d52ef5a98fc4056e95b7" +dependencies = [ + "bitflags", + "memchr", + "miette", + "strum", + "strum_macros", + "thiserror", + "turso_macros", +] + +[[package]] +name = "turso_sdk_kit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18c1dc1c0304348c39b97bc6b27cdcb1d7292454ebd0de0f30b5ee3a4c61f9bb" +dependencies = [ + "bindgen", + "env_logger", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_ext", + "turso_sdk_kit_macros", +] + +[[package]] +name = "turso_sdk_kit_macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4708b5fe678b8730c85964e3d378f75e18c23c4409971360b2209f4e53e4956" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "turso_sync_engine" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8f03e430240d590d209ac874db52773b7382c627cd604a685affde77622b09" +dependencies = [ + "base64", + "bytes", + "crc32c", + "genawaiter", + "http", + "libc 0.2.189", + "prost", + "roaring", + "serde", + "serde_json", + "thiserror", + "tracing", + "turso_core", + "turso_parser", + "uuid", +] + +[[package]] +name = "turso_sync_sdk_kit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cb47d056c3ec567745761fa6f832358ca30ef9eb0435fdcfb77c558e70089" +dependencies = [ + "bindgen", + "env_logger", + "genawaiter", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sdk_kit_macros", + "turso_sync_engine", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" +dependencies = [ + "rand 0.10.2", +] + +[[package]] +name = "typenum" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +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", +] diff --git a/Cargo.toml b/Cargo.toml index f75168c..60dc93d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,25 +1,86 @@ [package] -name = "SmartMount" -version = "0.2.0" +name = "smart-mount" +version = "2.0.0" edition = "2024" authors = ['DragonSlayer_14'] readme = "README.md" -license-file = "LICENSE" -repository = "https://gitea.creative-dragonslayer.de/creative-dragonslayer/SmartMount" -description = "SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs." +license = "GPL-3.0-or-later" +repository = "https://gitea.creative-dragonslayer.de/Linuxapps/SmartMount" +description = "Bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und schaltet automatisch zwischen LAN und Cloud um" [dependencies] -time = { version="0.3.41", features = ["formatting", "macros", "local-offset"] } -serde = { version="1.0.219", features = ["derive"] } -confy = "1.0.0" -libc = "1.0.0-alpha.1" +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" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "fs", "time", "sync"] } +# WORKAROUND für RUSTSEC-2026-0253 (Use-after-free in lru < 0.18.2), siehe mac2ip Cargo.toml: +# das Default-Feature "fts" zieht tantivy -> lru "^0.16.3" (verwundbar) ein, obwohl +# smart-mount keine Volltextsuche nutzt. "fts" bleibt deaktiviert, bis eine stabile +# turso-Version tantivy>=eine lru>=0.18.2 zulassende Version pinnt. +turso = { version = "0.7", default-features = false, features = ["mimalloc"] } +serde = { version = "1", features = ["derive"] } +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 +# rand_core-Re-Export-Kette (siehe crypto/mod.rs, crypto/key.rs). +getrandom = "0.4" + +[dev-dependencies] +tempfile = "3" +toml = "1.1.6" [profile.release] debug = "none" -[package.metadata.deb] -section = "utils" -priority = "optional" +# --- Paketierungs-Metadaten für Linux-Distributionen --- -provides = ["smartmount"] -depends = ["nmap", "$auto"] \ No newline at end of file +[package.metadata.deb] +name = "smart-mount" +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" +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.\ +""" +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"], +] + +[package.metadata.generate-rpm] +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 }, +] +requires = { "mac2ip" = "*", "nmap" = "*" } +suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" } + +[package.metadata.arch] +pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben +arch = "x86_64" +depends = ["gcc-libs", "glibc", "mac2ip", "nmap"] +optdepends = [ + "davfs2: WebDAV-Laufwerke einbinden", + "cifs-utils: SMB/CIFS-Laufwerke einbinden", + "nfs-utils: NFS-Laufwerke einbinden", +] diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..952d570 --- /dev/null +++ b/src/cli/doctor.rs @@ -0,0 +1,50 @@ +//! `smart-mount doctor` - prüft die im Laufe der Entwicklung angesammelten Voraussetzungen +//! (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) gebündelt an einer Stelle. + +use smart_mount::config; +use smart_mount::doctor::{self, CheckStatus}; + +pub async fn run(json: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let results = doctor::run_checks(&cfg); + + if json { + #[derive(serde::Serialize)] + struct JsonResult<'a> { + label: &'a str, + status: &'static str, + detail: &'a str, + } + let json_results: Vec = results + .iter() + .map(|r| JsonResult { + label: &r.label, + status: match r.status { + CheckStatus::Ok => "ok", + CheckStatus::Warn => "warn", + CheckStatus::Fail => "fail", + }, + detail: &r.detail, + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_results)?); + } else { + for r in &results { + let symbol = match r.status { + CheckStatus::Ok => "[ok] ", + CheckStatus::Warn => "[warn]", + CheckStatus::Fail => "[FAIL]", + }; + println!("{symbol} {}: {}", r.label, r.detail); + } + } + + let failures = results + .iter() + .filter(|r| r.status == CheckStatus::Fail) + .count(); + if failures > 0 { + anyhow::bail!("{failures} problem(s) found"); + } + Ok(()) +} diff --git a/src/cli/drive.rs b/src/cli/drive.rs new file mode 100644 index 0000000..5a28d7f --- /dev/null +++ b/src/cli/drive.rs @@ -0,0 +1,607 @@ +//! `smart-mount drive add|edit|remove|list`. +//! +//! `add`/`edit` unterstützen zwei Modi: interaktiv (Standard, `dialoguer`-Prompts, mit +//! bereits per Flag/`edit` vorhandenen Werten als Vorbelegung) und nicht-interaktiv +//! (`--non-interactive`, für Skripte/Automatisierung - fehlende Pflichtfelder sind dann ein +//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde). + +use std::net::Ipv4Addr; +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, +}; +use smart_mount::db::credentials::{Credential, CredentialStore, Side}; + +#[derive(Subcommand)] +pub enum DriveAction { + /// Create a new drive pair (interactive, unless all fields are given via flags). + Add(DriveArgs), + /// Edit an existing drive pair - only the given fields change. + Edit { + id: String, + #[command(flatten)] + args: DriveArgs, + }, + /// Remove an existing drive pair. + Remove { id: String }, + /// List all configured drive pairs. + List { + /// Output as JSON instead of a table - for scripts. + #[arg(long)] + json: bool, + }, +} + +#[derive(Args, Default)] +pub struct DriveArgs { + #[arg(long)] + name: Option, + #[arg(long, value_enum)] + context: Option, + #[arg(long)] + owner_user: Option, + + #[arg(long, value_enum)] + local_kind: Option, + #[arg(long, conflicts_with = "local_mac")] + local_ip: Option, + #[arg(long, conflicts_with = "local_ip")] + local_mac: Option, + #[arg(long)] + local_share: Option, + #[arg(long)] + local_username: Option, + /// Insecure (visible in process listing/shell history) - prefer + /// `--local-password-stdin` for scripts. + #[arg(long, conflicts_with = "local_password_stdin")] + local_password: Option, + /// Reads the local password as one line from stdin instead of passing it as an argument. + #[arg(long)] + local_password_stdin: bool, + + #[arg(long, value_enum)] + cloud_kind: Option, + #[arg(long)] + cloud_host: Option, + #[arg(long)] + cloud_share: Option, + #[arg(long)] + cloud_username: Option, + /// Insecure (visible in process listing/shell history) - prefer + /// `--cloud-password-stdin` for scripts. + #[arg(long, conflicts_with = "cloud_password_stdin")] + cloud_password: Option, + /// Reads the cloud password as one line from stdin instead of passing it as an argument. + #[arg(long)] + cloud_password_stdin: bool, + + /// Does not prompt for anything interactively - missing required fields cause an error + /// instead of a prompt (which would fail anyway without a terminal). For scripts/automation. + #[arg(long)] + non_interactive: bool, +} + +pub async fn run(action: DriveAction) -> anyhow::Result<()> { + match action { + DriveAction::Add(args) => add(args).await, + DriveAction::Edit { id, args } => edit(&id, args).await, + DriveAction::Remove { id } => remove(&id).await, + DriveAction::List { json } => list(json).await, + } +} + +async fn list(json: bool) -> anyhow::Result<()> { + let cfg: AppConfig = config::pairs::load()?; + + if json { + println!("{}", serde_json::to_string_pretty(&cfg.pairs)?); + return Ok(()); + } + + if cfg.pairs.is_empty() { + println!("No drive pairs configured."); + return Ok(()); + } + for pair in &cfg.pairs { + println!( + "{} \"{}\" [{:?}] lokal={} cloud={} -> {}", + pair.id, + pair.name, + pair.context, + pair.local.kind.as_str(), + pair.cloud.kind.as_str(), + pair.mount_point.display() + ); + } + Ok(()) +} + +async fn remove(id: &str) -> anyhow::Result<()> { + // 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. + let cfg = config::pairs::load()?; + let pair = config::pairs::find_pair(&cfg, id)?; + + config::pairs::remove_pair(id)?; + let creds = CredentialStore::open().await?; + creds.delete(id, None).await?; + smart_mount::mount::cleanup_credentials(&pair, &cfg.settings); + + println!("Drive pair '{id}' removed."); + Ok(()) +} + +async fn add(args: DriveArgs) -> anyhow::Result<()> { + 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)?; + + 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)?; + let local_share = resolve_field(args.local_share, None, "Local share/export path", ni, true)? + .expect("required"); + let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?; + let (local_username, local_password) = resolve_credentials( + local_kind, + args.local_username, + local_password_flag, + None, + ni, + "Local credentials", + )?; + + let cloud_kind = resolve_kind(args.cloud_kind, None, "Cloud mount type", ni)?; + let cloud_host = resolve_field(args.cloud_host, None, "Cloud server address/URL", ni, true)? + .expect("required"); + let cloud_share = resolve_field(args.cloud_share, None, "Cloud share/export path", ni, true)? + .expect("required"); + let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?; + let (cloud_username, cloud_password) = resolve_credentials( + cloud_kind, + args.cloud_username, + cloud_password_flag, + None, + ni, + "Cloud credentials", + )?; + + let id = uuid::Uuid::new_v4().to_string(); + let settings = config::pairs::load() + .map(|c| c.settings) + .unwrap_or_default(); + let mount_point = settings.mount_base_dir.join(&id); + + let pair = DrivePair { + id: id.clone(), + name, + enabled: true, + context, + owner_user, + mount_point, + local: LocalSide { + kind: local_kind, + address: local_address, + share: local_share, + username: local_username.clone(), + extra_options: vec![], + }, + cloud: CloudSide { + kind: cloud_kind, + host_or_url: cloud_host, + share: cloud_share, + username: cloud_username.clone(), + extra_options: vec![], + }, + }; + + config::pairs::add_pair(pair)?; + + let creds = CredentialStore::open().await?; + if let Some(pw) = local_password { + creds + .put(&id, Side::Local, local_username.as_deref(), None, &pw) + .await?; + } + if let Some(pw) = cloud_password { + creds + .put(&id, Side::Cloud, cloud_username.as_deref(), None, &pw) + .await?; + } + + 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<()> { + 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 local_kind = resolve_kind( + args.local_kind, + Some(existing.local.kind), + "Local mount type", + ni, + )?; + let local_address = resolve_local_address( + args.local_ip, + args.local_mac, + Some(&existing.local.address), + ni, + )?; + let local_share = resolve_field( + args.local_share, + Some(&existing.local.share), + "Local share/export path", + ni, + true, + )? + .expect("required"); + + let creds = CredentialStore::open().await?; + let current_local_cred = creds.get(id, Side::Local).await?; + let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?; + let (local_username, local_password) = resolve_credentials( + local_kind, + args.local_username, + local_password_flag, + current_local_cred.as_ref(), + ni, + "Local credentials", + )?; + + let cloud_kind = resolve_kind( + args.cloud_kind, + Some(existing.cloud.kind), + "Cloud mount type", + ni, + )?; + let cloud_host = resolve_field( + args.cloud_host, + Some(&existing.cloud.host_or_url), + "Cloud server address/URL", + ni, + true, + )? + .expect("required"); + let cloud_share = resolve_field( + args.cloud_share, + Some(&existing.cloud.share), + "Cloud share/export path", + ni, + true, + )? + .expect("required"); + let current_cloud_cred = creds.get(id, Side::Cloud).await?; + let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?; + let (cloud_username, cloud_password) = resolve_credentials( + cloud_kind, + args.cloud_username, + cloud_password_flag, + current_cloud_cred.as_ref(), + ni, + "Cloud credentials", + )?; + + 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(), + local: LocalSide { + kind: local_kind, + address: local_address, + share: local_share, + username: local_username.clone(), + extra_options: existing.local.extra_options.clone(), + }, + cloud: CloudSide { + kind: cloud_kind, + host_or_url: cloud_host, + share: cloud_share, + username: cloud_username.clone(), + extra_options: existing.cloud.extra_options.clone(), + }, + }; + + config::pairs::update_pair(updated)?; + + if let Some(pw) = local_password { + creds + .put(id, Side::Local, local_username.as_deref(), None, &pw) + .await?; + } + if let Some(pw) = cloud_password { + creds + .put(id, Side::Cloud, cloud_username.as_deref(), None, &pw) + .await?; + } + + println!("Drive pair '{id}' updated."); + Ok(()) +} + +/// Liest ein Passwort entweder von stdin (eine Zeile, `\r`/`\n` abgeschnitten) oder gibt das +/// per Flag übergebene zurück. +fn read_password_flag(flag: Option, stdin: bool) -> anyhow::Result> { + if stdin { + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + Ok(Some(buf.trim_end_matches(['\n', '\r']).to_string())) + } else { + Ok(flag) + } +} + +/// Löst ein einzelnes String-Feld auf: Flag > (nicht-interaktiv: `current`, sonst Fehler bei +/// Pflichtfeld) > interaktiver Prompt (vorbelegt mit `current`, falls vorhanden). +fn resolve_field( + flag: Option, + current: Option<&str>, + label: &str, + non_interactive: bool, + required: bool, +) -> anyhow::Result> { + if let Some(v) = flag { + return Ok(Some(v)); + } + if non_interactive { + if required && current.is_none() { + anyhow::bail!( + "Field '{label}' is missing - specify it via a flag in non-interactive mode." + ); + } + return Ok(current.map(str::to_string)); + } + let mut input = Input::::new(); + input = input.with_prompt(label); + if let Some(d) = current { + input = input.default(d.to_string()); + } + Ok(Some(input.interact_text()?)) +} + +fn resolve_context( + flag: Option, + current: Option, + non_interactive: bool, +) -> anyhow::Result { + if let Some(c) = flag { + return Ok(c); + } + 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." + ); + } + 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 + }) +} + +fn resolve_owner_user( + flag: Option, + context: MountContext, + current: Option<&str>, + non_interactive: bool, +) -> anyhow::Result> { + 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)); + } + 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()) + .interact()?; + if !want_owner { + return Ok(None); + } + let mut input = Input::::new().with_prompt("Linux username"); + if let Some(c) = current { + input = input.default(c.to_string()); + } + Ok(Some(input.interact_text()?)) + } + } +} + +fn resolve_kind( + flag: Option, + current: Option, + label: &str, + non_interactive: bool, +) -> anyhow::Result { + if let Some(k) = flag { + return Ok(k); + } + if let Some(k) = current + && non_interactive + { + return Ok(k); + } + if non_interactive { + anyhow::bail!( + "Field '{label}' is missing - specify it via a flag in non-interactive mode." + ); + } + let default_idx = match current { + Some(MountKind::Smb) => 1, + Some(MountKind::Nfs) => 2, + _ => 0, + }; + let idx = Select::new() + .with_prompt(label) + .items(["WebDAV", "SMB/CIFS", "NFS"]) + .default(default_idx) + .interact()?; + Ok(match idx { + 0 => MountKind::WebDav, + 1 => MountKind::Smb, + _ => MountKind::Nfs, + }) +} + +fn resolve_local_address( + ip_flag: Option, + mac_flag: Option, + current: Option<&LocalAddress>, + non_interactive: bool, +) -> anyhow::Result { + if let Some(ip) = ip_flag { + return Ok(LocalAddress::Ip(ip)); + } + if let Some(mac) = mac_flag { + return Ok(LocalAddress::Mac(mac)); + } + if non_interactive { + return current.cloned().ok_or_else(|| { + anyhow::anyhow!("Local address is missing - specify '--local-ip' or '--local-mac'.") + }); + } + + let default_idx = usize::from(matches!(current, Some(LocalAddress::Mac(_)))); + let idx = Select::new() + .with_prompt("Local addressing") + .items(["IP address", "MAC address (resolved via mac2ip)"]) + .default(default_idx) + .interact()?; + + if idx == 0 { + let mut input = Input::::new().with_prompt("IP address"); + if let Some(LocalAddress::Ip(ip)) = current { + input = input.default(ip.to_string()); + } + let ip_str: String = input.interact_text()?; + Ok(LocalAddress::Ip(Ipv4Addr::from_str(&ip_str)?)) + } else { + let mut input = Input::::new().with_prompt("MAC address (e.g. aa:bb:cc:dd:ee:ff)"); + if let Some(LocalAddress::Mac(mac)) = current { + input = input.default(mac.clone()); + } + Ok(LocalAddress::Mac(input.interact_text()?)) + } +} + +/// Löst Nutzername/Passwort für eine Seite auf. +/// +/// - Explizit per Flag/stdin gegebenes Passwort wird immer übernommen. +/// - Nicht-interaktiv ohne neues Passwort: nur der Nutzername wird ggf. aktualisiert, das +/// gespeicherte Passwort bleibt unangetastet (`None` im Rückgabewert = "nicht ändern"). +/// - Interaktiv beim Bearbeiten (`current.is_some()`): fragt separat, ob das Passwort +/// überhaupt geändert werden soll (Standard: nein) - ein Edit erzwingt keine Neueingabe. +/// - Interaktiv beim Anlegen: fragt Nutzername+Passwort zusammen ab, sofern der Mount-Typ +/// Zugangsdaten braucht (WebDAV/SMB immer, NFS nur auf Wunsch für Kerberos). +fn resolve_credentials( + kind: MountKind, + username_flag: Option, + password_flag: Option, + current: Option<&Credential>, + non_interactive: bool, + label: &str, +) -> anyhow::Result<(Option, Option)> { + let current_username = current.and_then(|c| c.username.clone()); + + if let Some(pw) = password_flag { + let username = username_flag.or(current_username); + return Ok((username, Some(pw))); + } + + if non_interactive { + return Ok((username_flag.or(current_username), None)); + } + + let is_edit = current.is_some(); + let needs = match kind { + MountKind::WebDav | MountKind::Smb => true, + MountKind::Nfs => Confirm::new() + .with_prompt(format!("{label}: Kerberos credentials for NFS?")) + .default(is_edit) + .interact()?, + }; + if !needs { + return Ok((username_flag.or(current_username), None)); + } + + let mut username_input = Input::::new().with_prompt(format!("{label}: username")); + if let Some(u) = username_flag.or(current_username) { + username_input = username_input.default(u); + } + let username: String = username_input.interact_text()?; + + if is_edit { + let change_password = Confirm::new() + .with_prompt(format!("{label}: change password?")) + .default(false) + .interact()?; + if !change_password { + return Ok((Some(username), None)); + } + } + + let password: String = Password::new() + .with_prompt(format!("{label}: password")) + .with_confirmation("Confirm password", "Passwords do not match") + .interact()?; + Ok((Some(username), Some(password))) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..73ca3dd --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,100 @@ +//! `clap`-CLI-Definition und Dispatch. + +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; + +#[derive(Parser)] +#[command( + name = "smart-mount", + version, + about = "Dynamically mounts local/cloud drive pairs and switches between them automatically" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Manage drive pairs (add/edit/remove/list). + Drive { + #[command(subcommand)] + action: Box, + }, + /// Mount configured drive pairs. + Mount { + /// Only mount this pair (ID or name). + #[arg(long)] + name: Option, + /// Mount all configured pairs. + #[arg(long)] + all: bool, + }, + /// Unmount configured drive pairs. + Unmount { + #[arg(long)] + name: Option, + #[arg(long)] + all: bool, + }, + /// Shows the current mount status. + Status { + #[arg(long)] + name: Option, + /// Output as JSON instead of text - for scripts. + #[arg(long)] + json: bool, + }, + /// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron. + Watch, + /// Install/remove systemd units. + 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). + Doctor { + /// Output as JSON instead of text - for scripts. + #[arg(long)] + json: bool, + }, + /// Prints a shell completion script, e.g.: + /// `smart-mount completions bash > /etc/bash_completion.d/smart-mount`. + Completions { shell: Shell }, +} + +/// Führt das per `Cli` geparste Subcommand aus. +pub async fn dispatch(cli: Cli) -> anyhow::Result<()> { + match cli.command { + Commands::Drive { action } => drive::run(*action).await, + Commands::Mount { name, all } => mount_cmd::run_mount(name, all).await, + Commands::Unmount { name, all } => mount_cmd::run_unmount(name, all).await, + 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( + shell, + &mut Cli::command(), + "smart-mount", + &mut std::io::stdout(), + ); + Ok(()) + } + } +} diff --git a/src/cli/mount_cmd.rs b/src/cli/mount_cmd.rs new file mode 100644 index 0000000..986baaa --- /dev/null +++ b/src/cli/mount_cmd.rs @@ -0,0 +1,82 @@ +//! `smart-mount mount` / `smart-mount unmount`. + +use smart_mount::config::{self, DrivePair, MountContext}; +use smart_mount::db::credentials::CredentialStore; +use smart_mount::reconcile; + +fn select_pairs( + cfg: &smart_mount::config::AppConfig, + name: Option<&str>, + all: bool, +) -> anyhow::Result> { + if let Some(name) = name { + return Ok(vec![config::pairs::find_pair(cfg, name)?]); + } + if !all { + anyhow::bail!("Please specify '--name ' 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) +} + +pub async fn run_mount(name: Option, all: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let pairs = select_pairs(&cfg, name.as_deref(), all)?; + if pairs.is_empty() { + println!("No matching drive pairs found."); + return Ok(()); + } + + let creds = CredentialStore::open().await?; + for pair in &pairs { + let outcome = reconcile::reconcile_pair(pair, &cfg.settings, &creds).await; + print_outcome(&outcome); + } + Ok(()) +} + +pub async fn run_unmount(name: Option, all: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let pairs = select_pairs(&cfg, name.as_deref(), all)?; + if pairs.is_empty() { + println!("No matching drive pairs found."); + return Ok(()); + } + + for pair in &pairs { + match reconcile::unmount_pair(pair, &cfg.settings).await { + Ok(_) => println!("{} ({}): unmounted", pair.name, pair.id), + Err(e) => println!("{} ({}): ERROR: {e}", pair.name, pair.id), + } + } + Ok(()) +} + +pub(crate) fn print_outcome(outcome: &reconcile::ReconcileOutcome) { + use reconcile::Action; + let action_str = match &outcome.action { + Action::NoOp => "no change".to_string(), + Action::MountedLocal => "mounted local".to_string(), + Action::MountedCloud => "mounted cloud".to_string(), + Action::SwitchedToLocal => "switched to local".to_string(), + Action::SwitchedToCloud => "switched to cloud".to_string(), + Action::Failed(e) => format!("ERROR: {e}"), + }; + println!("{} ({}): {action_str}", outcome.pair_name, outcome.pair_id); +} diff --git a/src/cli/service.rs b/src/cli/service.rs new file mode 100644 index 0000000..158b05e --- /dev/null +++ b/src/cli/service.rs @@ -0,0 +1,152 @@ +//! `smart-mount service install|uninstall --system|--user`. + +use clap::{Args, Subcommand}; +use smart_mount::config; +use smart_mount::fstab; +use smart_mount::systemd::{self, Scope}; + +#[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 { + 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'."), + } + } +} + +pub fn run(action: ServiceAction) -> anyhow::Result<()> { + 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)." + ); + } + 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)? { + 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" + } + ); + print!("{}", systemd::crontab_equivalent(interval)); + } + } + Ok(()) + } + } +} diff --git a/src/cli/setup.rs b/src/cli/setup.rs new file mode 100644 index 0000000..4a47bdb --- /dev/null +++ b/src/cli/setup.rs @@ -0,0 +1,21 @@ +//! `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(()) + } + } +} diff --git a/src/cli/status.rs b/src/cli/status.rs new file mode 100644 index 0000000..bd911be --- /dev/null +++ b/src/cli/status.rs @@ -0,0 +1,98 @@ +//! `smart-mount status`. + +use serde::Serialize; + +use smart_mount::config; +use smart_mount::db::credentials::Side; +use smart_mount::mount::target; +use smart_mount::network; + +#[derive(Serialize)] +struct PairStatus { + id: String, + name: String, + mount_point: String, + active: Option<&'static str>, + local_source: String, + local_reachable: bool, + cloud_source: String, + cloud_reachable: bool, +} + +pub async fn run(name: Option, json: bool) -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let pairs = match &name { + Some(name) => vec![config::pairs::find_pair(&cfg, name)?], + None => cfg.pairs.clone(), + }; + + if pairs.is_empty() { + if json { + println!("[]"); + } else { + println!("No drive pairs configured."); + } + return Ok(()); + } + + let statuses: Vec = pairs + .iter() + .map(|pair| { + let active = match target::active_side(pair) { + Some(Side::Local) => Some("local"), + Some(Side::Cloud) => Some("cloud"), + None => None, + }; + PairStatus { + id: pair.id.clone(), + name: pair.name.clone(), + mount_point: pair.mount_point.display().to_string(), + active, + local_source: target::local_source(&pair.local, &cfg.settings), + local_reachable: network::is_reachable(&local_src_host(&pair.local, &cfg.settings)), + cloud_source: target::cloud_source(&pair.cloud), + cloud_reachable: network::is_reachable(&pair.cloud.host_or_url), + } + }) + .collect(); + + if json { + println!("{}", serde_json::to_string_pretty(&statuses)?); + return Ok(()); + } + + for status in &statuses { + println!("{} ({})", status.name, status.id); + println!(" Symlink: {}", status.mount_point); + println!(" Mounted: {}", status.active.unwrap_or("not mounted")); + println!( + " Local: {} [{}]", + status.local_source, + reachable_str(status.local_reachable) + ); + println!( + " Cloud: {} [{}]", + status.cloud_source, + reachable_str(status.cloud_reachable) + ); + } + + Ok(()) +} + +fn reachable_str(reachable: bool) -> &'static str { + if reachable { + "reachable" + } else { + "unreachable" + } +} + +fn local_src_host( + local: &smart_mount::config::LocalSide, + settings: &smart_mount::config::GlobalSettings, +) -> String { + smart_mount::network::address::resolve_ip(&local.address, settings) + .map(|ip| ip.to_string()) + .unwrap_or_else(|_| "unresolved".to_string()) +} diff --git a/src/cli/watch.rs b/src/cli/watch.rs new file mode 100644 index 0000000..39dea80 --- /dev/null +++ b/src/cli/watch.rs @@ -0,0 +1,26 @@ +//! `smart-mount watch` - ein einzelner Reconcile-Durchlauf, gedacht für systemd-Timer/Cron. + +use smart_mount::config; +use smart_mount::db::credentials::CredentialStore; +use smart_mount::reconcile::{self, Action}; + +use crate::cli::mount_cmd::print_outcome; + +pub async fn run() -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let creds = CredentialStore::open().await?; + let outcomes = reconcile::watch_once(&cfg, &creds).await; + + let mut had_failure = false; + for outcome in &outcomes { + print_outcome(outcome); + if matches!(outcome.action, Action::Failed(_)) { + had_failure = true; + } + } + + if had_failure { + anyhow::bail!("at least one drive pair could not be reconciled"); + } + Ok(()) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..692ecfd --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,15 @@ +//! Konfigurationsverwaltung: Schema + CRUD auf Laufwerkspaaren, aufbauend auf `config-ctdra`. + +pub mod pairs; +pub mod schema; + +pub use schema::{ + AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountContext, + MountKind, +}; + +/// Initialisiert den Konfigurationsdateinamen bei `config-ctdra`. Muss vor dem ersten +/// `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter Zustand). +pub fn init() { + config_ctdra::set_config_name("config"); +} diff --git a/src/config/pairs.rs b/src/config/pairs.rs new file mode 100644 index 0000000..a8c902d --- /dev/null +++ b/src/config/pairs.rs @@ -0,0 +1,51 @@ +//! CRUD-Operationen auf [`AppConfig::pairs`], atomar über `config_ctdra::modify`. + +use crate::config::schema::{AppConfig, DrivePair}; +use crate::error::{Error, Result}; + +/// Lädt die aktuelle Konfiguration frisch von der Platte. +pub fn load() -> Result { + Ok(config_ctdra::load::()?) +} + +/// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt). +pub fn add_pair(pair: DrivePair) -> Result { + Ok(config_ctdra::modify::(|cfg| { + cfg.pairs.push(pair.clone()); + })?) +} + +/// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`). +pub fn update_pair(pair: DrivePair) -> Result { + let id = pair.id.clone(); + let updated = config_ctdra::modify::(move |cfg| { + if let Some(existing) = cfg.pairs.iter_mut().find(|p| p.id == pair.id) { + *existing = pair.clone(); + } + })?; + if !updated.pairs.iter().any(|p| p.id == id) { + return Err(Error::PairNotFound(id)); + } + Ok(updated) +} + +/// Entfernt ein Laufwerkspaar per ID. +pub fn remove_pair(id: &str) -> Result { + let before_len = load()?.pairs.len(); + let updated = config_ctdra::modify::(|cfg| { + cfg.pairs.retain(|p| p.id != id); + })?; + if updated.pairs.len() == before_len { + return Err(Error::PairNotFound(id.to_string())); + } + Ok(updated) +} + +/// Sucht ein Laufwerkspaar per ID oder (fallback) exaktem Namen. +pub fn find_pair(cfg: &AppConfig, id_or_name: &str) -> Result { + cfg.pairs + .iter() + .find(|p| p.id == id_or_name || p.name == id_or_name) + .cloned() + .ok_or_else(|| Error::PairNotFound(id_or_name.to_string())) +} diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..0b14b7f --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,221 @@ +//! Konfigurationsschema: globale Einstellungen + Liste konfigurierter Laufwerkspaare. +//! +//! Passwörter sind hier bewusst NICHT enthalten - sie leben verschlüsselt in der +//! Turso-Datenbank (siehe [`crate::db::credentials`]), niemals im Klartext in der TOML-Datei. + +use std::net::Ipv4Addr; +use std::path::PathBuf; + +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//smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich +/// nicht denselben Namensraum teilen. +fn default_mount_base_dir() -> PathBuf { + if sudo_ctdra::is_run_as_root() { + PathBuf::from("/run/media/smart-mount") + } else { + let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string()); + PathBuf::from("/run/media").join(user).join("smart-mount") + } +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_watch_interval_secs() -> u64 { + 120 +} + +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). +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct AppConfig { + #[serde(default)] + pub settings: GlobalSettings, + #[serde(default)] + pub pairs: Vec, +} + +/// Globale, paarunabhängige Einstellungen. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GlobalSettings { + #[serde(default = "default_mount_base_dir")] + 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. + #[serde(default = "default_watch_interval_secs")] + pub watch_interval_secs: u64, + /// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad). + #[serde(default = "default_mac2ip_binary")] + pub mac2ip_binary: String, +} + +impl Default for GlobalSettings { + fn default() -> Self { + Self { + mount_base_dir: default_mount_base_dir(), + log_level: default_log_level(), + watch_interval_secs: default_watch_interval_secs(), + mac2ip_binary: default_mac2ip_binary(), + } + } +} + +/// Ein konfiguriertes Laufwerkspaar: eine lokale (LAN) und eine Cloud-Seite, die dasselbe +/// logische Laufwerk repräsentieren. Nur eine Seite ist zu einem Zeitpunkt an `mount_point` +/// 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. + 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. + #[serde(default)] + pub owner_user: Option, + pub mount_point: PathBuf, + pub local: LocalSide, + pub cloud: CloudSide, +} + +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 { + pub kind: MountKind, + pub address: LocalAddress, + /// Freigabename/Exportpfad (SMB-Share, NFS-Export, WebDAV-Pfadsegment). + pub share: String, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub extra_options: Vec, +} + +/// Die Cloud-Seite eines Laufwerkspaars. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CloudSide { + pub kind: MountKind, + /// Volle URL (WebDAV) bzw. Server-Adresse (SMB/NFS). + pub host_or_url: String, + pub share: String, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub extra_options: Vec, +} + +/// Unterstützte Mount-Verfahren. Jede Variante wird dynamisch auf ein +/// [`crate::mount::MountBackend`] dispatcht - siehe dort für die "nur bei Bedarf laden"-Logik. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] +#[value(rename_all = "lowercase")] +pub enum MountKind { + WebDav, + Smb, + Nfs, +} + +impl MountKind { + pub fn as_str(&self) -> &'static str { + match self { + MountKind::WebDav => "webdav", + MountKind::Smb => "smb", + MountKind::Nfs => "nfs", + } + } +} + +/// Adressierung der lokalen Seite: entweder direkt per IP oder per MAC-Adresse, die zur +/// Laufzeit über `mac2ip` aufgelöst wird (siehe [`crate::network::mac2ip`]). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum LocalAddress { + Ip(Ipv4Addr), + Mac(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_config_round_trips_through_toml() { + let cfg = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![DrivePair { + 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 { + kind: MountKind::WebDav, + address: LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into()), + share: "/dav".into(), + username: Some("nasuser".into()), + extra_options: vec![], + }, + cloud: CloudSide { + kind: MountKind::WebDav, + host_or_url: "https://cloud.example.com/remote.php/dav/files/dragon".into(), + share: "/".into(), + username: Some("dragon".into()), + extra_options: vec![], + }, + }], + }; + + let toml_str = toml::to_string_pretty(&cfg).expect("serialize"); + let round_tripped: AppConfig = toml::from_str(&toml_str).expect("deserialize"); + + 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"), + } + } + + #[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)); + } + } +} diff --git a/src/crypto/key.rs b/src/crypto/key.rs new file mode 100644 index 0000000..016585c --- /dev/null +++ b/src/crypto/key.rs @@ -0,0 +1,151 @@ +//! 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. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +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_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. +pub fn resolve_master_key() -> Result<[u8; 32]> { + if sudo_ctdra::is_run_as_root() { + return file_key::load_or_create(&key_file_path()); + } + + match keyring_key::load_or_create() { + Ok(key) => Ok(key), + Err(reason) => { + logger_ctdra::warn( + "crypto", + &format!("OS keyring not available ({reason}), using key file"), + ); + file_key::load_or_create(&key_file_path()) + } + } +} + +fn key_file_path() -> PathBuf { + let config_path = config_ctdra::get_config_path(); + config_path + .parent() + .map(|dir| dir.join(KEY_FILE_NAME)) + .unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME)) +} + +mod file_key { + use super::*; + + pub fn load_or_create(path: &Path) -> Result<[u8; 32]> { + if path.exists() { + return read(path); + } + create(path) + } + + fn read(path: &Path) -> Result<[u8; 32]> { + let mut file = File::open(path).map_err(|e| Error::io(path, e))?; + let mut buf = Vec::with_capacity(KEY_LEN); + file.read_to_end(&mut buf).map_err(|e| Error::io(path, e))?; + if buf.len() != KEY_LEN { + return Err(Error::Crypto(format!( + "key file '{}' has unexpected length ({} instead of {KEY_LEN} bytes)", + path.display(), + buf.len() + ))); + } + let mut key = [0u8; KEY_LEN]; + key.copy_from_slice(&buf); + Ok(key) + } + + fn create(path: &Path) -> Result<[u8; 32]> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + } + + let mut key = [0u8; KEY_LEN]; + fill_random(&mut key)?; + + #[cfg(unix)] + let mut opts = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + #[cfg(not(unix))] + let mut opts = OpenOptions::new(); + + let mut file = opts + .write(true) + .create_new(true) + .open(path) + .map_err(|e| Error::io(path, e))?; + file.write_all(&key).map_err(|e| Error::io(path, e))?; + Ok(key) + } + + fn fill_random(buf: &mut [u8]) -> Result<()> { + ::getrandom::fill(buf) + .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()?; + entry + .set_password(&encode(&key)) + .map_err(|e| format!("Could not store key in keyring: {e}"))?; + 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) + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..a73d600 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,84 @@ +//! Verschlüsselung von Zugangsdaten vor der Ablage in der Turso-Datenbank. +//! +//! Turso hat aktuell keine produktionsreife eingebaute Verschlüsselung (nur ein +//! experimentelles, unauditiertes Feature) - Passwörter werden daher hier selbst mit +//! AES-256-GCM verschlüsselt, bevor sie als Ciphertext-BLOB in die DB geschrieben werden. + +pub mod key; + +use aes_gcm::aead::{Aead, KeyInit}; +// `aead::Nonce` ist über den AEAD-Algorithmus-Typ parametrisiert (löst intern +// `::NonceSize` auf) - anders als `aes_gcm::Nonce`, das direkt +// über die Array-Länge parametrisiert ist. Für `Nonce::` brauchen wir Ersteres. +use aes_gcm::aead::Nonce; +use aes_gcm::{Aes256Gcm, Key}; + +use crate::error::{Error, Result}; + +/// AES-GCM-Nonce-Länge in Byte (96 Bit, Standard für AES-256-GCM). +pub const NONCE_LEN: usize = 12; + +/// Verschlüsselt `plaintext` mit dem gegebenen 256-Bit-Schlüssel. +/// +/// Gibt `(ciphertext, nonce)` zurück - beide werden zusammen mit dem Datensatz gespeichert; +/// der Schlüssel selbst wird niemals in der Datenbank abgelegt. +/// +/// Erzeugt die Nonce bewusst über eine eigene, direkte `getrandom`-Abhängigkeit statt über +/// `aes_gcm::aead::rand_core::OsRng` - Letzteres ist seit aes-gcm 0.11 nicht mehr ohne +/// Weiteres erreichbar (rand_core 0.9s `OsRng` steckt hinter dem `os_rng`-Feature, das über +/// aes-gcms Re-Export-Kette nicht automatisch aktiviert wird). `getrandom::fill` ist +/// unabhängig davon stabil und genau für diesen Zweck gedacht. +pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec, Vec)> { + let cipher = Aes256Gcm::new(&Key::::from(*key)); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + getrandom::fill(&mut nonce_bytes) + .map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))?; + let nonce: Nonce = nonce_bytes.into(); + + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| Error::Crypto(format!("Encryption failed: {e}")))?; + Ok((ciphertext, nonce.to_vec())) +} + +/// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext. +pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result> { + if nonce.len() != NONCE_LEN { + return Err(Error::Crypto(format!( + "invalid nonce length: expected {NONCE_LEN}, got {}", + nonce.len() + ))); + } + let cipher = Aes256Gcm::new(&Key::::from(*key)); + let nonce: Nonce = Nonce::::try_from(nonce) + .map_err(|_| Error::Crypto("invalid nonce".to_string()))?; + cipher + .decrypt(&nonce, ciphertext) + .map_err(|e| Error::Crypto(format!("Decryption failed: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_round_trip() { + let key = [7u8; 32]; + let plaintext = b"correct horse battery staple"; + + let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt"); + assert_ne!(ciphertext, plaintext); + + let decrypted = decrypt(&ciphertext, &nonce, &key).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_fails_with_wrong_key() { + let key = [1u8; 32]; + let other_key = [2u8; 32]; + let (ciphertext, nonce) = encrypt(b"secret", &key).expect("encrypt"); + assert!(decrypt(&ciphertext, &nonce, &other_key).is_err()); + } +} diff --git a/src/db/credentials.rs b/src/db/credentials.rs new file mode 100644 index 0000000..8090314 --- /dev/null +++ b/src/db/credentials.rs @@ -0,0 +1,140 @@ +//! Verschlüsselte Zugangsdaten-CRUD auf der `credentials`-Tabelle. + +use crate::crypto; +use crate::crypto::key::resolve_master_key; +use crate::error::Result; + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Welche Seite eines Laufwerkspaars die Zugangsdaten betreffen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Local, + Cloud, +} + +impl Side { + pub fn as_str(&self) -> &'static str { + match self { + Side::Local => "local", + Side::Cloud => "cloud", + } + } +} + +/// Entschlüsselte Zugangsdaten für eine Seite eines Laufwerkspaars. +#[derive(Debug, Clone)] +pub struct Credential { + pub username: Option, + pub domain: Option, + pub password: String, +} + +/// Dünner Wrapper um die `credentials`-Tabelle; ver-/entschlüsselt transparent mit dem +/// per [`resolve_master_key`] ermittelten Schlüssel. +pub struct CredentialStore { + conn: turso::Connection, +} + +impl CredentialStore { + /// Öffnet die Datenbank und initialisiert das Schema bei Bedarf. + pub async fn open() -> Result { + Ok(Self { + conn: crate::db::open().await?, + }) + } + + /// Speichert (oder ersetzt) die Zugangsdaten für `pair_id`/`side`. + pub async fn put( + &self, + pair_id: &str, + side: Side, + username: Option<&str>, + domain: Option<&str>, + password: &str, + ) -> Result<()> { + let key = resolve_master_key()?; + let (ciphertext, nonce) = crypto::encrypt(password.as_bytes(), &key)?; + let now = now_unix(); + + self.conn + .execute( + "INSERT INTO credentials (pair_id, side, username, domain, ciphertext, nonce, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(pair_id, side) DO UPDATE SET \ + username = excluded.username, domain = excluded.domain, \ + ciphertext = excluded.ciphertext, nonce = excluded.nonce, updated_at = excluded.updated_at", + ( + pair_id.to_string(), + side.as_str().to_string(), + username.map(str::to_string), + domain.map(str::to_string), + ciphertext, + nonce, + now, + ), + ) + .await?; + Ok(()) + } + + /// Liest und entschlüsselt die Zugangsdaten für `pair_id`/`side`, falls vorhanden. + pub async fn get(&self, pair_id: &str, side: Side) -> Result> { + let mut rows = self + .conn + .query( + "SELECT username, domain, ciphertext, nonce FROM credentials WHERE pair_id = ?1 AND side = ?2", + (pair_id.to_string(), side.as_str().to_string()), + ) + .await?; + + let Some(row) = rows.next().await? else { + return Ok(None); + }; + + let username: Option = row.get(0)?; + let domain: Option = row.get(1)?; + let ciphertext: Vec = row.get(2)?; + let nonce: Vec = row.get(3)?; + + let key = resolve_master_key()?; + let plaintext = crypto::decrypt(&ciphertext, &nonce, &key)?; + let password = String::from_utf8(plaintext).map_err(|e| { + crate::error::Error::Crypto(format!("password is not valid UTF-8: {e}")) + })?; + + Ok(Some(Credential { + username, + domain, + password, + })) + } + + /// Löscht Zugangsdaten. `side = None` löscht beide Seiten (z. B. beim Entfernen eines Paars). + pub async fn delete(&self, pair_id: &str, side: Option) -> Result<()> { + match side { + Some(side) => { + self.conn + .execute( + "DELETE FROM credentials WHERE pair_id = ?1 AND side = ?2", + (pair_id.to_string(), side.as_str().to_string()), + ) + .await?; + } + None => { + self.conn + .execute( + "DELETE FROM credentials WHERE pair_id = ?1", + (pair_id.to_string(),), + ) + .await?; + } + } + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..db3a816 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,53 @@ +//! Lokale Turso-Datenbank für verschlüsselt gespeicherte Zugangsdaten. + +pub mod credentials; + +use std::path::PathBuf; + +use crate::error::{Error, Result}; + +const DB_FILE_NAME: &str = "smart-mount.db"; + +/// Pfad zur Datenbankdatei: dasselbe Verzeichnis wie die Konfigurationsdatei, folgt also +/// automatisch derselben Root-/User-Auflösung wie `config-ctdra`. +pub fn resolve_db_path() -> PathBuf { + let config_path = config_ctdra::get_config_path(); + config_path + .parent() + .map(|dir| dir.join(DB_FILE_NAME)) + .unwrap_or_else(|| PathBuf::from(DB_FILE_NAME)) +} + +/// Öffnet (und initialisiert bei Bedarf) die lokale Datenbank am aufgelösten Pfad. +pub async fn open() -> Result { + let path = resolve_db_path(); + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| Error::io(dir, e))?; + } + let db = turso::Builder::new_local(path.to_string_lossy().as_ref()) + .build() + .await?; + let conn = db.connect()?; + init_schema(&conn).await?; + Ok(conn) +} + +async fn init_schema(conn: &turso::Connection) -> Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS credentials (\ + pair_id TEXT NOT NULL, \ + side TEXT NOT NULL CHECK(side IN ('local','cloud')), \ + username TEXT, \ + domain TEXT, \ + ciphertext BLOB NOT NULL, \ + nonce BLOB NOT NULL, \ + updated_at INTEGER NOT NULL, \ + PRIMARY KEY (pair_id, side)\ + )", + (), + ) + .await?; + Ok(()) +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..a854ef0 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,344 @@ +//! 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. + +use std::collections::HashSet; +use std::process::Command; + +use crate::config::{AppConfig, DrivePair, LocalAddress, MountContext, MountKind}; +use crate::mount; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Ok, + Warn, + Fail, +} + +#[derive(Debug)] +pub struct CheckResult { + pub label: String, + pub status: CheckStatus, + pub detail: String, +} + +fn ok(label: impl Into, detail: impl Into) -> CheckResult { + CheckResult { + label: label.into(), + status: CheckStatus::Ok, + detail: detail.into(), + } +} +fn warn(label: impl Into, detail: impl Into) -> CheckResult { + CheckResult { + label: label.into(), + status: CheckStatus::Warn, + detail: detail.into(), + } +} +fn fail(label: impl Into, detail: impl Into) -> CheckResult { + CheckResult { + label: label.into(), + status: CheckStatus::Fail, + detail: detail.into(), + } +} + +/// Führt alle Checks gegen die aktuelle Konfiguration aus. +pub fn run_checks(cfg: &AppConfig) -> Vec { + let mut results = Vec::new(); + + results.push(check_timeout_binary()); + results.push(check_scheduler()); + results.push(check_mount_base_dir(&cfg.settings.mount_base_dir)); + + let used_kinds = used_mount_kinds(cfg); + for kind in [MountKind::WebDav, MountKind::Smb, MountKind::Nfs] { + if used_kinds.contains(&kind) { + results.push(check_backend(kind)); + } + } + + if uses_mac_addressing(cfg) { + results.push(check_binary( + "mac2ip", + &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 +} + +fn used_mount_kinds(cfg: &AppConfig) -> HashSet { + let mut set = HashSet::new(); + for pair in &cfg.pairs { + set.insert(pair.local.kind); + set.insert(pair.cloud.kind); + } + set +} + +fn uses_mac_addressing(cfg: &AppConfig) -> bool { + cfg.pairs + .iter() + .any(|p| matches!(p.local.address, LocalAddress::Mac(_))) +} + +fn check_timeout_binary() -> CheckResult { + if mount::binary_available("timeout") { + ok( + "timeout (coreutils)", + "found - protects mount/umount against hanging indefinitely", + ) + } else { + fail( + "timeout (coreutils)", + "not found - mount/umount calls would block indefinitely, should be present on every Linux system", + ) + } +} + +fn check_binary(name: &str, binary: &str, install_hint: &str) -> CheckResult { + if mount::binary_available(binary) { + ok(name, format!("'{binary}' found")) + } else { + fail(name, format!("'{binary}' not found - {install_hint}")) + } +} + +fn check_backend(kind: MountKind) -> CheckResult { + let backend = mount::backend_for(kind); + match backend.check_available() { + Ok(()) => ok(format!("Backend: {}", backend.name()), "available"), + Err(e) => fail(format!("Backend: {}", backend.name()), e.to_string()), + } +} + +fn check_scheduler() -> CheckResult { + let systemd = crate::systemd::is_available(); + let cron_d = std::path::Path::new("/etc/cron.d").is_dir(); + let crontab = mount::binary_available("crontab"); + + if systemd { + ok( + "Scheduler", + "systemd found - 'smart-mount service install' uses systemd timers", + ) + } else if cron_d || crontab { + warn( + "Scheduler", + "no systemd, but cron found - 'smart-mount service install' automatically falls back to cron", + ) + } else { + fail( + "Scheduler", + "neither systemd nor cron found - periodic 'watch' must be set up manually", + ) + } +} + +fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult { + if dir.is_dir() { + ok("mount_base_dir", format!("'{}' exists", dir.display())) + } else if dir.parent().is_some_and(std::path::Path::is_dir) { + warn( + "mount_base_dir", + format!( + "'{}' does not exist yet, will be created on first mount", + dir.display() + ), + ) + } else { + fail( + "mount_base_dir", + format!( + "'{}' does not exist and its parent directory is also missing", + dir.display() + ), + ) + } +} + +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 std::net::Ipv4Addr; + + fn sample_pair(context: MountContext, 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 { + kind: MountKind::Nfs, + address: if mac { + LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into()) + } else { + LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)) + }, + share: "share".into(), + username: None, + extra_options: vec![], + }, + cloud: CloudSide { + kind: MountKind::WebDav, + host_or_url: "https://cloud.example.com/dav".into(), + share: "/".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn used_mount_kinds_collects_both_sides_across_pairs() { + let cfg = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::System, None, false)], + }; + let kinds = used_mount_kinds(&cfg); + assert!(kinds.contains(&MountKind::Nfs)); + assert!(kinds.contains(&MountKind::WebDav)); + assert!(!kinds.contains(&MountKind::Smb)); + } + + #[test] + fn uses_mac_addressing_detects_mac_pairs() { + let with_mac = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::System, None, true)], + }; + let without_mac = AppConfig { + settings: GlobalSettings::default(), + pairs: vec![sample_pair(MountContext::System, 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(); + let results = run_checks(&cfg); + assert!(results.iter().any(|r| r.label.contains("timeout"))); + assert!(results.iter().any(|r| r.label == "Scheduler")); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..fedbb2f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,59 @@ +//! Zentraler Fehlertyp für alle Bibliotheksmodule. + +use std::path::PathBuf; + +/// Sammelfehler für alle `smart-mount`-Bibliotheksmodule. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Configuration error: {0}")] + Config(#[from] config_ctdra::ConfyError), + + #[error("Database error: {0}")] + Db(#[from] turso::Error), + + #[error("Encryption error: {0}")] + Crypto(String), + + #[error("Key storage error: {0}")] + Keyring(String), + + #[error("I/O error on '{path}': {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("Drive pair '{0}' not found")] + PairNotFound(String), + + #[error("Mount backend '{backend}' not available: {reason}")] + BackendUnavailable { + backend: &'static str, + reason: String, + }, + + #[error("Mount command failed ({context}): {stderr}")] + MountFailed { context: String, stderr: String }, + + #[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), +} + +/// Ergebnistyp-Alias für `smart-mount`-Bibliotheksfunktionen. +pub type Result = std::result::Result; + +impl Error { + pub fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } +} diff --git a/src/fstab/mod.rs b/src/fstab/mod.rs new file mode 100644 index 0000000..bd4d5e4 --- /dev/null +++ b/src/fstab/mod.rs @@ -0,0 +1,425 @@ +//! 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::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 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)?; + } + + write_managed_block(&user_pairs, &cfg.settings)?; + + 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 { + 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); + std::fs::write(&fstab_path, without_block).map_err(|e| Error::io(&fstab_path, e))?; + 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//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) -> 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 block = render_managed_block(pairs, settings)?; + + let new_contents = format!( + "{}\n{}\n{}\n{}\n", + without_block.trim_end(), + BEGIN_MARKER, + block.trim_end(), + END_MARKER + ); + std::fs::write(&fstab_path, new_contents).map_err(|e| Error::io(&fstab_path, e)) +} + +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(()) +} + +fn render_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result { + let mut lines = Vec::new(); + for pair in pairs { + lines.push(fstab_line(pair, Side::Local, settings)?); + lines.push(fstab_line(pair, Side::Cloud, settings)?); + } + Ok(lines.join("\n")) +} + +fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result { + // 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() + )) +} + +/// 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 = render_managed_block(&[&pair], &GlobalSettings::default()).expect("render"); + 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 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()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..70a86c2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +//! smart-mount: bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und +//! schaltet automatisch zwischen LAN und Cloud um. + +pub mod config; +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}; diff --git a/src/main.rs b/src/main.rs index cb7fbe4..f9238f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,335 +1,34 @@ -use crate::config::{Storage, get_config, modify_config}; -use crate::filesystem::credentials::save_credentials_webdav; -use crate::filesystem::mount::{mount, unmount}; -use crate::filesystem::mounted::{is_mounted, is_mounted_as}; -use crate::log::LogLevel; -use crate::log::log; -use crate::network::network_interface::{get_active_network_interface, get_interface_ip_address}; -use crate::network::utils::{ - get_ip_from_mac, get_mac_from_ip, get_network_address, is_reachable, wake_on_land, -}; -use crate::sudo::{is_run_as_root, run_as_root}; -use std::process::exit; -use std::thread; -use std::thread::{JoinHandle, sleep}; -use std::time::Duration; +mod cli; -mod config; -mod filesystem; -mod log; -mod network; -mod program; -mod sudo; +use std::process::ExitCode; -fn main() { - log( - "main", - "========== PROGRAM START ==========", - LogLevel::Info, - ); +use clap::Parser; - if !is_run_as_root() { - log( - "main", - "Program is not run as root. Trying to run as root...", - LogLevel::Warn, - ); - run_as_root(); - } +#[tokio::main] +async fn main() -> ExitCode { + smart_mount::config::init(); - let network_interface: String; + let log_level = match smart_mount::config::pairs::load() { + Ok(cfg) => cfg.settings.log_level, + Err(_) => "info".to_string(), + }; + logger_ctdra::set_log_level(parse_log_level(&log_level)); - let mut count: i32 = 0; - loop { - let interface_str: String = get_active_network_interface().unwrap().trim().to_string(); - - if !interface_str.is_empty() { - network_interface = interface_str; - break; - } else if count >= 10 { - log( - "main", - "Couldn't find active network card, exiting.", - LogLevel::Error, - ); - exit(1); - } - - log( - "main", - "No active network card found, waiting 1 second.", - LogLevel::Warn, - ); - count = count + 1; - sleep(Duration::from_secs(1)); - } - log( - "main", - &*format!("Active network interface found: {}", network_interface), - LogLevel::Info, - ); - - let interface_address = get_interface_ip_address(network_interface.as_str()) - .unwrap() - .trim() - .to_string(); - log( - "main", - &*format!("Interface address: {}", interface_address), - LogLevel::Info, - ); - - let network_address = get_network_address(interface_address.as_str()) - .unwrap() - .trim() - .to_string(); - log( - "main", - &*format!("Network address: {}", network_address), - LogLevel::Info, - ); - - count = 0; - loop { - if is_reachable(network_address.as_str()) { - break; - } else if count >= 10 { - log( - "main", - "Couldn't reach network address, exiting.", - LogLevel::Error, - ); - exit(1); - } - - log( - "main", - "Network address not reachable, waiting 1 second.", - LogLevel::Warn, - ); - count = count + 1; - sleep(Duration::from_secs(1)); - } - log("main", "Network address is reachable.", LogLevel::Info); - - let handle_local: JoinHandle<()> = thread::spawn(move || mount_local(network_address)); - let handle_remote: JoinHandle<()> = thread::spawn(mount_remote); - - handle_local.join().unwrap(); - handle_remote.join().unwrap(); - - log("main", "========== PROGRAM END ==========", LogLevel::Info); -} - -fn mount_local(network_address: String) { - log( - "main", - "Trying to mount filesystem locally...", - LogLevel::Info, - ); - - let mount_point: &str = get_config().general.mount_point.as_str(); - - let mac_address: &str = get_config().local.device_mac.as_str(); - let mount_type: &str = get_config().local.mount_type.as_str(); - - let mut device_address: Option = None; - - if get_config().storage.is_some() { - device_address = Some(get_config().storage.clone().unwrap().device_ip); - - if is_reachable(&device_address.clone().unwrap()) { - log( - "main", - format!( - "Searching mac for device address {}.", - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Info, - ); - let mac_of_ip = - get_mac_from_ip(device_address.clone().unwrap().as_str()).unwrap_or("".to_string()); - log( - "main", - format!( - "Found mac {} for device address {}.", - mac_of_ip, - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Info, - ); - - if mac_of_ip == mac_address { - log( - "main", - format!( - "Found device mac {} on saved ip {}.", - mac_address, - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Info, - ); - } else { - log( - "main", - format!( - "Device mac {} is not the same as {} of ip {}.", - mac_address, - mac_of_ip, - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Warn, - ); - device_address = None; - } - } else { - log( - "main", - format!( - "Device address {} is not reachable.", - device_address.clone().unwrap() - ) - .as_str(), - LogLevel::Warn, - ); - device_address = None; - } - } - - let mut count: i32 = 0; - - if device_address.is_none() { - loop { - device_address = get_ip_from_mac(mac_address, network_address.as_str()); - if device_address.is_some() || count >= 10 { - modify_config(|config| { - let storage = config.storage.get_or_insert(Storage::default()); - storage.device_ip = device_address.clone().unwrap(); - }); - - break; - } - - log( - "main", - "Couldn't find MAC adress in local network, sending awake call...", - LogLevel::Info, - ); - wake_on_land(mac_address); - - log("main", "Waiting 30 seconds...", LogLevel::Info); - count = count + 1; - sleep(Duration::from_secs(30)); - } - } - - if device_address.is_none() { - log( - "main", - "Couldn't find device address for MAC address.", - LogLevel::Warn, - ); - - if is_mounted_as(mount_point, mount_type) { - log( - "main", - "Filesystem is mounted locally. Unmounting...", - LogLevel::Info, - ); - unmount(mount_point); - } - - mount_remote(); - } else { - let dev_ip: String = device_address.unwrap(); - log( - "main", - "Found MAC address in local network.", - LogLevel::Info, - ); - - if !is_mounted_as(mount_point, mount_type) { - if is_mounted(mount_point) { - log( - "main", - "Filesystem is mounted. Unmounting...", - LogLevel::Info, - ); - unmount(mount_point); - } - - log("main", "Mounting local filesystem...", LogLevel::Info); - mount( - mount_point, - &*format!("{}:{}", dev_ip, get_config().local.mount_path), - mount_type, - ); - } else { - log( - "main", - "Filesystem is already mounted locally. Doing nothing.", - LogLevel::Info, - ); + let cli = cli::Cli::parse(); + match cli::dispatch(cli).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("Error: {e:#}"); + ExitCode::FAILURE } } } -fn mount_remote() { - let mount_point: &str = get_config().general.mount_point.as_str(); - let mount_type: &str = &get_config().remote.mount_type.as_str(); - - let mut could_reach: bool; - let mut count: i32 = 0; - - loop { - could_reach = is_reachable("https://ping.creative-dragonslayer.de"); - - if could_reach || count >= 10 { - break; - } - - log( - "main", - "Couldn't reach remote server, waiting 1 second.", - LogLevel::Warn, - ); - count = count + 1; - sleep(Duration::from_secs(1)); - } - - if could_reach { - log("main", "Remote server reachable.", LogLevel::Info); - - if mount_type == "davfs" || mount_type == "webdav" { - save_credentials_webdav(); - } - - if is_mounted_as(mount_point, get_config().local.mount_type.as_str()) - || is_mounted_as(mount_point, mount_type) - { - log( - "main", - "Filesystem is already mounted. Doing nothing.", - LogLevel::Info, - ); - } else { - if is_mounted(mount_point) { - log( - "main", - "Filesystem is already mounted. Unmounting...", - LogLevel::Info, - ); - unmount(mount_point); - } - - log("main", "Mounting remote filesystem...", LogLevel::Info); - mount(mount_point, &*get_config().remote.mount_path, mount_type); - } - } else { - log("main", "Remote server not reachable.", LogLevel::Warn); +fn parse_log_level(level: &str) -> logger_ctdra::LogLevel { + match level.to_lowercase().as_str() { + "error" => logger_ctdra::LogLevel::Error, + "warn" => logger_ctdra::LogLevel::Warn, + "debug" => logger_ctdra::LogLevel::Debug, + _ => logger_ctdra::LogLevel::Info, } } diff --git a/src/mount/lock.rs b/src/mount/lock.rs new file mode 100644 index 0000000..a78b549 --- /dev/null +++ b/src/mount/lock.rs @@ -0,0 +1,31 @@ +//! Pro-Paar-Mutex-Registry, damit ein manueller `mount --name X` nicht mit einem +//! gleichzeitig laufenden `watch` für dasselbe Paar kollidiert. +//! +//! Ersetzt den globalen `RwLock`+`Mutex` aus dem alten `src/filesystem/mount.rs` (v0.2.0): +//! dort war die Sperre prozessweit global, hier ist sie pro Laufwerkspaar - mehrere Paare +//! können also parallel gemountet werden, ohne sich gegenseitig zu blockieren. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; + +type Registry = Mutex>>>; + +fn registry() -> &'static Registry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Sperrt ein Laufwerkspaar für die Dauer des zurückgegebenen Guards. `tokio::sync::Mutex`s +/// `lock_owned()` erlaubt einen Guard, der seine eigene `Arc`-Referenz hält - keine +/// selbstreferenzielle Struktur/`unsafe` nötig. +pub async fn acquire(pair_id: &str) -> OwnedMutexGuard<()> { + let mutex = { + let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + reg.entry(pair_id.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() + }; + mutex.lock_owned().await +} diff --git a/src/mount/mod.rs b/src/mount/mod.rs new file mode 100644 index 0000000..c8fcdfc --- /dev/null +++ b/src/mount/mod.rs @@ -0,0 +1,257 @@ +//! Mount-Backends für WebDAV/SMB/NFS mit dynamischem Dispatch. +//! +//! "Dynamisch nachladen" bedeutet hier: Trait-Object-Dispatch je nach [`MountKind`], und +//! jedes Backend prüft sein benötigtes System-Binary (`mount.davfs`/`mount.cifs`/`mount.nfs`) +//! erst, wenn es tatsächlich benutzt wird ([`MountBackend::check_available`]) - ein reiner +//! WebDAV-Nutzer wird also nicht gezwungen, `cifs-utils`/`nfs-common` zu installieren. + +pub mod lock; +pub mod nfs; +pub mod smb; +pub mod state; +pub mod target; +pub mod webdav; + +use std::path::PathBuf; + +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 -o `. + Direct, + /// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount ` (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. +pub struct MountTarget { + pub pair_id: String, + /// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile + /// Dateinamen für Credentials-/Secrets-Dateien. + pub side: Side, + 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`. + pub options: Vec, + pub invocation: MountInvocation, + /// Für `MountContext::User`-Paare: der Linux-Benutzername, dem Zugangsdaten-/Secrets- + /// Dateien gehören sollen. + pub owner_user: Option, +} + +/// Gemeinsame Schnittstelle für WebDAV/SMB/NFS-Backends. +pub trait MountBackend: Send + Sync { + fn name(&self) -> &'static str; + + /// Prüft, ob das für dieses Backend nötige System-Binary vorhanden ist. Liefert bei + /// Fehlen einen Fehler mit dem Namen des nachzuinstallierenden Pakets. + fn check_available(&self) -> Result<()>; + + /// Schreibt/aktualisiert alles, was der eigentliche `mount`-Aufruf voraussetzt + /// (Credentials-/Secrets-Dateien, Config-Anpassungen wie davfs2s `gui_optimize`). + fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()>; + + fn mount(&self, target: &MountTarget) -> Result<()>; + fn unmount(&self, target: &MountTarget) -> Result<()>; +} + +/// Wählt das passende Backend für einen [`MountKind`] (Trait-Object-Dispatch). +pub fn backend_for(kind: MountKind) -> Box { + match kind { + MountKind::WebDav => Box::new(webdav::WebDavBackend), + MountKind::Smb => Box::new(smb::SmbBackend), + MountKind::Nfs => Box::new(nfs::NfsBackend), + } +} + +/// Timeout in Sekunden für `mount`/`umount`-Subprozesse (siehe [`run_tolerating_already_done`]). +/// Ein nicht mehr erreichbarer Server darf `smart-mount watch` niemals unbegrenzt blockieren - +/// z. B. ist ein `umount` auf einem davfs2-Mount laut davfs2-eigener FAQ absichtlich so lange +/// blockierend, bis alle zwischengespeicherten Daten geschrieben sind, was bei einem dauerhaft +/// unerreichbaren Server sonst nie zurückkehren würde. +pub(crate) const MOUNT_TIMEOUT_SECS: u64 = 30; + +/// Führt einen `mount`/`umount`-Subprozess mit einem externen Timeout aus (über das +/// coreutils-Tool `timeout`, auf jedem Linux-System vorhanden) und toleriert "bereits +/// eingebunden"/"busy"/"nicht eingebunden" als No-op statt als Fehler (portiert aus dem alten +/// `src/filesystem/mount.rs`, v0.2.0). +/// +/// `tolerate_timeout`: bei `umount`-Aufrufen (`true`) wird ein durch den Timeout abgebrochener +/// Versuch nur geloggt und als Erfolg gewertet - der nächste `watch`-Durchlauf versucht es +/// erneut (idempotent, `umount` toleriert bereits "nicht eingebunden"). Bei `mount`-Aufrufen +/// (`false`) ist ein Timeout ein echter Fehlschlag, da dabei nichts erfolgreich eingebunden +/// wurde. +pub(crate) fn run_tolerating_already_done( + cmd: std::process::Command, + context: &str, + tolerate_timeout: bool, +) -> Result<()> { + run_tolerating_already_done_with_timeout(cmd, context, tolerate_timeout, MOUNT_TIMEOUT_SECS) +} + +fn run_tolerating_already_done_with_timeout( + cmd: std::process::Command, + context: &str, + tolerate_timeout: bool, + timeout_secs: u64, +) -> Result<()> { + let output = + run_with_timeout(cmd, timeout_secs).map_err(|e| crate::error::Error::MountFailed { + context: context.to_string(), + stderr: e.to_string(), + })?; + + if output.status.success() { + return Ok(()); + } + + // GNU coreutils `timeout` beendet sich mit Exit-Code 124, wenn es den Kindprozess wegen + // Zeitüberschreitung abbrechen musste (dokumentiertes Verhalten von `timeout(1)`). + if tolerate_timeout && output.status.code() == Some(124) { + logger_ctdra::warn( + "mount", + &format!( + "{context}: aborted after {timeout_secs}s (server likely unreachable) - will be retried on the next run" + ), + ); + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); + if stderr.contains("already mounted") + || stderr.contains("busy") + || stderr.contains("not mounted") + { + return Ok(()); + } + + Err(crate::error::Error::MountFailed { + context: context.to_string(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +/// Führt `cmd` über das coreutils-Tool `timeout` aus, damit ein hängender `mount`/`umount` +/// den aufrufenden `smart-mount`-Prozess nie unbegrenzt blockiert. +fn run_with_timeout( + cmd: std::process::Command, + timeout_secs: u64, +) -> std::io::Result { + let program = cmd.get_program().to_os_string(); + let args: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect(); + std::process::Command::new("timeout") + .arg(timeout_secs.to_string()) + .arg(program) + .args(args) + .output() +} + +/// Prüft per `which`, ob ein Binary im `PATH` auffindbar ist. +pub(crate) fn binary_available(binary: &str) -> bool { + std::process::Command::new("which") + .arg(binary) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Entfernt (best-effort) die für `pair` auf beiden Seiten hinterlegten Klartext- +/// Zugangsdaten, die `mount -t davfs`/`mount -t cifs` benötigen (NICHT die verschlüsselten +/// DB-Zeilen - die werden separat über `CredentialStore::delete` entfernt). Für +/// `smart-mount drive remove`, damit kein Passwort für ein gelöschtes Paar auf der Platte +/// zurückbleibt. Fehler werden nur geloggt, nie propagiert: eine nicht perfekt aufräumbare +/// Restdatei darf das Entfernen des Paars nicht blockieren. +pub fn cleanup_credentials(pair: &DrivePair, settings: &GlobalSettings) { + cleanup_side_credentials(pair, settings, Side::Local, pair.local.kind); + cleanup_side_credentials(pair, settings, Side::Cloud, pair.cloud.kind); +} + +fn cleanup_side_credentials( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, + kind: MountKind, +) { + match kind { + MountKind::Smb => { + let path = smb::credentials_path(&pair.id, side); + if path.exists() + && let Err(e) = std::fs::remove_file(&path) + { + logger_ctdra::warn( + "mount", + &format!( + "Could not delete credentials file '{}': {e}", + path.display() + ), + ); + } + } + MountKind::WebDav => { + let source = match side { + Side::Local => target::local_source(&pair.local, settings), + Side::Cloud => target::cloud_source(&pair.cloud), + }; + let path = webdav::davfs2_secrets_path(); + if let Err(e) = webdav::remove_secrets_entry(&path, &source) { + logger_ctdra::warn( + "mount", + &format!("Could not remove secrets entry for '{source}': {e}"), + ); + } + } + MountKind::Nfs => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[test] + fn run_with_timeout_kills_a_hanging_command_and_reports_exit_124() { + let mut cmd = Command::new("sleep"); + cmd.arg("5"); + let output = run_with_timeout(cmd, 1).expect("timeout wrapper itself should run"); + // GNU coreutils `timeout` exits 124 when it had to kill the child - this is the + // signal `run_tolerating_already_done_with_timeout` checks for below. + assert_eq!(output.status.code(), Some(124)); + } + + #[test] + fn run_with_timeout_returns_promptly_for_a_command_that_finishes_in_time() { + let cmd = Command::new("true"); + let started = std::time::Instant::now(); + let output = run_with_timeout(cmd, 10).expect("run"); + assert!(output.status.success()); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + } + + #[test] + fn timeout_is_tolerated_for_unmount_and_returns_ok() { + let mut cmd = Command::new("sleep"); + cmd.arg("5"); + let result = run_tolerating_already_done_with_timeout(cmd, "test umount", true, 1); + assert!(result.is_ok()); + } + + #[test] + fn timeout_is_a_hard_error_for_mount() { + let mut cmd = Command::new("sleep"); + cmd.arg("5"); + let result = run_tolerating_already_done_with_timeout(cmd, "test mount", false, 1); + assert!(result.is_err()); + } +} diff --git a/src/mount/nfs.rs b/src/mount/nfs.rs new file mode 100644 index 0000000..1b07822 --- /dev/null +++ b/src/mount/nfs.rs @@ -0,0 +1,59 @@ +//! NFS-Backend (`mount.nfs`). In der Regel keine Zugangsdaten nötig - Autorisierung erfolgt +//! serverseitig über Export-ACLs (`sec=sys`), optional `sec=krb5*` über `extra_options`. + +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, +}; + +pub struct NfsBackend; + +impl MountBackend for NfsBackend { + fn name(&self) -> &'static str { + "nfs" + } + + fn check_available(&self) -> Result<()> { + if binary_available("mount.nfs") || binary_available("mount.nfs4") { + Ok(()) + } else { + Err(Error::BackendUnavailable { + backend: "nfs", + reason: "'mount.nfs'/'mount.nfs4' not found - install package 'nfs-common' (Debian/Ubuntu) or 'nfs-utils' (Fedora/Arch)".to_string(), + }) + } + } + + fn prepare(&self, _target: &MountTarget, _cred: Option<&Credential>) -> Result<()> { + // Normalerweise kein Vorbereitungsschritt nötig (kein Credentials-File). + Ok(()) + } + + 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) + .arg(&target.mount_point); + 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) + } + + fn unmount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("umount"); + cmd.arg(&target.mount_point); + run_tolerating_already_done(cmd, "nfs umount", true) + } +} diff --git a/src/mount/smb.rs b/src/mount/smb.rs new file mode 100644 index 0000000..2553039 --- /dev/null +++ b/src/mount/smb.rs @@ -0,0 +1,147 @@ +//! SMB/CIFS-Backend (`mount.cifs`). + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +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, +}; + +pub struct SmbBackend; + +impl MountBackend for SmbBackend { + fn name(&self) -> &'static str { + "cifs" + } + + fn check_available(&self) -> Result<()> { + if binary_available("mount.cifs") { + Ok(()) + } else { + Err(Error::BackendUnavailable { + backend: "cifs", + reason: "'mount.cifs' not found - install package 'cifs-utils'".to_string(), + }) + } + } + + fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> { + if let Some(cred) = cred { + write_credentials_file(&credentials_path(&target.pair_id, target.side), cred)?; + } + Ok(()) + } + + 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) + .arg(&target.mount_point); + let mut opts = target.options.clone(); + opts.push(format!( + "credentials={}", + 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) + } + + fn unmount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("umount"); + cmd.arg(&target.mount_point); + run_tolerating_already_done(cmd, "cifs umount", true) + } +} + +/// Stabiler Pfad (nicht ein Tempfile!), da bei `MountInvocation::ViaFstab` die fstab-Zeile +/// (von `setup fstab` einmalig geschrieben) exakt auf diesen `credentials=`-Pfad verweist. +pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf { + let base = config_ctdra::get_config_path() + .parent() + .map(|d| d.join("creds")) + .unwrap_or_else(|| PathBuf::from("creds")); + base.join(format!("{pair_id}-{}.cred", side.as_str())) +} + +fn write_credentials_file(path: &PathBuf, cred: &Credential) -> Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + } + + let mut contents = String::new(); + if let Some(username) = &cred.username { + contents.push_str(&format!("username={username}\n")); + } + contents.push_str(&format!("password={}\n", cred.password)); + if let Some(domain) = &cred.domain { + contents.push_str(&format!("domain={domain}\n")); + } + + #[cfg(unix)] + let mut opts = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + #[cfg(not(unix))] + let mut opts = OpenOptions::new(); + + let mut file = opts + .write(true) + .create(true) + .truncate(true) + .open(path) + .map_err(|e| Error::io(path, e))?; + file.write_all(contents.as_bytes()) + .map_err(|e| Error::io(path, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::credentials::Side; + + #[test] + fn writes_expected_credentials_format() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.cred"); + let cred = Credential { + username: Some("nasuser".to_string()), + domain: Some("WORKGROUP".to_string()), + password: "s3cret".to_string(), + }; + + write_credentials_file(&path, &cred).expect("write"); + let contents = std::fs::read_to_string(&path).expect("read"); + + assert!(contents.contains("username=nasuser")); + assert!(contents.contains("password=s3cret")); + assert!(contents.contains("domain=WORKGROUP")); + } + + #[test] + fn credentials_path_differs_per_side() { + let local = credentials_path("pair-1", Side::Local); + let cloud = credentials_path("pair-1", Side::Cloud); + assert_ne!(local, cloud); + } +} diff --git a/src/mount/state.rs b/src/mount/state.rs new file mode 100644 index 0000000..c95ffea --- /dev/null +++ b/src/mount/state.rs @@ -0,0 +1,76 @@ +//! Ermittelt den aktuellen Mount-Zustand eines Mountpoints durch Parsen von +//! `/proc/self/mountinfo` - keine Zusatzabhängigkeit auf `findmnt`. + +use std::path::Path; + +use crate::error::{Error, Result}; + +/// Ob `mount_point` aktuell eingebunden ist. +pub fn is_mounted(mount_point: &Path) -> Result { + Ok(current_source(mount_point)?.is_some()) +} + +/// Die aktuell an `mount_point` eingebundene Quelle (die Spalte "source" aus mountinfo), +/// oder `None`, falls dort nichts eingebunden ist. +pub fn current_source(mount_point: &Path) -> Result> { + let contents = std::fs::read_to_string("/proc/self/mountinfo") + .map_err(|e| Error::io("/proc/self/mountinfo", e))?; + Ok(parse_mountinfo_source(&contents, mount_point)) +} + +/// Reine, testbare Parse-Funktion: `mountinfo`-Zeilenformat ist +/// `... - `. +/// Bei mehreren Treffern (verschachtelte Mounts) zählt der letzte (= zuletzt gemountete, +/// aktuell sichtbare) Eintrag. +fn parse_mountinfo_source(mountinfo: &str, mount_point: &Path) -> Option { + let target = mount_point.to_string_lossy(); + let mut result = None; + + for line in mountinfo.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + // Feld 4 (Index 4) ist der Mountpoint; danach folgen optionale Felder bis zum + // Trenner "-", danach fs_type (Index+1) und source (Index+2). + if fields.len() < 5 || fields[4] != target { + continue; + } + let Some(dash_pos) = fields.iter().position(|&f| f == "-") else { + continue; + }; + if let Some(source) = fields.get(dash_pos + 2) { + result = Some(source.to_string()); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn finds_source_for_matching_mount_point() { + let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw\n\ + 43 36 0:26 / /home/dragon/smart-mount/pair-1 rw,relatime shared:2 - cifs //server/share rw,uid=1000"; + let source = + parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1")); + assert_eq!(source.as_deref(), Some("//server/share")); + } + + #[test] + fn returns_none_for_unmounted_path() { + let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw"; + let source = + parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1")); + assert_eq!(source, None); + } + + #[test] + fn last_matching_entry_wins_for_stacked_mounts() { + let mountinfo = "36 35 98:0 / /mnt/x rw - nfs server:/export rw\n\ + 37 36 0:26 / /mnt/x rw - davfs https://cloud/dav rw"; + let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/x")); + assert_eq!(source.as_deref(), Some("https://cloud/dav")); + } +} diff --git a/src/mount/target.rs b/src/mount/target.rs new file mode 100644 index 0000000..45657cc --- /dev/null +++ b/src/mount/target.rs @@ -0,0 +1,444 @@ +//! Baut [`MountTarget`]s für eine Seite eines Paars und verwaltet den symlink-basierten +//! "welche Seite ist aktiv"-Zustand. +//! +//! **Warum ein Symlink statt zwei fstab-Zeilen auf denselben Mountpoint:** `mount(8)`s +//! Berechtigungsprüfung für unprivilegierte `user`-Mounts ist nur für den Fall EINER +//! passenden fstab-Zeile dokumentiert (`man 8 mount`, Abschnitt "Non-superuser mounts", +//! Beispiel `mount /cd`). Der Fall zweier `user,noauto`-Zeilen mit demselben Ziel, aber +//! unterschiedlicher Quelle, ist nirgends spezifiziert - und `mount --fstab ` +//! verlangt selbst Root, sodass sich das Verhalten nicht einmal gefahrlos in einer Sandbox +//! verifizieren ließ. Um uns nicht auf unspezifiziertes Verhalten zu verlassen, bekommt jede +//! Seite stattdessen ein eigenes, eindeutiges Backing-Verzeichnis mit genau einer fstab-Zeile +//! (der dokumentierte, eindeutige Fall). Der konfigurierte `pair.mount_point` ist kein +//! Mountpoint mehr, sondern ein Symlink, den smart-mount selbst atomar zwischen beiden +//! Backing-Verzeichnissen umschaltet ([`activate_symlink`]). Zu jedem Zeitpunkt ist höchstens +//! 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::path::{Path, PathBuf}; + +use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountContext, MountKind}; +use crate::db::credentials::Side; +use crate::error::{Error, Result}; +use crate::mount::{self, MountInvocation, MountTarget}; +use crate::network::address; + +/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird +/// tatsächlich `mount(8)` aufgerufen. Liegt als verstecktes Verzeichnis neben `pair.mount_point`. +pub fn backing_dir(pair: &DrivePair, side: Side) -> PathBuf { + let parent = pair.mount_point.parent().unwrap_or_else(|| Path::new(".")); + parent.join(format!(".{}-{}", pair.id, side.as_str())) +} + +/// Welches [`MountKind`] eine Seite eines Paars hat. +pub fn side_kind(pair: &DrivePair, side: Side) -> MountKind { + match side { + Side::Local => pair.local.kind, + Side::Cloud => pair.cloud.kind, + } +} + +/// Baut ein [`MountTarget`] für eine Seite eines Paars, inkl. Auflösung der lokalen +/// MAC-Adresse zu einer IP (siehe [`address::resolve_ip`]). `target.mount_point` ist das +/// Backing-Verzeichnis (siehe [`backing_dir`]), nicht der sichtbare `pair.mount_point`. +pub fn build_target( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, +) -> Result { + 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 { + Side::Local => ( + local_source(&pair.local, settings), + parse_options(&pair.local.extra_options), + ), + Side::Cloud => ( + cloud_source(&pair.cloud), + parse_options(&pair.cloud.extra_options), + ), + }; + apply_owner_permissions(kind, pair.owner_user.as_deref(), &mut options)?; + apply_nfs_resilience_defaults(kind, &mut options); + + Ok(MountTarget { + pair_id: pair.id.clone(), + side, + mount_point: backing_dir(pair, side), + source, + options, + invocation, + owner_user: pair.owner_user.clone(), + }) +} + +/// Setzt `uid`/`gid`/`file_mode`/`dir_mode` für Protokolle ohne native Unix-Rechte (CIFS, +/// WebDAV), damit `pair.owner_user` vollen Zugriff auf den Mount hat - inklusive +/// Ausführrechten, damit dort liegende Skripte laufen können (`file_mode`/`dir_mode` steuern +/// bei diesen Protokollen den simulierten `stat()`-Modus jeder Datei/jedes Verzeichnisses +/// einheitlich; `0700` gibt ausschließlich dem Owner volle Rechte). Bereits in +/// `extra_options` explizit gesetzte Werte werden respektiert und nicht überschrieben. +/// +/// **NFS ist bewusst ausgenommen:** dort gibt es keine clientseitige `uid=`/`gid=`-Option - +/// welcher lokale Nutzer Zugriff hat, bestimmt der NFS-Server über die tatsächlichen +/// Datei-Eigentümer/-Rechte des Exports (siehe README, Abschnitt "Voraussetzungen"). +fn apply_owner_permissions( + kind: MountKind, + owner_user: Option<&str>, + options: &mut Vec, +) -> Result<()> { + if !matches!(kind, MountKind::Smb | MountKind::WebDav) { + return Ok(()); + } + let Some(owner) = owner_user else { + return Ok(()); + }; + + if !has_option(options, "uid") || !has_option(options, "gid") { + let (uid, gid) = resolve_uid_gid(owner)?; + if !has_option(options, "uid") { + options.push(format!("uid={uid}")); + } + if !has_option(options, "gid") { + options.push(format!("gid={gid}")); + } + } + if !has_option(options, "file_mode") { + options.push("file_mode=0700".to_string()); + } + if !has_option(options, "dir_mode") { + options.push("dir_mode=0700".to_string()); + } + Ok(()) +} + +/// Setzt `soft` als NFS-Standard, sofern der Nutzer nicht bereits selbst `hard`/`soft`/ +/// `softerr` gesetzt hat. `hard` (der Standard von `mount.nfs`, wenn nichts angegeben ist) +/// lässt NFS-Anfragen unbegrenzt oft erneut versuchen, wenn der Server nicht antwortet - +/// genau das Einfrierverhalten (auch bei `umount`), das automatisches Umschalten unmöglich +/// machen würde. Siehe `man 5 nfs`, Abschnitt "soft / softerr / hard": dort wird ein +/// dauerhaft nicht erreichbarer Server als der Anwendungsfall genannt, für den `soft` +/// gedacht ist. +fn apply_nfs_resilience_defaults(kind: MountKind, options: &mut Vec) { + if kind != MountKind::Nfs { + return; + } + if !has_option(options, "hard") + && !has_option(options, "soft") + && !has_option(options, "softerr") + { + options.push("soft".to_string()); + } +} + +/// Prüft, ob `options` bereits einen Eintrag für `key` enthält - entweder als `key=wert` +/// oder als bloßes Flag `key` (z. B. `soft`, `exec`). +fn has_option(options: &[String], key: &str) -> bool { + options + .iter() + .any(|o| o == key || o.starts_with(&format!("{key}="))) +} + +fn resolve_uid_gid(username: &str) -> Result<(u32, u32)> { + Ok((run_id(username, "-u")?, run_id(username, "-g")?)) +} + +fn run_id(username: &str, flag: &str) -> Result { + let output = std::process::Command::new("id") + .arg(flag) + .arg(username) + .output() + .map_err(|e| Error::Other(format!("could not run 'id': {e}")))?; + if !output.status.success() { + return Err(Error::Other(format!( + "user '{username}' not found ('id {flag} {username}' failed): {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .map_err(|e| { + Error::Other(format!( + "unexpected output from 'id {flag} {username}': {e}" + )) + }) +} + +pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> String { + let ip = address::resolve_ip(&local.address, settings) + .map(|ip| ip.to_string()) + .unwrap_or_else(|_| "unresolved".to_string()); + format_source(local.kind, &ip, &local.share) +} + +pub fn cloud_source(cloud: &CloudSide) -> String { + match cloud.kind { + MountKind::WebDav => cloud.host_or_url.clone(), + MountKind::Smb | MountKind::Nfs => { + format_source(cloud.kind, &cloud.host_or_url, &cloud.share) + } + } +} + +fn format_source(kind: MountKind, host: &str, share: &str) -> String { + let share = if share.starts_with('/') { + share.to_string() + } else { + format!("/{share}") + }; + match kind { + MountKind::WebDav => format!("http://{host}{share}"), + MountKind::Smb => format!("//{host}{share}"), + MountKind::Nfs => format!("{host}:{share}"), + } +} + +/// `extra_options` sind bereits einzelne Tokens (kein komma-getrennter String) - einfach +/// übernehmen. Frühere Versionen filterten hier auf `key=value`-Paare, wodurch bloße Flags +/// wie `soft`/`exec`/`ro` in `extra_options` still verworfen wurden - siehe Testfall unten. +fn parse_options(extra: &[String]) -> Vec { + extra.to_vec() +} + +/// Welche Seite aktuell aktiv ist: `pair.mount_point` muss auf das Backing-Verzeichnis dieser +/// Seite zeigen UND dieses Verzeichnis muss tatsächlich gemountet sein (Schutz gegen einen +/// veralteten Symlink, dessen Backing-Verzeichnis extern ausgehängt wurde). +pub fn active_side(pair: &DrivePair) -> Option { + let link_target = std::fs::read_link(&pair.mount_point).ok()?; + let side = if link_target == backing_dir(pair, Side::Local) { + Side::Local + } else if link_target == backing_dir(pair, Side::Cloud) { + Side::Cloud + } else { + return None; + }; + + match mount::state::is_mounted(&backing_dir(pair, side)) { + Ok(true) => Some(side), + _ => None, + } +} + +/// Setzt `pair.mount_point` atomar als Symlink auf das Backing-Verzeichnis von `side`. +/// +/// Atomar über `symlink` auf einen Temp-Pfad + `rename()` (POSIX-garantiert atomar auf +/// demselben Dateisystem) - es gibt also kein Zeitfenster, in dem der Pfad fehlt oder auf ein +/// veraltetes Ziel zeigt. Schlägt kontrolliert fehl (statt zu überschreiben), falls an +/// `pair.mount_point` bereits ein echtes Verzeichnis (kein Symlink) existiert. +pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> { + let link_path = &pair.mount_point; + + if let Ok(meta) = std::fs::symlink_metadata(link_path) + && !meta.file_type().is_symlink() + { + return Err(Error::Other(format!( + "'{}' already exists as a real directory (not as a symlink managed by smart-mount) - \ + please remove/rename it manually before activating this pair.", + link_path.display() + ))); + } + + if let Some(parent) = link_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; + } + + let target = backing_dir(pair, side); + let tmp_name = format!( + ".{}.smart-mount-tmp", + link_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + ); + let tmp_path = link_path.with_file_name(tmp_name); + + let _ = std::fs::remove_file(&tmp_path); + std::os::unix::fs::symlink(&target, &tmp_path).map_err(|e| Error::io(&tmp_path, e))?; + std::fs::rename(&tmp_path, link_path).map_err(|e| Error::io(link_path, e))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn sample_pair() -> DrivePair { + DrivePair { + 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 { + kind: MountKind::Smb, + address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)), + share: "share".into(), + username: None, + extra_options: vec!["vers=3.0".into()], + }, + cloud: CloudSide { + kind: MountKind::WebDav, + host_or_url: "https://cloud.example.com/dav".into(), + share: "share".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn local_source_formats_smb_unc_path() { + let pair = sample_pair(); + assert_eq!( + local_source(&pair.local, &GlobalSettings::default()), + "//192.168.1.10/share" + ); + } + + #[test] + fn cloud_source_uses_full_url_for_webdav() { + let pair = sample_pair(); + assert_eq!(cloud_source(&pair.cloud), "https://cloud.example.com/dav"); + } + + #[test] + fn parse_options_passes_flags_and_key_value_pairs_through_unchanged() { + let opts = parse_options(&["vers=3.0".to_string(), "soft".to_string()]); + assert_eq!(opts, vec!["vers=3.0".to_string(), "soft".to_string()]); + } + + #[test] + fn apply_owner_permissions_injects_uid_gid_and_owner_only_modes_for_cifs() { + let user = std::env::var("USER").expect("USER env var set in test environment"); + let mut options = vec!["vers=3.0".to_string()]; + apply_owner_permissions(MountKind::Smb, Some(&user), &mut options).expect("apply"); + + assert!(options.contains(&"file_mode=0700".to_string())); + assert!(options.contains(&"dir_mode=0700".to_string())); + assert!(options.iter().any(|o| o.starts_with("uid="))); + assert!(options.iter().any(|o| o.starts_with("gid="))); + // vorhandene Option bleibt unangetastet + assert!(options.contains(&"vers=3.0".to_string())); + } + + #[test] + fn apply_owner_permissions_respects_explicit_overrides() { + let user = std::env::var("USER").expect("USER env var set in test environment"); + let mut options = vec!["file_mode=0755".to_string()]; + apply_owner_permissions(MountKind::WebDav, Some(&user), &mut options).expect("apply"); + + assert!(options.contains(&"file_mode=0755".to_string())); + assert!(!options.contains(&"file_mode=0700".to_string())); + assert!(options.contains(&"dir_mode=0700".to_string())); + } + + #[test] + fn apply_owner_permissions_is_noop_for_nfs() { + let mut options = vec![]; + apply_owner_permissions(MountKind::Nfs, Some("root"), &mut options).expect("apply"); + assert!(options.is_empty()); + } + + #[test] + fn apply_owner_permissions_is_noop_without_owner_user() { + let mut options = vec![]; + apply_owner_permissions(MountKind::Smb, None, &mut options).expect("apply"); + assert!(options.is_empty()); + } + + #[test] + fn apply_owner_permissions_fails_clearly_for_unknown_user() { + let mut options = vec![]; + let err = apply_owner_permissions(MountKind::Smb, Some("no-such-user-xyz"), &mut options) + .unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn apply_nfs_resilience_defaults_adds_soft_when_unset() { + let mut options = vec![]; + apply_nfs_resilience_defaults(MountKind::Nfs, &mut options); + assert_eq!(options, vec!["soft".to_string()]); + } + + #[test] + fn apply_nfs_resilience_defaults_respects_explicit_hard() { + let mut options = vec!["hard".to_string()]; + apply_nfs_resilience_defaults(MountKind::Nfs, &mut options); + assert_eq!(options, vec!["hard".to_string()]); + } + + #[test] + fn apply_nfs_resilience_defaults_respects_explicit_softerr() { + let mut options = vec!["softerr".to_string()]; + apply_nfs_resilience_defaults(MountKind::Nfs, &mut options); + assert_eq!(options, vec!["softerr".to_string()]); + } + + #[test] + fn apply_nfs_resilience_defaults_is_noop_for_other_kinds() { + let mut options = vec![]; + apply_nfs_resilience_defaults(MountKind::Smb, &mut options); + assert!(options.is_empty()); + } + + #[test] + fn build_target_injects_soft_for_plain_nfs_pair() { + let mut pair = sample_pair(); + pair.local.kind = MountKind::Nfs; + pair.cloud.kind = MountKind::Nfs; + let t = build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build"); + assert!(t.options.contains(&"soft".to_string())); + } + + #[test] + fn backing_dirs_are_unique_per_side_and_live_next_to_mount_point() { + let pair = sample_pair(); + let local = backing_dir(&pair, Side::Local); + let cloud = backing_dir(&pair, Side::Cloud); + assert_ne!(local, cloud); + assert_eq!(local.parent(), pair.mount_point.parent()); + assert_eq!(cloud.parent(), pair.mount_point.parent()); + } + + #[test] + fn activate_symlink_points_at_the_right_backing_dir_and_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pair = sample_pair(); + pair.mount_point = dir.path().join("pair-1"); + + activate_symlink(&pair, Side::Local).expect("activate local"); + assert_eq!( + std::fs::read_link(&pair.mount_point).unwrap(), + backing_dir(&pair, Side::Local) + ); + + // Erneutes Aktivieren derselben Seite darf nicht fehlschlagen (Symlink wird ersetzt, + // kein "existiert bereits als echtes Verzeichnis"-Fehler für einen eigenen Symlink). + activate_symlink(&pair, Side::Local).expect("re-activate local"); + + activate_symlink(&pair, Side::Cloud).expect("activate cloud"); + assert_eq!( + std::fs::read_link(&pair.mount_point).unwrap(), + backing_dir(&pair, Side::Cloud) + ); + } + + #[test] + fn activate_symlink_refuses_to_clobber_a_real_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pair = sample_pair(); + pair.mount_point = dir.path().join("pair-1"); + std::fs::create_dir_all(&pair.mount_point).expect("create real dir"); + + let err = activate_symlink(&pair, Side::Local).unwrap_err(); + assert!(err.to_string().contains("real directory")); + } +} diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs new file mode 100644 index 0000000..e9c47fc --- /dev/null +++ b/src/mount/webdav.rs @@ -0,0 +1,356 @@ +//! WebDAV-Backend (davfs2). + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +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, +}; + +pub struct WebDavBackend; + +const GUI_OPTIMIZE_COMMENT: &str = + "# smart-mount: gui_optimize enabled (batches PROPFIND requests for a directory)"; + +/// Deutlich über dem Standardwert (16), siehe [`ensure_buf_size`] für die Begründung. +const BUF_SIZE_KIB: &str = "16384"; +const BUF_SIZE_COMMENT: &str = "# smart-mount: buf_size increased so directory contents are listed reliably even with many files"; + +impl MountBackend for WebDavBackend { + fn name(&self) -> &'static str { + "davfs2" + } + + fn check_available(&self) -> Result<()> { + if binary_available("mount.davfs") { + Ok(()) + } else { + Err(Error::BackendUnavailable { + backend: "davfs2", + reason: "'mount.davfs' not found - install package 'davfs2'".to_string(), + }) + } + } + + fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> { + let conf_path = davfs2_conf_path(); + ensure_gui_optimize(&conf_path)?; + ensure_buf_size(&conf_path)?; + if let Some(cred) = cred + && let Some(username) = &cred.username + { + write_secrets_entry( + &davfs2_secrets_path(), + &target.source, + username, + &cred.password, + )?; + } + Ok(()) + } + + 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) + .arg(&target.mount_point); + 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) + } + + fn unmount(&self, target: &MountTarget) -> Result<()> { + let mut cmd = Command::new("umount"); + cmd.arg(&target.mount_point); + run_tolerating_already_done(cmd, "davfs2 umount", true) + } +} + +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 +/// Dateimanagern (die dazu neigen, jede Datei zu öffnen) die Reaktionszeit bei großen +/// Verzeichnissen, indem die "ist eine neuere Version vorhanden?"-Abfrage für ein ganzes +/// Verzeichnis in einem PROPFIND-Request gebündelt wird. Siehe `man davfs2.conf`. +fn ensure_gui_optimize(path: &Path) -> Result<()> { + ensure_config_line(path, "gui_optimize", "1", GUI_OPTIMIZE_COMMENT) +} + +/// Setzt `buf_size` in `davfs2.conf` idempotent auf einen deutlich über dem Standard (16 +/// KiB) liegenden Wert. Bekanntes Praxisproblem bei davfs2 (unabhängig davon, ob die +/// `man davfs2.conf`-Beschreibung - reiner I/O-Geschwindigkeits-Tuningparameter - das exakt +/// so vorsieht): bei zu kleinem `buf_size` liefert `ls` in Verzeichnissen mit vielen Dateien +/// einen leeren/unvollständigen Inhalt zurück, obwohl einzelne Dateien direkt geöffnet werden +/// können (der FUSE-readdir-Puffer wird dabei stillschweigend abgeschnitten). Ein deutlich +/// größerer Puffer behebt das bei vertretbarem Speicher-Mehrverbrauch für einen einzelnen +/// Mount. +fn ensure_buf_size(path: &Path) -> Result<()> { + ensure_config_line(path, "buf_size", BUF_SIZE_KIB, BUF_SIZE_COMMENT) +} + +/// Setzt ` ` in einer davfs2-Konfigurationsdatei idempotent: ersetzt eine +/// bestehende (unkommentierte) Zeile mit demselben Schlüssel (unabhängig vom bisherigen +/// Wert) statt sie zu duplizieren, und lässt alle anderen Zeilen unangetastet. +fn ensure_config_line(path: &Path, key: &str, value: &str, comment: &str) -> Result<()> { + let existing = fs::read_to_string(path).unwrap_or_default(); + + let is_key_line = |line: &str| { + let trimmed = line.trim(); + !trimmed.starts_with('#') && trimmed.split_whitespace().next() == Some(key) + }; + + if existing + .lines() + .any(|l| is_key_line(l) && l.split_whitespace().nth(1) == Some(value)) + { + return Ok(()); + } + + let mut new_lines: Vec = existing + .lines() + .filter(|l| !is_key_line(l)) + .map(str::to_string) + .collect(); + + new_lines.push(comment.to_string()); + new_lines.push(format!("{key} {value}")); + + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + } + fs::write(path, format!("{}\n", new_lines.join("\n"))).map_err(|e| Error::io(path, e)) +} + +/// Schreibt/aktualisiert eine Zeile in davfs2s `secrets`-Datei (` `, +/// muss chmod 600 sein). Ersetzt eine bestehende Zeile für dieselbe URL statt sie zu duplizieren. +fn write_secrets_entry(path: &PathBuf, url: &str, username: &str, password: &str) -> Result<()> { + let existing = fs::read_to_string(path).unwrap_or_default(); + let mut lines: Vec = existing + .lines() + .filter(|l| !l.trim_start().starts_with(url)) + .map(str::to_string) + .collect(); + lines.push(format!("{url} {username} {password}")); + + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } + } + + #[cfg(unix)] + let mut opts = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + #[cfg(not(unix))] + let mut opts = OpenOptions::new(); + + let mut file = opts + .write(true) + .create(true) + .truncate(true) + .open(path) + .map_err(|e| Error::io(path, e))?; + file.write_all(format!("{}\n", lines.join("\n")).as_bytes()) + .map_err(|e| Error::io(path, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +/// Entfernt (best-effort) die Zeile für `url` aus der `secrets`-Datei, falls vorhanden - für +/// `smart-mount drive remove`, damit kein Klartext-Passwort für ein gelöschtes Paar zurückbleibt. +/// +/// **Bekannte Grenze:** die Zeile ist über die URL indiziert (inkl. IP bei MAC-adressierten +/// lokalen Seiten). Hat sich die IP seit dem letzten Mount geändert, berechnet der Aufrufer +/// eine andere, aktuelle URL als die tatsächlich gespeicherte - die eigentliche Alt-Zeile +/// bleibt dann zurück. Kein Fehler, falls die Datei nicht existiert oder `url` nicht enthält. +pub(crate) fn remove_secrets_entry(path: &Path, url: &str) -> Result<()> { + let Ok(existing) = fs::read_to_string(path) else { + return Ok(()); + }; + let remaining: Vec<&str> = existing + .lines() + .filter(|l| !l.trim_start().starts_with(url)) + .collect(); + if remaining.len() == existing.lines().count() { + return Ok(()); + } + fs::write(path, format!("{}\n", remaining.join("\n"))).map_err(|e| Error::io(path, e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ensure_gui_optimize_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + + ensure_gui_optimize(&path).expect("first call"); + let first = fs::read_to_string(&path).expect("read"); + ensure_gui_optimize(&path).expect("second call"); + let second = fs::read_to_string(&path).expect("read"); + + assert_eq!(first, second); + assert_eq!( + first + .lines() + .filter(|l| l.trim() == "gui_optimize 1") + .count(), + 1 + ); + } + + #[test] + fn ensure_gui_optimize_replaces_disabled_value() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + fs::write(&path, "use_locks 1\ngui_optimize 0\n").expect("write"); + + ensure_gui_optimize(&path).expect("patch"); + let contents = fs::read_to_string(&path).expect("read"); + + assert!(contents.contains("use_locks 1")); + assert!(contents.contains("gui_optimize 1")); + assert!(!contents.contains("gui_optimize 0")); + } + + #[test] + fn ensure_buf_size_raises_the_default_and_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + fs::write(&path, "buf_size 16\n").expect("write default"); + + ensure_buf_size(&path).expect("first call"); + let first = fs::read_to_string(&path).expect("read"); + ensure_buf_size(&path).expect("second call"); + let second = fs::read_to_string(&path).expect("read"); + + assert_eq!(first, second); + assert!( + !first.contains("buf_size 16\n") && !first.lines().any(|l| l.trim() == "buf_size 16") + ); + assert!( + first + .lines() + .any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}")) + ); + assert_eq!( + first + .lines() + .filter(|l| l.trim().starts_with("buf_size ")) + .count(), + 1 + ); + } + + #[test] + fn gui_optimize_and_buf_size_coexist_without_clobbering_each_other() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("davfs2.conf"); + + ensure_gui_optimize(&path).expect("gui_optimize"); + ensure_buf_size(&path).expect("buf_size"); + + let contents = fs::read_to_string(&path).expect("read"); + assert!(contents.lines().any(|l| l.trim() == "gui_optimize 1")); + assert!( + contents + .lines() + .any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}")) + ); + } + + #[test] + fn write_secrets_entry_replaces_existing_line_for_same_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets"); + + write_secrets_entry(&path, "https://cloud/dav", "user", "old-pass").expect("write 1"); + write_secrets_entry(&path, "https://cloud/dav", "user", "new-pass").expect("write 2"); + + let contents = fs::read_to_string(&path).expect("read"); + assert_eq!( + contents + .lines() + .filter(|l| l.contains("https://cloud/dav")) + .count(), + 1 + ); + assert!(contents.contains("new-pass")); + assert!(!contents.contains("old-pass")); + } + + #[test] + fn remove_secrets_entry_deletes_only_the_matching_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets"); + + write_secrets_entry(&path, "https://cloud/dav", "user1", "pass1").expect("write 1"); + write_secrets_entry(&path, "http://192.168.1.5/dav", "user2", "pass2").expect("write 2"); + + remove_secrets_entry(&path, "https://cloud/dav").expect("remove"); + let contents = fs::read_to_string(&path).expect("read"); + + assert!(!contents.contains("https://cloud/dav")); + assert!(!contents.contains("pass1")); + assert!(contents.contains("http://192.168.1.5/dav")); + assert!(contents.contains("pass2")); + } + + #[test] + fn remove_secrets_entry_is_a_noop_for_missing_file_or_unmatched_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist"); + assert!(remove_secrets_entry(&path, "https://cloud/dav").is_ok()); + + write_secrets_entry(&path, "https://cloud/dav", "user", "pass").expect("write"); + assert!(remove_secrets_entry(&path, "https://other/dav").is_ok()); + let contents = fs::read_to_string(&path).expect("read"); + assert!(contents.contains("https://cloud/dav")); + } +} diff --git a/src/network/address.rs b/src/network/address.rs new file mode 100644 index 0000000..2ab534e --- /dev/null +++ b/src/network/address.rs @@ -0,0 +1,18 @@ +//! Auflösung der konfigurierten lokalen Adresse (IP oder MAC) zu einer konkreten IPv4-Adresse. + +use std::net::Ipv4Addr; + +use crate::config::{GlobalSettings, LocalAddress}; +use crate::error::Result; +use crate::network::mac2ip; + +/// Löst eine [`LocalAddress`] zu einer konkreten IPv4-Adresse auf. +/// +/// `Ip`-Adressen werden direkt durchgereicht, `Mac`-Adressen über das externe `mac2ip`-Tool +/// aufgelöst (siehe [`mac2ip::resolve`]). +pub fn resolve_ip(address: &LocalAddress, settings: &GlobalSettings) -> Result { + match address { + LocalAddress::Ip(ip) => Ok(*ip), + LocalAddress::Mac(mac) => mac2ip::resolve(mac, &settings.mac2ip_binary), + } +} diff --git a/src/network/mac2ip.rs b/src/network/mac2ip.rs new file mode 100644 index 0000000..3185e87 --- /dev/null +++ b/src/network/mac2ip.rs @@ -0,0 +1,107 @@ +//! Isolierter Wrapper um das externe, private `mac2ip`-CLI-Tool. +//! +//! `mac2ip` ist kein Rust-Crate, sondern ein eigenständiges, bereits vorhandenes CLI-Tool +//! des Nutzers (siehe Projekt "Mac2Ip"). smart-mount ruft es ausschließlich als Subprozess +//! auf; die JSON-Ausgabeform (`{"status":"ok","mac":..,"ip":..,"source":..}` bzw. +//! `{"status":"error","mac":..,"error":..}`) wurde gegen den tatsächlichen Quellcode +//! (`src/output.rs`) verifiziert. +//! +//! `--auto-trust-networks` wird immer mitgegeben: mac2ip beantwortet damit seine eigene +//! "nmap-Scan in diesem Netzwerk erlauben?"-Rückfrage automatisch mit Ja und merkt sich das +//! Netzwerk dauerhaft in seiner eigenen Cache-Datenbank - funktional identisch zum manuellen +//! Eintragen in mac2ips Config, aber ohne dass smart-mount das Config-Schema eines fremden +//! Tools kennen oder dort hineinschreiben muss. + +use std::net::Ipv4Addr; +use std::process::Command; + +use serde::Deserialize; + +use crate::error::{Error, Result}; + +#[derive(Deserialize)] +#[serde(untagged)] +enum Mac2IpOutput { + Success { ip: Ipv4Addr }, + Failure { error: String }, +} + +/// Löst eine MAC-Adresse über das externe `mac2ip`-Tool zu einer IPv4-Adresse auf. +/// +/// `binary` ist der konfigurierte Binary-Name/-Pfad (`GlobalSettings::mac2ip_binary`, +/// standardmäßig `"mac2ip"`, per PATH aufgelöst). +pub fn resolve(mac: &str, binary: &str) -> Result { + let output = Command::new(binary) + .args(["--json", "--auto-trust-networks", mac]) + .output() + .map_err(|e| Error::Mac2Ip { + mac: mac.to_string(), + reason: format!("could not run '{binary}': {e}"), + })?; + + parse_output(&output.stdout, mac) +} + +/// Prüft, ob das konfigurierte `mac2ip`-Binary über `PATH` auffindbar ist. +pub fn is_installed(binary: &str) -> bool { + Command::new("which") + .arg(binary) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn parse_output(stdout: &[u8], mac: &str) -> Result { + let text = String::from_utf8_lossy(stdout); + let line = text.lines().next().unwrap_or("").trim(); + + if line.is_empty() { + return Err(Error::Mac2Ip { + mac: mac.to_string(), + reason: "no output received from mac2ip".to_string(), + }); + } + + match serde_json::from_str::(line) { + Ok(Mac2IpOutput::Success { ip }) => Ok(ip), + Ok(Mac2IpOutput::Failure { error }) => Err(Error::Mac2Ip { + mac: mac.to_string(), + reason: error, + }), + Err(e) => Err(Error::Mac2Ip { + mac: mac.to_string(), + reason: format!("could not parse output as JSON: {e} (output: '{line}')"), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_success_output() { + let stdout = + br#"{"status":"ok","mac":"aa:bb:cc:dd:ee:ff","ip":"192.168.1.42","source":"cache"}"#; + let ip = parse_output(stdout, "aa:bb:cc:dd:ee:ff").expect("should parse"); + assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 42)); + } + + #[test] + fn parses_failure_output_as_error() { + let stdout = + br#"{"status":"error","mac":"aa:bb:cc:dd:ee:ff","error":"keine IP-Adresse gefunden"}"#; + let err = parse_output(stdout, "aa:bb:cc:dd:ee:ff").unwrap_err(); + assert!(matches!(err, Error::Mac2Ip { .. })); + } + + #[test] + fn empty_output_is_an_error_not_a_panic() { + assert!(parse_output(b"", "aa:bb:cc:dd:ee:ff").is_err()); + } + + #[test] + fn garbage_output_is_an_error_not_a_panic() { + assert!(parse_output(b"not json at all", "aa:bb:cc:dd:ee:ff").is_err()); + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..d5151b3 --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,30 @@ +//! Erreichbarkeitsprüfungen und lokale Adressauflösung. + +pub mod address; +pub mod mac2ip; + +use std::process::{Command, Stdio}; + +/// Prüft, ob `addr` erreichbar ist. Erkennt anhand des Präfixes, ob es sich um eine URL +/// (HTTP HEAD via `curl`) oder eine reine Host-/IP-Adresse (ICMP-Ping) handelt. +/// +/// Portiert aus dem alten `src/network/utils.rs` (v0.2.0). +pub fn is_reachable(addr: &str) -> bool { + if addr.starts_with("http://") || addr.starts_with("https://") { + Command::new("curl") + .args(["--head", "--silent", "--fail", "--max-time", "5", addr]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } else { + Command::new("ping") + .args(["-c", "1", "-W", "2", addr]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } +} diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs new file mode 100644 index 0000000..ae7a59a --- /dev/null +++ b/src/reconcile/mod.rs @@ -0,0 +1,221 @@ +//! Watchdog-Reconciling: entscheidet pro Laufwerkspaar, ob lokal oder Cloud eingebunden +//! sein sollte, und schaltet bei Bedarf um. +//! +//! `watch_once` ist bewusst ein einzelner, synchroner Durchlauf ohne internen Sleep-Loop - +//! dieselbe Kommandozeile (`smart-mount watch`) funktioniert dadurch sowohl unter einem +//! systemd-Timer als auch als Crontab-Zeile. +//! +//! **Zu jedem Zeitpunkt ist im Ruhezustand genau eine Seite gemountet** (wie ursprünglich +//! vorgesehen) - der sichtbare `pair.mount_point` ist ein Symlink auf das jeweils aktive +//! Backing-Verzeichnis (siehe [`crate::mount::target`]). Nur *während* eines aktiven +//! Umschaltens sind kurz beide Backing-Verzeichnisse gemountet: [`switch_to`] mountet die +//! neue Seite zuerst, flippt dann den Symlink, und hängt erst danach die alte Seite aus - so +//! gibt es nie ein Zeitfenster, in dem der sichtbare Pfad auf nichts Gemountetes zeigt. + +use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide}; +use crate::db::credentials::{CredentialStore, Side}; +use crate::error::Result; +use crate::mount::{self, lock, target}; +use crate::network::{self, address}; + +/// Ergebnis eines Reconcile-Durchlaufs für ein Paar. +#[derive(Debug)] +pub enum Action { + NoOp, + MountedLocal, + MountedCloud, + SwitchedToLocal, + SwitchedToCloud, + Failed(String), +} + +#[derive(Debug)] +pub struct ReconcileOutcome { + pub pair_id: String, + pub pair_name: String, + pub action: Action, +} + +/// Führt `reconcile_pair` für jedes aktivierte Paar in `cfg` aus. +pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec { + let mut outcomes = Vec::with_capacity(cfg.pairs.len()); + for pair in cfg.pairs.iter().filter(|p| p.enabled) { + outcomes.push(reconcile_pair(pair, &cfg.settings, creds).await); + } + outcomes +} + +/// Entscheidungstabelle (siehe Moduldoku): prüft Erreichbarkeit beider Seiten, vergleicht +/// mit der aktuell aktiven Seite, und schaltet bei Bedarf um. +pub async fn reconcile_pair( + pair: &DrivePair, + settings: &GlobalSettings, + creds: &CredentialStore, +) -> ReconcileOutcome { + let pair_id = pair.id.clone(); + let pair_name = pair.name.clone(); + + match reconcile_pair_inner(pair, settings, creds).await { + Ok(action) => ReconcileOutcome { + pair_id, + pair_name, + action, + }, + Err(e) => ReconcileOutcome { + pair_id, + pair_name, + action: Action::Failed(e.to_string()), + }, + } +} + +async fn reconcile_pair_inner( + pair: &DrivePair, + settings: &GlobalSettings, + creds: &CredentialStore, +) -> Result { + let _guard = lock::acquire(&pair.id).await; + + let local_reachable = check_local_reachable(&pair.local, settings); + let active = target::active_side(pair); + + if local_reachable { + if active == Some(Side::Local) { + return Ok(Action::NoOp); + } + switch_to(pair, settings, Side::Local, creds, active).await?; + return Ok(if active.is_some() { + Action::SwitchedToLocal + } else { + Action::MountedLocal + }); + } + + let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url); + if cloud_reachable { + if active == Some(Side::Cloud) { + return Ok(Action::NoOp); + } + switch_to(pair, settings, Side::Cloud, creds, active).await?; + return Ok(if active.is_some() { + Action::SwitchedToCloud + } else { + Action::MountedCloud + }); + } + + Ok(Action::NoOp) +} + +fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool { + match address::resolve_ip(&local.address, settings) { + Ok(ip) => network::is_reachable(&ip.to_string()), + Err(_) => false, + } +} + +/// Mountet `new_side` zuerst, flippt danach den sichtbaren Symlink, und hängt erst zum +/// Schluss `old_active` (falls vorhanden und verschieden) aus. Diese Reihenfolge stellt +/// sicher, dass der sichtbare `pair.mount_point` nie auf ein gerade ausgehängtes oder noch +/// nicht bereites Backing-Verzeichnis zeigt. +async fn switch_to( + pair: &DrivePair, + settings: &GlobalSettings, + new_side: Side, + creds: &CredentialStore, + old_active: Option, +) -> Result<()> { + mount_side(pair, settings, new_side, creds).await?; + target::activate_symlink(pair, new_side)?; + + if let Some(old_side) = old_active + && old_side != new_side + { + unmount_side(pair, settings, old_side).await?; + } + Ok(()) +} + +async fn mount_side( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, + creds: &CredentialStore, +) -> Result<()> { + let mount_target = target::build_target(pair, settings, side)?; + std::fs::create_dir_all(&mount_target.mount_point) + .map_err(|e| crate::error::Error::io(&mount_target.mount_point, e))?; + + let backend = mount::backend_for(target::side_kind(pair, side)); + backend.check_available()?; + let cred = creds.get(&pair.id, side).await?; + backend.prepare(&mount_target, cred.as_ref())?; + backend.mount(&mount_target) +} + +/// Hängt aus, was aktuell aktiv ist (siehe [`target::active_side`]), unabhängig von der +/// Erreichbarkeit - für `smart-mount unmount`. Der Symlink bleibt bestehen (zeigt danach auf +/// ein leeres, ausgehängtes Backing-Verzeichnis) - der nächste `mount`/`watch`-Lauf räumt das +/// beim erneuten Aktivieren automatisch auf. +pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result { + let _guard = lock::acquire(&pair.id).await; + + let Some(side) = target::active_side(pair) else { + return Ok(Action::NoOp); + }; + unmount_side(pair, settings, side).await?; + Ok(Action::NoOp) +} + +async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -> Result<()> { + let mount_target = target::build_target(pair, settings, side)?; + mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CloudSide, MountContext, MountKind}; + use std::net::Ipv4Addr; + + fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair { + DrivePair { + 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 { + kind: local_kind, + address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)), + share: "share".into(), + username: None, + extra_options: vec!["vers=3.0".into()], + }, + cloud: CloudSide { + kind: cloud_kind, + host_or_url: "https://cloud.example.com/dav".into(), + share: "share".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn local_source_formats_smb_unc_path() { + let pair = sample_pair(MountKind::Smb, MountKind::WebDav); + let source = target::local_source(&pair.local, &GlobalSettings::default()); + assert_eq!(source, "//192.168.1.10/share"); + } + + #[test] + fn build_target_uses_backing_dir_not_visible_mount_point() { + let pair = sample_pair(MountKind::Smb, MountKind::WebDav); + let t = + target::build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build"); + assert_ne!(t.mount_point, pair.mount_point); + assert_eq!(t.mount_point, target::backing_dir(&pair, Side::Local)); + } +} diff --git a/src/systemd/mod.rs b/src/systemd/mod.rs new file mode 100644 index 0000000..ed265a6 --- /dev/null +++ b/src/systemd/mod.rs @@ -0,0 +1,392 @@ +//! Generiert/installiert systemd-Units (System- und User-Kontext) sowie das Crontab-Äquivalent +//! für Systeme ohne systemd. +//! +//! Die systemd-Units rufen ausschließlich einfache `smart-mount`-Subcommands auf, damit +//! dieselben Zeilen 1:1 als Crontab-Einträge funktionieren. + +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() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "/usr/bin/smart-mount".to_string()) +} + +fn mount_service_unit() -> String { + format!( + "[Unit]\nDescription=smart-mount: mount configured drive pairs at boot\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart={} mount --all\n\n[Install]\nWantedBy=multi-user.target\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 { + 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()) + .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 '. \ + 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. +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 { + 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. +pub fn crontab_equivalent(watch_interval_secs: u64) -> String { + let bin = binary_path(); + let schedule = cron_schedule_for_interval(watch_interval_secs); + format!("@reboot {bin} mount --all\n{schedule} {bin} watch\n") +} + +/// Ergebnis von [`install_cron`]. +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 { + 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 { + if !sudo_ctdra::is_run_as_root() { + return Err(Error::RequiresRoot("service crontab (system context)")); + } + 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")); + std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(CRON_D_PATH, std::fs::Permissions::from_mode(0o644)) + .map_err(|e| Error::io(CRON_D_PATH, e))?; + } + Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH))) +} + +fn install_cron_user(watch_interval_secs: u64) -> Result { + 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) +} + +/// Ergebnis von [`uninstall_cron`]. +pub enum CronUninstallOutcome { + /// Ein verwalteter Cron-Eintrag wurde gefunden und entfernt. + Removed, + /// Kein von smart-mount verwalteter Cron-Eintrag vorhanden - nichts zu tun. + NotPresent, +} + +/// 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 { + match scope { + Scope::System => uninstall_cron_system(), + Scope::User => uninstall_cron_user(), + } +} + +fn uninstall_cron_system() -> Result { + if !sudo_ctdra::is_run_as_root() { + return Err(Error::RequiresRoot( + "service uninstall (system context, cron)", + )); + } + let path = Path::new(CRON_D_PATH); + if !path.exists() { + return Ok(CronUninstallOutcome::NotPresent); + } + std::fs::remove_file(path).map_err(|e| Error::io(CRON_D_PATH, e))?; + Ok(CronUninstallOutcome::Removed) +} + +fn uninstall_cron_user() -> Result { + 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 +/// umgestellt (`0 */N * * *`); Intervalle über 24h werden grob als "täglich um Mitternacht" +/// angenähert, da ein reiner `*/N`-Ausdruck das nicht mehr sauber abbilden kann. +fn cron_schedule_for_interval(interval_secs: u64) -> String { + let minutes = ((interval_secs as f64 / 60.0).round() as u64).max(1); + if minutes <= 59 { + format!("*/{minutes} * * * *") + } else { + let hours = ((minutes as f64 / 60.0).round() as u64).max(1); + if hours <= 23 { + format!("0 */{hours} * * *") + } else { + "0 0 * * *".to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_cron_block_for_user_crontab_has_no_user_field() { + let block = managed_cron_block(120, None); + 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("*/2 * * * * root ")); + } + + #[test] + fn default_watch_interval_maps_to_every_two_minutes() { + assert_eq!(cron_schedule_for_interval(120), "*/2 * * * *"); + } + + #[test] + fn rounds_to_the_nearest_minute() { + assert_eq!(cron_schedule_for_interval(90), "*/2 * * * *"); + assert_eq!(cron_schedule_for_interval(80), "*/1 * * * *"); + } + + #[test] + fn sub_minute_intervals_clamp_to_one_minute() { + assert_eq!(cron_schedule_for_interval(30), "*/1 * * * *"); + assert_eq!(cron_schedule_for_interval(1), "*/1 * * * *"); + } + + #[test] + fn switches_to_hourly_expression_above_59_minutes() { + assert_eq!(cron_schedule_for_interval(60 * 90), "0 */2 * * *"); + } + + #[test] + fn very_long_intervals_fall_back_to_daily_at_midnight() { + assert_eq!(cron_schedule_for_interval(60 * 60 * 30), "0 0 * * *"); + } + + #[test] + fn crontab_equivalent_includes_both_reboot_and_watch_lines() { + let output = crontab_equivalent(120); + assert!(output.contains("@reboot")); + assert!(output.contains("mount --all")); + assert!(output.contains("*/2 * * * *")); + assert!(output.contains("watch")); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..cf93ea9 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,56 @@ +//! 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 +} + +#[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" + ); + } +} From 2a4619c8e811406a8f0d8cf5790ab53993eef236 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 00:50:30 +0200 Subject: [PATCH 03/28] Docs: Dokumentiert SmartMount 2.0.0 in der README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beschreibt die neue Architektur (Laufwerkspaare, Mount-Backends, verschlüsselte Zugangsdaten, mac2ip-Integration), Voraussetzungen, Setup-Ablauf (setup fstab, service install) sowie die vollständige CLI-Referenz. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FZ3VzCWgbQRMyFEEKPvnZz --- README.md | 313 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 305 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0ce2061..1e32030 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,311 @@ # SmartMount -SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das -automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten -Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf -wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs. +SmartMount 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](https://github.com/tursodatabase/turso)-Datenbank +gespeichert, niemals im Klartext in der Konfigurationsdatei. Das Command heißt `smart-mount`. -### Build: +Die Synchronisation der Inhalte zwischen lokalem und Cloud-Laufwerk ist **nicht** Teil dieses +Programms - SmartMount setzt voraus, dass beide Seiten bereits inhaltlich synchron gehalten +werden (z. B. über ein separates Sync-Tool). -Für Debian muss `cargo-deb` installiert sein, dann kann man das Paket mit diesem Paket builden: +--- -```shell -cargo deb --separate-debug-symbols --compress-debug-symbols +## 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). +- **`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. +- **Pro genutztem Mount-Typ** (nur was tatsächlich konfiguriert ist, wird zur Laufzeit + geprüft - siehe `MountBackend::check_available`): + - `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. + +--- + +## CLI-Nutzung + +```bash +# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Kontext, Mount-Typ, Adresse, +# Freigabe und ggf. Zugangsdaten ab) +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 \ + --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" +# (--local-password/--cloud-password gehen auch direkt als Argument, sind aber im +# Prozess-Listing/Shell-Verlauf sichtbar - für Skripte lieber *-stdin verwenden) + +# 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 --name "Neuer Name" --local-ip 192.168.1.99 +smart-mount drive edit --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 + +# Ein einzelnes Paar oder alle einbinden/aushängen +smart-mount mount --name +smart-mount mount --all +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 + +# systemd-Units installieren (System- bzw. Nutzerkontext) +sudo smart-mount service install --system +smart-mount service install --user + +# 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 +smart-mount doctor +smart-mount doctor --json + +# Shell-Completion-Skript ausgeben (bash/zsh/fish/elvish/powershell) +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. + +### Wo die Laufwerke eingebunden werden + +Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir` +(`/`) - mehrere Paare stören sich also nie gegenseitig. Standardwert +für `mount_base_dir`: `/run/media/smart-mount` im System-Kontext (root, ein gemeinsamer, +keinem Nutzer zugeordneter Namensraum), `/run/media//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. + +**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/` +sowie `/run/media//smart-mount` an und macht den Nutzer zum Besitzer) - ohne diesen +Schritt schlägt das automatische Anlegen für Nutzer-Kontext-Paare fehl. + +Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit +auf einen beliebigen anderen Pfad ändern. + +--- + +## Automatischer Start beim Systemstart + +```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 +``` + +`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 +@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 + +- **Verhalten bei nicht mehr erreichbarem Server (der ganze Sinn dieses Tools)**: Netzwerk- + Dateisysteme können unter Linux "einfrieren", wenn der Server verschwindet - `umount` + oder Schreibzugriffe hängen dann scheinbar unbegrenzt. Das wird gezielt adressiert: + - **NFS**: Standardmäßig setzt `mount.nfs` `hard` (unbegrenzte Wiederholungsversuche bei + Zeitüberschreitung) - genau das Einfrierverhalten. SmartMount setzt automatisch `soft`, + sofern nicht bereits `hard`/`soft`/`softerr` explizit in `extra_options` gesetzt ist + (`man 5 nfs`, Abschnitt "soft / softerr / hard" nennt einen dauerhaft nicht erreichbaren + Server explizit als Zielszenario für `soft`). + - **CIFS**: `mount.cifs` verwendet laut eigener Dokumentation bereits standardmäßig `soft` - + kein Eingriff nötig. + - **WebDAV (davfs2)**: `umount` ist laut davfs2-eigener FAQ *absichtlich* so lange + blockierend, bis alle zwischengespeicherten Daten geschrieben sind - bei einem dauerhaft + unerreichbaren Server kehrt das nie zurück. Dafür gibt es keine passende Mount-Option. + `umount -l` (lazy) würde zwar sofort zurückkehren, macht laut `man umount` aber + **künftige Remounts derselben Freigabe bis zum nächsten Neustart unmöglich** - für ein + Tool, dessen ganzer Zweck automatisches Zurückschalten ist, wäre das schlimmer als das + ursprüngliche Problem. + - **Der eigentliche Fix**: jeder `mount`/`umount`-Aufruf läuft über das coreutils-Tool + `timeout` (30s). Bei `umount` wird ein Abbruch wegen Zeitüberschreitung toleriert (nur + geloggt, `Ok`) statt als Fehler behandelt - der nächste `watch`-Durchlauf versucht es + erneut (idempotent). Dadurch kann `smart-mount watch`/`unmount` selbst dann nicht mehr + unbegrenzt hängen bleiben, wenn eine einzelne Seite dauerhaft nicht antwortet - und weil + 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. +- **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. +- **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 + dem `ls` in Verzeichnissen mit vielen Dateien einen leeren/unvollständigen Inhalt zeigt, + obwohl einzelne Dateien direkt geöffnet werden können (der FUSE-readdir-Puffer wird bei zu + 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. +- **`mac2ip`-Integration**: SmartMount ruft `mac2ip --json --auto-trust-networks ` 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 + erspart SmartMount, das Konfigurationsschema eines fremden Tools zu kennen oder dort direkt + hineinzuschreiben. + +--- + +## Struktur des Repositories + +```text +├── .cargo/ +│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry) +├── .gitea/ +│ └── workflows/ # CI/CD-Pipelines (Build, Tests, Security-Scans, Releases) +├── scripts/ +│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer +│ ├── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen +│ └── report-security-issue.py # Security-Scan-Ergebnisse als Gitea-Issue melden +├── src/ +│ ├── main.rs # Dünner Einstiegspunkt (CLI-Parsing, Dispatch) +│ ├── lib.rs # Bibliotheks-Wurzel +│ ├── cli/ # `clap`-Subcommands +│ ├── config/ # Konfigurationsschema + CRUD (config-ctdra) +│ ├── crypto/ # Verschlüsselung + Master-Key-Auflösung +│ ├── db/ # Verschlüsselte Zugangsdaten (Turso) +│ ├── 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 +├── tests/ # Cross-Modul-Integrationstests +├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch) +├── LICENSE # Lizenzdatei (GPL-3.0-or-later) +├── AGENTS.md # Richtlinien und Leitfaden für KI-Coding-Agenten +└── README.md # Diese Datei +``` + +--- + +## Lokale Entwicklung + +```bash +cargo build +cargo test +cargo build --release + +# Debian-Paket bauen (.deb) +cargo deb + +# RPM-Paket bauen (.rpm) +cargo generate-rpm + +# Arch Linux-Paket bauen (.pkg.tar.zst) +python3 scripts/package-arch.py --arch x86_64 --pkgrel 1 +``` + +--- + +## CI/CD Workflow-Übersicht + +| Branch | Workflow | Paket-Kanal | Release-Typ | +| :--- | :--- | :--- | :--- | +| `main` | `.gitea/workflows/main.yaml` | `stable` | Offizielles Release (`v`) | +| `testing` | `.gitea/workflows/testing.yaml` | `testing` | Pre-Release (`v-preview`) | + +Zusätzlich: automatisierte Security-Scans (Trivy, OSV-Scanner, TruffleHog), Renovate für +Abhängigkeits-Updates, sowie automatisiertes `cargo fmt`/`cargo clippy --fix` auf dem +`dev`-Branch. + +--- + +## Lizenz + +[GPL-3.0-or-later](LICENSE). From 706494d106dc33e9d52b8614a379b3eef98fa257 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:05:29 +0200 Subject: [PATCH 04/28] Fix: Verhindert Datenverlust bei Master-Key-Backend-Wechsel und -Race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_master_key() erzeugte bei vorübergehend nicht erreichbarem OS-Keyring (z. B. unter systemd --user ohne D-Bus-Secret-Service) stillschweigend einen NEUEN Datei-Schlüssel statt den zuvor genutzten Keyring-Schlüssel weiterzuverwenden - bereits verschlüsselte Zugangsdaten wurden dadurch dauerhaft unentschlüsselbar. Welches Backend (Keyring oder Datei) genutzt wird, wird jetzt beim ersten Aufruf in einer Marker-Datei festgehalten und danach konsistent wiederverwendet. Zusätzlich: sowohl die Datei- als auch die Keyring-Variante von load_or_create() gingen bei zwei gleichzeitigen ersten Aufrufen (Race) unterschiedliche, sich widersprechende Schlüssel ein - beide lesen jetzt den tatsächlich persistierten Schlüssel zurück, statt blind ihren eigenen zu verwenden. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/crypto/key.rs | 99 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 20 deletions(-) diff --git a/src/crypto/key.rs b/src/crypto/key.rs index 016585c..72a9c91 100644 --- a/src/crypto/key.rs +++ b/src/crypto/key.rs @@ -16,24 +16,60 @@ 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. pub fn resolve_master_key() -> Result<[u8; 32]> { if sudo_ctdra::is_run_as_root() { return file_key::load_or_create(&key_file_path()); } - match keyring_key::load_or_create() { - Ok(key) => Ok(key), - Err(reason) => { - logger_ctdra::warn( - "crypto", - &format!("OS keyring not available ({reason}), using key file"), - ); - 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() + ), + ); } } @@ -45,6 +81,14 @@ 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::*; @@ -89,13 +133,16 @@ mod file_key { #[cfg(not(unix))] let mut opts = OpenOptions::new(); - let mut file = opts - .write(true) - .create_new(true) - .open(path) - .map_err(|e| Error::io(path, e))?; - file.write_all(&key).map_err(|e| Error::io(path, e))?; - Ok(key) + match opts.write(true).create_new(true).open(path) { + Ok(mut file) => { + file.write_all(&key).map_err(|e| Error::io(path, e))?; + Ok(key) + } + // Another process won the race and created the file first; use its key instead of + // silently generating our own (which would desynchronize the two processes' keys). + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read(path), + Err(e) => Err(Error::io(path, e)), + } } fn fill_random(buf: &mut [u8]) -> Result<()> { @@ -115,10 +162,22 @@ mod keyring_key { Ok(hex_key) => decode(&hex_key), Err(keyring::Error::NoEntry) => { let key = generate()?; - entry - .set_password(&encode(&key)) - .map_err(|e| format!("Could not store key in keyring: {e}"))?; - Ok(key) + 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}")), } From efabfdf7b11ece0efaeb01dedd0bebd452a82547 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:05:38 +0200 Subject: [PATCH 05/28] Fix: davfs2-secrets-Datei - Quoting und exaktes URL-Matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_secrets_entry()/remove_secrets_entry() identifizierten eine Zeile per l.trim_start().starts_with(url) - eine URL, die textlich Präfix einer anderen ist (z. B. .../dav vs. .../dav-archive), traf dadurch auf beide Zeilen und konnte das falsche Paar korrumpieren oder dessen Zugangsdaten löschen. Matching erfolgt jetzt exakt gegen das erste Feld der Zeile. Passwörter/Nutzernamen mit Leerraum wurden zudem unquotiert geschrieben - davfs2 trennt Felder per Leerraum und hätte ein Passwort mit Leerzeichen am ersten Leerzeichen abgeschnitten. Werte mit Leerraum werden jetzt in Anführungszeichen gefasst (siehe man davfs2.conf). Leere Nutzername/Passwort-Felder werden zusätzlich vor dem Schreiben abgelehnt statt eine kaputte Zeile zu erzeugen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/mount/webdav.rs | 85 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs index e9c47fc..816d70f 100644 --- a/src/mount/webdav.rs +++ b/src/mount/webdav.rs @@ -43,6 +43,11 @@ impl MountBackend for WebDavBackend { if let Some(cred) = cred && let Some(username) = &cred.username { + if username.is_empty() || cred.password.is_empty() { + return Err(Error::Other( + "davfs2 credential has an empty username or password".to_string(), + )); + } write_secrets_entry( &davfs2_secrets_path(), &target.source, @@ -154,16 +159,43 @@ fn ensure_config_line(path: &Path, key: &str, value: &str, comment: &str) -> Res fs::write(path, format!("{}\n", new_lines.join("\n"))).map_err(|e| Error::io(path, e)) } +/// Erste (durch Leerraum getrennte oder in Anführungszeichen gefasste) Spalte einer +/// `secrets`-Zeile - die URL, gegen die eine Zeile identifiziert wird. +fn secrets_entry_url(line: &str) -> Option<&str> { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix('"') { + return rest.split('"').next(); + } + trimmed.split_whitespace().next() +} + +/// davfs2 trennt Felder in der `secrets`-Datei per Leerraum; enthält ein Wert Leerraum, muss +/// er nach `man davfs2.conf` in doppelte Anführungszeichen gefasst werden. Ohne diese +/// Behandlung würde davfs2 ein Passwort mit Leerzeichen stillschweigend am ersten Leerzeichen +/// abschneiden. +fn quote_secrets_field(value: &str) -> String { + if value.chars().any(char::is_whitespace) { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + value.to_string() + } +} + /// Schreibt/aktualisiert eine Zeile in davfs2s `secrets`-Datei (` `, /// muss chmod 600 sein). Ersetzt eine bestehende Zeile für dieselbe URL statt sie zu duplizieren. fn write_secrets_entry(path: &PathBuf, url: &str, username: &str, password: &str) -> Result<()> { let existing = fs::read_to_string(path).unwrap_or_default(); let mut lines: Vec = existing .lines() - .filter(|l| !l.trim_start().starts_with(url)) + .filter(|l| secrets_entry_url(l) != Some(url)) .map(str::to_string) .collect(); - lines.push(format!("{url} {username} {password}")); + lines.push(format!( + "{} {} {}", + quote_secrets_field(url), + quote_secrets_field(username), + quote_secrets_field(password) + )); if let Some(dir) = path.parent() { fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; @@ -213,7 +245,7 @@ pub(crate) fn remove_secrets_entry(path: &Path, url: &str) -> Result<()> { }; let remaining: Vec<&str> = existing .lines() - .filter(|l| !l.trim_start().starts_with(url)) + .filter(|l| secrets_entry_url(l) != Some(url)) .collect(); if remaining.len() == existing.lines().count() { return Ok(()); @@ -325,6 +357,34 @@ mod tests { assert!(!contents.contains("old-pass")); } + #[test] + fn write_secrets_entry_quotes_a_password_containing_whitespace() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets"); + + write_secrets_entry(&path, "https://cloud/dav", "user", "my pass word").expect("write"); + + let contents = fs::read_to_string(&path).expect("read"); + assert!(contents.contains("\"my pass word\"")); + } + + #[test] + fn write_secrets_entry_does_not_clobber_a_url_that_is_a_prefix_of_another() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets"); + + write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1"); + write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2") + .expect("write 2"); + write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1-updated") + .expect("write 3"); + + 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"); + } + #[test] fn remove_secrets_entry_deletes_only_the_matching_url() { let dir = tempfile::tempdir().expect("tempdir"); @@ -342,6 +402,25 @@ mod tests { assert!(contents.contains("pass2")); } + #[test] + fn remove_secrets_entry_does_not_remove_a_url_that_the_target_url_is_a_prefix_of() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("secrets"); + + write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1"); + write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2") + .expect("write 2"); + + remove_secrets_entry(&path, "http://192.168.1.5/dav").expect("remove"); + let contents = fs::read_to_string(&path).expect("read"); + + assert!(!contents.contains("pass1")); + assert!( + contents.contains("http://192.168.1.5/dav-archive") && contents.contains("pass2"), + "removing the shorter URL must not delete the longer URL's entry" + ); + } + #[test] fn remove_secrets_entry_is_a_noop_for_missing_file_or_unmatched_url() { let dir = tempfile::tempdir().expect("tempdir"); From bb9290c113126472be44eeae1b9d657d53246ffb Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:05:52 +0200 Subject: [PATCH 06/28] Fix: fstab-Setup - Mehrnutzer-sicheres Merging statt Komplett-Ersetzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup() ersetzte den gemeinsam genutzten verwalteten Block in /etc/fstab bei jedem Lauf komplett anhand der aktuell geladenen (nutzerspezifischen) Konfiguration. Führte ein zweiter Nutzer `sudo smart-mount setup fstab` für sein eigenes Konto aus, wurden dadurch die zuvor vom ersten Nutzer installierten Zeilen stillschweigend gelöscht. write_managed_block() führt den Block jetzt zeilenweise zusammen (jede Zeile trägt einen Tag-Kommentar mit Paar-ID/Seite/Besitzer): eigene, jetzt gelöschte Paare werden entfernt, Zeilen anderer Nutzer bleiben unangetastet. Zusätzlich behoben: - render_managed_block() brach beim ersten Paar, dessen Neuberechnung fehlschlug (z. B. ein gerade offline-MAC-adressiertes Gerät), die gesamte Blockerstellung ab - inklusive aller anderen, gesunden Paare. Fehlschläge werden jetzt pro Paar/Seite geloggt und übersprungen, die bestehende Zeile bleibt in diesem Fall erhalten. - /etc/fstab wurde per direktem std::fs::write (Trunkieren) statt atomar geschrieben - ein Absturz mitten im Schreiben konnte die Datei in einem leeren/kaputten Zustand zurücklassen. Alle Schreib- zugriffe (setup, teardown) laufen jetzt über Temp-Datei + rename. - user_home_dir() erriet bei fehlgeschlagener getent/passwd-Auflösung stillschweigend "/home/" - loggt jetzt eine Warnung. util.rs bekommt dafür extract_managed_block() als Gegenstück zu strip_managed_block(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/fstab/mod.rs | 294 ++++++++++++++++++++++++++++++++++++++++++++--- src/util.rs | 47 ++++++++ 2 files changed, 327 insertions(+), 14 deletions(-) diff --git a/src/fstab/mod.rs b/src/fstab/mod.rs index bd4d5e4..f4ea1c2 100644 --- a/src/fstab/mod.rs +++ b/src/fstab/mod.rs @@ -11,7 +11,8 @@ //! zur Laufzeit zwischen den beiden Backing-Verzeichnissen umschaltet (siehe //! [`crate::reconcile`]). -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::{AppConfig, DrivePair, GlobalSettings, MountContext, MountKind}; @@ -38,6 +39,17 @@ pub fn setup() -> Result<()> { ))); } + 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 @@ -57,7 +69,24 @@ pub fn setup() -> Result<()> { ensure_group_membership(pair)?; } - write_managed_block(&user_pairs, &cfg.settings)?; + // 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 = match &sudo_user { + Some(u) => vec![u.clone()], + None => user_pairs + .iter() + .filter_map(|p| p.owner_user.clone()) + .collect::>() + .into_iter() + .collect(), + }; + + write_managed_block(&user_pairs, &cfg.settings, &owner_scope)?; logger_ctdra::info( "fstab", @@ -103,7 +132,7 @@ pub fn teardown() -> Result { backup(&fstab_path, &existing)?; let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER); - std::fs::write(&fstab_path, without_block).map_err(|e| Error::io(&fstab_path, e))?; + write_atomic(&fstab_path, &without_block)?; Ok(FstabTeardownOutcome::Removed) } @@ -205,23 +234,29 @@ fn ensure_group_membership(pair: &DrivePair) -> Result<()> { Ok(()) } -fn write_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result<()> { +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 block = render_managed_block(pairs, settings)?; + let current_block = + crate::util::extract_managed_block(&existing, BEGIN_MARKER, END_MARKER).unwrap_or_default(); + let new_block = merge_managed_block(¤t_block, pairs, settings, owner_scope); let new_contents = format!( "{}\n{}\n{}\n{}\n", without_block.trim_end(), BEGIN_MARKER, - block.trim_end(), + new_block.trim_end(), END_MARKER ); - std::fs::write(&fstab_path, new_contents).map_err(|e| Error::io(&fstab_path, e)) + write_atomic(&fstab_path, &new_contents) } fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> { @@ -231,13 +266,134 @@ fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> { Ok(()) } -fn render_managed_block(pairs: &[&DrivePair], settings: &GlobalSettings) -> Result { - let mut lines = Vec::new(); - for pair in pairs { - lines.push(fstab_line(pair, Side::Local, settings)?); - lines.push(fstab_line(pair, Side::Cloud, settings)?); +/// 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)) +} + +fn side_str(side: Side) -> &'static str { + match side { + Side::Local => "local", + Side::Cloud => "cloud", } - Ok(lines.join("\n")) +} + +/// 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_str(side), + pair.owner_user.as_deref().unwrap_or("-") + ) +} + +fn tagged_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result { + 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_str(side))); + 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_str(side) + ), + ); + } + } + } + } + + let mut kept: Vec = 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 { @@ -288,6 +444,53 @@ fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result )) } +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 { + 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/` 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) @@ -345,7 +548,7 @@ mod tests { #[test] fn renders_two_lines_per_pair_each_with_its_own_unique_target() { let pair = sample_pair(); - let block = render_managed_block(&[&pair], &GlobalSettings::default()).expect("render"); + let block = merge_managed_block("", &[&pair], &GlobalSettings::default(), &[]); let lines: Vec<&str> = block.lines().collect(); assert_eq!(lines.len(), 2); @@ -377,6 +580,60 @@ mod tests { ); } + #[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"; @@ -422,4 +679,13 @@ mod tests { 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")); + } } diff --git a/src/util.rs b/src/util.rs index cf93ea9..7870fc9 100644 --- a/src/util.rs +++ b/src/util.rs @@ -25,6 +25,35 @@ pub(crate) fn strip_managed_block(contents: &str, begin_marker: &str, end_marker 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 { + 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::*; @@ -53,4 +82,22 @@ mod tests { "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 + ); + } } From bc324dd4b780feac6a3b428eb535510ed12a1bda Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:06:03 +0200 Subject: [PATCH 07/28] Fix: drive remove/add - kein blockierender Unmount, kein stiller Fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drive remove` brach bei einem Unmount-Fehler (z. B. fehlende Rechte, weil das Paar in einem anderen Kontext gemountet wurde) komplett ab, bevor das Paar aus der Konfiguration entfernt wurde - der Nutzer kam dann nicht mehr an sein eigenes `drive remove` heran. Der Unmount läuft jetzt best-effort (wie das bereits bestehende cleanup_credentials), ein Fehler wird geloggt statt zu blockieren. `drive add` fiel bei einem fehlgeschlagenen Konfigurations-Laden (z. B. korrupte Datei - eine fehlende Datei liefert bereits Standardwerte) still auf GlobalSettings::default() zurück. Das hätte nicht nur einen abweichenden mount_base_dir ignoriert, sondern auch das nachfolgende add_pair() (das intern selbst neu lädt) auf denselben kaputten Zustand treffen lassen - mit dem Risiko, dass alle bestehenden Paare durch die eine neue Konfiguration ersetzt werden. Ein Ladefehler bricht jetzt früh mit einer klaren Fehlermeldung ab. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/cli/drive.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/cli/drive.rs b/src/cli/drive.rs index 5a28d7f..35a08af 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -127,6 +127,17 @@ async fn remove(id: &str) -> anyhow::Result<()> { let cfg = config::pairs::load()?; let pair = config::pairs::find_pair(&cfg, id)?; + // Best-effort wie `cleanup_credentials` weiter unten: ein Unmount-Fehler (z. B. weil das + // Paar von einem anderen Nutzer/Kontext gemountet wurde und wir keine Berechtigung haben) + // darf das Entfernen aus der Konfiguration nicht blockieren - sonst käme der Nutzer nicht + // mehr an sein eigenes `drive remove` heran, ohne vorher erst als root/mit den richtigen + // Rechten manuell auszuhängen. + if let Err(e) = smart_mount::reconcile::unmount_pair(&pair, &cfg.settings).await { + logger_ctdra::warn( + "drive", + &format!("could not unmount drive pair '{id}' before removal: {e}"), + ); + } config::pairs::remove_pair(id)?; let creds = CredentialStore::open().await?; creds.delete(id, None).await?; @@ -173,9 +184,15 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { )?; let id = uuid::Uuid::new_v4().to_string(); - let settings = config::pairs::load() - .map(|c| c.settings) - .unwrap_or_default(); + // Bewusst KEIN `unwrap_or_default()`: ein Ladefehler bedeutet eine kaputte/korrupte + // Konfigurationsdatei (eine fehlende Datei liefert bereits Standardwerte, siehe + // `config_ctdra::load`), nicht "noch keine Konfiguration vorhanden". Stillschweigend mit + // `GlobalSettings::default()` weiterzumachen würde nicht nur einen ggf. abweichenden + // `mount_base_dir` ignorieren, sondern - schwerwiegender - auch das nachfolgende + // `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 pair = DrivePair { From 2b3514ab66afc4ebe0384199d60b10c30a296b32 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:06:11 +0200 Subject: [PATCH 08/28] =?UTF-8?q?Fix:=20mountinfo-Vergleich=20entschl?= =?UTF-8?q?=C3=BCsselt=20oktal=20escapte=20Pfade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /proc/self/mountinfo escaped Leerzeichen/Tab/Newline/Backslash im Mountpoint-Feld oktal (siehe man 5 proc), der Vergleich erfolgte aber gegen den unescapten, tatsächlichen Pfad - ein Mountpoint mit z. B. einem Leerzeichen wurde dadurch nie als gemountet erkannt. parse_mountinfo_source() entschlüsselt das Feld jetzt vor dem Vergleich (byteweise, um mehrbytige UTF-8-Zeichen im Pfad nicht zu zerlegen). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/mount/state.rs | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/mount/state.rs b/src/mount/state.rs index c95ffea..caa62c5 100644 --- a/src/mount/state.rs +++ b/src/mount/state.rs @@ -30,7 +30,12 @@ fn parse_mountinfo_source(mountinfo: &str, mount_point: &Path) -> Option let fields: Vec<&str> = line.split_whitespace().collect(); // Feld 4 (Index 4) ist der Mountpoint; danach folgen optionale Felder bis zum // Trenner "-", danach fs_type (Index+1) und source (Index+2). - if fields.len() < 5 || fields[4] != target { + // + // Der Kernel escaped Leerzeichen/Tab/Newline/Backslash in Pfadfeldern oktal + // (siehe `man 5 proc`, Abschnitt zu mountinfo) - ohne `unescape_octal_field` würde ein + // Mountpoint mit z. B. einem Leerzeichen im Pfad hier nie als "gemountet" erkannt, weil + // `\040` niemals gleich einem echten Leerzeichen ist. + if fields.len() < 5 || unescape_octal_field(fields[4]) != target { continue; } let Some(dash_pos) = fields.iter().position(|&f| f == "-") else { @@ -44,6 +49,36 @@ fn parse_mountinfo_source(mountinfo: &str, mount_point: &Path) -> Option result } +/// Kehrt die oktale Escape-Kodierung um, die der Kernel in `/proc/self/mountinfo` für +/// Leerzeichen (`\040`), Tab (`\011`), Newline (`\012`) und Backslash (`\134`) in Pfadfeldern +/// verwendet (siehe `man 5 proc`). +fn unescape_octal_field(field: &str) -> std::borrow::Cow<'_, str> { + if !field.contains('\\') { + return std::borrow::Cow::Borrowed(field); + } + + // Arbeitet auf rohen Bytes statt `char`s: ein Byte einer mehrbytigen UTF-8-Sequenz per + // `as char` in einen eigenständigen `char` umzuwandeln würde nicht-ASCII-Zeichen im Pfad + // (z. B. Umlaute) in mehrere falsche Codepoints zerlegen. + let bytes = field.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\\' + && i + 3 < bytes.len() + && let Ok(octal) = std::str::from_utf8(&bytes[i + 1..i + 4]) + && let Ok(value) = u8::from_str_radix(octal, 8) + { + out.push(value); + i += 4; + } else { + out.push(bytes[i]); + i += 1; + } + } + std::borrow::Cow::Owned(String::from_utf8_lossy(&out).into_owned()) +} + #[cfg(test)] mod tests { use super::*; @@ -66,6 +101,13 @@ mod tests { assert_eq!(source, None); } + #[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 source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/my drive")); + assert_eq!(source.as_deref(), Some("//server/share")); + } + #[test] fn last_matching_entry_wins_for_stacked_mounts() { let mountinfo = "36 35 98:0 / /mnt/x rw - nfs server:/export rw\n\ From d4d80be1bf354db62239c735f736278bd67605ab Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:06:20 +0200 Subject: [PATCH 09/28] =?UTF-8?q?Fix:=20TOCTOU=20in=20remove=5Fpair()=20be?= =?UTF-8?q?i=20nebenl=C3=A4ufigem=20Schreiber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die "vorher"-Länge wurde über einen separaten, vorgelagerten load() ermittelt statt innerhalb desselben atomaren modify()-Aufrufs - zwischen beiden Aufrufen konnte ein nebenläufiger Schreiber die Paarliste ändern, was zu einem falschen PairNotFound trotz erfolgreichem Entfernen führen konnte. Der Vorher/Nachher-Vergleich läuft jetzt vollständig innerhalb des modify()-Closures. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/config/pairs.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/config/pairs.rs b/src/config/pairs.rs index a8c902d..63aa67c 100644 --- a/src/config/pairs.rs +++ b/src/config/pairs.rs @@ -31,11 +31,18 @@ pub fn update_pair(pair: DrivePair) -> Result { /// Entfernt ein Laufwerkspaar per ID. pub fn remove_pair(id: &str) -> Result { - let before_len = load()?.pairs.len(); + // Die "vorher"-Länge wird INNERHALB desselben `modify`-Aufrufs (auf der bereits frisch + // geladenen `cfg`) ermittelt statt über einen separaten, vorgelagerten `load()`-Aufruf: + // zwischen zwei getrennten Aufrufen könnte ein nebenläufiger Schreiber die Paarliste + // ändern, was hier sonst zu einem falschen `PairNotFound` trotz erfolgreichem Entfernen + // (oder umgekehrt) führen könnte. + let removed = std::cell::Cell::new(false); let updated = config_ctdra::modify::(|cfg| { + let before_len = cfg.pairs.len(); cfg.pairs.retain(|p| p.id != id); + removed.set(cfg.pairs.len() != before_len); })?; - if updated.pairs.len() == before_len { + if !removed.get() { return Err(Error::PairNotFound(id.to_string())); } Ok(updated) From 6695a61e0494a5228ca44fd00659399c96109b90 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:06:28 +0200 Subject: [PATCH 10/28] Fix: default_mount_base_dir() ohne festen "user"-Platzhalter-Fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei fehlendem $USER (z. B. unter systemd --user) fiel die Funktion auf den festen String "user" zurück - zwei verschiedene reale Nutzer hätten sich dadurch denselben Mountpoint-Namensraum /run/media/user/smart-mount geteilt, obwohl die Aufteilung nach Nutzername genau das verhindern soll. Fällt jetzt zuerst auf `id -un`, dann auf eine UID-basierte Kennung zurück, bevor als letzter Ausweg weiterhin "user" verwendet wird. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/config/schema.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/config/schema.rs b/src/config/schema.rs index 0b14b7f..cfb536d 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -19,7 +19,29 @@ fn default_mount_base_dir() -> PathBuf { if sudo_ctdra::is_run_as_root() { PathBuf::from("/run/media/smart-mount") } else { - let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string()); + // 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") } } From 690f727600350f13ccb79d4f3e22cf55e68a14e3 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:06:37 +0200 Subject: [PATCH 11/28] =?UTF-8?q?Fix:=20WebDAV-Credential-Cleanup=20loggt?= =?UTF-8?q?=20Aufl=C3=B6sungsfehler=20statt=20zu=20schweigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanup_side_credentials() gab bei einem Fehler beim Auflösen der lokalen Quelladresse (für den WebDAV-Zweig) kommentarlos auf, statt den Fehler zu loggen - eine zurückbleibende Klartext-davfs2-secrets- Zeile für ein entferntes Paar blieb dadurch spurlos zurück. Fehler wird jetzt mit Kontext geloggt, bevor die Funktion (weiterhin best-effort) zurückkehrt. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/mount/mod.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mount/mod.rs b/src/mount/mod.rs index c8fcdfc..c045f82 100644 --- a/src/mount/mod.rs +++ b/src/mount/mod.rs @@ -200,7 +200,21 @@ fn cleanup_side_credentials( } MountKind::WebDav => { let source = match side { - Side::Local => target::local_source(&pair.local, settings), + Side::Local => match target::local_source(&pair.local, settings) { + Ok(s) => s, + Err(e) => { + logger_ctdra::warn( + "mount", + &format!( + "Could not resolve local source for pair '{}' to clean up its \ + davfs2 secrets entry: {e} - a stale plaintext credential may \ + remain on disk", + pair.id + ), + ); + return; + } + }, Side::Cloud => target::cloud_source(&pair.cloud), }; let path = webdav::davfs2_secrets_path(); From fa1592e18e20c4231ffb07afe529374af5e0a5fb Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:06:44 +0200 Subject: [PATCH 12/28] Fix: cifs-Credentials-Datei lehnt Newlines und leeres Passwort ab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_credentials_file() schrieb username/password/domain ungeprüft in die zeilenbasierte (key=value) Credentials-Datei für mount.cifs - ein eingebettetes Newline in einem Feld hätte eine zusätzliche, nicht vorgesehene Zeile einschleusen können (z. B. ein überraschendes domain= oder ein zweites password=, das das eigentliche überschreibt). Felder mit Newline/Carriage-Return sowie ein leeres Passwort werden jetzt vor dem Schreiben abgelehnt. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/mount/smb.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/mount/smb.rs b/src/mount/smb.rs index 2553039..29789e9 100644 --- a/src/mount/smb.rs +++ b/src/mount/smb.rs @@ -75,7 +75,33 @@ pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> Pa base.join(format!("{pair_id}-{}.cred", side.as_str())) } +/// `mount.cifs`s Credentials-Datei ist zeilenbasiert (`key=value`) - ein eingebettetes +/// Newline in einem Feld würde eine zusätzliche, nicht vorgesehene `key=value`-Zeile +/// einschleusen (z. B. ein überraschendes `domain=...` oder ein zweites `password=`, das das +/// eigentliche überschreibt). Ein leeres Passwort ist ebenfalls kein sinnvoller Wert. +fn validate_credential_field(name: &str, value: &str) -> Result<()> { + if value.contains(['\n', '\r']) { + return Err(Error::Other(format!( + "cifs credential field '{name}' must not contain a newline" + ))); + } + Ok(()) +} + fn write_credentials_file(path: &PathBuf, cred: &Credential) -> Result<()> { + if let Some(username) = &cred.username { + validate_credential_field("username", username)?; + } + validate_credential_field("password", &cred.password)?; + if cred.password.is_empty() { + return Err(Error::Other( + "cifs credential has an empty password".to_string(), + )); + } + if let Some(domain) = &cred.domain { + validate_credential_field("domain", domain)?; + } + if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?; } @@ -138,6 +164,33 @@ mod tests { assert!(contents.contains("domain=WORKGROUP")); } + #[test] + fn rejects_password_with_embedded_newline() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.cred"); + let cred = Credential { + username: Some("nasuser".to_string()), + domain: None, + password: "s3cret\ndomain=INJECTED".to_string(), + }; + + assert!(write_credentials_file(&path, &cred).is_err()); + assert!(!path.exists()); + } + + #[test] + fn rejects_empty_password() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.cred"); + let cred = Credential { + username: Some("nasuser".to_string()), + domain: None, + password: String::new(), + }; + + assert!(write_credentials_file(&path, &cred).is_err()); + } + #[test] fn credentials_path_differs_per_side() { let local = credentials_path("pair-1", Side::Local); From c8c3409b98fc68a79545dc29b8113f59cbaea523 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:07:27 +0200 Subject: [PATCH 13/28] Fix: switch_to() haengt bei fehlgeschlagener Symlink-Aktivierung wieder aus Schlug target::activate_symlink() nach dem Mounten der neuen Seite fehl, blieb diese neu gemountete Seite unbemerkt gemountet: der sichtbare Symlink zeigt weiterhin auf old_active, wodurch der naechste Reconcile-Durchlauf new_side nie wieder aushaengt - ein dauerhaft verwaister Mount. switch_to() haengt die neue Seite jetzt wieder aus, falls die Symlink-Aktivierung fehlschlaegt, bevor der Fehler weitergereicht wird. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/reconcile/mod.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index ae7a59a..bdb67f8 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -126,7 +126,16 @@ async fn switch_to( old_active: Option, ) -> Result<()> { mount_side(pair, settings, new_side, creds).await?; - target::activate_symlink(pair, new_side)?; + + // Falls das Aktivieren des Symlinks fehlschlägt, muss die gerade gemountete `new_side` + // wieder ausgehängt werden, statt sie unbemerkt gemountet zu lassen: der nächste + // Reconcile-Durchlauf sähe sonst weiterhin `old_active` als aktive Seite (der Symlink zeigt + // ja weiter darauf) und würde `new_side` nie wieder aushängen - ein dauerhaft verwaister + // Mount. + if let Err(e) = target::activate_symlink(pair, new_side) { + let _ = unmount_side(pair, settings, new_side).await; + return Err(e); + } if let Some(old_side) = old_active && old_side != new_side From 629959ad41102b62f41c30274fd06a1aae66824a Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Tue, 15 Sep 2026 23:09:01 +0200 Subject: [PATCH 14/28] Fix: status verwendet keinen Fake-Hostnamen fuer die Erreichbarkeitspruefung local_reachable() reichte bei fehlgeschlagener Adressaufloesung (MAC->IP) den Platzhalter-String "unresolved" an network::is_reachable() weiter - das loeste dort einen sinnlosen ping-Aufruf gegen einen nicht existierenden Hostnamen aus, statt korrekt "nicht erreichbar" zu melden. Gibt jetzt direkt false zurueck, wenn die Adresse nicht aufgeloest werden kann. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz --- src/cli/status.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cli/status.rs b/src/cli/status.rs index bd911be..4f42b39 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -48,8 +48,9 @@ pub async fn run(name: Option, json: bool) -> anyhow::Result<()> { name: pair.name.clone(), mount_point: pair.mount_point.display().to_string(), active, - local_source: target::local_source(&pair.local, &cfg.settings), - local_reachable: network::is_reachable(&local_src_host(&pair.local, &cfg.settings)), + local_source: target::local_source(&pair.local, &cfg.settings) + .unwrap_or_else(|_| "unresolved".to_string()), + local_reachable: local_reachable(&pair.local, &cfg.settings), cloud_source: target::cloud_source(&pair.cloud), cloud_reachable: network::is_reachable(&pair.cloud.host_or_url), } @@ -88,11 +89,16 @@ fn reachable_str(reachable: bool) -> &'static str { } } -fn local_src_host( +/// `false` wenn die Adresse (MAC->IP) gar nicht erst aufgelöst werden kann, statt den +/// Platzhalter-String `"unresolved"` an `network::is_reachable` weiterzureichen - das würde +/// dort einen sinnlosen `ping unresolved`-Aufruf gegen einen nicht existierenden Hostnamen +/// auslösen statt korrekt "nicht erreichbar" zu melden. +fn local_reachable( local: &smart_mount::config::LocalSide, settings: &smart_mount::config::GlobalSettings, -) -> String { - smart_mount::network::address::resolve_ip(&local.address, settings) - .map(|ip| ip.to_string()) - .unwrap_or_else(|_| "unresolved".to_string()) +) -> bool { + match smart_mount::network::address::resolve_ip(&local.address, settings) { + Ok(ip) => network::is_reachable(&ip.to_string()), + Err(_) => false, + } } From c757d19eae5a9f722f62aa535940423b8e3287d9 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:41:06 +0200 Subject: [PATCH 15/28] Fix: local_source() gibt wieder ein Result zurueck mount::target::local_source() gab bei fehlgeschlagener Adressaufloesung (mac2ip findet die MAC-Adresse nicht) bisher den Platzhalter-String "unresolved" zurueck und formatierte daraus eine syntaktisch gueltige, aber sinnlose Mount-Quelle - statt den Fehler an den Aufrufer durchzureichen. mount::cleanup_side_credentials() (WebDAV-Zweig) erwartet bereits seit 690f727 ein Result von lokal_source(), um einen Aufloesungsfehler zu loggen statt ihn zu verschlucken; ohne diese Aenderung war das im Repository committete HEAD dadurch nicht kompilierbar. local_source() gibt jetzt ein Result zurueck und reicht den Aufloesungsfehler ueber `?` durch. Co-Authored-By: Claude Sonnet 5 --- src/mount/target.rs | 25 ++++++++++++++++++------- src/reconcile/mod.rs | 3 ++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/mount/target.rs b/src/mount/target.rs index 45657cc..209f985 100644 --- a/src/mount/target.rs +++ b/src/mount/target.rs @@ -54,7 +54,7 @@ pub fn build_target( let (source, mut options) = match side { Side::Local => ( - local_source(&pair.local, settings), + local_source(&pair.local, settings)?, parse_options(&pair.local.extra_options), ), Side::Cloud => ( @@ -169,11 +169,14 @@ fn run_id(username: &str, flag: &str) -> Result { }) } -pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> String { - let ip = address::resolve_ip(&local.address, settings) - .map(|ip| ip.to_string()) - .unwrap_or_else(|_| "unresolved".to_string()); - format_source(local.kind, &ip, &local.share) +/// Löst die konfigurierte lokale Adresse auf und formatiert die Mount-Quelle daraus. Gibt +/// einen Fehler zurück, statt eine fehlgeschlagene Auflösung (z. B. `mac2ip` findet die +/// MAC-Adresse nicht) hinter dem Platzhalter-String `"unresolved"` zu verstecken - Aufrufer +/// sollen einen echten Auflösungsfehler von einer tatsächlich formatierten, aber unerreichbaren +/// Quelle unterscheiden können. +pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> Result { + let ip = address::resolve_ip(&local.address, settings)?; + Ok(format_source(local.kind, &ip.to_string(), &local.share)) } pub fn cloud_source(cloud: &CloudSide) -> String { @@ -297,11 +300,19 @@ mod tests { fn local_source_formats_smb_unc_path() { let pair = sample_pair(); assert_eq!( - local_source(&pair.local, &GlobalSettings::default()), + local_source(&pair.local, &GlobalSettings::default()).expect("local_source"), "//192.168.1.10/share" ); } + #[test] + fn local_source_propagates_mac2ip_error() { + let mut pair = sample_pair(); + pair.local.address = crate::config::LocalAddress::Mac("00:11:22:33:44:55".into()); + let err = local_source(&pair.local, &GlobalSettings::default()).unwrap_err(); + assert!(matches!(err, crate::error::Error::Mac2Ip { .. })); + } + #[test] fn cloud_source_uses_full_url_for_webdav() { let pair = sample_pair(); diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index bdb67f8..059dd10 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -215,7 +215,8 @@ mod tests { #[test] fn local_source_formats_smb_unc_path() { let pair = sample_pair(MountKind::Smb, MountKind::WebDav); - let source = target::local_source(&pair.local, &GlobalSettings::default()); + let source = + target::local_source(&pair.local, &GlobalSettings::default()).expect("local_source"); assert_eq!(source, "//192.168.1.10/share"); } From af88a306f9cf085602888888b492041645e07318 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:41:49 +0200 Subject: [PATCH 16/28] Fix: Paar-Sperre ist jetzt prozessuebergreifend statt nur prozessintern mount::lock::acquire() war ein rein prozessinterner Mutex>. smart-mount ist aber ein One-Shot-CLI (siehe reconcile-Moduldoku) - jeder Aufruf ist ein eigener Prozess mit eigener, leerer Registry. Ein manuelles `unmount --name X` waehrend ein gleichzeitig laufender systemd-Timer- `watch` fuer dasselbe Paar arbeitet, kollidierte dadurch ungebremst statt serialisiert zu werden. acquire() sperrt jetzt ueber eine `flock(2)`-Datei pro Paar (`std::fs::File::lock()`, seit Rust 1.89 Teil der Standardbibliothek) in einem root- bzw. XDG_RUNTIME_DIR-basierten Verzeichnis - damit blockieren sich zwei Prozesse fuer dasselbe Paar tatsaechlich gegenseitig. Co-Authored-By: Claude Sonnet 5 --- src/mount/lock.rs | 121 +++++++++++++++++++++++++++++++++++-------- src/reconcile/mod.rs | 4 +- 2 files changed, 102 insertions(+), 23 deletions(-) diff --git a/src/mount/lock.rs b/src/mount/lock.rs index a78b549..fe45cff 100644 --- a/src/mount/lock.rs +++ b/src/mount/lock.rs @@ -1,31 +1,110 @@ -//! Pro-Paar-Mutex-Registry, damit ein manueller `mount --name X` nicht mit einem -//! gleichzeitig laufenden `watch` für dasselbe Paar kollidiert. +//! Pro-Paar-Dateisperre, damit ein manueller `mount --name X` nicht mit einem gleichzeitig +//! laufenden `watch` für dasselbe Paar kollidiert. +//! +//! **Warum eine Datei-basierte Sperre (`flock(2)`) statt eines simplen `Mutex`:** smart-mount +//! ist ein One-Shot-CLI (siehe [`crate::reconcile`]-Moduldoku) - jeder Aufruf ist ein eigener, +//! kurzlebiger Prozess. Ein rein prozessinterner `Mutex>` schützt daher NICHT vor +//! zwei gleichzeitig laufenden `smart-mount`-Prozessen (z. B. ein manuelles `unmount --name X` +//! während ein systemd-Timer-`watch` läuft): beide bekämen ihre eigene, leere Registry und +//! würden sich nie gegenseitig blockieren. Die Sperre muss daher außerhalb des Prozess-Speichers +//! liegen - hier über eine `flock`-Datei pro Paar (`std::fs::File::lock()`, seit Rust 1.89 +//! Teil der Standardbibliothek, keine zusätzliche Abhängigkeit nötig). //! //! Ersetzt den globalen `RwLock`+`Mutex` aus dem alten `src/filesystem/mount.rs` (v0.2.0): //! dort war die Sperre prozessweit global, hier ist sie pro Laufwerkspaar - mehrere Paare //! können also parallel gemountet werden, ohne sich gegenseitig zu blockieren. -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::fs::{File, OpenOptions}; +use std::path::PathBuf; -use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; +use crate::error::{Error, Result}; -type Registry = Mutex>>>; - -fn registry() -> &'static Registry { - static REGISTRY: OnceLock = OnceLock::new(); - REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +/// Hält die Sperrdatei offen (und damit die `flock`-Sperre über den zugehörigen +/// Datei-Deskriptor), bis der Guard gedroppt wird - Schließen des Deskriptors gibt die Sperre +/// implizit frei, ein explizites `unlock()` ist dafür nicht nötig. +pub struct PairLock { + _file: File, } -/// Sperrt ein Laufwerkspaar für die Dauer des zurückgegebenen Guards. `tokio::sync::Mutex`s -/// `lock_owned()` erlaubt einen Guard, der seine eigene `Arc`-Referenz hält - keine -/// selbstreferenzielle Struktur/`unsafe` nötig. -pub async fn acquire(pair_id: &str) -> OwnedMutexGuard<()> { - let mutex = { - let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); - reg.entry(pair_id.to_string()) - .or_insert_with(|| Arc::new(AsyncMutex::new(()))) - .clone() - }; - mutex.lock_owned().await +/// Verzeichnis für die Sperrdateien: `/run/smart-mount/locks` im Root-/System-Kontext (root ist +/// dort ohnehin die einzige Partei, die Paare in diesem Kontext mountet), sonst +/// `$XDG_RUNTIME_DIR` (per-Nutzer, von systemd `0700`-geschützt angelegt) mit Fallback auf das +/// System-Temp-Verzeichnis, falls `$XDG_RUNTIME_DIR` nicht gesetzt ist (z. B. ohne aktive +/// Login-Session). +fn lock_dir() -> PathBuf { + if sudo_ctdra::is_run_as_root() { + PathBuf::from("/run/smart-mount/locks") + } else { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("smart-mount-locks") + } +} + +fn acquire_blocking(pair_id: &str) -> Result { + let dir = lock_dir(); + std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?; + + let path = dir.join(format!("{pair_id}.lock")); + // `truncate(false)`: der Dateiinhalt ist irrelevant, nur der Deskriptor/die `flock`-Sperre + // darauf zählt - ein Zurücksetzen auf leer bei jedem Aufruf wäre unnötig. + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + .map_err(|e| Error::io(&path, e))?; + + // Blockiert, bis die Sperre frei wird - `flock(2)` kennt keinen Async-Mechanismus, daher + // läuft dieser gesamte Aufruf über `spawn_blocking` (siehe [`acquire`]) auf einem + // Blocking-Thread statt einem Tokio-Worker-Thread. + file.lock().map_err(|e| Error::io(&path, e))?; + Ok(PairLock { _file: file }) +} + +/// Sperrt ein Laufwerkspaar prozessübergreifend für die Dauer des zurückgegebenen Guards. +pub async fn acquire(pair_id: &str) -> Result { + let pair_id = pair_id.to_string(); + tokio::task::spawn_blocking(move || acquire_blocking(&pair_id)) + .await + .map_err(|e| Error::Other(format!("lock task panicked: {e}")))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn acquire_returns_a_guard_and_releases_on_drop() { + let id = format!("test-lock-{}", uuid::Uuid::new_v4()); + { + let _guard = acquire(&id).await.expect("first acquire"); + } + // Sollte nicht blockieren: der obige Guard wurde bereits gedroppt. + let _guard2 = acquire(&id).await.expect("second acquire after drop"); + } + + #[tokio::test] + async fn a_second_concurrent_acquire_waits_for_the_first_to_be_dropped() { + let id = format!("test-lock-{}", uuid::Uuid::new_v4()); + let guard = acquire(&id).await.expect("first acquire"); + + let id2 = id.clone(); + let handle = tokio::spawn(async move { acquire(&id2).await }); + + // Kurz warten, damit der spawnte Task realistischerweise Zeit hatte, in `acquire` zu + // blockieren, bevor die erste Sperre freigegeben wird. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !handle.is_finished(), + "second acquire must block while the first guard is held" + ); + + drop(guard); + handle + .await + .expect("task") + .expect("second acquire after release"); + } } diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index 059dd10..e2e158c 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -74,7 +74,7 @@ async fn reconcile_pair_inner( settings: &GlobalSettings, creds: &CredentialStore, ) -> Result { - let _guard = lock::acquire(&pair.id).await; + let _guard = lock::acquire(&pair.id).await?; let local_reachable = check_local_reachable(&pair.local, settings); let active = target::active_side(pair); @@ -167,7 +167,7 @@ async fn mount_side( /// ein leeres, ausgehängtes Backing-Verzeichnis) - der nächste `mount`/`watch`-Lauf räumt das /// beim erneuten Aktivieren automatisch auf. pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result { - let _guard = lock::acquire(&pair.id).await; + let _guard = lock::acquire(&pair.id).await?; let Some(side) = target::active_side(pair) else { return Ok(Action::NoOp); From e05d1174e722859c7cd351acd7bb0a873a305449 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:43:10 +0200 Subject: [PATCH 17/28] Fix: Aus-/Umhaengen behandelt Fehler robuster - unmount_side() verschluckte einen fehlgeschlagenen build_target() kommentarlos und mountete stattdessen einen leeren Platzhalter aus - z. B. wenn owner_user zwischenzeitlich vom System geloescht wurde. Wird jetzt geloggt, bevor mit denselben Best-effort-Defaults weitergemacht wird. - unmount_pair() brach beim ersten `?` (auch bei einem reinen is_mounted()-Pruepffehler) komplett ab, statt die zweite Seite trotzdem zu versuchen - ein echt gemounteter Cloud-Anteil blieb dann unangetastet, wenn schon die Local-Pruefung fehlschlug. Beide Seiten werden jetzt unabhaengig voneinander versucht; der erste Fehler wird erst nach beiden Versuchen zurueckgegeben. - target::active_side() erkennt "die" aktive Seite ausschliesslich ueber den sichtbaren Symlink - ein Mount auf der jeweils anderen Seite (z. B. nach einem abgebrochenen Umschalten oder externem manuellen Mount) blieb dadurch fuer status/watch unsichtbar und wurde nie automatisch wieder ausgehaengt. cleanup_orphaned_mounts() raeumt einen solchen verwaisten Mount jetzt bei jedem Reconcile-Durchlauf best-effort auf. Co-Authored-By: Claude Sonnet 5 --- src/reconcile/mod.rs | 128 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 7 deletions(-) diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index e2e158c..1c5716d 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -14,7 +14,7 @@ use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide}; use crate::db::credentials::{CredentialStore, Side}; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::mount::{self, lock, target}; use crate::network::{self, address}; @@ -78,6 +78,7 @@ async fn reconcile_pair_inner( let local_reachable = check_local_reachable(&pair.local, settings); let active = target::active_side(pair); + cleanup_orphaned_mounts(pair, settings, active).await; if local_reachable { if active == Some(Side::Local) { @@ -114,6 +115,55 @@ fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool { } } +/// Räumt einen Mount auf der jeweils NICHT aktiven Seite auf, falls einer besteht. +/// +/// [`target::active_side`] identifiziert "die" aktive Seite ausschließlich über den sichtbaren +/// Symlink - ein Mount auf der jeweils anderen Seite (z. B. weil eine vorherige +/// `activate_symlink`-Aktivierung mitten im Umschalten abgebrochen wurde, oder weil extern +/// manuell gemountet wurde) bliebe dadurch unbemerkt: `status`/`watch` sähen ihn nie, und er +/// würde nie automatisch wieder ausgehängt. Best-effort (nur geloggt, nicht propagiert) - ein +/// hier fehlschlagendes Aufräumen darf den eigentlichen Reconcile-Schritt nicht blockieren. +async fn cleanup_orphaned_mounts(pair: &DrivePair, settings: &GlobalSettings, active: Option) { + for side in [Side::Local, Side::Cloud] { + if Some(side) == active { + continue; + } + let dir = target::backing_dir(pair, side); + match mount::state::is_mounted(&dir) { + Ok(true) => { + logger_ctdra::warn( + "reconcile", + &format!( + "pair '{}': found an orphaned mount on the inactive side '{}' (not \ + reflected by the active symlink) - unmounting it", + pair.id, + side.as_str() + ), + ); + if let Err(e) = unmount_side(pair, settings, side).await { + logger_ctdra::warn( + "reconcile", + &format!( + "pair '{}': could not clean up orphaned mount on side '{}': {e}", + pair.id, + side.as_str() + ), + ); + } + } + Ok(false) => {} + Err(e) => logger_ctdra::warn( + "reconcile", + &format!( + "pair '{}': could not check mount state for side '{}': {e}", + pair.id, + side.as_str() + ), + ), + } + } +} + /// Mountet `new_side` zuerst, flippt danach den sichtbaren Symlink, und hängt erst zum /// Schluss `old_active` (falls vorhanden und verschieden) aus. Diese Reihenfolge stellt /// sicher, dass der sichtbare `pair.mount_point` nie auf ein gerade ausgehängtes oder noch @@ -169,15 +219,70 @@ async fn mount_side( pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result { let _guard = lock::acquire(&pair.id).await?; - let Some(side) = target::active_side(pair) else { - return Ok(Action::NoOp); - }; - unmount_side(pair, settings, side).await?; - Ok(Action::NoOp) + // Beide Seiten werden unabhängig voneinander versucht - ein Fehler (auch ein + // `is_mounted`-Prüffehler) bei der ersten Seite darf die zweite nicht ungeprüft + // überspringen. Der erste aufgetretene Fehler wird nach beiden Versuchen zurückgegeben, + // damit der Aufrufer weiterhin erkennt, dass etwas fehlgeschlagen ist. + let mut first_err: Option = None; + for side in [Side::Local, Side::Cloud] { + let dir = target::backing_dir(pair, side); + match mount::state::is_mounted(&dir) { + Ok(true) => { + if let Err(e) = unmount_side(pair, settings, side).await + && first_err.is_none() + { + first_err = Some(e); + } + } + Ok(false) => {} + Err(e) => { + logger_ctdra::warn( + "reconcile", + &format!( + "pair '{}': could not check mount state for side '{}': {e} - still \ + attempting the other side", + pair.id, + side.as_str() + ), + ); + if first_err.is_none() { + first_err = Some(e); + } + } + } + } + + match first_err { + Some(e) => Err(e), + None => Ok(Action::NoOp), + } } async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -> Result<()> { - let mount_target = target::build_target(pair, settings, side)?; + let mount_target = target::build_target(pair, settings, side).unwrap_or_else(|e| { + logger_ctdra::warn( + "reconcile", + &format!( + "pair '{}': could not fully resolve the mount target for side '{}' while \ + unmounting ({e}) - unmounting its backing directory anyway with best-effort \ + defaults", + pair.id, + side.as_str() + ), + ); + crate::mount::MountTarget { + pair_id: pair.id.clone(), + 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(), + } + }); mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target) } @@ -228,4 +333,13 @@ mod tests { assert_ne!(t.mount_point, pair.mount_point); assert_eq!(t.mount_point, target::backing_dir(&pair, Side::Local)); } + + #[tokio::test] + async fn unmount_pair_noop_when_neither_side_mounted() { + let pair = sample_pair(MountKind::Smb, MountKind::WebDav); + let action = unmount_pair(&pair, &GlobalSettings::default()) + .await + .expect("unmount"); + assert!(matches!(action, Action::NoOp)); + } } From 38c95cc4bff36c63b60d761dddb29265e8a7e02d Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:43:59 +0200 Subject: [PATCH 18/28] Perf: Erreichbarkeitspruefung parallelisiert, lokale IP nur einmal aufgeloest watch_once() rief reconcile_pair() sequenziell fuer jedes Paar auf - bei mehreren/langsamen Paaren (Ping-/curl-Timeouts von mehreren Sekunden pro Seite) wuchs die Laufzeit eines watch-Durchlaufs dadurch linear mit der Anzahl konfigurierter Paare, obwohl die pro-Paar-Sperre (siehe lock.rs) genau fuer parallele Reconciles gedacht ist. Die Erreichbarkeitspruefungen laufen jetzt ueber spawn_blocking parallel; das eigentliche Mounten/ Aushaengen bleibt sequenziell (teilt sich CredentialStore/Dateizustand). Ausserdem loeste ein Reconcile-Durchlauf fuer ein MAC-adressiertes Paar dieselbe Adresse bislang zweimal ueber mac2ip auf: einmal fuer die Erreichbarkeitspruefung (check_local_reachable), ein zweites Mal beim tatsaechlichen Mounten (build_target -> local_source). check_local_reachable gibt die aufgeloeste IP jetzt zurueck und reicht sie bis zu mount_side() durch (target::build_target_with_cached_local_ip); status.rs loest die lokale Adresse ebenfalls nur noch einmal pro Paar auf und nutzt sie fuer Quelle UND Erreichbarkeitsanzeige. Co-Authored-By: Claude Sonnet 5 --- src/cli/status.rs | 34 +++++++------- src/mount/target.rs | 34 +++++++++++--- src/reconcile/mod.rs | 106 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 136 insertions(+), 38 deletions(-) diff --git a/src/cli/status.rs b/src/cli/status.rs index 4f42b39..524bc3b 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -43,14 +43,28 @@ pub async fn run(name: Option, json: bool) -> anyhow::Result<()> { Some(Side::Cloud) => Some("cloud"), None => None, }; + // Die lokale Adresse (bei MAC-Adressierung ein `mac2ip`-Subprozessaufruf) wird nur + // einmal pro Paar aufgelöst und für Quelle UND Erreichbarkeitsprüfung + // wiederverwendet, statt sie für beide Zwecke unabhängig voneinander ein zweites + // Mal aufzulösen. + let resolved_local_ip = + smart_mount::network::address::resolve_ip(&pair.local.address, &cfg.settings); + let local_source = resolved_local_ip + .as_ref() + .map(|ip| target::format_local_source(&pair.local, *ip)) + .unwrap_or_else(|_| "unresolved".to_string()); + let local_reachable = resolved_local_ip + .as_ref() + .map(|ip| network::is_reachable(&ip.to_string())) + .unwrap_or(false); + PairStatus { id: pair.id.clone(), name: pair.name.clone(), mount_point: pair.mount_point.display().to_string(), active, - local_source: target::local_source(&pair.local, &cfg.settings) - .unwrap_or_else(|_| "unresolved".to_string()), - local_reachable: local_reachable(&pair.local, &cfg.settings), + local_source, + local_reachable, cloud_source: target::cloud_source(&pair.cloud), cloud_reachable: network::is_reachable(&pair.cloud.host_or_url), } @@ -88,17 +102,3 @@ fn reachable_str(reachable: bool) -> &'static str { "unreachable" } } - -/// `false` wenn die Adresse (MAC->IP) gar nicht erst aufgelöst werden kann, statt den -/// Platzhalter-String `"unresolved"` an `network::is_reachable` weiterzureichen - das würde -/// dort einen sinnlosen `ping unresolved`-Aufruf gegen einen nicht existierenden Hostnamen -/// auslösen statt korrekt "nicht erreichbar" zu melden. -fn local_reachable( - local: &smart_mount::config::LocalSide, - settings: &smart_mount::config::GlobalSettings, -) -> bool { - match smart_mount::network::address::resolve_ip(&local.address, settings) { - Ok(ip) => network::is_reachable(&ip.to_string()), - Err(_) => false, - } -} diff --git a/src/mount/target.rs b/src/mount/target.rs index 209f985..46d4286 100644 --- a/src/mount/target.rs +++ b/src/mount/target.rs @@ -45,6 +45,20 @@ pub fn build_target( pair: &DrivePair, settings: &GlobalSettings, side: Side, +) -> Result { + build_target_with_cached_local_ip(pair, settings, side, None) +} + +/// Wie [`build_target`], nimmt aber optional eine bereits aufgelöste lokale IP entgegen +/// (`Side::Local` mit `LocalAddress::Mac`), um eine zweite `mac2ip`-Subprozess-Auflösung +/// derselben Adresse innerhalb desselben Reconcile-Durchlaufs zu vermeiden (siehe +/// [`crate::reconcile`], das die Erreichbarkeit ohnehin schon per `address::resolve_ip` prüft, +/// bevor es ggf. auf diese Seite umschaltet). Wird für `Side::Cloud` ignoriert. +pub fn build_target_with_cached_local_ip( + pair: &DrivePair, + settings: &GlobalSettings, + side: Side, + cached_local_ip: Option, ) -> Result { let invocation = match pair.context { MountContext::System => MountInvocation::Direct, @@ -54,7 +68,10 @@ pub fn build_target( let (source, mut options) = match side { Side::Local => ( - local_source(&pair.local, settings)?, + match cached_local_ip { + Some(ip) => format_local_source(&pair.local, ip), + None => local_source(&pair.local, settings)?, + }, parse_options(&pair.local.extra_options), ), Side::Cloud => ( @@ -169,14 +186,17 @@ fn run_id(username: &str, flag: &str) -> Result { }) } -/// Löst die konfigurierte lokale Adresse auf und formatiert die Mount-Quelle daraus. Gibt -/// einen Fehler zurück, statt eine fehlgeschlagene Auflösung (z. B. `mac2ip` findet die -/// MAC-Adresse nicht) hinter dem Platzhalter-String `"unresolved"` zu verstecken - Aufrufer -/// sollen einen echten Auflösungsfehler von einer tatsächlich formatierten, aber unerreichbaren -/// Quelle unterscheiden können. pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> Result { let ip = address::resolve_ip(&local.address, settings)?; - Ok(format_source(local.kind, &ip.to_string(), &local.share)) + Ok(format_local_source(local, ip)) +} + +/// Formatiert die Mount-Quelle für eine bereits aufgelöste lokale IP, ohne selbst erneut +/// aufzulösen - für Aufrufer, die die IP ohnehin schon für eine andere Prüfung (z. B. +/// Erreichbarkeit) aufgelöst haben und damit eine zweite `mac2ip`-Auflösung derselben +/// MAC-Adresse vermeiden wollen. +pub fn format_local_source(local: &LocalSide, ip: std::net::Ipv4Addr) -> String { + format_source(local.kind, &ip.to_string(), &local.share) } pub fn cloud_source(cloud: &CloudSide) -> String { diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index 1c5716d..d1c585a 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -12,6 +12,8 @@ //! neue Seite zuerst, flippt dann den Symlink, und hängt erst danach die alte Seite aus - so //! gibt es nie ein Zeitfenster, in dem der sichtbare Pfad auf nichts Gemountetes zeigt. +use std::net::Ipv4Addr; + use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide}; use crate::db::credentials::{CredentialStore, Side}; use crate::error::{Error, Result}; @@ -37,10 +39,45 @@ pub struct ReconcileOutcome { } /// Führt `reconcile_pair` für jedes aktivierte Paar in `cfg` aus. +/// +/// Die Erreichbarkeitsprüfungen (Ping/`curl`-Subprozesse, bis zu mehrere Sekunden pro Seite) +/// sind rein lesend und unabhängig voneinander - sie laufen daher parallel über +/// `spawn_blocking` statt sequenziell, damit die Gesamtlaufzeit eines `watch`-Durchlaufs bei +/// vielen/langsamen Paaren nicht linear mit deren Anzahl wächst. Das eigentliche Mounten/ +/// Aushängen bleibt dagegen sequenziell: es teilt sich den `CredentialStore` und schreibt +/// Zustand (Symlinks, Credential-/Secrets-Dateien) - die Pro-Paar-Sperre (siehe [`lock`]) +/// verhindert zwar Kollisionen, unbegrenzt paralleler DB-/Dateizugriff wäre aber unnötiges +/// Risiko für einen Effizienzgewinn, den die Reachability-Parallelisierung bereits liefert. pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec { - let mut outcomes = Vec::with_capacity(cfg.pairs.len()); - for pair in cfg.pairs.iter().filter(|p| p.enabled) { - outcomes.push(reconcile_pair(pair, &cfg.settings, creds).await); + let enabled: Vec<&DrivePair> = cfg.pairs.iter().filter(|p| p.enabled).collect(); + + let mut checks = Vec::with_capacity(enabled.len()); + for pair in &enabled { + let local = pair.local.clone(); + let cloud_host = pair.cloud.host_or_url.clone(); + let settings = cfg.settings.clone(); + checks.push(tokio::task::spawn_blocking(move || { + let (local_reachable, local_ip) = check_local_reachable(&local, &settings); + let cloud_reachable = network::is_reachable(&cloud_host); + (local_reachable, local_ip, cloud_reachable) + })); + } + + let mut outcomes = Vec::with_capacity(enabled.len()); + for (pair, check) in enabled.iter().zip(checks) { + let (local_reachable, local_ip, cloud_reachable) = + check.await.unwrap_or((false, None, false)); + outcomes.push( + reconcile_pair_checked( + pair, + &cfg.settings, + creds, + local_reachable, + local_ip, + cloud_reachable, + ) + .await, + ); } outcomes } @@ -51,11 +88,41 @@ pub async fn reconcile_pair( pair: &DrivePair, settings: &GlobalSettings, creds: &CredentialStore, +) -> ReconcileOutcome { + let (local_reachable, local_ip) = check_local_reachable(&pair.local, settings); + let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url); + reconcile_pair_checked( + pair, + settings, + creds, + local_reachable, + local_ip, + cloud_reachable, + ) + .await +} + +async fn reconcile_pair_checked( + pair: &DrivePair, + settings: &GlobalSettings, + creds: &CredentialStore, + local_reachable: bool, + local_ip: Option, + cloud_reachable: bool, ) -> ReconcileOutcome { let pair_id = pair.id.clone(); let pair_name = pair.name.clone(); - match reconcile_pair_inner(pair, settings, creds).await { + match reconcile_pair_inner( + pair, + settings, + creds, + local_reachable, + local_ip, + cloud_reachable, + ) + .await + { Ok(action) => ReconcileOutcome { pair_id, pair_name, @@ -73,10 +140,12 @@ async fn reconcile_pair_inner( pair: &DrivePair, settings: &GlobalSettings, creds: &CredentialStore, + local_reachable: bool, + local_ip: Option, + cloud_reachable: bool, ) -> Result { let _guard = lock::acquire(&pair.id).await?; - let local_reachable = check_local_reachable(&pair.local, settings); let active = target::active_side(pair); cleanup_orphaned_mounts(pair, settings, active).await; @@ -84,7 +153,7 @@ async fn reconcile_pair_inner( if active == Some(Side::Local) { return Ok(Action::NoOp); } - switch_to(pair, settings, Side::Local, creds, active).await?; + switch_to(pair, settings, Side::Local, creds, active, local_ip).await?; return Ok(if active.is_some() { Action::SwitchedToLocal } else { @@ -92,12 +161,11 @@ async fn reconcile_pair_inner( }); } - let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url); if cloud_reachable { if active == Some(Side::Cloud) { return Ok(Action::NoOp); } - switch_to(pair, settings, Side::Cloud, creds, active).await?; + switch_to(pair, settings, Side::Cloud, creds, active, local_ip).await?; return Ok(if active.is_some() { Action::SwitchedToCloud } else { @@ -108,10 +176,13 @@ async fn reconcile_pair_inner( Ok(Action::NoOp) } -fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool { +/// Prüft die Erreichbarkeit der lokalen Seite und gibt dabei - falls erfolgreich aufgelöst - +/// die konkrete IP zurück, damit ein nachfolgender `switch_to(..., Side::Local, ...)` dieselbe +/// MAC-Adresse nicht ein zweites Mal über `mac2ip` auflösen muss. +fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> (bool, Option) { match address::resolve_ip(&local.address, settings) { - Ok(ip) => network::is_reachable(&ip.to_string()), - Err(_) => false, + Ok(ip) => (network::is_reachable(&ip.to_string()), Some(ip)), + Err(_) => (false, None), } } @@ -123,7 +194,11 @@ fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> bool { /// manuell gemountet wurde) bliebe dadurch unbemerkt: `status`/`watch` sähen ihn nie, und er /// würde nie automatisch wieder ausgehängt. Best-effort (nur geloggt, nicht propagiert) - ein /// hier fehlschlagendes Aufräumen darf den eigentlichen Reconcile-Schritt nicht blockieren. -async fn cleanup_orphaned_mounts(pair: &DrivePair, settings: &GlobalSettings, active: Option) { +async fn cleanup_orphaned_mounts( + pair: &DrivePair, + settings: &GlobalSettings, + active: Option, +) { for side in [Side::Local, Side::Cloud] { if Some(side) == active { continue; @@ -174,8 +249,9 @@ async fn switch_to( new_side: Side, creds: &CredentialStore, old_active: Option, + cached_local_ip: Option, ) -> Result<()> { - mount_side(pair, settings, new_side, creds).await?; + mount_side(pair, settings, new_side, creds, cached_local_ip).await?; // Falls das Aktivieren des Symlinks fehlschlägt, muss die gerade gemountete `new_side` // wieder ausgehängt werden, statt sie unbemerkt gemountet zu lassen: der nächste @@ -200,8 +276,10 @@ async fn mount_side( settings: &GlobalSettings, side: Side, creds: &CredentialStore, + cached_local_ip: Option, ) -> Result<()> { - let mount_target = target::build_target(pair, settings, side)?; + let mount_target = + 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))?; From d807548b43b073e446b8585099457c8a5f1fcd0e Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:44:26 +0200 Subject: [PATCH 19/28] Fix: is_reachable() akzeptiert 401/403, aber nicht dauerhafte 5xx-Fehler Der HTTP-Zweig von is_reachable() nutzte curl --fail, was einen HTTP-Server, der auf das HEAD mit 401/403 (Auth erforderlich, aber erreichbar) antwortet, faelschlich als nicht erreichbar meldete - relevant fuer WebDAV-Cloud-Seiten, die Auth verlangen. Der tatsaechliche Statuscode wird jetzt ausgelesen: nur ein Serverfehler (5xx) gilt als nicht erreichbar, da ein dauerhaft 5xx-antwortender Server (z. B. ein kaputter Reverse-Proxy) sonst bei jedem watch-Durchlauf einen vollen, letztlich erfolglosen Umschaltversuch (inkl. MOUNT_TIMEOUT_SECS-Wartezeit) ausloesen wuerde. Co-Authored-By: Claude Sonnet 5 --- src/network/mod.rs | 69 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/network/mod.rs b/src/network/mod.rs index d5151b3..4531136 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -9,15 +9,37 @@ use std::process::{Command, Stdio}; /// (HTTP HEAD via `curl`) oder eine reine Host-/IP-Adresse (ICMP-Ping) handelt. /// /// Portiert aus dem alten `src/network/utils.rs` (v0.2.0). +/// +/// Für HTTP wird bewusst NICHT `curl --fail` verwendet: ein WebDAV-Server, der auf das HEAD +/// mit 401/403 (Auth erforderlich) antwortet, ist trotzdem erreichbar - `--fail` würde das als +/// Fehler werten. Der tatsächliche Statuscode wird stattdessen ausgelesen und geprüft: nur ein +/// Serverfehler (5xx) gilt als NICHT erreichbar, da ein dauerhaft 5xx-antwortender Server +/// (z. B. ein kaputter Reverse-Proxy) sonst bei jedem `watch`-Durchlauf einen vollen, +/// letztlich erfolglosen Umschaltversuch (inkl. `MOUNT_TIMEOUT_SECS`-Wartezeit) auslösen würde. pub fn is_reachable(addr: &str) -> bool { if addr.starts_with("http://") || addr.starts_with("https://") { Command::new("curl") - .args(["--head", "--silent", "--fail", "--max-time", "5", addr]) - .stdout(Stdio::null()) + .args([ + "--head", + "--silent", + "--max-time", + "5", + "--output", + "/dev/null", + "--write-out", + "%{http_code}", + addr, + ]) .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + .output() + .ok() + .and_then(|o| { + String::from_utf8_lossy(&o.stdout) + .trim() + .parse::() + .ok() + }) + .is_some_and(|code| (100..500).contains(&code)) } else { Command::new("ping") .args(["-c", "1", "-W", "2", addr]) @@ -28,3 +50,40 @@ pub fn is_reachable(addr: &str) -> bool { .unwrap_or(false) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::net::TcpListener; + + #[test] + fn http_401_endpoint_is_reported_as_reachable() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("port").port(); + + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let response = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + } + }); + + assert!(is_reachable(&format!("http://127.0.0.1:{port}"))); + } + + #[test] + fn http_500_endpoint_is_reported_as_unreachable() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("port").port(); + + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + } + }); + + assert!(!is_reachable(&format!("http://127.0.0.1:{port}"))); + } +} From 5f8b81ee3ec918cca3093a32449e686e5c7dd0e2 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:44:49 +0200 Subject: [PATCH 20/28] Fix: mac2ip-Aufloesung hat jetzt ein Timeout gegen unbegrenztes Haengen mac2ip::resolve() rief das externe mac2ip-Binary (das intern einen nmap-Scan ausloesen kann) ohne jedes Timeout auf - anders als mount/umount, die bereits ueber run_with_timeout/MOUNT_TIMEOUT_SECS gegen einen haengenden Subprozess abgesichert sind. Ein nicht mehr reagierendes mac2ip/nmap haette damit smart-mount watch/status unbegrenzt blockieren koennen. Der Aufruf laeuft jetzt (wie mount/umount) ueber die coreutils timeout(1), mit einer klaren Fehlermeldung bei Ueberschreitung. Co-Authored-By: Claude Sonnet 5 --- src/network/mac2ip.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/network/mac2ip.rs b/src/network/mac2ip.rs index 3185e87..2ca991f 100644 --- a/src/network/mac2ip.rs +++ b/src/network/mac2ip.rs @@ -19,6 +19,13 @@ use serde::Deserialize; use crate::error::{Error, Result}; +/// Timeout in Sekunden für den `mac2ip`-Subprozess (dieselbe `timeout`-Coreutils-Technik wie +/// [`crate::mount::run_with_timeout`]). `mac2ip` kann intern einen `nmap`-Scan auslösen - +/// gegen ein dauerhaft nicht erreichbares/reagierendes Netz sonst ein unbegrenztes Hängen, das +/// `smart-mount watch`/`status` komplett blockieren würde, statt korrekt "nicht auflösbar" zu +/// melden. +const MAC2IP_TIMEOUT_SECS: u64 = 15; + #[derive(Deserialize)] #[serde(untagged)] enum Mac2IpOutput { @@ -31,7 +38,9 @@ enum Mac2IpOutput { /// `binary` ist der konfigurierte Binary-Name/-Pfad (`GlobalSettings::mac2ip_binary`, /// standardmäßig `"mac2ip"`, per PATH aufgelöst). pub fn resolve(mac: &str, binary: &str) -> Result { - let output = Command::new(binary) + let output = Command::new("timeout") + .arg(MAC2IP_TIMEOUT_SECS.to_string()) + .arg(binary) .args(["--json", "--auto-trust-networks", mac]) .output() .map_err(|e| Error::Mac2Ip { @@ -39,6 +48,13 @@ pub fn resolve(mac: &str, binary: &str) -> Result { reason: format!("could not run '{binary}': {e}"), })?; + if output.status.code() == Some(124) { + return Err(Error::Mac2Ip { + mac: mac.to_string(), + reason: format!("'{binary}' did not respond within {MAC2IP_TIMEOUT_SECS}s (timed out)"), + }); + } + parse_output(&output.stdout, mac) } From 75a23646dd51375cd5e28c29f65dfe8dd0e5112d Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:45:14 +0200 Subject: [PATCH 21/28] Fix: Mount-Unit nutzt pro Scope das richtige WantedBy und network-online.target mount_service_unit() schrieb bislang unabhaengig vom Installations-Scope immer "WantedBy=multi-user.target" - fuer eine User-Scope-Installation (systemctl --user) ist dieses Ziel falsch/wirkungslos, da es dort keine Entsprechung hat; die Unit haette nie zuverlaessig automatisch gestartet werden koennen. Nimmt jetzt den Scope entgegen und setzt fuer System "multi-user.target", fuer User "default.target". Ebenso wartete die Unit unabhaengig vom Scope auf network-online.target - auch das ist ein Ziel des System-Managers ohne sinnvolle Entsprechung unter systemctl --user. Die Ordnungsabhaengigkeit (After=/Wants=) wird jetzt nur noch im System-Kontext gesetzt. Co-Authored-By: Claude Sonnet 5 --- src/systemd/mod.rs | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/systemd/mod.rs b/src/systemd/mod.rs index ed265a6..362fcf2 100644 --- a/src/systemd/mod.rs +++ b/src/systemd/mod.rs @@ -32,9 +32,21 @@ fn binary_path() -> String { .unwrap_or_else(|| "/usr/bin/smart-mount".to_string()) } -fn mount_service_unit() -> 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\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nExecStart={} mount --all\n\n[Install]\nWantedBy=multi-user.target\n", + "[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() ) } @@ -93,7 +105,7 @@ 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()) + 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))?; @@ -337,6 +349,31 @@ fn cron_schedule_for_interval(interval_secs: u64) -> String { 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); From 05a4a908df0f075685cbd4ddb4f9dd08a3008c9d Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sat, 19 Sep 2026 21:45:37 +0200 Subject: [PATCH 22/28] Refactor: fstab nutzt Side::as_str() statt einer eigenen Kopie fstab::side_str() bildete exakt dieselbe Zuordnung wie das bereits importierte db::credentials::Side::as_str() nach - eine kuenftige Aenderung der String-Repraesentation (oder ein dritter Side-Wert) haette leicht dazu fuehren koennen, dass die fstab-Tag-Zeilen unbemerkt von der ueberall sonst genutzten Darstellung abweichen. Co-Authored-By: Claude Sonnet 5 --- src/fstab/mod.rs | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/fstab/mod.rs b/src/fstab/mod.rs index f4ea1c2..47a67ab 100644 --- a/src/fstab/mod.rs +++ b/src/fstab/mod.rs @@ -39,9 +39,7 @@ pub fn setup() -> Result<()> { ))); } - let sudo_user = std::env::var("SUDO_USER") - .ok() - .filter(|s| !s.is_empty()); + 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() @@ -276,13 +274,6 @@ fn write_atomic(path: &Path, contents: &str) -> Result<()> { std::fs::rename(&tmp_path, path).map_err(|e| Error::io(path, e)) } -fn side_str(side: Side) -> &'static str { - match side { - Side::Local => "local", - Side::Cloud => "cloud", - } -} - /// 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` @@ -293,7 +284,7 @@ fn line_tag(pair: &DrivePair, side: Side) -> String { format!( "smart-mount pair={} side={} owner={}", pair.id, - side_str(side), + side.as_str(), pair.owner_user.as_deref().unwrap_or("-") ) } @@ -359,7 +350,7 @@ fn merge_managed_block( for side in [Side::Local, Side::Cloud] { match tagged_line(pair, side, settings) { Ok(line) => { - replaced.insert((pair.id.clone(), side_str(side))); + replaced.insert((pair.id.clone(), side.as_str())); new_lines.push(line); } Err(e) => { @@ -369,7 +360,7 @@ fn merge_managed_block( "could not compute fstab entry for pair '{}' ({}): {e} - leaving \ any existing entry for it untouched", pair.id, - side_str(side) + side.as_str() ), ); } @@ -455,7 +446,9 @@ fn user_config_path(username: &str) -> PathBuf { 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}")) + PathBuf::from(format!( + "/home/{username}/.config/{program_name}/{file_name}" + )) }) } @@ -609,9 +602,8 @@ mod tests { ); // `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, - ]); + let block = + merge_managed_block(&stale_line, &[&pair], &GlobalSettings::default(), &[owner]); assert!(!block.contains("deleted-pair")); assert!(block.contains("pair=pair-1")); From 17c1f6d2ba2b921e2b344e5fddea256c50a092ec Mon Sep 17 00:00:00 2001 From: Gitea-Bot Date: Sat, 19 Sep 2026 19:54:19 +0000 Subject: [PATCH 23/28] Style: Automatische Formatierung & Clippy-Fixes --- src/mount/state.rs | 3 ++- src/mount/webdav.rs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mount/state.rs b/src/mount/state.rs index caa62c5..82b1689 100644 --- a/src/mount/state.rs +++ b/src/mount/state.rs @@ -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")); } diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs index 816d70f..cbb63ad 100644 --- a/src/mount/webdav.rs +++ b/src/mount/webdav.rs @@ -382,7 +382,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] From c6526e2130067c0b395f7dd076dca0fc16605e06 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 13:32:02 +0200 Subject: [PATCH 24/28] 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 --- Cargo.lock | 632 ------------------ Cargo.toml | 26 +- README.md | 204 +++--- packaging/deb/postinst | 13 + packaging/deb/postrm | 13 + packaging/systemd/smart-mount-mount.service | 11 + packaging/systemd/smart-mount-watch.service | 6 + packaging/systemd/smart-mount-watch.timer | 11 + scripts/package-arch.py | 68 +- src/cli/drive.rs | 116 +--- src/cli/mod.rs | 24 +- src/cli/mount_cmd.rs | 23 +- src/cli/service.rs | 184 ++---- src/cli/setup.rs | 21 - src/cli/watch.rs | 1 + src/config/mod.rs | 16 +- src/config/schema.rs | 80 +-- src/crypto/key.rs | 130 +--- src/doctor.rs | 134 +--- src/error.rs | 3 - src/fstab/mod.rs | 683 -------------------- src/lib.rs | 2 - src/mount/mod.rs | 25 +- src/mount/nfs.rs | 23 +- src/mount/smb.rs | 35 +- src/mount/target.rs | 10 +- src/mount/webdav.rs | 43 +- src/reconcile/mod.rs | 7 +- src/systemd/mod.rs | 329 +--------- src/util.rs | 103 --- 30 files changed, 450 insertions(+), 2526 deletions(-) create mode 100755 packaging/deb/postinst create mode 100755 packaging/deb/postrm create mode 100644 packaging/systemd/smart-mount-mount.service create mode 100644 packaging/systemd/smart-mount-watch.service create mode 100644 packaging/systemd/smart-mount-watch.timer delete mode 100644 src/cli/setup.rs delete mode 100644 src/fstab/mod.rs delete mode 100644 src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 561d0f8..3aa82ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -2530,7 +2061,6 @@ dependencies = [ "config-ctdra", "dialoguer", "getrandom 0.4.3", - "keyring", "logger-ctdra", "program-ctdra", "serde", @@ -2803,18 +2333,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 +2629,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 +2702,6 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "sha1_smol", "wasm-bindgen", ] @@ -3325,19 +2831,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 +2942,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 +2993,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 +3080,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", -] diff --git a/Cargo.toml b/Cargo.toml index 60dc93d..d886009 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,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 @@ -58,12 +57,17 @@ smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud 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,9 +75,27 @@ 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" = "*" } 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 diff --git a/README.md b/README.md index 1e32030..49e92d9 100644 --- a/README.md +++ b/README.md @@ -24,28 +24,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 +58,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 --name "Neuer Name" --local-ip 192.168.1.99 -smart-mount drive edit --non-interactive --local-password-stdin <<< "neues-passwort" +sudo smart-mount drive edit --name "Neuer Name" --local-ip 192.168.1.99 +sudo smart-mount drive edit --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 +sudo smart-mount drive remove # Ein einzelnes Paar oder alle einbinden/aushängen -smart-mount mount --name -smart-mount mount --all -smart-mount unmount --all +sudo smart-mount mount --name +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,27 +93,22 @@ 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` -(`/`) - 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//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. - -**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/` -sowie `/run/media//smart-mount` an und macht den Nutzer zum Besitzer) - ohne diesen -Schritt schlägt das automatische Anlegen für Nutzer-Kontext-Paare fehl. +(`/`) - mehrere Paare stören sich also nie gegenseitig. Standardwert: +`/run/media/smart-mount`, ein einzelner, flacher Namensraum (smart-mount mountet immer als +root, siehe oben) - `/run/media` ist auf den meisten Systemen bereits die übliche Konvention +für eingebundene Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, +muss also nie persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden +bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen. Ist für ein Paar +`owner_user` gesetzt, bekommt genau dieser Nutzer über die Mount-Optionen (`uid=`/`gid=`, siehe +"Architekturentscheidungen" unten) vollen Zugriff auf den Inhalt - die Trennung passiert also +über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer. Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern. @@ -121,45 +117,50 @@ 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 +190,20 @@ 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 -o `) 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. 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. - **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 +212,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 ` 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 +230,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 +248,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) diff --git a/packaging/deb/postinst b/packaging/deb/postinst new file mode 100755 index 0000000..a9cd8ea --- /dev/null +++ b/packaging/deb/postinst @@ -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 diff --git a/packaging/deb/postrm b/packaging/deb/postrm new file mode 100755 index 0000000..b6e577b --- /dev/null +++ b/packaging/deb/postrm @@ -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 diff --git a/packaging/systemd/smart-mount-mount.service b/packaging/systemd/smart-mount-mount.service new file mode 100644 index 0000000..9027017 --- /dev/null +++ b/packaging/systemd/smart-mount-mount.service @@ -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 diff --git a/packaging/systemd/smart-mount-watch.service b/packaging/systemd/smart-mount-watch.service new file mode 100644 index 0000000..144b687 --- /dev/null +++ b/packaging/systemd/smart-mount-watch.service @@ -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 diff --git a/packaging/systemd/smart-mount-watch.timer b/packaging/systemd/smart-mount-watch.timer new file mode 100644 index 0000000..5bedd4f --- /dev/null +++ b/packaging/systemd/smart-mount-watch.timer @@ -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 diff --git a/scripts/package-arch.py b/scripts/package-arch.py index d03aeee..5527523 100755 --- a/scripts/package-arch.py +++ b/scripts/package-arch.py @@ -45,6 +45,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 +148,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,14 +178,23 @@ 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}") diff --git a/src/cli/drive.rs b/src/cli/drive.rs index 35a08af..c07f902 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -12,7 +12,7 @@ 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 +40,6 @@ pub enum DriveAction { pub struct DriveArgs { #[arg(long)] name: Option, - #[arg(long, value_enum)] - context: Option, #[arg(long)] owner_user: Option, @@ -108,10 +106,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 +118,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 +147,11 @@ 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)?; + let owner_user = resolve_owner_user(args.owner_user, None, 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)?; @@ -199,7 +198,6 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { id: id.clone(), name, enabled: true, - context, owner_user, mount_point, local: LocalSide { @@ -233,22 +231,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, @@ -320,10 +314,9 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { 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. + // eventuell noch aktive Mounts verwaisen. mount_point: existing.mount_point.clone(), local: LocalSide { kind: local_kind, @@ -398,89 +391,32 @@ fn resolve_field( Ok(Some(input.interact_text()?)) } -fn resolve_context( - flag: Option, - current: Option, - non_interactive: bool, -) -> anyhow::Result { - if let Some(c) = flag { - return Ok(c); - } - 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." - ); - } - 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 - }) -} - +/// 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`]). fn resolve_owner_user( flag: Option, - context: MountContext, current: Option<&str>, non_interactive: bool, ) -> anyhow::Result> { 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)); - } - 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()) - .interact()?; - if !want_owner { - return Ok(None); - } - let mut input = Input::::new().with_prompt("Linux username"); - if let Some(c) = current { - input = input.default(c.to_string()); - } - Ok(Some(input.interact_text()?)) - } + if non_interactive { + return Ok(current.map(str::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()) + .interact()?; + if !want_owner { + return Ok(None); + } + let mut input = Input::::new().with_prompt("Linux username"); + if let Some(c) = current { + input = input.default(c.to_string()); + } + Ok(Some(input.interact_text()?)) } fn resolve_kind( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 73ca3dd..f7ba31d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,13 +4,23 @@ 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; +/// Bricht mit einer klaren Fehlermeldung ab, falls nicht 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. +pub(crate) fn require_root(context: &str) -> anyhow::Result<()> { + if !sudo_ctdra::is_run_as_root() { + anyhow::bail!("'{context}' requires root privileges (re-run with sudo)."); + } + Ok(()) +} + #[derive(Parser)] #[command( name = "smart-mount", @@ -55,17 +65,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 +92,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( diff --git a/src/cli/mount_cmd.rs b/src/cli/mount_cmd.rs index 986baaa..6fad7d5 100644 --- a/src/cli/mount_cmd.rs +++ b/src/cli/mount_cmd.rs @@ -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 ' 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, 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, all: bool) -> anyhow::Result<()> { } pub async fn run_unmount(name: Option, 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() { diff --git a/src/cli/service.rs b/src/cli/service.rs index 158b05e..9539da5 100644 --- a/src/cli/service.rs +++ b/src/cli/service.rs @@ -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 { - 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)." - ); - } - 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)? { - 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" - } - ); - print!("{}", systemd::crontab_equivalent(interval)); - } - } - Ok(()) - } + 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; + + match systemd::install_cron(interval)? { + systemd::CronInstallOutcome::SystemFile(path) => { + println!("Cron entry written: {}", path.display()); + } + systemd::CronInstallOutcome::Unavailable => { + println!( + "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(()) +} diff --git a/src/cli/setup.rs b/src/cli/setup.rs deleted file mode 100644 index 4a47bdb..0000000 --- a/src/cli/setup.rs +++ /dev/null @@ -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(()) - } - } -} diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 39dea80..df4e677 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -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; diff --git a/src/config/mod.rs b/src/config/mod.rs index 692ecfd..cbb053e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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//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)); } diff --git a/src/config/schema.rs b/src/config/schema.rs index cfb536d..5bccafd 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -11,39 +11,13 @@ 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//smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich -/// nicht denselben Namensraum teilen. +/// persistieren müssen. Ein einzelner, flacher Namensraum reicht: smart-mount läuft +/// ausschließlich als root/System-Dienst (siehe [`crate::systemd`]) und mountet dort auch +/// Paare mit gesetztem `owner_user` - die Trennung nach Linux-Nutzer passiert über die +/// `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über +/// unterschiedliche Mountpoint-Namensräume. 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/smart-mount") } fn default_log_level() -> String { @@ -58,9 +32,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 +49,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 +76,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, pub mount_point: PathBuf, @@ -120,16 +95,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 +163,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 +187,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 +195,9 @@ 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/smart-mount") + ); } } diff --git a/src/crypto/key.rs b/src/crypto/key.rs index 72a9c91..6b34aab 100644 --- a/src/crypto/key.rs +++ b/src/crypto/key.rs @@ -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) - } -} diff --git a/src/doctor.rs b/src/doctor.rs index a854ef0..8dff1f8 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -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)] @@ -68,23 +67,6 @@ pub fn run_checks(cfg: &AppConfig) -> Vec { 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 +123,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 +160,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 +198,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 +210,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(); diff --git a/src/error.rs b/src/error.rs index fedbb2f..1d054f4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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), } diff --git a/src/fstab/mod.rs b/src/fstab/mod.rs deleted file mode 100644 index 47a67ab..0000000 --- a/src/fstab/mod.rs +++ /dev/null @@ -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 = match &sudo_user { - Some(u) => vec![u.clone()], - None => user_pairs - .iter() - .filter_map(|p| p.owner_user.clone()) - .collect::>() - .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 { - 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//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(¤t_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 { - 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 = 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 { - // 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 { - 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/` 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")); - } -} diff --git a/src/lib.rs b/src/lib.rs index 70a86c2..3757831 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/src/mount/mod.rs b/src/mount/mod.rs index c045f82..96974b2 100644 --- a/src/mount/mod.rs +++ b/src/mount/mod.rs @@ -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 -o `. - Direct, - /// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount ` (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 -o ` +/// 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, - 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, } diff --git a/src/mount/nfs.rs b/src/mount/nfs.rs index 1b07822..8263684 100644 --- a/src/mount/nfs.rs +++ b/src/mount/nfs.rs @@ -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,19 +32,12 @@ 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) - .arg(&target.mount_point); - if !target.options.is_empty() { - cmd.arg("-o").arg(target.options.join(",")); - } - } - MountInvocation::ViaFstab => { - cmd.arg(&target.mount_point); - } + cmd.arg("-t") + .arg("nfs") + .arg(&target.source) + .arg(&target.mount_point); + if !target.options.is_empty() { + cmd.arg("-o").arg(target.options.join(",")); } run_tolerating_already_done(cmd, "nfs mount", false) } diff --git a/src/mount/smb.rs b/src/mount/smb.rs index 29789e9..848e286 100644 --- a/src/mount/smb.rs +++ b/src/mount/smb.rs @@ -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,23 +36,16 @@ 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) - .arg(&target.mount_point); - let mut opts = target.options.clone(); - opts.push(format!( - "credentials={}", - credentials_path(&target.pair_id, target.side).display() - )); - cmd.arg("-o").arg(opts.join(",")); - } - MountInvocation::ViaFstab => { - cmd.arg(&target.mount_point); - } - } + cmd.arg("-t") + .arg("cifs") + .arg(&target.source) + .arg(&target.mount_point); + let mut opts = target.options.clone(); + opts.push(format!( + "credentials={}", + credentials_path(&target.pair_id, target.side).display() + )); + cmd.arg("-o").arg(opts.join(",")); 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() diff --git a/src/mount/target.rs b/src/mount/target.rs index 46d4286..03e0168 100644 --- a/src/mount/target.rs +++ b/src/mount/target.rs @@ -17,10 +17,10 @@ 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 +60,6 @@ pub fn build_target_with_cached_local_ip( side: Side, cached_local_ip: Option, ) -> Result { - 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 +84,6 @@ pub fn build_target_with_cached_local_ip( mount_point: backing_dir(pair, side), source, options, - invocation, owner_user: pair.owner_user.clone(), }) } @@ -296,7 +291,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 { diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs index cbb63ad..60eedc6 100644 --- a/src/mount/webdav.rs +++ b/src/mount/webdav.rs @@ -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,19 +58,12 @@ 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) - .arg(&target.mount_point); - if !target.options.is_empty() { - cmd.arg("-o").arg(target.options.join(",")); - } - } - MountInvocation::ViaFstab => { - cmd.arg(&target.mount_point); - } + cmd.arg("-t") + .arg("davfs") + .arg(&target.source) + .arg(&target.mount_point); + if !target.options.is_empty() { + cmd.arg("-o").arg(target.options.join(",")); } 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") - } + PathBuf::from("/etc/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(".")) + PathBuf::from("/etc/davfs2/secrets") } /// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index d1c585a..af0c1c6 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -354,10 +354,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 +363,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 +371,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 { diff --git a/src/systemd/mod.rs b/src/systemd/mod.rs index 362fcf2..4a751da 100644 --- a/src/systemd/mod.rs +++ b/src/systemd/mod.rs @@ -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 { - 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 '. \ - 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 { - 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 { - 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 { - 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 { 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 { Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH))) } -fn install_cron_user(watch_interval_secs: u64) -> Result { - 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 { - match scope { - Scope::System => uninstall_cron_system(), - Scope::User => uninstall_cron_user(), - } -} - -fn uninstall_cron_system() -> Result { - 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 { let path = Path::new(CRON_D_PATH); if !path.exists() { return Ok(CronUninstallOutcome::NotPresent); @@ -279,53 +99,6 @@ fn uninstall_cron_system() -> Result { Ok(CronUninstallOutcome::Removed) } -fn uninstall_cron_user() -> Result { - 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 ")); } diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index 7870fc9..0000000 --- a/src/util.rs +++ /dev/null @@ -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 { - 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 - ); - } -} From f0c0a69b9cedb823448c36b552403c4699a66b07 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 13:32:45 +0200 Subject: [PATCH 25/28] 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 ' (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 --- README.md | 8 ++++++-- src/cli/drive.rs | 35 ++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 49e92d9..a25697e 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,12 @@ Eintragen ausgegeben: - **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 - optional, ohne `owner_user` gehört - der Mount root. 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. + 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. - **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 diff --git a/src/cli/drive.rs b/src/cli/drive.rs index c07f902..0e5e54c 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -151,7 +151,10 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { let ni = args.non_interactive; let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required"); - let owner_user = resolve_owner_user(args.owner_user, 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)?; @@ -391,29 +394,47 @@ fn resolve_field( Ok(Some(input.interact_text()?)) } +/// 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 { + 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`]). +/// 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, - current: Option<&str>, + default_owner: Option<&str>, non_interactive: bool, ) -> anyhow::Result> { if let Some(v) = flag { return Ok(Some(v)); } 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::::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()?)) From bd319523a7aa57d2a4f2a100d809e518319bab71 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 13:34:06 +0200 Subject: [PATCH 26/28] 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 --- README.md | 13 +++++++--- src/mount/target.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++ src/reconcile/mod.rs | 6 +++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a25697e..3bf8327 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,12 @@ Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mo root, siehe oben) - `/run/media` ist auf den meisten Systemen bereits die übliche Konvention für eingebundene Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden -bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen. Ist für ein Paar -`owner_user` gesetzt, bekommt genau dieser Nutzer über die Mount-Optionen (`uid=`/`gid=`, siehe -"Architekturentscheidungen" unten) vollen Zugriff auf den Inhalt - die Trennung passiert also +bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen - `mount_base_dir` selbst +fest mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses), +das jeweilige Backing-Verzeichnis (siehe unten) bei gesetztem `owner_user` zusätzlich fest +`chown`t und `0700` (nur der Owner). Ist für ein Paar `owner_user` gesetzt, bekommt genau dieser +Nutzer bei CIFS/WebDAV zusätzlich über die Mount-Optionen (`uid=`/`gid=`, siehe +"Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer. Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit @@ -207,7 +210,9 @@ Eintragen ausgegeben: 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. + 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 diff --git a/src/mount/target.rs b/src/mount/target.rs index 03e0168..90d38d2 100644 --- a/src/mount/target.rs +++ b/src/mount/target.rs @@ -15,6 +15,7 @@ //! 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, MountKind}; @@ -159,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 { let output = std::process::Command::new("id") .arg(flag) @@ -263,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); @@ -466,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); + } } diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index af0c1c6..ed59c47 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -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()?; From e2e6c06bce7fc92e3e8dba2d28082f7bdaed5b8b Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 13:36:12 +0200 Subject: [PATCH 27/28] Feature: Mountpoint heisst standardmaessig wie das Paar, frei ueberschreibbar Der Standard-Mountpunkt eines neuen Laufwerkspaares ist jetzt '/' (z. B. /run/media/NAS) statt '/run/media/smart-mount/' - 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 ' 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 --- README.md | 38 +++++++--- src/cli/drive.rs | 166 +++++++++++++++++++++++++++++++++++++++++-- src/config/schema.rs | 19 +++-- 3 files changed, 198 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 3bf8327..1947c7b 100644 --- a/README.md +++ b/README.md @@ -99,22 +99,40 @@ Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis. ### Wo die Laufwerke eingebunden werden -Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir` -(`/`) - mehrere Paare stören sich also nie gegenseitig. Standardwert: -`/run/media/smart-mount`, ein einzelner, flacher Namensraum (smart-mount mountet immer als -root, siehe oben) - `/run/media` ist auf den meisten Systemen bereits die übliche Konvention -für eingebundene Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, -muss also nie persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden -bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen - `mount_base_dir` selbst -fest mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses), +Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`, +standardmäßig `/` (z. B. `/run/media/NAS`) - mehrere Paare +stören sich also nie gegenseitig, und der Ordnername ist beim Durchsuchen sofort erkennbar +(statt einer kryptischen ID). Standardwert für `mount_base_dir`: `/run/media` - auf den +meisten Systemen bereits die übliche Konvention für eingebundene Wechseldatenträger/ +Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie persistieren. Der +Name wird dabei zu einem sicheren Verzeichnisnamen bereinigt (nur alphanumerische Zeichen +sowie `-`/`_`, alles andere wird zu `-`); kollidiert das Ergebnis mit einem bereits +bestehenden Paar (Namen müssen nicht eindeutig sein), wird `-2`, `-3`, ... angehängt. + +**Der Mountpoint lässt sich beim Anlegen frei überschreiben** - per `--mount-point ` +(nicht-interaktiv) oder im interaktiven Prompt, komplett unabhängig von `mount_base_dir`: + +```bash +sudo smart-mount drive add --mount-point /mnt/nas --non-interactive ... +``` + +Bei `drive edit` bleibt der Mountpoint standardmäßig unverändert (kein Prompt dafür) - sonst +würde ein aktiver Mount verwaisen. Nur eine explizite `--mount-point`-Angabe ändert ihn (mit +einer Warnung, falls das Paar gerade aktiv gemountet ist - dann erst `unmount`, editieren, +dann erneut `mount`). + +Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem `mount`/ +`watch`-Lauf automatisch angelegt, falls sie fehlen - der gemeinsame Elternordner +(`mount_base_dir`, bzw. bei einem komplett eigenen `--mount-point` dessen Elternordner) fest +mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses), das jeweilige Backing-Verzeichnis (siehe unten) bei gesetztem `owner_user` zusätzlich fest `chown`t und `0700` (nur der Owner). Ist für ein Paar `owner_user` gesetzt, bekommt genau dieser Nutzer bei CIFS/WebDAV zusätzlich über die Mount-Optionen (`uid=`/`gid=`, siehe "Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer. -Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit -auf einen beliebigen anderen Pfad ändern. +Der Standard für `mount_base_dir` lässt sich in `config.toml` unter +`[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern. --- diff --git a/src/cli/drive.rs b/src/cli/drive.rs index 0e5e54c..2beeede 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -6,6 +6,7 @@ //! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde). use std::net::Ipv4Addr; +use std::path::{Path, PathBuf}; use std::str::FromStr; use clap::{Args, Subcommand}; @@ -40,6 +41,12 @@ pub enum DriveAction { pub struct DriveArgs { #[arg(long)] name: Option, + /// Where the pair's symlink/backing directories live. Default (on 'add', derived from the + /// name): '/' (see 'settings.mount_base_dir'). Ignored on 'edit' + /// unless explicitly given - an existing pair's mount point stays fixed otherwise, so an + /// active mount doesn't get orphaned. + #[arg(long)] + mount_point: Option, #[arg(long)] owner_user: Option, @@ -194,8 +201,9 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { // `add_pair()` (das intern selbst frisch lädt) auf denselben kaputten Zustand treffen // lassen, was dort ALLE bestehenden Paare durch die eine neue Konfiguration ersetzen // würde. Besser früh mit einem klaren Fehler abbrechen. - let settings = config::pairs::load()?.settings; - let mount_point = settings.mount_base_dir.join(&id); + let cfg = config::pairs::load()?; + let default_mount_point = default_mount_point(&cfg.settings.mount_base_dir, &name, &cfg.pairs); + let mount_point = resolve_mount_point(args.mount_point, &default_mount_point, ni)?; let pair = DrivePair { id: id.clone(), @@ -313,14 +321,29 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { "Cloud credentials", )?; + // Anders als bei allen anderen Feldern gibt es hier bewusst KEINEN interaktiven Prompt: der + // Mountpoint (und damit die Backing-Verzeichnisse) bleibt standardmäßig unverändert, sonst + // würde ein aktiver Mount verwaisen. Nur eine explizite '--mount-point'-Angabe ändert ihn. + let mount_point = match args.mount_point { + Some(p) => { + if smart_mount::mount::target::active_side(&existing).is_some() { + println!( + "Warning: pair '{id}' appears to be actively mounted - the old backing \ + directories will be orphaned. Run 'sudo smart-mount unmount --name {id}' \ + first, then 'mount --name {id}' again after this edit." + ); + } + p + } + None => existing.mount_point.clone(), + }; + let updated = DrivePair { id: id.to_string(), name, enabled: existing.enabled, owner_user, - // Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden - // eventuell noch aktive Mounts verwaisen. - mount_point: existing.mount_point.clone(), + mount_point, local: LocalSide { kind: local_kind, address: local_address, @@ -394,6 +417,62 @@ fn resolve_field( Ok(Some(input.interact_text()?)) } +/// Wandelt `name` in einen sicheren Verzeichnisnamen um (für den Standard-Mountpoint, siehe +/// `default_mount_point`): nur ASCII-alphanumerische Zeichen sowie `-`/`_` bleiben erhalten, +/// jede Folge anderer Zeichen (Leerzeichen, `/`, Sonderzeichen, Unicode) wird zu einem einzelnen +/// `-`; führende/folgende `-` werden entfernt. Ein leeres Ergebnis (z. B. bei einem rein aus +/// Sonderzeichen bestehenden Namen) fällt auf `"pair"` zurück, damit der Mountpoint nie +/// leer/unbrauchbar wird. +fn sanitize_dir_name(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for c in name.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + out.push(c); + } else if !out.ends_with('-') { + out.push('-'); + } + } + match out.trim_matches('-') { + "" => "pair".to_string(), + trimmed => trimmed.to_string(), + } +} + +/// Standard-Mountpoint für ein neues Paar: `/` (siehe +/// `sanitize_dir_name`). Namen sind - anders als `id` - nicht zwingend eindeutig (siehe +/// `config::pairs::find_pair`); kollidiert der Standard mit dem Mountpoint eines bestehenden +/// Paares, wird `-2`, `-3`, ... angehängt, bis er eindeutig ist. +fn default_mount_point(base: &Path, name: &str, existing: &[DrivePair]) -> PathBuf { + let sanitized = sanitize_dir_name(name); + let mut candidate = base.join(&sanitized); + let mut suffix = 2; + while existing.iter().any(|p| p.mount_point == candidate) { + candidate = base.join(format!("{sanitized}-{suffix}")); + suffix += 1; + } + candidate +} + +/// Löst den Mountpoint auf: Flag > (interaktiv: Prompt vorbelegt mit `default`, editierbar) > +/// (nicht-interaktiv: `default` ungefragt übernehmen). +fn resolve_mount_point( + flag: Option, + default: &Path, + non_interactive: bool, +) -> anyhow::Result { + if let Some(p) = flag { + return Ok(p); + } + if non_interactive { + return Ok(default.to_path_buf()); + } + let input: String = Input::new() + .with_prompt("Mount point") + .default(default.display().to_string()) + .interact_text()?; + Ok(PathBuf::from(input)) +} + /// Der Nutzer, in dessen Namen `sudo` aufgerufen wurde (falls so aufgerufen) - Standardwert für /// `owner_user` beim Anlegen eines neuen Paares (siehe `add`), damit der einrichtende Nutzer /// ohne extra Angabe vollen Zugriff auf sein eigenes Laufwerkspaar bekommt. `None`, wenn direkt @@ -579,3 +658,80 @@ fn resolve_credentials( .interact()?; Ok((Some(username), Some(password))) } + +#[cfg(test)] +mod tests { + use super::*; + use smart_mount::config::LocalAddress; + use std::net::Ipv4Addr; + + fn sample_pair(mount_point: &str) -> DrivePair { + DrivePair { + id: "pair-1".into(), + name: "Test".into(), + enabled: true, + owner_user: None, + mount_point: PathBuf::from(mount_point), + local: LocalSide { + kind: MountKind::Nfs, + address: LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)), + share: "share".into(), + username: None, + extra_options: vec![], + }, + cloud: CloudSide { + kind: MountKind::Nfs, + host_or_url: "cloud.example.com".into(), + share: "share".into(), + username: None, + extra_options: vec![], + }, + } + } + + #[test] + fn sanitize_dir_name_keeps_simple_names_unchanged() { + assert_eq!(sanitize_dir_name("NAS"), "NAS"); + assert_eq!(sanitize_dir_name("my-nas_2"), "my-nas_2"); + } + + #[test] + fn sanitize_dir_name_collapses_runs_of_special_characters_to_one_dash() { + assert_eq!(sanitize_dir_name("My NAS / Drive"), "My-NAS-Drive"); + } + + #[test] + fn sanitize_dir_name_trims_leading_and_trailing_dashes() { + assert_eq!(sanitize_dir_name(" NAS!!"), "NAS"); + assert_eq!(sanitize_dir_name("/etc/passwd"), "etc-passwd"); + } + + #[test] + fn sanitize_dir_name_falls_back_to_pair_for_only_special_characters() { + assert_eq!(sanitize_dir_name("!!!"), "pair"); + assert_eq!(sanitize_dir_name(""), "pair"); + } + + #[test] + fn default_mount_point_uses_base_dir_and_sanitized_name() { + let point = default_mount_point(Path::new("/run/media"), "NAS", &[]); + assert_eq!(point, PathBuf::from("/run/media/NAS")); + } + + #[test] + fn default_mount_point_dedupes_against_existing_pairs() { + let existing = vec![ + sample_pair("/run/media/NAS"), + sample_pair("/run/media/NAS-2"), + ]; + let point = default_mount_point(Path::new("/run/media"), "NAS", &existing); + assert_eq!(point, PathBuf::from("/run/media/NAS-3")); + } + + #[test] + fn default_mount_point_is_unaffected_by_unrelated_existing_pairs() { + let existing = vec![sample_pair("/run/media/OtherPair")]; + let point = default_mount_point(Path::new("/run/media"), "NAS", &existing); + assert_eq!(point, PathBuf::from("/run/media/NAS")); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index 5bccafd..eb01acf 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -11,13 +11,15 @@ use serde::{Deserialize, Serialize}; /// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene /// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei /// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie -/// persistieren müssen. Ein einzelner, flacher Namensraum reicht: smart-mount läuft -/// ausschließlich als root/System-Dienst (siehe [`crate::systemd`]) und mountet dort auch -/// Paare mit gesetztem `owner_user` - die Trennung nach Linux-Nutzer passiert über die -/// `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über -/// unterschiedliche Mountpoint-Namensräume. +/// persistieren müssen. Jedes Paar bekommt direkt darunter sein eigenes, nach seinem Namen +/// benanntes Unterverzeichnis (`/run/media/`, siehe `cli::drive::default_mount_point`) - +/// kein zusätzlicher `smart-mount`-Zwischenordner, damit der Ordnername beim Durchsuchen von +/// `/run/media` sofort erkennbar ist. Die Trennung nach Linux-Nutzer passiert bei Bedarf über +/// die `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über +/// unterschiedliche Mountpoint-Namensräume - smart-mount läuft ausschließlich als +/// root/System-Dienst (siehe [`crate::systemd`]). fn default_mount_base_dir() -> PathBuf { - PathBuf::from("/run/media/smart-mount") + PathBuf::from("/run/media") } fn default_log_level() -> String { @@ -195,9 +197,6 @@ mod tests { #[test] fn default_mount_base_dir_uses_run_media() { - assert_eq!( - default_mount_base_dir(), - PathBuf::from("/run/media/smart-mount") - ); + assert_eq!(default_mount_base_dir(), PathBuf::from("/run/media")); } } From f71f43a7c03d4256a18929dcc69797e3eb889f74 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 13:40:48 +0200 Subject: [PATCH 28/28] Ref: Entfernt nicht verwendete Crate `program-ctdra`. --- Cargo.lock | 1 - Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3aa82ef..e9d4496 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2062,7 +2062,6 @@ dependencies = [ "dialoguer", "getrandom 0.4.3", "logger-ctdra", - "program-ctdra", "serde", "serde_json", "sudo-ctdra", diff --git a/Cargo.toml b/Cargo.toml index d886009..2f0b3e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"