Compare commits
24
Commits
05a4a908df
...
nightly
@@ -10,6 +10,3 @@ git-fetch-with-cli = true
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.i686-unknown-linux-gnu]
|
||||
linker = "i686-linux-gnu-gcc"
|
||||
|
||||
@@ -24,16 +24,14 @@ jobs:
|
||||
components: clippy, rustfmt
|
||||
cache: false
|
||||
|
||||
# Ein rohes 'actions/cache' auf target/ liefert zwar einen technischen Cache-Hit (Dateien
|
||||
# werden wiederhergestellt), Cargo kompiliert die Abhängigkeiten aber oft trotzdem neu:
|
||||
# Der tar-basierte Restore-Vorgang setzt bei allen wiederhergestellten Dateien dieselbe
|
||||
# Mtime, wodurch Cargos Fingerprinting nicht mehr zuverlässig erkennen kann, was
|
||||
# älter/neuer als was ist, und sicherheitshalber alles neu baut. Swatinem/rust-cache ist
|
||||
# genau dafür gebaut (u. a. gezielte Mtime-Korrektur nach dem Restore).
|
||||
- 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 }}-
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Formatierung automatisch beheben
|
||||
run: cargo fmt
|
||||
|
||||
+17
-17
@@ -47,24 +47,24 @@ jobs:
|
||||
toolchain: stable
|
||||
cache: false
|
||||
|
||||
# Ein rohes 'actions/cache' auf target/ liefert zwar einen technischen Cache-Hit
|
||||
# (Dateien werden restauriert), Cargo kompiliert die Abhängigkeiten aber trotzdem neu:
|
||||
# Der tar-basierte Restore-Vorgang setzt bei allen wiederhergestellten Dateien
|
||||
# (Quelldateien wie kompilierte .rlib/.d-Dateien) dieselbe Mtime, wodurch Cargos
|
||||
# Fingerprinting nicht mehr zuverlässig erkennen kann, was tatsächlich älter/neuer als
|
||||
# was ist, und sicherheitshalber alles neu baut. Swatinem/rust-cache ist genau dafür
|
||||
# gebaut (u. a. gezielte Mtime-Korrektur nach dem Restore) und cached dabei automatisch
|
||||
# auch beide Cross-Compilation-Targets (x86_64 + aarch64) in diesem Job.
|
||||
- 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 }}-
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Alte Paketierungs-Ausgaben aus dem Cache entfernen
|
||||
run: rm -rf target/debian target/generate-rpm target/arch
|
||||
run: rm -rf target/debian target/generate-rpm target/arch target/completions
|
||||
|
||||
- name: Install Cross-Compilation Toolchains (apt)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-i686-linux-gnu
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
|
||||
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
|
||||
@@ -75,12 +75,11 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
|
||||
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/i686-unknown-linux-gnu
|
||||
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Add Rust Cross-Compilation Targets
|
||||
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
|
||||
run: rustup target add aarch64-unknown-linux-gnu i686-unknown-linux-gnu
|
||||
run: rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: PATH um Cargo-bin-Verzeichnis ergänzen
|
||||
run: |
|
||||
@@ -111,7 +110,11 @@ jobs:
|
||||
run: |
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
cargo build --release --target aarch64-unknown-linux-gnu
|
||||
cargo build --release --target i686-unknown-linux-gnu
|
||||
|
||||
- name: Generate Shell Completions
|
||||
run: |
|
||||
cargo build --release --bin generate-completions
|
||||
./target/release/generate-completions target/completions
|
||||
|
||||
- name: Determine Build Number
|
||||
id: build_num
|
||||
@@ -130,20 +133,17 @@ jobs:
|
||||
run: |
|
||||
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
cargo deb --target i686-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
|
||||
- name: Build Fedora / RPM Packages (.rpm)
|
||||
run: |
|
||||
mkdir -p target/generate-rpm
|
||||
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
cargo generate-rpm --target i686-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
|
||||
- name: Build Arch Linux Packages (.pkg.tar.zst)
|
||||
run: |
|
||||
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
python3 scripts/package-arch.py --target i686-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
|
||||
- name: Publish Packages to Gitea Package Registry
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Nightly Auto-Merge (Dev → Nightly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Gitea Actions interpretiert 'schedule'-Cron standardmaessig in der lokalen
|
||||
# Zeitzone des Gitea-Servers (anders als GitHub Actions, das immer UTC nutzt).
|
||||
# Das CRON_TZ-Praefix ist eine Gitea-Erweiterung und legt die Zeitzone explizit
|
||||
# und DST-sicher fest, unabhaengig von der Server-Konfiguration.
|
||||
- cron: "CRON_TZ=Europe/Berlin 0 9 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
merge-dev-into-nightly:
|
||||
name: Erstelle & merge automatisch PR von dev nach nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
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: Stelle sicher, dass der nightly-Branch existiert
|
||||
run: |
|
||||
git fetch origin dev
|
||||
if git ls-remote --exit-code --heads origin nightly > /dev/null 2>&1; then
|
||||
echo "nightly-Branch existiert bereits."
|
||||
else
|
||||
echo "nightly-Branch existiert noch nicht, erstelle ihn initial von dev..."
|
||||
git push origin origin/dev:refs/heads/nightly
|
||||
fi
|
||||
|
||||
- name: Prüfe auf Unterschiede zwischen dev und nightly
|
||||
id: diff
|
||||
run: |
|
||||
git fetch origin nightly
|
||||
if git diff --quiet origin/nightly origin/dev; then
|
||||
echo "Keine Unterschiede zwischen dev und nightly, überspringe."
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Prüfe auf bereits offenen PR nach nightly
|
||||
id: check_pr
|
||||
if: steps.diff.outputs.has_changes == 'true'
|
||||
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")
|
||||
PR_NUMBER=$(echo "$OPEN_PRS" | jq -r '[.[] | select(.base.ref == "nightly" and .head.ref == "dev")][0].number // empty')
|
||||
echo "Bereits offener dev→nightly PR: ${PR_NUMBER:-keiner}"
|
||||
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Erstelle PR (dev -> nightly)
|
||||
id: create_pr
|
||||
if: steps.diff.outputs.has_changes == 'true' && steps.check_pr.outputs.pr_number == ''
|
||||
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: |
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
PAYLOAD=$(jq -n --arg title "Nightly-Build: Merge dev in nightly (${DATE})" --arg head "dev" --arg base "nightly" \
|
||||
'{title: $title, head: $head, base: $base}')
|
||||
RESP=$(curl -f -s -S -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/pulls")
|
||||
PR_NUMBER=$(echo "$RESP" | jq -r '.number')
|
||||
echo "PR #${PR_NUMBER} erstellt."
|
||||
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge PR automatisch
|
||||
if: steps.diff.outputs.has_changes == 'true'
|
||||
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 }}
|
||||
PR_NUMBER: ${{ steps.create_pr.outputs.pr_number || steps.check_pr.outputs.pr_number }}
|
||||
run: |
|
||||
echo "Merge PR #${PR_NUMBER} automatisch (dev -> nightly)..."
|
||||
curl -f -s -S -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do": "merge"}' \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
echo "PR #${PR_NUMBER} erfolgreich gemerged."
|
||||
@@ -0,0 +1,171 @@
|
||||
name: Nightly Build & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- nightly
|
||||
|
||||
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/nightly.yaml'
|
||||
|
||||
build-and-publish:
|
||||
name: Build & Publish Packages (Nightly)
|
||||
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: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Alte Paketierungs-Ausgaben aus dem Cache entfernen
|
||||
run: rm -rf target/debian target/generate-rpm target/arch target/completions
|
||||
|
||||
- name: Install Cross-Compilation Toolchains (apt)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
|
||||
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- name: Generate Shell Completions
|
||||
run: |
|
||||
cargo build --release --bin generate-completions
|
||||
./target/release/generate-completions target/completions
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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}"
|
||||
|
||||
- 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: nightly, 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/nightly/main/upload"
|
||||
done
|
||||
|
||||
echo "Veröffentliche Fedora/RPM-Paket (Gruppe: nightly)..."
|
||||
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/nightly/upload"
|
||||
done
|
||||
|
||||
echo "Veröffentliche Arch Linux-Paket (Repository: nightly)..."
|
||||
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/nightly"
|
||||
done
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- dev
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 5 * * 1"
|
||||
- cron: "CRON_TZ=Europe/Berlin 0 5 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -47,24 +47,24 @@ jobs:
|
||||
toolchain: stable
|
||||
cache: false
|
||||
|
||||
# Ein rohes 'actions/cache' auf target/ liefert zwar einen technischen Cache-Hit
|
||||
# (Dateien werden restauriert), Cargo kompiliert die Abhängigkeiten aber trotzdem neu:
|
||||
# Der tar-basierte Restore-Vorgang setzt bei allen wiederhergestellten Dateien
|
||||
# (Quelldateien wie kompilierte .rlib/.d-Dateien) dieselbe Mtime, wodurch Cargos
|
||||
# Fingerprinting nicht mehr zuverlässig erkennen kann, was tatsächlich älter/neuer als
|
||||
# was ist, und sicherheitshalber alles neu baut. Swatinem/rust-cache ist genau dafür
|
||||
# gebaut (u. a. gezielte Mtime-Korrektur nach dem Restore) und cached dabei automatisch
|
||||
# auch beide Cross-Compilation-Targets (x86_64 + aarch64) in diesem Job.
|
||||
- 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 }}-
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Alte Paketierungs-Ausgaben aus dem Cache entfernen
|
||||
run: rm -rf target/debian target/generate-rpm target/arch
|
||||
run: rm -rf target/debian target/generate-rpm target/arch target/completions
|
||||
|
||||
- name: Install Cross-Compilation Toolchains (apt)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-i686-linux-gnu
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
|
||||
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
|
||||
@@ -75,12 +75,11 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
|
||||
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/i686-unknown-linux-gnu
|
||||
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Add Rust Cross-Compilation Targets
|
||||
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
|
||||
run: rustup target add aarch64-unknown-linux-gnu i686-unknown-linux-gnu
|
||||
run: rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: PATH um Cargo-bin-Verzeichnis ergänzen
|
||||
run: |
|
||||
@@ -111,7 +110,11 @@ jobs:
|
||||
run: |
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
cargo build --release --target aarch64-unknown-linux-gnu
|
||||
cargo build --release --target i686-unknown-linux-gnu
|
||||
|
||||
- name: Generate Shell Completions
|
||||
run: |
|
||||
cargo build --release --bin generate-completions
|
||||
./target/release/generate-completions target/completions
|
||||
|
||||
- name: Determine Build Number
|
||||
id: build_num
|
||||
@@ -130,20 +133,17 @@ jobs:
|
||||
run: |
|
||||
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
cargo deb --target i686-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
|
||||
- name: Build Fedora / RPM Packages (.rpm)
|
||||
run: |
|
||||
mkdir -p target/generate-rpm
|
||||
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
cargo generate-rpm --target i686-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
|
||||
- name: Build Arch Linux Packages (.pkg.tar.zst)
|
||||
run: |
|
||||
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
python3 scripts/package-arch.py --target i686-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
|
||||
- name: Publish Packages to Gitea Package Registry
|
||||
env:
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
- cron: "CRON_TZ=Europe/Berlin 0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- reopened
|
||||
branches:
|
||||
- testing
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -23,16 +24,11 @@ jobs:
|
||||
toolchain: stable
|
||||
cache: false
|
||||
|
||||
# Siehe main.yaml/testing.yaml: Swatinem/rust-cache statt eines rohen 'actions/cache' auf
|
||||
# target/, das trotz Cache-Hit wegen verschobener Mtimes nach dem Restore oft trotzdem
|
||||
# alles neu kompiliert.
|
||||
- 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 }}-
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run Tests
|
||||
run: cargo test
|
||||
|
||||
Generated
+16
@@ -3,6 +3,22 @@
|
||||
<component name="JsonSchemaMappingsProjectConfiguration">
|
||||
<state>
|
||||
<map>
|
||||
<entry key="Cargo Config">
|
||||
<value>
|
||||
<SchemaInfo>
|
||||
<option name="name" value="Cargo Config" />
|
||||
<option name="relativePathToSchema" value="https://www.schemastore.org/cargo-config.json" />
|
||||
<option name="applicationDefined" value="true" />
|
||||
<option name="patterns">
|
||||
<list>
|
||||
<Item>
|
||||
<option name="path" value=".cargo/config.toml" />
|
||||
</Item>
|
||||
</list>
|
||||
</option>
|
||||
</SchemaInfo>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="GitHub Workflow">
|
||||
<value>
|
||||
<SchemaInfo>
|
||||
|
||||
@@ -7,7 +7,7 @@ Dieses Dokument dient als technischer Leitfaden und Kontextdokument für KI-Codi
|
||||
## 1. Projektübersicht & Philosophie
|
||||
|
||||
Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und CLI-Tools mit Fokus auf:
|
||||
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`, `i686`).
|
||||
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`).
|
||||
- Native Paketierung für Debian (`.deb`), Fedora/RHEL (`.rpm`) und Arch Linux (`.pkg.tar.zst`) sowie Docker-Container-Images.
|
||||
- Vollständig automatisierte CI/CD-Pipelines via Gitea Actions (kompatibel mit Forgejo / GitHub Actions).
|
||||
- Automatisierte Sicherheits-Scans (Schwachstellen, Secrets) und Dependency-Updates.
|
||||
@@ -15,7 +15,7 @@ Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und C
|
||||
### Standards & Tech-Stack
|
||||
- **Sprache**: Rust (Edition 2024), Python 3 (für Hilfsskripte in `scripts/`).
|
||||
- **Rust Toolchain**: Stable.
|
||||
- **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `i686-unknown-linux-gnu`).
|
||||
- **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`).
|
||||
- **Container**: Docker-Images werden zusätzlich zu den nativen Paketen gebaut und in die Gitea Container Registry veröffentlicht.
|
||||
- **Sicherheits-Tooling**: Trivy, OSV-Scanner, TruffleHog (Secret-Scanning), Renovate (Dependency-Updates), Qodana (statische Codeanalyse).
|
||||
- **Lizenz**: GPL-3.0-or-later (sofern nicht im abgeleiteten Projekt anders definiert).
|
||||
@@ -118,7 +118,10 @@ Alle Workflows liegen unter `.gitea/workflows/` und nutzen gecachte Abhängigkei
|
||||
### 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<VERSION>` und baut/veröffentlicht zusätzlich ein Docker-Image (`:latest`, `:<VERSION>`, `:v<VERSION>`, `:<VERSION>.<BUILD_NUMBER>`).
|
||||
- **`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<VERSION>-preview` und taggt Docker-Images mit `:testing`, `:<VERSION>-preview`, `:<VERSION>-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.
|
||||
- **`nightly.yaml`** (Trigger: `push` auf `nightly`): Analog zu `testing.yaml`/`main.yaml` (Build, Tests, Paketierung für alle 3 Architekturen), veröffentlicht die Pakete jedoch ausschließlich in den `nightly`-Kanal der Paket-Registry (Distribution/Gruppe/Repository jeweils `nightly`) und erstellt **kein** Gitea Release.
|
||||
- **`nightly-pr.yaml`** (Trigger: `schedule` täglich `CRON_TZ=Europe/Berlin 0 9 * * *`, zusätzlich `workflow_dispatch`): Erstellt automatisch einen Pull Request von `dev` nach `nightly` (legt den `nightly`-Branch bei Bedarf initial von `dev` an) und merged diesen sofort ohne manuelle Freigabe, sofern es Unterschiede zwischen beiden Branches gibt. Der Merge löst anschließend `nightly.yaml` aus.
|
||||
> **Hinweis:** Gitea Actions interpretiert `schedule`-Cron standardmäßig in der lokalen Zeitzone des Gitea-Servers (anders als GitHub Actions, das immer UTC nutzt). Das `CRON_TZ=`-Präfix ist eine Gitea-Erweiterung, die die Zeitzone unabhängig von der Server-Konfiguration DST-sicher festlegt — bei neuen zeitgesteuerten Workflows bevorzugt verwenden statt einer fest verdrahteten UTC-Zeit (betrifft z. B. `security-scan.yaml`s `0 5 * * 1`, das ohne `CRON_TZ=` von der Server-Zeitzone abhängt).
|
||||
- **`unit-tests.yaml`** (Trigger: `pull_request` → `testing`/`dev`, 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`).
|
||||
|
||||
Generated
+1
-634
@@ -178,17 +178,6 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "apple-native-keyring-store"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"log",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -224,143 +213,6 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix 1.1.4",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix 1.1.4",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -436,28 +288,6 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "branches"
|
||||
version = "0.4.6"
|
||||
@@ -505,15 +335,6 @@ version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||
dependencies = [
|
||||
"cipher 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.6"
|
||||
@@ -714,22 +535,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc 0.2.189",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -860,18 +665,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
@@ -895,33 +688,6 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "2.0.0"
|
||||
@@ -969,26 +735,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -1030,36 +776,6 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
@@ -1073,7 +789,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
@@ -1203,24 +918,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
@@ -1453,7 +1150,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
@@ -1518,27 +1214,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
|
||||
dependencies = [
|
||||
"apple-native-keyring-store",
|
||||
"keyring-core",
|
||||
"windows-native-keyring-store",
|
||||
"zbus-secret-service-keyring-store",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring-core"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1755,20 +1430,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
@@ -1779,15 +1440,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -1803,27 +1455,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1860,16 +1491,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "3.5.0"
|
||||
@@ -1885,12 +1506,6 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1926,17 +1541,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -2010,15 +1614,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -2307,48 +1902,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"hkdf",
|
||||
"hybrid-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc 0.2.189",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc 0.2.189",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
@@ -2398,17 +1951,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -2424,17 +1966,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -2521,7 +2052,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||
|
||||
[[package]]
|
||||
name = "smart-mount"
|
||||
version = "2.0.0"
|
||||
version = "2.0.4"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"anyhow",
|
||||
@@ -2530,9 +2061,7 @@ dependencies = [
|
||||
"config-ctdra",
|
||||
"dialoguer",
|
||||
"getrandom 0.4.3",
|
||||
"keyring",
|
||||
"logger-ctdra",
|
||||
"program-ctdra",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sudo-ctdra",
|
||||
@@ -2803,18 +2332,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.15+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
@@ -3111,17 +2628,6 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uncased"
|
||||
version = "0.9.10"
|
||||
@@ -3195,7 +2701,6 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -3325,19 +2830,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-native-keyring-store"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"keyring-core",
|
||||
"regex",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -3449,9 +2941,6 @@ name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
@@ -3503,87 +2992,6 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc 0.2.189",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-secret-service-keyring-store"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"secret-service",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zcheapstr"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
@@ -3671,44 +3079,3 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 3.0.5",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
+65
-10
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "smart-mount"
|
||||
version = "2.0.0"
|
||||
version = "2.0.4"
|
||||
edition = "2024"
|
||||
authors = ['DragonSlayer_14']
|
||||
readme = "README.md"
|
||||
@@ -11,7 +11,6 @@ description = "Bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein
|
||||
[dependencies]
|
||||
config-ctdra = { version = "1.0.6", registry = "gitea" }
|
||||
logger-ctdra = { version = "1.0.5", registry = "gitea" }
|
||||
program-ctdra = { version = "1.0.1", registry = "gitea" }
|
||||
sudo-ctdra = { version = "1.0.1", registry = "gitea" }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4"
|
||||
@@ -26,7 +25,6 @@ serde_json = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
aes-gcm = "0.11.1"
|
||||
keyring = "4.2.0"
|
||||
dialoguer = "0.12.0"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms
|
||||
@@ -48,22 +46,39 @@ maintainer = "DragonSlayer_14"
|
||||
copyright = "2026 DragonSlayer_14"
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
# mac2ip und nmap sind harte Laufzeitabhängigkeiten (MAC->IP-Auflösung); davfs2/cifs-utils/
|
||||
# nfs-common werden bewusst NICHT hart verlangt, da smart-mount pro konfiguriertem Laufwerk
|
||||
# nur das jeweils benötigte Mount-Backend zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
|
||||
depends = "$auto, mac2ip, nmap"
|
||||
# mac2ip ist eine harte Laufzeitabhängigkeit (MAC->IP-Auflösung;
|
||||
# davfs2/cifs-utils/nfs-common werden bewusst NICHT hart verlangt,
|
||||
# da smart-mount pro konfiguriertem Laufwerk nur das jeweils benötigte Mount-Backend
|
||||
# zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
|
||||
depends = "$auto, mac2ip"
|
||||
recommends = "davfs2, cifs-utils, nfs-common"
|
||||
extended-description = """\
|
||||
smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud-Laufwerk
|
||||
ein: ist das lokale Laufwerk erreichbar, wird es gemountet, sonst automatisch das
|
||||
Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf
|
||||
zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank
|
||||
gespeichert; smart-mount kann als root/System-Dienst und als Nutzer-Dienst laufen.\
|
||||
gespeichert; smart-mount richtet sich beim Installieren automatisch als System-systemd-Dienst
|
||||
ein und mountet konfigurierte Laufwerkspaare für die jeweils zugeordneten lokalen Nutzer.\
|
||||
"""
|
||||
maintainer-scripts = "packaging/deb"
|
||||
assets = [
|
||||
["target/release/smart-mount", "usr/bin/smart-mount", "755"],
|
||||
["README.md", "usr/share/doc/smart-mount/README.md", "644"],
|
||||
["LICENSE", "usr/share/doc/smart-mount/copyright", "644"],
|
||||
["packaging/systemd/smart-mount-mount.service", "usr/lib/systemd/system/smart-mount-mount.service", "644"],
|
||||
["packaging/systemd/smart-mount-watch.service", "usr/lib/systemd/system/smart-mount-watch.service", "644"],
|
||||
["packaging/systemd/smart-mount-watch.timer", "usr/lib/systemd/system/smart-mount-watch.timer", "644"],
|
||||
# Von 'cargo build --bin generate-completions' erzeugt (siehe .gitea/workflows/main.yaml) -
|
||||
# kein 'smart-mount completions'-Subcommand mehr, Shell-Completion wird stattdessen wie jedes
|
||||
# andere Paket-Asset automatisch mit installiert/entfernt. Debian/Ubuntu laden Zsh-Completions
|
||||
# aus 'vendor-completions' (anders als Fedora/Arch, siehe [package.metadata.generate-rpm]).
|
||||
["target/completions/smart-mount.bash", "usr/share/bash-completion/completions/smart-mount", "644"],
|
||||
["target/completions/_smart-mount", "usr/share/zsh/vendor-completions/_smart-mount", "644"],
|
||||
["target/completions/smart-mount.fish", "usr/share/fish/vendor_completions.d/smart-mount.fish", "644"],
|
||||
# Kein 'smart-mount service'-Subcommand mehr - packaging/deb/postinst ruft dieses interne
|
||||
# Hilfsprogramm (siehe src/bin/setup-cron.rs) stattdessen automatisch auf und richtet damit
|
||||
# bei Bedarf (kein systemd, aber /etc/cron.d vorhanden) den Cron-Fallback ein.
|
||||
["target/release/setup-cron", "usr/lib/smart-mount/setup-cron", "755"],
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
@@ -71,14 +86,54 @@ 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" },
|
||||
# Fedora/RHEL (wie Arch, siehe scripts/package-arch.py) laden Zsh-Completions aus
|
||||
# 'site-functions' statt Debians 'vendor-completions' (siehe [package.metadata.deb]).
|
||||
{ source = "target/completions/smart-mount.bash", dest = "/usr/share/bash-completion/completions/smart-mount", mode = "644" },
|
||||
{ source = "target/completions/_smart-mount", dest = "/usr/share/zsh/site-functions/_smart-mount", mode = "644" },
|
||||
{ source = "target/completions/smart-mount.fish", dest = "/usr/share/fish/vendor_completions.d/smart-mount.fish", mode = "644" },
|
||||
# Kein 'smart-mount service'-Subcommand mehr - post_install_script ruft dieses interne
|
||||
# Hilfsprogramm (siehe src/bin/setup-cron.rs) stattdessen automatisch auf und richtet damit
|
||||
# bei Bedarf (kein systemd, aber /etc/cron.d vorhanden) den Cron-Fallback ein.
|
||||
{ source = "target/release/setup-cron", dest = "/usr/lib/smart-mount/setup-cron", mode = "755" },
|
||||
]
|
||||
requires = { "mac2ip" = "*", "nmap" = "*" }
|
||||
requires = { "mac2ip" = "*" }
|
||||
suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" }
|
||||
# postinst/postrm-Äquivalente: richten den paketierten systemd-Dienst (bzw. ohne systemd den
|
||||
# Cron-Fallback über setup-cron) 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. Die Cron-Datei wird
|
||||
# in %postun direkt per "rm -f" entfernt statt über setup-cron selbst: zu diesem Zeitpunkt sind
|
||||
# die Paketdateien - und damit auch setup-cron selbst - bereits entfernt.
|
||||
post_install_script = """
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || true
|
||||
# smart-mount-mount.service mountet echte Netzlaufwerke (kann bei nicht erreichbaren
|
||||
# Servern pro Paar mehrere zehn Sekunden dauern) - "enable --now" wuerde synchron darauf
|
||||
# warten und damit die Paketinstallation blockieren, daher aktivieren + nur als
|
||||
# asynchroner Job starten ("--no-block", kehrt sofort zurueck).
|
||||
systemctl enable smart-mount-mount.service || true
|
||||
systemctl start --no-block smart-mount-mount.service || true
|
||||
systemctl enable --now smart-mount-watch.timer || true
|
||||
fi
|
||||
/usr/lib/smart-mount/setup-cron || true
|
||||
"""
|
||||
post_uninstall_script = """
|
||||
if [ "$1" = "0" ]; then
|
||||
if 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
|
||||
rm -f /etc/cron.d/smart-mount
|
||||
fi
|
||||
"""
|
||||
|
||||
[package.metadata.arch]
|
||||
pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben
|
||||
arch = "x86_64"
|
||||
depends = ["gcc-libs", "glibc", "mac2ip", "nmap"]
|
||||
depends = ["gcc-libs", "glibc", "mac2ip"]
|
||||
optdepends = [
|
||||
"davfs2: WebDAV-Laufwerke einbinden",
|
||||
"cifs-utils: SMB/CIFS-Laufwerke einbinden",
|
||||
|
||||
@@ -14,8 +14,9 @@ werden (z. B. über ein separates Sync-Tool).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- **[`mac2ip`](https://gitea.creative-dragonslayer.de/Linuxapps/Mac2Ip)** und **`nmap`**: hart
|
||||
benötigt, sofern lokale Laufwerke per MAC-Adresse adressiert werden (Auflösung MAC → IP).
|
||||
- **[`mac2ip`](https://gitea.creative-dragonslayer.de/Linuxapps/Mac2Ip)**: hart benötigt, sofern
|
||||
lokale Laufwerke per MAC-Adresse adressiert werden (Auflösung MAC → IP). `mac2ip` verlangt
|
||||
`nmap` bereits selbst als eigene Paketabhängigkeit.
|
||||
- **`timeout`** (GNU coreutils): auf praktisch jedem Linux-System bereits vorhanden (Basis-
|
||||
Systempaket). Wird verwendet, um `mount`/`umount`-Aufrufe zeitlich zu begrenzen - siehe
|
||||
"Verhalten bei nicht mehr erreichbarem Server" unten.
|
||||
@@ -24,28 +25,32 @@ 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, aber mit
|
||||
`/etc/cron.d`, wird beim Installieren automatisch ein Cron-Fallback eingerichtet - kein
|
||||
manueller Schritt nötig.
|
||||
|
||||
---
|
||||
|
||||
## 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,110 +60,143 @@ smart-mount drive add --non-interactive \
|
||||
# Bestehendes Laufwerkspaar bearbeiten - nur angegebene Felder ändern sich, alles andere
|
||||
# (inkl. gespeichertem Passwort) bleibt unangetastet. Ohne Flags: interaktiver Durchlauf,
|
||||
# vorbelegt mit den aktuellen Werten (inkl. Rückfrage, ob das Passwort geändert werden soll).
|
||||
smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99
|
||||
smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort"
|
||||
sudo smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99
|
||||
sudo smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort"
|
||||
|
||||
# Konfigurierte Laufwerkspaare auflisten / entfernen (--json für Skripte)
|
||||
smart-mount drive list
|
||||
smart-mount drive list --json
|
||||
smart-mount drive remove <id>
|
||||
sudo smart-mount drive remove <id>
|
||||
|
||||
# Ein einzelnes Paar oder alle einbinden/aushängen
|
||||
smart-mount mount --name <id>
|
||||
smart-mount mount --all
|
||||
smart-mount unmount --all
|
||||
sudo smart-mount mount --name <id>
|
||||
sudo smart-mount mount --all
|
||||
sudo smart-mount unmount --all
|
||||
|
||||
# Aktuellen Status (Mount-Zustand + Erreichbarkeit beider Seiten) anzeigen (--json für Skripte)
|
||||
smart-mount status
|
||||
smart-mount status --json
|
||||
|
||||
# Ein Reconcile-Durchlauf (lokal/Cloud-Umschaltung) - für systemd-Timer/Cron gedacht
|
||||
smart-mount watch
|
||||
sudo smart-mount watch
|
||||
|
||||
# systemd-Units installieren (System- bzw. Nutzerkontext)
|
||||
sudo smart-mount service install --system
|
||||
smart-mount service install --user
|
||||
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
Shell-Completion (bash/zsh/fish) wird beim Installieren des .deb/.rpm/.pkg.tar.zst-Pakets
|
||||
automatisch mit installiert (und beim Entfernen des Pakets automatisch mit entfernt) - dafür ist
|
||||
kein eigenes `smart-mount`-Subcommand nötig.
|
||||
|
||||
Die Konfiguration liegt immer unter `/etc/smart-mount/config.toml`, unabhängig davon, ob
|
||||
`smart-mount` selbst mit oder ohne Root-Rechte aufgerufen wird (siehe oben). Die verschlüsselte
|
||||
Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis.
|
||||
|
||||
### Wo die Laufwerke eingebunden werden
|
||||
|
||||
Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`
|
||||
(`<mount_base_dir>/<pair-id>`) - mehrere Paare stören sich also nie gegenseitig. Standardwert
|
||||
für `mount_base_dir`: `/run/media/smart-mount` im System-Kontext (root, ein gemeinsamer,
|
||||
keinem Nutzer zugeordneter Namensraum), `/run/media/<Nutzer>/smart-mount` im Nutzerkontext -
|
||||
`/run/media` ist auf den meisten Systemen bereits die übliche Konvention für eingebundene
|
||||
Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie
|
||||
persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem
|
||||
`mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen - eigenes Anlegen ist nicht nötig.
|
||||
Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`,
|
||||
standardmäßig `<mount_base_dir>/<Name des Paares>` (z. B. `/run/media/NAS`) - mehrere Paare
|
||||
stören sich also nie gegenseitig, und der Ordnername ist beim Durchsuchen sofort erkennbar
|
||||
(statt einer kryptischen ID). Standardwert für `mount_base_dir`: `/run/media` - auf den
|
||||
meisten Systemen bereits die übliche Konvention für eingebundene Wechseldatenträger/
|
||||
Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie persistieren. Der
|
||||
Name wird dabei zu einem sicheren Verzeichnisnamen bereinigt (nur alphanumerische Zeichen
|
||||
sowie `-`/`_`, alles andere wird zu `-`); kollidiert das Ergebnis mit einem bereits
|
||||
bestehenden Paar (Namen müssen nicht eindeutig sein), wird `-2`, `-3`, ... angehängt.
|
||||
|
||||
**Achtung bei Nutzer-Kontext-Paaren:** `/run/media` gehört standardmäßig `root:root` mit Modus
|
||||
`0755` - ein normaler Nutzer kann dort also nicht einmal sein eigenes Unterverzeichnis
|
||||
anlegen. `sudo smart-mount setup fstab` übernimmt das einmalig (legt `/run/media/<Nutzer>`
|
||||
sowie `/run/media/<Nutzer>/smart-mount` an und macht den Nutzer zum Besitzer) - ohne diesen
|
||||
Schritt schlägt das automatische Anlegen für Nutzer-Kontext-Paare fehl.
|
||||
**Der Mountpoint lässt sich beim Anlegen frei überschreiben** - per `--mount-point <pfad>`
|
||||
(nicht-interaktiv) oder im interaktiven Prompt, komplett unabhängig von `mount_base_dir`:
|
||||
|
||||
Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit
|
||||
auf einen beliebigen anderen Pfad ändern.
|
||||
```bash
|
||||
sudo smart-mount drive add --mount-point /mnt/nas --non-interactive ...
|
||||
```
|
||||
|
||||
Bei `drive edit` bleibt der Mountpoint standardmäßig unverändert (kein Prompt dafür) - sonst
|
||||
würde ein aktiver Mount verwaisen. Nur eine explizite `--mount-point`-Angabe ändert ihn (mit
|
||||
einer Warnung, falls das Paar gerade aktiv gemountet ist - dann erst `unmount`, editieren,
|
||||
dann erneut `mount`).
|
||||
|
||||
Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem `mount`/
|
||||
`watch`-Lauf automatisch angelegt, falls sie fehlen - der gemeinsame Elternordner
|
||||
(`mount_base_dir`, bzw. bei einem komplett eigenen `--mount-point` dessen Elternordner) fest
|
||||
mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses),
|
||||
das jeweilige Backing-Verzeichnis (siehe unten) bei gesetztem `owner_user` zusätzlich fest
|
||||
`chown`t und `0700` (nur der Owner). Ist für ein Paar `owner_user` gesetzt, bekommt genau dieser
|
||||
Nutzer bei CIFS/WebDAV zusätzlich über die Mount-Optionen (`uid=`/`gid=`, siehe
|
||||
"Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also
|
||||
über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer.
|
||||
|
||||
Der Standard für `mount_base_dir` lässt sich in `config.toml` unter
|
||||
`[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern.
|
||||
|
||||
---
|
||||
|
||||
## Automatischer Start beim Systemstart
|
||||
|
||||
Das .deb/.rpm/.pkg.tar.zst-Paket richtet smart-mount beim Installieren **automatisch** als
|
||||
System-systemd-Dienst ein - kein manueller Schritt nötig. Die dafür paketierten Unit-Dateien
|
||||
(`packaging/systemd/` im Quellbaum, installiert nach `/usr/lib/systemd/system/`) bestehen aus:
|
||||
|
||||
- `smart-mount-mount.service`: ein `oneshot`-Service, der beim Boot `smart-mount mount --all`
|
||||
aufruft (nach `network-online.target`).
|
||||
- `smart-mount-watch.service` + `smart-mount-watch.timer`: ein Timer, der periodisch
|
||||
`smart-mount watch` aufruft (Standardintervall: 120s).
|
||||
|
||||
Die postinst/postrm-Skripte des Pakets (bzw. das `.INSTALL`-Skriptlet bei Arch) aktivieren und
|
||||
deaktivieren beide Units beim Installieren/Deinstallieren - 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.
|
||||
|
||||
`smart-mount-mount.service` mountet dabei echte Netzlaufwerke, was bei einem gerade nicht
|
||||
erreichbaren Server pro Paar mehrere zehn Sekunden dauern kann (begrenzt auf max. 10 Minuten
|
||||
insgesamt, siehe `TimeoutStartSec` in der Unit) - das Paket-Setup wird dadurch **nicht**
|
||||
blockiert: `systemctl start --no-block` stößt den ersten Mount-Versuch beim Installieren nur an,
|
||||
statt synchron auf ihn zu warten. `smart-mount-watch.timer` wird dagegen sofort synchron
|
||||
gestartet (das Timer-"Armen" selbst ist immer augenblicklich fertig).
|
||||
|
||||
**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:
|
||||
**Logs:** beide Units schreiben mit festem `SyslogIdentifier=smart-mount` ins systemd-Journal -
|
||||
`journalctl -u smart-mount-mount` / `-u smart-mount-watch` (oder gebündelt `journalctl -t
|
||||
smart-mount`) zeigt sie an. Zusätzlich schreibt smart-mount dieselben Meldungen dauerhaft nach
|
||||
`/var/log/smart-mount/log-<Datum>.log` (siehe `settings.log_level` für die Ausführlichkeit,
|
||||
Standard `info`) - unabhängig von der Journal-Rotation. `smart-mount-mount.service` bleibt nach
|
||||
einem erfolgreichen Lauf als "active (exited)" sichtbar (`RemainAfterExit=yes`), und ein
|
||||
Fehlschlag beim Mounten mindestens eines konfigurierten Laufwerkspaars lässt den Dienst jetzt
|
||||
auch tatsächlich als "failed" erscheinen, statt es stillschweigend zu ignorieren.
|
||||
|
||||
### Cron-Fallback (Systeme ohne systemd)
|
||||
|
||||
Ist beim Installieren/Upgraden des Pakets kein `systemctl` gefunden, aber `/etc/cron.d`
|
||||
vorhanden, schreibt das Paket **automatisch** `/etc/cron.d/smart-mount` (kein manueller Schritt,
|
||||
kein eigenes `smart-mount`-Subcommand) - das Intervall folgt `settings.watch_interval_secs`
|
||||
(Standard 120s):
|
||||
|
||||
```cron
|
||||
@reboot smart-mount mount --all
|
||||
*/2 * * * * smart-mount watch
|
||||
@reboot root /usr/bin/smart-mount mount --all
|
||||
*/2 * * * * root /usr/bin/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).
|
||||
Zuständig dafür ist `setup-cron` (`src/bin/setup-cron.rs`, installiert nach
|
||||
`/usr/lib/smart-mount/setup-cron`) - ein internes Hilfsprogramm, kein Teil der öffentlichen
|
||||
`smart-mount`-CLI, das die postinst-Skripte des Pakets (bzw. das `.INSTALL`-Skriptlet bei Arch)
|
||||
automatisch aufrufen; beim Deinstallieren wird `/etc/cron.d/smart-mount` ebenso automatisch
|
||||
wieder entfernt. Ist weder systemd noch `/etc/cron.d` vorhanden, meldet `setup-cron` das nur
|
||||
auf der Konsole (Paketinstallation schlägt dadurch nicht fehl) - die periodische Ausführung
|
||||
muss dann manuell eingerichtet werden, z. B. über eine eigene Crontab-Zeile analog zu obigem
|
||||
Beispiel.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,39 +227,26 @@ Architekturentscheidungen unten).
|
||||
beim Umschalten zuerst die neue Seite gemountet und der Symlink umgebogen wird, bevor die
|
||||
alte Seite (mit eben diesem tolerierten Timeout) ausgehängt wird, blockiert ein
|
||||
hängender Alt-Mount den sichtbaren Wechsel ohnehin nicht.
|
||||
- **Privilegienmodell für Nutzer-Mounts**: `sudo smart-mount setup fstab` schreibt einmalig
|
||||
`/etc/fstab`-Einträge mit `user,exec,noauto` sowie nötige Gruppenmitgliedschaften (z. B.
|
||||
`davfs2`-Gruppe). Jede Seite (lokal/Cloud) bekommt dabei ihr **eigenes, eindeutiges**
|
||||
verstecktes Backing-Verzeichnis (nicht denselben Mountpoint für beide) - das entspricht
|
||||
exakt dem einzigen in `man 8 mount` ("Non-superuser mounts") dokumentierten Fall, statt sich
|
||||
auf unspezifiziertes Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der
|
||||
konfigurierte, sichtbare Mountpoint selbst ist ein Symlink, den SmartMount zur Laufzeit
|
||||
zwischen den beiden Backing-Verzeichnissen umschaltet. `exec` wird explizit gesetzt, weil die
|
||||
`user`-Option laut `man 8 mount` sonst für jedes Dateisystem automatisch `noexec` erzwingt -
|
||||
ohne das könnten auf einem Nutzer-Kontext-Laufwerk liegende Skripte nicht ausgeführt werden.
|
||||
- **Privilegienmodell**: SmartMount läuft ausschließlich als root/System-Dienst - es gibt
|
||||
keinen unprivilegierten Mount-Weg mehr (früher: `/etc/fstab`-Einträge mit `user,noauto`, über
|
||||
die ein einzelner Nutzer unprivilegiert selbst mounten konnte). Root mountet direkt
|
||||
(`mount -t <typ> <quelle> <ziel> -o <optionen>`) für jedes konfigurierte Paar, unabhängig
|
||||
davon, ob/für welchen Nutzer `owner_user` gesetzt ist - siehe `owner_user` unten für die
|
||||
Zugriffssteuerung. Das vereinfacht das Rechtemodell erheblich und schließt nebenbei die
|
||||
frühere strukturelle CIFS-Lücke (jeder unprivilegiert mount-berechtigte Nutzer konnte zuvor
|
||||
jedes CIFS-Nutzer-Kontext-Paar mounten, nicht nur sein eigenes) - Zugangsdaten sind jetzt nur
|
||||
noch root zugänglich.
|
||||
- **Voller Zugriff für einen bestimmten Nutzer (`owner_user`)**: Bei CIFS/WebDAV (keine
|
||||
nativen Unix-Rechte) setzt SmartMount automatisch `uid=`/`gid=`/`file_mode=0700`/
|
||||
`dir_mode=0700`, sobald ein Paar ein `owner_user` hat (Pflicht bei Nutzer-Kontext-Paaren,
|
||||
optional bei System-Kontext). Das ist nicht nur für vollen Zugriff (inkl. Skript-Ausführung)
|
||||
nötig, sondern bei WebDAV auch sicherheitsrelevant: `mount.davfs` erlaubt einem
|
||||
unprivilegierten Nutzer das Mounten einer `user`-Zeile laut `man mount.davfs` nur, wenn
|
||||
`uid=` auf ihn selbst zeigt - ohne das dürfte jedes Mitglied der Gruppe `davfs2` jedes
|
||||
konfigurierte Paar mounten, nicht nur sein eigenes (`setup fstab` verweigert daher Paare ohne
|
||||
`owner_user`).
|
||||
|
||||
**Bekannte, bewusst nicht behobene Lücke bei CIFS im Nutzerkontext:** `man mount.cifs`
|
||||
bestätigt, dass `uid=`/`gid=` dort **ausschließlich** die simulierte Datei-Ownership nach dem
|
||||
Mount betreffen - anders als bei davfs2 gibt es **keinen** Mechanismus, der das Mount-*Recht*
|
||||
einer `user`-fstab-Zeile auf eine bestimmte Person einschränkt. Jeder lokale Nutzer, der
|
||||
unprivilegiert mounten darf, kann daher aktuell jedes konfigurierte CIFS-Nutzer-Kontext-Paar
|
||||
mounten (nicht nur sein eigenes) und dabei dessen gespeicherte Zugangsdaten für die Dauer des
|
||||
Mounts mitbenutzen. Das ist eine strukturelle Grenze von `mount(8)`/`mount.cifs`, keine Lücke,
|
||||
die sich über Mount-Optionen schließen ließe. **Konsequenz:** CIFS-Nutzer-Kontext-Paare nur
|
||||
auf Einzelnutzer-Maschinen oder unter sich gegenseitig bereits vertrauenden lokalen Nutzern
|
||||
einsetzen. davfs2 ist von diesem Problem nicht betroffen (siehe oben); bei NFS gibt es keine
|
||||
clientseitige `uid=`/`gid=`-Option, Zugriff bestimmt dort ausschließlich der Server über die
|
||||
tatsächlichen Datei-Eigentümer/-Rechte des Exports - wer die Freigabe mounten kann, sieht
|
||||
dadurch nicht automatisch fremde Daten.
|
||||
`dir_mode=0700`, sobald ein Paar ein `owner_user` hat - optional, ohne `owner_user` gehört
|
||||
der Mount root. `smart-mount drive add` setzt `owner_user` standardmäßig auf den Nutzer, der
|
||||
`sudo` aufgerufen hat (`$SUDO_USER`) - wer ein Paar einrichtet, bekommt also automatisch
|
||||
vollen Zugriff darauf, sofern nicht per `--owner-user` (nicht-interaktiv) oder im
|
||||
interaktiven Prompt bewusst ein anderer Nutzer angegeben bzw. abgelehnt wird. Bei NFS gibt es
|
||||
keine clientseitige `uid=`/`gid=`-Option, Zugriff bestimmt dort ausschließlich der Server über
|
||||
die tatsächlichen Datei-Eigentümer/-Rechte des Exports - `owner_user` wirkt sich hier nur auf
|
||||
das lokale Backing-Verzeichnis selbst aus (`chown`+`0700`, s. o.), nicht auf das, was der
|
||||
Server tatsächlich exportiert.
|
||||
- **davfs2-Konfiguration**: SmartMount setzt in `davfs2.conf` automatisch `gui_optimize 1`
|
||||
(bündelt PROPFIND-Anfragen, wichtig für grafische Dateimanager) sowie `buf_size 16384`
|
||||
(deutlich über dem Standard von 16 KiB) - Letzteres behebt ein bekanntes Praxisproblem, bei
|
||||
@@ -230,10 +255,9 @@ Architekturentscheidungen unten).
|
||||
kleinem `buf_size` stillschweigend abgeschnitten).
|
||||
- **Zugangsdaten-Verschlüsselung**: Turso hat aktuell keine produktionsreife eingebaute
|
||||
Verschlüsselung, daher verschlüsselt SmartMount Passwörter selbst (AES-256-GCM) vor der
|
||||
Ablage. Der Master-Schlüssel wird bevorzugt im OS-Keyring (GNOME Keyring/KWallet über
|
||||
secret-service) abgelegt; ist keins verfügbar (typisch für den root/System-Dienst sowie
|
||||
Headless-Systeme), wird automatisch auf eine Schlüsseldatei (`chmod 600`, neben der
|
||||
Konfiguration) zurückgegriffen.
|
||||
Ablage. Der Master-Schlüssel liegt in einer Schlüsseldatei (`chmod 600`, neben der
|
||||
Konfiguration, also `/etc/smart-mount/master.key`) - kein OS-Keyring, da SmartMount
|
||||
ausschließlich als root/System-Dienst läuft, für den es kein Nutzer-Keyring gibt.
|
||||
- **`mac2ip`-Integration**: SmartMount ruft `mac2ip --json --auto-trust-networks <mac>` auf.
|
||||
`--auto-trust-networks` lässt mac2ip die eigene "nmap-Scan in diesem Netzwerk erlauben?"-
|
||||
Rückfrage automatisch bejahen und dauerhaft in seiner eigenen Cache-Datenbank merken - das
|
||||
@@ -249,13 +273,19 @@ 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
|
||||
│ └── report-security-issue.py # Security-Scan-Ergebnisse als Gitea-Issue melden
|
||||
├── src/
|
||||
│ ├── main.rs # Dünner Einstiegspunkt (CLI-Parsing, Dispatch)
|
||||
│ ├── lib.rs # Bibliotheks-Wurzel
|
||||
│ ├── lib.rs # Bibliotheks-Wurzel (inkl. `pub mod cli`)
|
||||
│ ├── bin/ # Interne Hilfsprogramme (kein Teil der `smart-mount`-CLI):
|
||||
│ │ # generate-completions (Build-Zeit), setup-cron (Paket-Installation)
|
||||
│ ├── cli/ # `clap`-Subcommands
|
||||
│ ├── config/ # Konfigurationsschema + CRUD (config-ctdra)
|
||||
│ ├── crypto/ # Verschlüsselung + Master-Key-Auflösung
|
||||
@@ -263,8 +293,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)
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Richtet smart-mount beim Installieren/Upgraden des Pakets automatisch als
|
||||
# System-systemd-Dienst ein (Gegenstueck: postrm); ist kein systemd verfuegbar, richtet
|
||||
# stattdessen setup-cron (siehe src/bin/setup-cron.rs) automatisch den Cron-Fallback ein,
|
||||
# sofern /etc/cron.d existiert. 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" ]; then
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || true
|
||||
# smart-mount-mount.service mountet echte Netzlaufwerke (kann pro konfiguriertem Paar
|
||||
# mehrere zehn Sekunden dauern, wenn ein Server gerade nicht erreichbar ist) - "enable
|
||||
# --now" wuerde synchron darauf warten und damit "apt"/"dpkg" fuer die gesamte Dauer
|
||||
# blockieren. Daher: aktivieren (fuer den naechsten Boot) und der eigentliche Mount-Lauf
|
||||
# nur als asynchroner Job ("--no-block", kehrt sofort zurueck) - das Paket-Setup selbst
|
||||
# wartet also nie auf Netzwerk-Timeouts.
|
||||
systemctl enable smart-mount-mount.service || true
|
||||
systemctl start --no-block smart-mount-mount.service || true
|
||||
systemctl enable --now smart-mount-watch.timer || true
|
||||
fi
|
||||
/usr/lib/smart-mount/setup-cron || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Entfernt den beim Installieren eingerichteten systemd-Dienst sowie einen eventuell von
|
||||
# setup-cron (siehe src/bin/setup-cron.rs) angelegten Cron-Fallback wieder (Gegenstueck:
|
||||
# postinst). Nur bei tatsaechlicher Entfernung ("remove"/"purge"), nicht bei einem
|
||||
# Upgrade (dort ersetzt dpkg die Unit-/Paket-Dateien einfach durch die neue Version, ohne
|
||||
# den laufenden Dienst/Cron-Eintrag zwischenzeitlich zu deaktivieren). Die Cron-Datei wird
|
||||
# hier direkt per "rm -f" entfernt statt ueber setup-cron selbst aufgerufen zu werden: zum
|
||||
# Zeitpunkt, zu dem "postrm remove" laeuft, hat dpkg die Paketdateien - und damit auch
|
||||
# setup-cron selbst - bereits entfernt (siehe Debian Policy Manual, Abschnitt 6.5).
|
||||
set -e
|
||||
|
||||
if { [ "$1" = "remove" ] || [ "$1" = "purge" ]; }; then
|
||||
if 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
|
||||
rm -f /etc/cron.d/smart-mount
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=smart-mount: mount configured drive pairs at boot
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Bleibt nach einem erfolgreichen Lauf als "active (exited)" sichtbar (statt sofort wieder
|
||||
# "inactive") - macht in 'systemctl status' auf einen Blick erkennbar, dass der letzte Boot-Lauf
|
||||
# tatsächlich durchlief, statt nur "inactive" zu zeigen (das genauso gut "nie gelaufen" heißen
|
||||
# könnte).
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/smart-mount mount --all
|
||||
# Harte Obergrenze statt des systemd-Standards (üblicherweise 90s): pro konfiguriertem Paar
|
||||
# werden Erreichbarkeit UND Mount-Versuch sequenziell geprüft (bis zu ~20s Erreichbarkeits-
|
||||
# check + 30s Mount-Timeout, siehe crate::mount::MOUNT_TIMEOUT_SECS), bei mehreren Paaren
|
||||
# summiert sich das - 90s würde bei zwei oder mehr gerade nicht erreichbaren Servern schon
|
||||
# reichen, um mitten im Mount-Versuch abgebrochen zu werden. 10 Minuten sind großzügig genug für
|
||||
# eine typische Anzahl Paare, ohne bei einem echten Hänger (z. B. einer verwaisten Dateisperre)
|
||||
# unbegrenzt zu warten.
|
||||
TimeoutStartSec=600
|
||||
# Explizit (statt sich auf den systemd-Standard zu verlassen): stdout/stderr gehen ins Journal,
|
||||
# unter einem festen, von der jeweiligen Unit unabhängigen Tag - 'journalctl -t smart-mount'
|
||||
# zeigt damit die Ausgaben aller smart-mount-Units gebündelt.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=smart-mount
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=smart-mount: check reachability and switch local/cloud if needed
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/smart-mount watch
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=smart-mount
|
||||
@@ -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
|
||||
+35
-5
@@ -1,29 +1,59 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"timezone": "Europe/Berlin",
|
||||
"schedule": ["before 6am on monday"],
|
||||
"schedule": [
|
||||
"before 6am"
|
||||
],
|
||||
"baseBranchPatterns": [
|
||||
"dev"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchFileNames": [".gitea/workflows/**"],
|
||||
"matchFileNames": [
|
||||
".gitea/workflows/**"
|
||||
],
|
||||
"groupName": "Gitea Actions",
|
||||
"separateMajorMinor": false,
|
||||
"separateMinorPatch": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["cargo"],
|
||||
"matchManagers": [
|
||||
"cargo"
|
||||
],
|
||||
"groupName": "Cargo Dependencies",
|
||||
"separateMajorMinor": false,
|
||||
"separateMinorPatch": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"matchManagers": [
|
||||
"dockerfile",
|
||||
"docker-compose"
|
||||
],
|
||||
"groupName": "Docker-Images",
|
||||
"separateMajorMinor": false,
|
||||
"separateMinorPatch": false
|
||||
},
|
||||
{
|
||||
"description": "Patch-/Minor-/Digest-Updates automatisch mergen, sobald alle CI-Checks (inkl. Unit-Tests) erfolgreich sind - Major-Updates sind unten explizit ausgenommen (siehe nächste Regel).",
|
||||
"matchUpdateTypes": [
|
||||
"patch",
|
||||
"minor",
|
||||
"digest",
|
||||
"lockFileMaintenance"
|
||||
],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"platformAutomerge": true
|
||||
},
|
||||
{
|
||||
"description": "Major-Updates immer manuell prüfen, da potenziell brechende Änderungen.",
|
||||
"matchUpdateTypes": [
|
||||
"major"
|
||||
],
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
|
||||
+144
-7
@@ -17,10 +17,6 @@ TARGET_ARCH_MAP = {
|
||||
"aarch64-unknown-linux-musl": "aarch64",
|
||||
"aarch64": "aarch64",
|
||||
"arm64": "aarch64",
|
||||
"i686-unknown-linux-gnu": "i686",
|
||||
"i686-unknown-linux-musl": "i686",
|
||||
"i686": "i686",
|
||||
"i386": "i686",
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +41,108 @@ 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 collect_completions(completions_dir="target/completions"):
|
||||
"""Findet die von 'cargo build --bin generate-completions' erzeugten Shell-Completion-
|
||||
Skripte (siehe src/bin/generate-completions.rs), falls vorhanden, und ordnet sie generisch -
|
||||
ohne den Anwendungsnamen zu kennen - allein anhand ihrer clap_complete-Namenskonvention
|
||||
(Bash: '<name>.bash', Zsh: '_<name>', Fish: '<name>.fish') dem jeweiligen
|
||||
System-Verzeichnis zu. Gibt eine Liste aus (Quellpfad, Zielverzeichnis, Zieldateiname) zurück."""
|
||||
if not os.path.isdir(completions_dir):
|
||||
return []
|
||||
|
||||
mapping = []
|
||||
for f in sorted(os.listdir(completions_dir)):
|
||||
src = os.path.join(completions_dir, f)
|
||||
if f.endswith(".bash"):
|
||||
mapping.append((src, "usr/share/bash-completion/completions", f[: -len(".bash")]))
|
||||
elif f.endswith(".fish"):
|
||||
mapping.append((src, "usr/share/fish/vendor_completions.d", f))
|
||||
elif f.startswith("_"):
|
||||
mapping.append((src, "usr/share/zsh/site-functions", f))
|
||||
return mapping
|
||||
|
||||
|
||||
def find_helper_binary(name, helper, target_triple=None):
|
||||
"""Sucht eine optionale interne Hilfs-Binary (eigenes Cargo-Binary-Target unter
|
||||
'src/bin/<helper>.rs', z. B. fuer einen Cron-Fallback) am selben kompilierten Ort wie die
|
||||
Haupt-Binary - generisch ueber den fest bekannten Hilfsprogramm-Namen, ohne den
|
||||
Anwendungsnamen selbst zu kennen. Gibt None zurueck, falls nicht gefunden (z. B. weil das
|
||||
abgeleitete Projekt kein solches Hilfsprogramm hat)."""
|
||||
candidates = []
|
||||
if target_triple:
|
||||
candidates.append(f"target/{target_triple}/release/{helper}")
|
||||
candidates.append(f"target/release/{helper}")
|
||||
for p in candidates:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def build_install_scriptlet(name, installable, all_units, has_cron_helper):
|
||||
"""Erzeugt den Inhalt einer Arch-'.INSTALL'-Datei (siehe `man PKGBUILD`, Abschnitt
|
||||
'install'): aktiviert/startet paketierte systemd-Units beim Installieren/Upgraden und
|
||||
deaktiviert sie beim Entfernen, und ruft - sofern vorhanden - generisch das interne
|
||||
Cron-Fallback-Hilfsprogramm ('usr/lib/<name>/setup-cron', siehe src/bin/setup-cron.rs) beim
|
||||
Installieren/Upgraden auf bzw. entfernt dessen Cron-Datei beim Entfernen wieder. Rein anhand
|
||||
der tatsächlich gefundenen Unit-Dateien/Hilfsprogramme zusammengesetzt, ohne den
|
||||
Anwendungsnamen selbst hart zu codieren (der als `name`-Parameter hereinkommt).
|
||||
|
||||
'.service'-Units werden bewusst NICHT über 'enable --now' gestartet: Bei einem oneshot-
|
||||
Service (typischer Fall für ein Boot-Setup-Skript) würde das den ExecStart-Befehl synchron
|
||||
ausführen und damit 'pacman' für die gesamte Laufzeit des Skripts blockieren (siehe
|
||||
packaging/deb/postinst für den konkreten Fall, der das erst sichtbar gemacht hat: ein
|
||||
Mount-Versuch gegen ein gerade nicht erreichbares Netzlaufwerk). '.timer'-Units sind davon
|
||||
nicht betroffen - sie nur zu "armen" ist immer sofort fertig - und starten daher weiterhin
|
||||
synchron mit 'enable --now'."""
|
||||
timers = [u for u in installable if u.endswith(".timer")]
|
||||
services = [u for u in installable if not u.endswith(".timer")]
|
||||
all_units_str = " ".join(all_units)
|
||||
|
||||
systemd_enable = " systemctl daemon-reload >/dev/null 2>&1 || true\n" if installable else ""
|
||||
if timers:
|
||||
systemd_enable += f" systemctl enable --now {' '.join(timers)} >/dev/null 2>&1 || true\n"
|
||||
for service in services:
|
||||
systemd_enable += f" systemctl enable {service} >/dev/null 2>&1 || true\n"
|
||||
systemd_enable += f" systemctl start --no-block {service} >/dev/null 2>&1 || true\n"
|
||||
|
||||
systemd_reload = " systemctl daemon-reload >/dev/null 2>&1 || true\n" if all_units else ""
|
||||
systemd_disable = (
|
||||
f" systemctl disable --now {all_units_str} >/dev/null 2>&1 || true\n" if all_units else ""
|
||||
)
|
||||
# Ein zusaetzlicher Cron-Eintrag ist redundant, wenn systemd verfuegbar ist - das
|
||||
# Hilfsprogramm selbst prueft das (siehe src/bin/setup-cron.rs), hier wird es einfach
|
||||
# unconditional aufgerufen.
|
||||
cron_install = f" /usr/lib/{name}/setup-cron >/dev/null 2>&1 || true\n" if has_cron_helper else ""
|
||||
cron_remove = f" rm -f /etc/cron.d/{name}\n" if has_cron_helper else ""
|
||||
|
||||
return (
|
||||
"post_install() {\n" + systemd_enable + cron_install + "}\n\n"
|
||||
"post_upgrade() {\n" + systemd_reload + cron_install + "}\n\n"
|
||||
"pre_remove() {\n" + systemd_disable + cron_remove + "}\n\n"
|
||||
"post_remove() {\n" + systemd_reload + "}\n"
|
||||
)
|
||||
|
||||
|
||||
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 +200,35 @@ 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,
|
||||
)
|
||||
|
||||
for src, dest_dir, dest_name in collect_completions():
|
||||
target_dir = os.path.join(build_dir, dest_dir)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
subprocess.run(
|
||||
["install", "-m", "644", src, os.path.join(target_dir, dest_name)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
setup_cron_path = find_helper_binary(name, "setup-cron", target_triple)
|
||||
has_cron_helper = setup_cron_path is not None
|
||||
if has_cron_helper:
|
||||
helper_dir = os.path.join(build_dir, f"usr/lib/{name}")
|
||||
os.makedirs(helper_dir, exist_ok=True)
|
||||
subprocess.run(
|
||||
["install", "-m", "755", setup_cron_path, os.path.join(helper_dir, "setup-cron")],
|
||||
check=True,
|
||||
)
|
||||
|
||||
installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0]
|
||||
builddate = str(int(time.time()))
|
||||
|
||||
@@ -121,21 +248,31 @@ 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}")
|
||||
needs_install_scriptlet = bool(all_units) or has_cron_helper
|
||||
if needs_install_scriptlet:
|
||||
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 needs_install_scriptlet:
|
||||
install_script_name = f"{name}.install"
|
||||
with open(os.path.join(build_dir, install_script_name), "w") as f:
|
||||
f.write(build_install_scriptlet(name, installable_units, all_units, has_cron_helper))
|
||||
tar_members.append(install_script_name)
|
||||
|
||||
os.makedirs("target/arch", exist_ok=True)
|
||||
output_file = os.path.abspath(f"target/arch/{name}-{version}-{pkgrel}-{arch}.pkg.tar.zst")
|
||||
subprocess.run(["tar", "--zstd", "-cf", output_file, ".PKGINFO", "usr"], cwd=build_dir, check=True)
|
||||
subprocess.run(["tar", "--zstd", "-cf", output_file, *tar_members], cwd=build_dir, check=True)
|
||||
print(f"Arch-Paket erfolgreich erstellt: {output_file}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Erstellt Arch Linux-Pakete (.pkg.tar.zst)")
|
||||
parser.add_argument("--target", help="Rust Target-Triple (z.B. x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, i686-unknown-linux-gnu)")
|
||||
parser.add_argument("--arch", help="Architektur (z.B. x86_64, aarch64, i686)")
|
||||
parser.add_argument("--target", help="Rust Target-Triple (z.B. x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu)")
|
||||
parser.add_argument("--arch", help="Architektur (z.B. x86_64, aarch64)")
|
||||
parser.add_argument("--pkgrel", help="Release-/Build-Nummer (z.B. 1, 2, ...)")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Erzeugt die Shell-Completion-Skripte (bash/zsh/fish) für `smart-mount` als Dateien in einem
|
||||
//! Ausgabeverzeichnis - kein `smart-mount`-Subcommand mehr (siehe `smart_mount::cli::Commands`),
|
||||
//! sondern ein separates Build-Hilfsprogramm, das ausschließlich während der CI-Paketierung
|
||||
//! aufgerufen wird (siehe `.gitea/workflows/main.yaml`/`testing.yaml`): die erzeugten Dateien
|
||||
//! werden dort als normale Paket-Assets gebündelt, sodass sie beim Installieren des .deb/.rpm/
|
||||
//! .pkg.tar.zst-Pakets automatisch mit installiert und beim Entfernen automatisch mit entfernt
|
||||
//! werden - ganz ohne Zutun des Nutzers.
|
||||
//!
|
||||
//! Nutzt `smart_mount::cli::Cli` als einzige Quelle der CLI-Definition (kein separat gepflegtes
|
||||
//! Duplikat), da `cli` als `pub mod` Teil der Library ist und so auch von diesem zusätzlichen
|
||||
//! Binary-Target aus der Cargo-eigenen `src/bin/`-Autodiscovery erreichbar ist.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::CommandFactory;
|
||||
use clap_complete::Shell;
|
||||
use smart_mount::cli::Cli;
|
||||
|
||||
fn main() {
|
||||
let Some(out_dir) = std::env::args_os().nth(1).map(PathBuf::from) else {
|
||||
eprintln!("usage: generate-completions <output-dir>");
|
||||
std::process::exit(1);
|
||||
};
|
||||
std::fs::create_dir_all(&out_dir)
|
||||
.unwrap_or_else(|e| panic!("could not create '{}': {e}", out_dir.display()));
|
||||
|
||||
let mut cmd = Cli::command();
|
||||
let bin_name = cmd.get_name().to_string();
|
||||
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
|
||||
let path = clap_complete::generate_to(shell, &mut cmd, &bin_name, &out_dir)
|
||||
.unwrap_or_else(|e| panic!("could not generate {shell} completions: {e}"));
|
||||
println!("generated: {}", path.display());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Richtet den Cron-Fallback für den periodischen `watch`-Lauf automatisch ein, sofern kein
|
||||
//! systemd verfügbar ist - kein `smart-mount service`-Subcommand mehr (siehe
|
||||
//! `smart_mount::cli::Commands`), sondern ein separates, nicht auf `PATH` liegendes
|
||||
//! Hilfsprogramm (installiert nach `/usr/lib/smart-mount/setup-cron`), das ausschließlich vom
|
||||
//! postinst-Skript der .deb/.rpm/.pkg.tar.zst-Pakete mit dem Argument `install` aufgerufen wird
|
||||
//! (siehe `packaging/deb/postinst`, `[package.metadata.generate-rpm].post_install_script` in
|
||||
//! `Cargo.toml`, sowie `scripts/package-arch.py`). Das Entfernen eines zuvor installierten
|
||||
//! Cron-Eintrags beim Deinstallieren übernehmen die jeweiligen postrm-Skripte/Scriptlets direkt
|
||||
//! (ein einfaches `rm -f`) statt dieses Programm mit `remove` aufzurufen: zum Zeitpunkt, zu dem
|
||||
//! `postrm remove` bei Debian bzw. `%postun` bei RPM laufen, sind die Paketdateien - und damit
|
||||
//! auch dieses Hilfsprogramm selbst - bereits von der Platte entfernt.
|
||||
//!
|
||||
//! Nutzt `smart_mount::systemd`/`smart_mount::config` als einzige Quelle der
|
||||
//! Cron-Installationslogik (kein separat gepflegtes Duplikat in Shell), da beide als `pub mod`
|
||||
//! Teil der Library sind und so auch von diesem zusätzlichen Binary-Target aus der
|
||||
//! Cargo-eigenen `src/bin/`-Autodiscovery erreichbar sind.
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::systemd;
|
||||
|
||||
fn main() {
|
||||
// Ein zusätzlicher Cron-Eintrag wäre redundant/unerwünscht, wenn systemd verfügbar ist - die
|
||||
// paketierten systemd-Units (siehe packaging/systemd/) übernehmen dann bereits alles.
|
||||
if systemd::is_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
config::init();
|
||||
let watch_interval_secs = config::pairs::load()
|
||||
.map(|cfg| cfg.settings.watch_interval_secs)
|
||||
.unwrap_or(120);
|
||||
|
||||
match systemd::install_cron(watch_interval_secs) {
|
||||
Ok(systemd::CronInstallOutcome::SystemFile(path)) => {
|
||||
println!(
|
||||
"smart-mount: cron fallback installed at '{}'",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(systemd::CronInstallOutcome::Unavailable) => {
|
||||
eprintln!(
|
||||
"smart-mount: neither systemd nor '/etc/cron.d' found - the periodic 'watch' \
|
||||
call must be set up manually, e.g.:\n{}",
|
||||
systemd::crontab_equivalent(watch_interval_secs)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("smart-mount: could not install the cron fallback: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
//! `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};
|
||||
use crate::config;
|
||||
use crate::doctor::{self, CheckStatus};
|
||||
|
||||
pub async fn run(json: bool) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
|
||||
+232
-90
@@ -6,15 +6,14 @@
|
||||
//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde).
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use dialoguer::{Confirm, Input, Password, Select};
|
||||
|
||||
use smart_mount::config::{
|
||||
self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountContext, MountKind,
|
||||
};
|
||||
use smart_mount::db::credentials::{Credential, CredentialStore, Side};
|
||||
use crate::config::{self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountKind};
|
||||
use crate::db::credentials::{Credential, CredentialStore, Side};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DriveAction {
|
||||
@@ -40,8 +39,12 @@ pub enum DriveAction {
|
||||
pub struct DriveArgs {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long, value_enum)]
|
||||
context: Option<MountContext>,
|
||||
/// Where the pair's symlink/backing directories live. Default (on 'add', derived from the
|
||||
/// name): '<mount_base_dir>/<name>' (see 'settings.mount_base_dir'). Ignored on 'edit'
|
||||
/// unless explicitly given - an existing pair's mount point stays fixed otherwise, so an
|
||||
/// active mount doesn't get orphaned.
|
||||
#[arg(long)]
|
||||
mount_point: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
owner_user: Option<String>,
|
||||
|
||||
@@ -51,6 +54,8 @@ pub struct DriveArgs {
|
||||
local_ip: Option<Ipv4Addr>,
|
||||
#[arg(long, conflicts_with = "local_ip")]
|
||||
local_mac: Option<String>,
|
||||
/// Meaning depends on '--local-kind': the SMB share name (e.g. 'media'), the NFS export
|
||||
/// path (e.g. '/export/media'), or the WebDAV URL path (e.g. '/remote.php/dav/files/user').
|
||||
#[arg(long)]
|
||||
local_share: Option<String>,
|
||||
#[arg(long)]
|
||||
@@ -67,6 +72,8 @@ pub struct DriveArgs {
|
||||
cloud_kind: Option<MountKind>,
|
||||
#[arg(long)]
|
||||
cloud_host: Option<String>,
|
||||
/// Meaning depends on '--cloud-kind': the SMB share name (e.g. 'media'), the NFS export
|
||||
/// path (e.g. '/export/media'), or the WebDAV URL path (e.g. '/remote.php/dav/files/user').
|
||||
#[arg(long)]
|
||||
cloud_share: Option<String>,
|
||||
#[arg(long)]
|
||||
@@ -85,6 +92,18 @@ pub struct DriveArgs {
|
||||
non_interactive: bool,
|
||||
}
|
||||
|
||||
/// Beschriftet das `share`-Feld abhängig vom bereits gewählten Mount-Typ statt des
|
||||
/// generischen "share/export path" - die drei Backends legen dort inhaltlich verschiedene
|
||||
/// Dinge ab (SMB-Freigabename, NFS-Export-Pfad, WebDAV-URL-Pfad), siehe
|
||||
/// `mount::target::format_source`.
|
||||
fn share_field_label(kind: MountKind, prefix: &str) -> String {
|
||||
match kind {
|
||||
MountKind::WebDav => format!("{prefix} WebDAV path (e.g. '/remote.php/dav/files/user')"),
|
||||
MountKind::Smb => format!("{prefix} share name (e.g. 'media')"),
|
||||
MountKind::Nfs => format!("{prefix} export path (e.g. '/export/media')"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(action: DriveAction) -> anyhow::Result<()> {
|
||||
match action {
|
||||
DriveAction::Add(args) => add(args).await,
|
||||
@@ -108,10 +127,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,9 +139,11 @@ 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.
|
||||
// (davfs2 secrets, .cred-Datei) aufzuräumen, siehe crate::mount::cleanup_credentials.
|
||||
let cfg = config::pairs::load()?;
|
||||
let pair = config::pairs::find_pair(&cfg, id)?;
|
||||
|
||||
@@ -132,7 +152,7 @@ async fn remove(id: &str) -> anyhow::Result<()> {
|
||||
// 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 {
|
||||
if let Err(e) = crate::reconcile::unmount_pair(&pair, &cfg.settings).await {
|
||||
logger_ctdra::warn(
|
||||
"drive",
|
||||
&format!("could not unmount drive pair '{id}' before removal: {e}"),
|
||||
@@ -141,22 +161,32 @@ async fn remove(id: &str) -> anyhow::Result<()> {
|
||||
config::pairs::remove_pair(id)?;
|
||||
let creds = CredentialStore::open().await?;
|
||||
creds.delete(id, None).await?;
|
||||
smart_mount::mount::cleanup_credentials(&pair, &cfg.settings);
|
||||
crate::mount::cleanup_credentials(&pair, &cfg.settings);
|
||||
|
||||
logger_ctdra::info("drive", &format!("drive pair '{id}' removed"));
|
||||
println!("Drive pair '{id}' removed.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add(args: DriveArgs) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("drive add")?;
|
||||
let ni = args.non_interactive;
|
||||
|
||||
let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required");
|
||||
let context = resolve_context(args.context, None, ni)?;
|
||||
let owner_user = resolve_owner_user(args.owner_user, context, None, ni)?;
|
||||
// Der Nutzer, der das Paar einrichtet, bekommt standardmäßig vollen Zugriff darauf (siehe
|
||||
// `invoking_user`) - überschreibbar per '--owner-user' oder (interaktiv) durch Ablehnen/
|
||||
// einen anderen Namen im Prompt.
|
||||
let owner_user = resolve_owner_user(args.owner_user, invoking_user().as_deref(), ni)?;
|
||||
|
||||
let local_kind = resolve_kind(args.local_kind, None, "Local mount type", ni)?;
|
||||
let local_address = resolve_local_address(args.local_ip, args.local_mac, None, ni)?;
|
||||
let local_share = resolve_field(args.local_share, None, "Local share/export path", ni, true)?
|
||||
let local_share = resolve_field(
|
||||
args.local_share,
|
||||
None,
|
||||
&share_field_label(local_kind, "Local"),
|
||||
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(
|
||||
@@ -171,7 +201,13 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> {
|
||||
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)?
|
||||
let cloud_share = resolve_field(
|
||||
args.cloud_share,
|
||||
None,
|
||||
&share_field_label(cloud_kind, "Cloud"),
|
||||
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(
|
||||
@@ -192,14 +228,14 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> {
|
||||
// `add_pair()` (das intern selbst frisch lädt) auf denselben kaputten Zustand treffen
|
||||
// lassen, was dort ALLE bestehenden Paare durch die eine neue Konfiguration ersetzen
|
||||
// würde. Besser früh mit einem klaren Fehler abbrechen.
|
||||
let settings = config::pairs::load()?.settings;
|
||||
let mount_point = settings.mount_base_dir.join(&id);
|
||||
let cfg = config::pairs::load()?;
|
||||
let default_mount_point = default_mount_point(&cfg.settings.mount_base_dir, &name, &cfg.pairs);
|
||||
let mount_point = resolve_mount_point(args.mount_point, &default_mount_point, ni)?;
|
||||
|
||||
let pair = DrivePair {
|
||||
id: id.clone(),
|
||||
name,
|
||||
enabled: true,
|
||||
context,
|
||||
owner_user,
|
||||
mount_point,
|
||||
local: LocalSide {
|
||||
@@ -232,23 +268,20 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
logger_ctdra::info("drive", &format!("drive pair '{id}' created"));
|
||||
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,
|
||||
@@ -265,7 +298,7 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
|
||||
let local_share = resolve_field(
|
||||
args.local_share,
|
||||
Some(&existing.local.share),
|
||||
"Local share/export path",
|
||||
&share_field_label(local_kind, "Local"),
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
@@ -300,7 +333,7 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
|
||||
let cloud_share = resolve_field(
|
||||
args.cloud_share,
|
||||
Some(&existing.cloud.share),
|
||||
"Cloud share/export path",
|
||||
&share_field_label(cloud_kind, "Cloud"),
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
@@ -316,15 +349,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 crate::mount::target::active_side(&existing).is_some() {
|
||||
println!(
|
||||
"Warning: pair '{id}' appears to be actively mounted - the old backing \
|
||||
directories will be orphaned. Run 'sudo smart-mount unmount --name {id}' \
|
||||
first, then 'mount --name {id}' again after this edit."
|
||||
);
|
||||
}
|
||||
p
|
||||
}
|
||||
None => existing.mount_point.clone(),
|
||||
};
|
||||
|
||||
let updated = DrivePair {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
enabled: existing.enabled,
|
||||
context,
|
||||
owner_user,
|
||||
// Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden
|
||||
// eventuell noch aktive Mounts/fstab-Einträge verwaisen.
|
||||
mount_point: existing.mount_point.clone(),
|
||||
mount_point,
|
||||
local: LocalSide {
|
||||
kind: local_kind,
|
||||
address: local_address,
|
||||
@@ -354,6 +401,7 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
logger_ctdra::info("drive", &format!("drive pair '{id}' updated"));
|
||||
println!("Drive pair '{id}' updated.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -398,89 +446,106 @@ fn resolve_field(
|
||||
Ok(Some(input.interact_text()?))
|
||||
}
|
||||
|
||||
fn resolve_context(
|
||||
flag: Option<MountContext>,
|
||||
current: Option<MountContext>,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<MountContext> {
|
||||
if let Some(c) = flag {
|
||||
return Ok(c);
|
||||
/// Wandelt `name` in einen sicheren Verzeichnisnamen um (für den Standard-Mountpoint, siehe
|
||||
/// `default_mount_point`): nur ASCII-alphanumerische Zeichen sowie `-`/`_` bleiben erhalten,
|
||||
/// jede Folge anderer Zeichen (Leerzeichen, `/`, Sonderzeichen, Unicode) wird zu einem einzelnen
|
||||
/// `-`; führende/folgende `-` werden entfernt. Ein leeres Ergebnis (z. B. bei einem rein aus
|
||||
/// Sonderzeichen bestehenden Namen) fällt auf `"pair"` zurück, damit der Mountpoint nie
|
||||
/// leer/unbrauchbar wird.
|
||||
fn sanitize_dir_name(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
out.push(c);
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
if let Some(c) = current
|
||||
&& non_interactive
|
||||
{
|
||||
return Ok(c);
|
||||
}
|
||||
if non_interactive {
|
||||
anyhow::bail!(
|
||||
"Field 'context' is missing - specify it via '--context system|user' in non-interactive mode."
|
||||
);
|
||||
match out.trim_matches('-') {
|
||||
"" => "pair".to_string(),
|
||||
trimmed => trimmed.to_string(),
|
||||
}
|
||||
let default_idx = if current == Some(MountContext::System) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let idx = Select::new()
|
||||
.with_prompt("Context")
|
||||
.items(["System (root)", "User"])
|
||||
.default(default_idx)
|
||||
.interact()?;
|
||||
Ok(if idx == 0 {
|
||||
MountContext::System
|
||||
} else {
|
||||
MountContext::User
|
||||
})
|
||||
}
|
||||
|
||||
/// Standard-Mountpoint für ein neues Paar: `<base>/<sanitierter Name>` (siehe
|
||||
/// `sanitize_dir_name`). Namen sind - anders als `id` - nicht zwingend eindeutig (siehe
|
||||
/// `config::pairs::find_pair`); kollidiert der Standard mit dem Mountpoint eines bestehenden
|
||||
/// Paares, wird `-2`, `-3`, ... angehängt, bis er eindeutig ist.
|
||||
fn default_mount_point(base: &Path, name: &str, existing: &[DrivePair]) -> PathBuf {
|
||||
let sanitized = sanitize_dir_name(name);
|
||||
let mut candidate = base.join(&sanitized);
|
||||
let mut suffix = 2;
|
||||
while existing.iter().any(|p| p.mount_point == candidate) {
|
||||
candidate = base.join(format!("{sanitized}-{suffix}"));
|
||||
suffix += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Löst den Mountpoint auf: Flag > (interaktiv: Prompt vorbelegt mit `default`, editierbar) >
|
||||
/// (nicht-interaktiv: `default` ungefragt übernehmen).
|
||||
fn resolve_mount_point(
|
||||
flag: Option<PathBuf>,
|
||||
default: &Path,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
if let Some(p) = flag {
|
||||
return Ok(p);
|
||||
}
|
||||
if non_interactive {
|
||||
return Ok(default.to_path_buf());
|
||||
}
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Mount point")
|
||||
.default(default.display().to_string())
|
||||
.interact_text()?;
|
||||
Ok(PathBuf::from(input))
|
||||
}
|
||||
|
||||
/// Der Nutzer, in dessen Namen `sudo` aufgerufen wurde (falls so aufgerufen) - Standardwert für
|
||||
/// `owner_user` beim Anlegen eines neuen Paares (siehe `add`), damit der einrichtende Nutzer
|
||||
/// ohne extra Angabe vollen Zugriff auf sein eigenes Laufwerkspaar bekommt. `None`, wenn direkt
|
||||
/// als root angemeldet (kein "einrichtender Nutzer" außer root selbst) oder `SUDO_USER` leer/
|
||||
/// nicht gesetzt ist.
|
||||
fn invoking_user() -> Option<String> {
|
||||
std::env::var("SUDO_USER").ok().filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff
|
||||
/// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership - der
|
||||
/// Mount selbst läuft immer als root (siehe [`crate::systemd`]). `default_owner` ist der
|
||||
/// vorbelegte/vorgeschlagene Wert (bei `add`: der einrichtende Nutzer, siehe `invoking_user`;
|
||||
/// bei `edit`: der bisherige `owner_user`) - immer überschreibbar per Flag, im interaktiven
|
||||
/// Prompt auch durch Ablehnen oder einen anderen Namen.
|
||||
fn resolve_owner_user(
|
||||
flag: Option<String>,
|
||||
context: MountContext,
|
||||
current: Option<&str>,
|
||||
default_owner: Option<&str>,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if let Some(v) = flag {
|
||||
return Ok(Some(v));
|
||||
}
|
||||
match context {
|
||||
MountContext::User => {
|
||||
// Pflicht: wird auch für 'setup fstab' (Gruppenmitgliedschaft, Verzeichnis-Owner)
|
||||
// und für die uid=/gid=-Zugriffsrechte benötigt.
|
||||
if non_interactive {
|
||||
return current.map(str::to_string).map(Some).ok_or_else(|| {
|
||||
anyhow::anyhow!("Field 'owner_user' is required for context 'user'.")
|
||||
});
|
||||
}
|
||||
let default_user = current
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
|
||||
Ok(Some(
|
||||
Input::new()
|
||||
.with_prompt("Linux username (owner)")
|
||||
.default(default_user)
|
||||
.interact_text()?,
|
||||
))
|
||||
}
|
||||
MountContext::System => {
|
||||
// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff
|
||||
// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership.
|
||||
if non_interactive {
|
||||
return Ok(current.map(str::to_string));
|
||||
return Ok(default_owner.map(str::to_string));
|
||||
}
|
||||
let prompt = match default_owner {
|
||||
Some(user) => format!(
|
||||
"Should '{user}' get full access to this drive (uid/gid, including script execution)?"
|
||||
),
|
||||
None => "Should a specific user get full access to this drive (uid/gid, including script execution)?".to_string(),
|
||||
};
|
||||
let want_owner = Confirm::new()
|
||||
.with_prompt("Should a specific user get full access to this drive (uid/gid, including script execution)?")
|
||||
.default(current.is_some())
|
||||
.with_prompt(prompt)
|
||||
.default(default_owner.is_some())
|
||||
.interact()?;
|
||||
if !want_owner {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut input = Input::<String>::new().with_prompt("Linux username");
|
||||
if let Some(c) = current {
|
||||
if let Some(c) = default_owner {
|
||||
input = input.default(c.to_string());
|
||||
}
|
||||
Ok(Some(input.interact_text()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_kind(
|
||||
@@ -622,3 +687,80 @@ fn resolve_credentials(
|
||||
.interact()?;
|
||||
Ok((Some(username), Some(password)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::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"));
|
||||
}
|
||||
}
|
||||
|
||||
+40
-29
@@ -3,13 +3,31 @@
|
||||
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;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// Startet den Prozess bei Bedarf transparent über `sudo` neu, falls nicht bereits als root
|
||||
/// aufgerufen. Mounten läuft ausschließlich als root/System-Dienst (siehe
|
||||
/// [`crate::systemd`]) - es gibt seit dem Wegfall des Nutzerkontexts keine
|
||||
/// unprivilegierte Mount-Variante mehr, für die ein Rechte-Check hier zu früh käme.
|
||||
///
|
||||
/// `run_as_root()` ersetzt den aktuellen Prozess per `execve` und kehrt bei Erfolg nie zurück
|
||||
/// (siehe sudo-ctdra); der `io::Error`-Rückgabewert ist ausschließlich der Fehlerfall (z. B.
|
||||
/// `sudo` nicht gefunden).
|
||||
pub(crate) fn require_root(context: &str) -> anyhow::Result<()> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
logger_ctdra::info(
|
||||
"cli",
|
||||
&format!("'{context}' requires root - re-executing via sudo"),
|
||||
);
|
||||
let err = sudo_ctdra::run_as_root();
|
||||
anyhow::bail!("'{context}' requires root privileges: could not re-exec via sudo: {err}");
|
||||
}
|
||||
logger_ctdra::debug("cli", &format!("'{context}': already running as root"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
@@ -55,46 +73,39 @@ pub enum Commands {
|
||||
},
|
||||
/// 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).
|
||||
/// Checks prerequisites (binaries, 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 },
|
||||
}
|
||||
|
||||
/// Kurzname eines Subcommands fürs Logging (siehe [`dispatch`]) - kein `Debug`-Derive auf
|
||||
/// `Commands` nötig, das würde auch die (teils sensiblen) Argument-Felder mit abdrucken.
|
||||
fn command_label(cmd: &Commands) -> &'static str {
|
||||
match cmd {
|
||||
Commands::Drive { .. } => "drive",
|
||||
Commands::Mount { .. } => "mount",
|
||||
Commands::Unmount { .. } => "unmount",
|
||||
Commands::Status { .. } => "status",
|
||||
Commands::Watch => "watch",
|
||||
Commands::Doctor { .. } => "doctor",
|
||||
}
|
||||
}
|
||||
|
||||
/// Führt das per `Cli` geparste Subcommand aus.
|
||||
pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
|
||||
logger_ctdra::debug(
|
||||
"cli",
|
||||
&format!("dispatching '{}'", command_label(&cli.command)),
|
||||
);
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-24
@@ -1,11 +1,11 @@
|
||||
//! `smart-mount mount` / `smart-mount unmount`.
|
||||
|
||||
use smart_mount::config::{self, DrivePair, MountContext};
|
||||
use smart_mount::db::credentials::CredentialStore;
|
||||
use smart_mount::reconcile;
|
||||
use crate::config::{self, DrivePair};
|
||||
use crate::db::credentials::CredentialStore;
|
||||
use crate::reconcile::{self, Action};
|
||||
|
||||
fn select_pairs(
|
||||
cfg: &smart_mount::config::AppConfig,
|
||||
cfg: &crate::config::AppConfig,
|
||||
name: Option<&str>,
|
||||
all: bool,
|
||||
) -> anyhow::Result<Vec<DrivePair>> {
|
||||
@@ -15,61 +15,73 @@ fn select_pairs(
|
||||
if !all {
|
||||
anyhow::bail!("Please specify '--name <id>' or '--all'.");
|
||||
}
|
||||
|
||||
let pairs = if sudo_ctdra::is_run_as_root() {
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.filter(|p| p.context == MountContext::System)
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
let user = std::env::var("USER").unwrap_or_default();
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.context == MountContext::User && p.owner_user.as_deref() == Some(user.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
Ok(pairs)
|
||||
Ok(cfg.pairs.clone())
|
||||
}
|
||||
|
||||
pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("mount")?;
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
|
||||
if pairs.is_empty() {
|
||||
println!("No matching drive pairs found.");
|
||||
return Ok(());
|
||||
}
|
||||
logger_ctdra::info(
|
||||
"mount",
|
||||
&format!("mount: processing {} drive pair(s)", pairs.len()),
|
||||
);
|
||||
|
||||
let creds = CredentialStore::open().await?;
|
||||
let mut had_failure = false;
|
||||
for pair in &pairs {
|
||||
let outcome = reconcile::reconcile_pair(pair, &cfg.settings, &creds).await;
|
||||
print_outcome(&outcome);
|
||||
if matches!(outcome.action, Action::Failed(_)) {
|
||||
had_failure = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne dies wäre `smart-mount-mount.service` (siehe packaging/systemd/) beim Booten selbst
|
||||
// dann "erfolgreich" (Exit-Code 0), wenn tatsächlich kein einziges Laufwerkspaar gemountet
|
||||
// werden konnte - `systemctl status` würde den Fehlschlag also nie sichtbar machen. Analog
|
||||
// zu `watch::run` unten.
|
||||
if had_failure {
|
||||
anyhow::bail!("at least one drive pair could not be mounted");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("unmount")?;
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
|
||||
if pairs.is_empty() {
|
||||
println!("No matching drive pairs found.");
|
||||
return Ok(());
|
||||
}
|
||||
logger_ctdra::info(
|
||||
"mount",
|
||||
&format!("unmount: processing {} drive pair(s)", pairs.len()),
|
||||
);
|
||||
|
||||
let mut had_failure = false;
|
||||
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),
|
||||
Err(e) => {
|
||||
println!("{} ({}): ERROR: {e}", pair.name, pair.id);
|
||||
had_failure = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if had_failure {
|
||||
anyhow::bail!("at least one drive pair could not be unmounted");
|
||||
}
|
||||
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(),
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
//! `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<Scope> {
|
||||
match (self.system, self.user) {
|
||||
(true, false) => Ok(Scope::System),
|
||||
(false, true) => Ok(Scope::User),
|
||||
_ => anyhow::bail!("Please specify exactly one of '--system' or '--user'."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -2,10 +2,10 @@
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::db::credentials::Side;
|
||||
use smart_mount::mount::target;
|
||||
use smart_mount::network;
|
||||
use crate::config;
|
||||
use crate::db::credentials::Side;
|
||||
use crate::mount::target;
|
||||
use crate::network;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PairStatus {
|
||||
@@ -48,7 +48,7 @@ pub async fn run(name: Option<String>, json: bool) -> anyhow::Result<()> {
|
||||
// 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);
|
||||
crate::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))
|
||||
|
||||
+16
-4
@@ -1,12 +1,12 @@
|
||||
//! `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;
|
||||
use crate::config;
|
||||
use crate::db::credentials::CredentialStore;
|
||||
use crate::reconcile::{self, Action};
|
||||
|
||||
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;
|
||||
@@ -18,6 +18,18 @@ pub async fn run() -> anyhow::Result<()> {
|
||||
had_failure = true;
|
||||
}
|
||||
}
|
||||
logger_ctdra::info(
|
||||
"watch",
|
||||
&format!(
|
||||
"watch pass done: {} pair(s), {}",
|
||||
outcomes.len(),
|
||||
if had_failure {
|
||||
"with failures"
|
||||
} else {
|
||||
"no failures"
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
if had_failure {
|
||||
anyhow::bail!("at least one drive pair could not be reconciled");
|
||||
|
||||
+14
-4
@@ -4,12 +4,22 @@ pub mod pairs;
|
||||
pub mod schema;
|
||||
|
||||
pub use schema::{
|
||||
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountContext,
|
||||
MountKind,
|
||||
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountKind,
|
||||
};
|
||||
|
||||
/// Initialisiert den Konfigurationsdateinamen bei `config-ctdra`. Muss vor dem ersten
|
||||
/// `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter Zustand).
|
||||
/// Initialisiert `config-ctdra`: Dateiname und - unabhängig von den Rechten des aufrufenden
|
||||
/// Prozesses - immer der System-Pfad (`/etc/<programmname>/config.toml`). Es gibt nur noch
|
||||
/// eine einzige, systemweite Konfiguration (smart-mount läuft ausschließlich als root/
|
||||
/// System-Dienst, siehe [`crate::systemd`]) statt einer je nach Aufrufer wechselnden
|
||||
/// Root-/Nutzerpfad-Auflösung - `drive add`/`edit`/`remove` verlangen deshalb explizit Root
|
||||
/// (siehe `cli::require_root`), auch wenn sie nicht direkt mounten.
|
||||
///
|
||||
/// Muss vor dem ersten `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter
|
||||
/// Zustand).
|
||||
pub fn init() {
|
||||
config_ctdra::set_config_name("config");
|
||||
let program = config_ctdra::get_program_name();
|
||||
let dir = std::path::PathBuf::from("/etc").join(program);
|
||||
logger_ctdra::debug("config", &format!("config directory: {}", dir.display()));
|
||||
config_ctdra::set_custom_dir(dir);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ pub fn load() -> Result<AppConfig> {
|
||||
|
||||
/// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt).
|
||||
pub fn add_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
logger_ctdra::debug(
|
||||
"config",
|
||||
&format!("writing new pair '{}' to config", pair.id),
|
||||
);
|
||||
Ok(config_ctdra::modify::<AppConfig, _>(|cfg| {
|
||||
cfg.pairs.push(pair.clone());
|
||||
})?)
|
||||
@@ -18,6 +22,7 @@ pub fn add_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
/// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`).
|
||||
pub fn update_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
let id = pair.id.clone();
|
||||
logger_ctdra::debug("config", &format!("writing updated pair '{id}' to config"));
|
||||
let updated = config_ctdra::modify::<AppConfig, _>(move |cfg| {
|
||||
if let Some(existing) = cfg.pairs.iter_mut().find(|p| p.id == pair.id) {
|
||||
*existing = pair.clone();
|
||||
@@ -31,6 +36,7 @@ pub fn update_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
|
||||
/// Entfernt ein Laufwerkspaar per ID.
|
||||
pub fn remove_pair(id: &str) -> Result<AppConfig> {
|
||||
logger_ctdra::debug("config", &format!("removing pair '{id}' from config"));
|
||||
// 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
|
||||
|
||||
+19
-60
@@ -11,39 +11,15 @@ use serde::{Deserialize, Serialize};
|
||||
/// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene
|
||||
/// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei
|
||||
/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie
|
||||
/// persistieren müssen. Root-/System-Kontext-Paare landen flach unter `/run/media/smart-mount`
|
||||
/// (ein systemweiter Dienst, keinem einzelnen Nutzer zugeordnet); Nutzer-Kontext-Paare unter
|
||||
/// `/run/media/<Nutzer>/smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich
|
||||
/// nicht denselben Namensraum teilen.
|
||||
/// persistieren müssen. Jedes Paar bekommt direkt darunter sein eigenes, nach seinem Namen
|
||||
/// benanntes Unterverzeichnis (`/run/media/<Name>`, siehe `cli::drive::default_mount_point`) -
|
||||
/// kein zusätzlicher `smart-mount`-Zwischenordner, damit der Ordnername beim Durchsuchen von
|
||||
/// `/run/media` sofort erkennbar ist. Die Trennung nach Linux-Nutzer passiert bei Bedarf über
|
||||
/// die `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über
|
||||
/// unterschiedliche Mountpoint-Namensräume - smart-mount läuft ausschließlich als
|
||||
/// root/System-Dienst (siehe [`crate::systemd`]).
|
||||
fn default_mount_base_dir() -> PathBuf {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
PathBuf::from("/run/media/smart-mount")
|
||||
} else {
|
||||
// Absichtlich KEIN Fallback auf einen festen Platzhalter wie "user": unter `systemd
|
||||
// --user` (wo $USER nicht immer gesetzt ist) würden dann zwei verschiedene reale
|
||||
// Nutzer denselben Mountpoint-Namensraum `/run/media/user/smart-mount` teilen - genau
|
||||
// die Kollision, die die Aufteilung nach Nutzername eigentlich verhindern soll. `id
|
||||
// -un` liest den Nutzernamen stattdessen direkt vom Kernel.
|
||||
let run_id = |flag: &str| {
|
||||
std::process::Command::new("id")
|
||||
.arg(flag)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
let user = std::env::var("USER")
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty())
|
||||
.or_else(|| run_id("-un"))
|
||||
// Letzter Ausweg: die numerische UID ist immer verfügbar und auf jedem System
|
||||
// eindeutig, anders als ein fest codierter Platzhalter-String wie "user", der bei
|
||||
// mehreren betroffenen Nutzern denselben Namensraum kollidieren ließe.
|
||||
.or_else(|| run_id("-u").map(|uid| format!("uid-{uid}")))
|
||||
.unwrap_or_else(|| "user".to_string());
|
||||
PathBuf::from("/run/media").join(user).join("smart-mount")
|
||||
}
|
||||
PathBuf::from("/run/media")
|
||||
}
|
||||
|
||||
fn default_log_level() -> String {
|
||||
@@ -58,9 +34,8 @@ fn default_mac2ip_binary() -> String {
|
||||
"mac2ip".to_string()
|
||||
}
|
||||
|
||||
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` unter
|
||||
/// `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. `/etc/smart-mount/config.toml`
|
||||
/// (Root-Kontext).
|
||||
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` immer unter
|
||||
/// `/etc/smart-mount/config.toml` (siehe [`crate::config::init`]).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
@@ -76,8 +51,10 @@ pub struct GlobalSettings {
|
||||
pub mount_base_dir: PathBuf,
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: String,
|
||||
/// Periode, mit der `smart-mount watch` über den generierten systemd-Timer bzw. die
|
||||
/// dokumentierte Crontab-Zeile ausgeführt werden soll.
|
||||
/// Periode, mit der `smart-mount watch` über den Cron-Fallback (`smart-mount service
|
||||
/// crontab`, siehe [`crate::systemd`]) ausgeführt werden soll. Der paketierte systemd-Timer
|
||||
/// hat ein fest eingebautes Intervall (siehe `packaging/systemd/smart-mount-watch.timer`)
|
||||
/// und liest dieses Feld nicht.
|
||||
#[serde(default = "default_watch_interval_secs")]
|
||||
pub watch_interval_secs: u64,
|
||||
/// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad).
|
||||
@@ -101,14 +78,14 @@ impl Default for GlobalSettings {
|
||||
/// eingebunden - siehe [`crate::reconcile`].
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DrivePair {
|
||||
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten, Mount-Unterverzeichnis,
|
||||
/// systemd-Unit-Namen und fstab-Einträge.
|
||||
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten sowie das Mount-Unterverzeichnis.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
pub context: MountContext,
|
||||
/// Pflicht bei `context == User`: der Linux-Benutzername, dem dieses Paar gehört.
|
||||
/// Optional: der Linux-Benutzername, der vollen Zugriff (uid/gid) auf dieses Paar
|
||||
/// bekommen soll (siehe [`crate::mount::target::build_target`]). Ohne `owner_user` gehört
|
||||
/// der Mount root.
|
||||
#[serde(default)]
|
||||
pub owner_user: Option<String>,
|
||||
pub mount_point: PathBuf,
|
||||
@@ -120,16 +97,6 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Ob ein Laufwerkspaar systemweit (root, `/etc/fstab`+systemd-System-Service) oder als
|
||||
/// einzelner Nutzer (`systemd --user`, unprivilegiert über `setup fstab`) eingebunden wird.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[value(rename_all = "lowercase")]
|
||||
pub enum MountContext {
|
||||
System,
|
||||
User,
|
||||
}
|
||||
|
||||
/// Die lokale (LAN-)Seite eines Laufwerkspaars.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LocalSide {
|
||||
@@ -198,7 +165,6 @@ mod tests {
|
||||
id: "pair-1".into(),
|
||||
name: "NAS".into(),
|
||||
enabled: true,
|
||||
context: MountContext::User,
|
||||
owner_user: Some("dragon".into()),
|
||||
mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"),
|
||||
local: LocalSide {
|
||||
@@ -223,7 +189,6 @@ mod tests {
|
||||
|
||||
assert_eq!(round_tripped.pairs.len(), 1);
|
||||
assert_eq!(round_tripped.pairs[0].id, "pair-1");
|
||||
assert_eq!(round_tripped.pairs[0].context, MountContext::User);
|
||||
match &round_tripped.pairs[0].local.address {
|
||||
LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"),
|
||||
LocalAddress::Ip(_) => panic!("expected Mac variant"),
|
||||
@@ -232,12 +197,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_mount_base_dir_uses_run_media() {
|
||||
// Tests laufen nie als root, daher greift hier immer der Nutzerkontext-Zweig.
|
||||
let dir = default_mount_base_dir();
|
||||
assert!(dir.starts_with("/run/media"));
|
||||
assert!(dir.ends_with("smart-mount"));
|
||||
if let Ok(user) = std::env::var("USER") {
|
||||
assert!(dir.to_string_lossy().contains(&user));
|
||||
}
|
||||
assert_eq!(default_mount_base_dir(), PathBuf::from("/run/media"));
|
||||
}
|
||||
}
|
||||
|
||||
+13
-125
@@ -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,21 +27,21 @@ 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::*;
|
||||
|
||||
pub fn load_or_create(path: &Path) -> Result<[u8; 32]> {
|
||||
if path.exists() {
|
||||
logger_ctdra::debug(
|
||||
"crypto",
|
||||
&format!("loading master key from '{}'", path.display()),
|
||||
);
|
||||
return read(path);
|
||||
}
|
||||
logger_ctdra::info(
|
||||
"crypto",
|
||||
&format!("creating new master key at '{}'", path.display()),
|
||||
);
|
||||
create(path)
|
||||
}
|
||||
|
||||
@@ -150,61 +96,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub const NONCE_LEN: usize = 12;
|
||||
/// 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<u8>, Vec<u8>)> {
|
||||
logger_ctdra::debug("crypto", "encrypting credential data");
|
||||
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
@@ -44,6 +45,7 @@ pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
|
||||
/// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext.
|
||||
pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
logger_ctdra::debug("crypto", "decrypting credential data");
|
||||
if nonce.len() != NONCE_LEN {
|
||||
return Err(Error::Crypto(format!(
|
||||
"invalid nonce length: expected {NONCE_LEN}, got {}",
|
||||
|
||||
@@ -58,6 +58,13 @@ impl CredentialStore {
|
||||
domain: Option<&str>,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
logger_ctdra::debug(
|
||||
"db",
|
||||
&format!(
|
||||
"storing credential for pair '{pair_id}' ({})",
|
||||
side.as_str()
|
||||
),
|
||||
);
|
||||
let key = resolve_master_key()?;
|
||||
let (ciphertext, nonce) = crypto::encrypt(password.as_bytes(), &key)?;
|
||||
let now = now_unix();
|
||||
@@ -85,6 +92,13 @@ impl CredentialStore {
|
||||
|
||||
/// Liest und entschlüsselt die Zugangsdaten für `pair_id`/`side`, falls vorhanden.
|
||||
pub async fn get(&self, pair_id: &str, side: Side) -> Result<Option<Credential>> {
|
||||
logger_ctdra::debug(
|
||||
"db",
|
||||
&format!(
|
||||
"reading credential for pair '{pair_id}' ({})",
|
||||
side.as_str()
|
||||
),
|
||||
);
|
||||
let mut rows = self
|
||||
.conn
|
||||
.query(
|
||||
@@ -117,6 +131,13 @@ impl CredentialStore {
|
||||
|
||||
/// 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<Side>) -> Result<()> {
|
||||
logger_ctdra::debug(
|
||||
"db",
|
||||
&format!(
|
||||
"deleting credential(s) for pair '{pair_id}' ({})",
|
||||
side.map(|s| s.as_str()).unwrap_or("both sides")
|
||||
),
|
||||
);
|
||||
match side {
|
||||
Some(side) => {
|
||||
self.conn
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn resolve_db_path() -> PathBuf {
|
||||
/// Öffnet (und initialisiert bei Bedarf) die lokale Datenbank am aufgelösten Pfad.
|
||||
pub async fn open() -> Result<turso::Connection> {
|
||||
let path = resolve_db_path();
|
||||
logger_ctdra::debug("db", &format!("opening database at '{}'", path.display()));
|
||||
if let Some(dir) = path.parent() {
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
|
||||
+226
-142
@@ -1,13 +1,86 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Installationshinweise (fehlendes Paket, Scheduler-Fallback) sind bewusst je nach
|
||||
//! erkannter Distribution unterschiedlich (siehe [`PackageFamily`]): auf einem Debian-System
|
||||
//! soll `doctor` nur den `apt`-Hinweis zeigen, nicht zusätzlich Fedora/Arch-Varianten, die dort
|
||||
//! ohnehin nicht anwendbar sind.
|
||||
|
||||
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;
|
||||
|
||||
/// Grob erkannte Paketmanager-Familie des laufenden Systems, allein zur Auswahl der passenden
|
||||
/// Installationshinweise in `doctor` - keine vollständige Distributions-Erkennung.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PackageFamily {
|
||||
/// Debian, Ubuntu und Ableitungen (`apt`).
|
||||
Debian,
|
||||
/// Fedora, RHEL/CentOS und Ableitungen (`dnf`).
|
||||
Fedora,
|
||||
/// Arch Linux und Ableitungen (Manjaro, EndeavourOS, ...) (`pacman`).
|
||||
Arch,
|
||||
/// Nicht erkannt - Installationshinweise bleiben generisch (nur Paketname, kein Befehl).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PackageFamily {
|
||||
fn install_command(self, package: &str) -> Option<String> {
|
||||
match self {
|
||||
PackageFamily::Debian => Some(format!("apt install {package}")),
|
||||
PackageFamily::Fedora => Some(format!("dnf install {package}")),
|
||||
PackageFamily::Arch => Some(format!("pacman -S {package}")),
|
||||
PackageFamily::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest `/etc/os-release` (auf praktisch jedem modernen Linux-System vorhanden, siehe
|
||||
/// `man os-release`) und leitet daraus die [`PackageFamily`] ab.
|
||||
fn detect_package_family() -> PackageFamily {
|
||||
std::fs::read_to_string("/etc/os-release")
|
||||
.map(|contents| parse_package_family(&contents))
|
||||
.unwrap_or(PackageFamily::Unknown)
|
||||
}
|
||||
|
||||
/// Reine, testbare Parse-Funktion: sammelt `ID=`/`ID_LIKE=` aus dem `os-release`-Inhalt (deckt
|
||||
/// z. B. Ubuntu `ID=ubuntu ID_LIKE=debian` oder Manjaro `ID=manjaro ID_LIKE=arch` mit ab, nicht
|
||||
/// nur die jeweilige "reine" Distribution) und ordnet sie grob einer [`PackageFamily`] zu.
|
||||
fn parse_package_family(os_release: &str) -> PackageFamily {
|
||||
let mut fields = String::new();
|
||||
for line in os_release.lines() {
|
||||
if let Some(value) = line
|
||||
.strip_prefix("ID=")
|
||||
.or_else(|| line.strip_prefix("ID_LIKE="))
|
||||
{
|
||||
fields.push(' ');
|
||||
fields.push_str(value.trim_matches('"'));
|
||||
}
|
||||
}
|
||||
let fields = fields.to_lowercase();
|
||||
if fields.contains("debian") {
|
||||
PackageFamily::Debian
|
||||
} else if fields.contains("fedora") || fields.contains("rhel") {
|
||||
PackageFamily::Fedora
|
||||
} else if fields.contains("arch") {
|
||||
PackageFamily::Arch
|
||||
} else {
|
||||
PackageFamily::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatiert einen Installationshinweis für `package`, inkl. des passenden Befehls für die
|
||||
/// erkannte [`PackageFamily`] (kein Befehl bei [`PackageFamily::Unknown`] - dann nur der
|
||||
/// Paketname, ohne eine der drei Varianten zu raten).
|
||||
fn install_hint(package: &str, family: PackageFamily) -> String {
|
||||
match family.install_command(package) {
|
||||
Some(cmd) => format!("install package '{package}' ({cmd})"),
|
||||
None => format!("install package '{package}'"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CheckStatus {
|
||||
Ok,
|
||||
@@ -46,16 +119,17 @@ fn fail(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
|
||||
|
||||
/// Führt alle Checks gegen die aktuelle Konfiguration aus.
|
||||
pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
|
||||
let family = detect_package_family();
|
||||
let mut results = Vec::new();
|
||||
|
||||
results.push(check_timeout_binary());
|
||||
results.push(check_scheduler());
|
||||
results.push(check_scheduler(family));
|
||||
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));
|
||||
results.push(check_backend(kind, family));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,24 +139,6 @@ pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
|
||||
&cfg.settings.mac2ip_binary,
|
||||
"install mac2ip (private tool, see README)",
|
||||
));
|
||||
results.push(check_binary("nmap", "nmap", "install package 'nmap'"));
|
||||
}
|
||||
|
||||
if cfg.pairs.iter().any(|p| p.context == MountContext::User) {
|
||||
results.push(check_fstab_setup(cfg));
|
||||
if used_kinds.contains(&MountKind::WebDav) {
|
||||
results.push(check_davfs2_group_membership(cfg));
|
||||
}
|
||||
}
|
||||
|
||||
for pair in &cfg.pairs {
|
||||
if pair.context == MountContext::User && pair.owner_user.is_none() {
|
||||
results.push(fail(
|
||||
format!("Pair '{}': owner_user", pair.name),
|
||||
"context 'user', but no owner_user set - 'setup fstab' will reject this."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
@@ -125,35 +181,99 @@ fn check_binary(name: &str, binary: &str, install_hint: &str) -> CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_backend(kind: MountKind) -> CheckResult {
|
||||
fn check_backend(kind: MountKind, family: PackageFamily) -> 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()),
|
||||
if backend.check_available().is_ok() {
|
||||
return ok(format!("Backend: {}", backend.name()), "available");
|
||||
}
|
||||
|
||||
let (missing_binary, package) = match kind {
|
||||
MountKind::WebDav => ("mount.davfs", "davfs2"),
|
||||
MountKind::Smb => ("mount.cifs", "cifs-utils"),
|
||||
MountKind::Nfs => (
|
||||
"mount.nfs/mount.nfs4",
|
||||
match family {
|
||||
PackageFamily::Debian => "nfs-common",
|
||||
_ => "nfs-utils",
|
||||
},
|
||||
),
|
||||
};
|
||||
fail(
|
||||
format!("Backend: {}", backend.name()),
|
||||
format!(
|
||||
"'{missing_binary}' not found - {}",
|
||||
install_hint(package, family)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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");
|
||||
/// Prüft, ob die periodische Ausführung tatsächlich eingerichtet ist UND läuft - nicht nur, ob
|
||||
/// `systemctl`/ein Cron-Mechanismus grundsätzlich auf dem System vorhanden wäre (das allein
|
||||
/// sagt nichts darüber aus, ob smart-mounts eigene Units/Cron-Eintrag auch tatsächlich
|
||||
/// installiert sind, siehe [`crate::systemd::is_unit_installed`]/[`crate::systemd::is_cron_installed`]).
|
||||
fn check_scheduler(family: PackageFamily) -> CheckResult {
|
||||
let timer_installed = crate::systemd::is_unit_installed("smart-mount-watch.timer");
|
||||
let mount_installed = crate::systemd::is_unit_installed("smart-mount-mount.service");
|
||||
|
||||
if systemd {
|
||||
if timer_installed || mount_installed {
|
||||
let timer_enabled = crate::systemd::is_unit_enabled("smart-mount-watch.timer");
|
||||
let timer_active = crate::systemd::is_unit_active("smart-mount-watch.timer");
|
||||
let mount_enabled = crate::systemd::is_unit_enabled("smart-mount-mount.service");
|
||||
|
||||
return if timer_enabled && timer_active && mount_enabled {
|
||||
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",
|
||||
"systemd: 'smart-mount-watch.timer' is enabled and running, 'smart-mount-mount.service' is enabled",
|
||||
)
|
||||
} else {
|
||||
warn(
|
||||
"Scheduler",
|
||||
format!(
|
||||
"systemd units installed but not fully set up (smart-mount-watch.timer: enabled={timer_enabled} running={timer_active}, smart-mount-mount.service: enabled={mount_enabled}) - try 'sudo systemctl enable --now smart-mount-mount.service smart-mount-watch.timer'"
|
||||
),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
if crate::systemd::is_available() {
|
||||
return fail(
|
||||
"Scheduler",
|
||||
"systemd found, but smart-mount's systemd units are not installed - was the package installed correctly?",
|
||||
);
|
||||
}
|
||||
|
||||
if crate::systemd::is_cron_installed() {
|
||||
return if crate::systemd::is_cron_daemon_running() {
|
||||
ok(
|
||||
"Scheduler",
|
||||
"cron: '/etc/cron.d/smart-mount' is installed, cron daemon is running",
|
||||
)
|
||||
} else {
|
||||
warn(
|
||||
"Scheduler",
|
||||
"cron: '/etc/cron.d/smart-mount' is installed, but no cron daemon process was found - is cron running?",
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
if std::path::Path::new("/etc/cron.d").is_dir() {
|
||||
return warn(
|
||||
"Scheduler",
|
||||
"no systemd, cron is available but smart-mount's entry ('/etc/cron.d/smart-mount') is missing - was the package installed correctly?",
|
||||
);
|
||||
}
|
||||
|
||||
let cron_package = match family {
|
||||
PackageFamily::Debian => "cron",
|
||||
_ => "cronie",
|
||||
};
|
||||
fail(
|
||||
"Scheduler",
|
||||
"neither systemd nor cron found - periodic 'watch' must be set up manually",
|
||||
format!(
|
||||
"neither systemd nor cron found - periodic 'watch' must be set up manually ({})",
|
||||
install_hint(cron_package, family)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult {
|
||||
@@ -178,89 +298,77 @@ 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 {
|
||||
#[test]
|
||||
fn parse_package_family_detects_debian_and_derivatives() {
|
||||
assert_eq!(
|
||||
parse_package_family("ID=debian\nVERSION_ID=\"13\"\n"),
|
||||
PackageFamily::Debian
|
||||
);
|
||||
assert_eq!(
|
||||
parse_package_family("ID=ubuntu\nID_LIKE=debian\n"),
|
||||
PackageFamily::Debian
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_package_family_detects_fedora_and_rhel_derivatives() {
|
||||
assert_eq!(parse_package_family("ID=fedora\n"), PackageFamily::Fedora);
|
||||
assert_eq!(
|
||||
parse_package_family("ID=centos\nID_LIKE=\"rhel fedora\"\n"),
|
||||
PackageFamily::Fedora
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_package_family_detects_arch_and_derivatives() {
|
||||
assert_eq!(parse_package_family("ID=arch\n"), PackageFamily::Arch);
|
||||
assert_eq!(
|
||||
parse_package_family("ID=manjaro\nID_LIKE=arch\n"),
|
||||
PackageFamily::Arch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_package_family_falls_back_to_unknown() {
|
||||
assert_eq!(parse_package_family(""), PackageFamily::Unknown);
|
||||
assert_eq!(parse_package_family("ID=gentoo\n"), PackageFamily::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_hint_includes_package_manager_command_for_known_families() {
|
||||
assert_eq!(
|
||||
install_hint("davfs2", PackageFamily::Debian),
|
||||
"install package 'davfs2' (apt install davfs2)"
|
||||
);
|
||||
assert_eq!(
|
||||
install_hint("davfs2", PackageFamily::Fedora),
|
||||
"install package 'davfs2' (dnf install davfs2)"
|
||||
);
|
||||
assert_eq!(
|
||||
install_hint("davfs2", PackageFamily::Arch),
|
||||
"install package 'davfs2' (pacman -S davfs2)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_hint_omits_command_for_unknown_family() {
|
||||
assert_eq!(
|
||||
install_hint("davfs2", PackageFamily::Unknown),
|
||||
"install package 'davfs2'"
|
||||
);
|
||||
}
|
||||
|
||||
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 +396,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 +408,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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -1,683 +0,0 @@
|
||||
//! Einmaliges root-Setup, das unprivilegierten `User`-Kontext-Paaren erlaubt, sich selbst
|
||||
//! (unprivilegiert) zu mounten/unmounten.
|
||||
//!
|
||||
//! Kernidee: pro Paar werden **zwei** `/etc/fstab`-Zeilen geschrieben - je eine pro Seite,
|
||||
//! auf das jeweils eindeutige Backing-Verzeichnis dieser Seite (siehe
|
||||
//! [`crate::mount::target::backing_dir`]), nicht auf einen gemeinsamen Mountpoint. Damit
|
||||
//! entspricht jede Zeile exakt dem einzigen in `man 8 mount` ("Non-superuser mounts")
|
||||
//! dokumentierten Fall - genau eine fstab-Zeile pro Ziel - statt sich auf unspezifiziertes
|
||||
//! Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der sichtbare `pair.mount_point`
|
||||
//! selbst erscheint dadurch gar nicht in `/etc/fstab` - er ist ein Symlink, den smart-mount
|
||||
//! zur Laufzeit zwischen den beiden Backing-Verzeichnissen umschaltet (siehe
|
||||
//! [`crate::reconcile`]).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::{AppConfig, DrivePair, GlobalSettings, MountContext, MountKind};
|
||||
use crate::db::credentials::Side;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::smb;
|
||||
use crate::mount::target::{self, backing_dir};
|
||||
|
||||
const BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
|
||||
const END_MARKER: &str = "# END smart-mount managed block";
|
||||
const FSTAB_PATH: &str = "/etc/fstab";
|
||||
|
||||
/// Führt das einmalige root-Setup für alle `User`-Kontext-Paare aus: fstab-Block
|
||||
/// regenerieren, Gruppenmitgliedschaft sicherstellen, Mountpoints anlegen.
|
||||
///
|
||||
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root - das ist der
|
||||
/// einzige Befehl in smart-mount, der das tut (alle anderen root-Aktionen verlangen
|
||||
/// explizit, bereits als root aufgerufen zu werden).
|
||||
pub fn setup() -> Result<()> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
let err = sudo_ctdra::run_as_root();
|
||||
return Err(Error::Other(format!(
|
||||
"Restart with root privileges failed: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let sudo_user = std::env::var("SUDO_USER").ok().filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(sudo_user) = &sudo_user
|
||||
&& config_ctdra::get_custom_path().is_none()
|
||||
{
|
||||
let user_config = user_config_path(sudo_user);
|
||||
config_ctdra::set_custom_path(&user_config);
|
||||
}
|
||||
|
||||
let cfg = crate::config::pairs::load()?;
|
||||
let user_pairs: Vec<&DrivePair> = cfg
|
||||
.pairs
|
||||
.iter()
|
||||
.filter(|p| p.context == MountContext::User)
|
||||
.collect();
|
||||
|
||||
if user_pairs.is_empty() {
|
||||
logger_ctdra::info("fstab", "No user-context pairs configured - nothing to do.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_user_pairs_have_owner(&user_pairs)?;
|
||||
|
||||
for pair in &user_pairs {
|
||||
ensure_backing_dirs(pair)?;
|
||||
ensure_group_membership(pair)?;
|
||||
}
|
||||
|
||||
// Wessen zuvor installierte, jetzt aber nicht mehr konfigurierte Zeilen beim
|
||||
// Zusammenführen (siehe `merge_managed_block`) entfernt werden dürfen: bei einem
|
||||
// `sudo`-Aufruf im Namen eines bestimmten Nutzers ausschließlich dessen eigene Zeilen,
|
||||
// sonst die Menge der in der (dann root-eigenen) Konfiguration genannten `owner_user`.
|
||||
// Zeilen ANDERER Nutzer bleiben immer unangetastet - andernfalls würde ein zweiter Nutzer,
|
||||
// der `setup fstab` für sein eigenes Konto ausführt, die vom ersten Nutzer installierten
|
||||
// Zeilen löschen, weil sich beide denselben verwalteten Block in `/etc/fstab` teilen.
|
||||
let owner_scope: Vec<String> = match &sudo_user {
|
||||
Some(u) => vec![u.clone()],
|
||||
None => user_pairs
|
||||
.iter()
|
||||
.filter_map(|p| p.owner_user.clone())
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
};
|
||||
|
||||
write_managed_block(&user_pairs, &cfg.settings, &owner_scope)?;
|
||||
|
||||
logger_ctdra::info(
|
||||
"fstab",
|
||||
"Done. Affected users may need to log out and back in for new group memberships to \
|
||||
take effect. For MAC-based local drives in user context: set up passwordless sudo \
|
||||
access to 'nmap' if resolution does not already succeed via the ARP neighbor table \
|
||||
(see README).",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ergebnis von [`teardown`].
|
||||
pub enum FstabTeardownOutcome {
|
||||
/// Der verwaltete Block wurde gefunden und entfernt.
|
||||
Removed,
|
||||
/// Kein von smart-mount verwalteter Block vorhanden - nichts zu tun.
|
||||
NotPresent,
|
||||
}
|
||||
|
||||
/// Gegenstück zu [`setup`]: entfernt den von smart-mount verwalteten Block wieder aus
|
||||
/// `/etc/fstab` (Backup wie bei `setup` nach `/etc/fstab.smart-mount.bak`). Rührt bewusst
|
||||
/// **keine** Backing-Verzeichnisse, gemounteten Daten oder Gruppenmitgliedschaften an - nur
|
||||
/// die fstab-Zeilen selbst, da das Löschen von Verzeichnissen/Cache-Daten oder das Entfernen
|
||||
/// aus einer Gruppe ungewollte Nebenwirkungen haben könnte (die Gruppe könnte z. B. auch
|
||||
/// unabhängig von smart-mount genutzt werden).
|
||||
///
|
||||
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root (wie `setup`).
|
||||
pub fn teardown() -> Result<FstabTeardownOutcome> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
let err = sudo_ctdra::run_as_root();
|
||||
return Err(Error::Other(format!(
|
||||
"Restart with root privileges failed: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
let fstab_path = PathBuf::from(FSTAB_PATH);
|
||||
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
|
||||
|
||||
if !existing.contains(BEGIN_MARKER) {
|
||||
return Ok(FstabTeardownOutcome::NotPresent);
|
||||
}
|
||||
|
||||
backup(&fstab_path, &existing)?;
|
||||
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
|
||||
write_atomic(&fstab_path, &without_block)?;
|
||||
Ok(FstabTeardownOutcome::Removed)
|
||||
}
|
||||
|
||||
/// Ohne `owner_user` würde die fstab-Zeile ohne `uid=`/`gid=` geschrieben - bei davfs2 heißt
|
||||
/// das laut `man mount.davfs` ("uid=user"/"gid=group"): JEDES Mitglied der Gruppe 'davfs2'
|
||||
/// dürfte dieses Paar mounten, nicht nur der vorgesehene Besitzer. Lieber hart fehlschlagen,
|
||||
/// bevor eine unsichere Zeile geschrieben wird, als das still zuzulassen.
|
||||
fn validate_user_pairs_have_owner(pairs: &[&DrivePair]) -> Result<()> {
|
||||
for pair in pairs {
|
||||
if pair.owner_user.is_none() {
|
||||
return Err(Error::Other(format!(
|
||||
"Drive pair '{}' has context 'user', but no owner_user set. \
|
||||
Without owner_user, mount access cannot be restricted to a specific \
|
||||
user - please set owner_user in the configuration \
|
||||
(e.g. via 'smart-mount drive add').",
|
||||
pair.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legt beide Backing-Verzeichnisse an (nicht `pair.mount_point` selbst - das bleibt ein
|
||||
/// Symlink, siehe Moduldoku) und macht `owner_user` zum Besitzer beider.
|
||||
fn ensure_backing_dirs(pair: &DrivePair) -> Result<()> {
|
||||
for side in [Side::Local, Side::Cloud] {
|
||||
create_dir_all_owned(&backing_dir(pair, side), pair.owner_user.as_deref())?;
|
||||
}
|
||||
|
||||
// Der Elternordner des sichtbaren Mountpoints (z. B. `/run/media/<Nutzer>/smart-mount`)
|
||||
// muss dem Nutzer ebenfalls gehören - dort legt `activate_symlink` bei JEDEM `mount`/
|
||||
// `watch`-Lauf den Symlink an/ersetzt ihn, und das läuft (anders als dieses einmalige
|
||||
// Setup) unprivilegiert als der Nutzer selbst. `/run/media` ist standardmäßig `root:root
|
||||
// 0755` - ohne diesen Schritt könnte der Nutzer dort nicht einmal ein eigenes
|
||||
// Unterverzeichnis anlegen.
|
||||
if let Some(parent) = pair.mount_point.parent() {
|
||||
create_dir_all_owned(parent, pair.owner_user.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wie `std::fs::create_dir_all`, macht aber zusätzlich `owner` zum Besitzer aller dabei
|
||||
/// **neu angelegten** Verzeichnisse - nicht bereits vorhandener Elternverzeichnisse (z. B.
|
||||
/// `/run/media` selbst, das root-eigen bleiben muss). Läuft von `dir` aus rückwärts nach
|
||||
/// oben, bis der erste bereits existierende Vorfahre gefunden ist.
|
||||
fn create_dir_all_owned(dir: &std::path::Path, owner: Option<&str>) -> Result<()> {
|
||||
let Some(owner) = owner else {
|
||||
return std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e));
|
||||
};
|
||||
|
||||
let mut newly_created = Vec::new();
|
||||
let mut current = dir;
|
||||
while !current.exists() {
|
||||
newly_created.push(current.to_path_buf());
|
||||
match current.parent() {
|
||||
Some(parent) => current = parent,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
|
||||
// Von oben nach unten chownen (Eltern vor Kindern) - rein kosmetisch, jeder Aufruf ist
|
||||
// unabhängig, aber so bleibt die Reihenfolge nachvollziehbar.
|
||||
for path in newly_created.iter().rev() {
|
||||
let status = Command::new("chown")
|
||||
.arg(format!("{owner}:{owner}"))
|
||||
.arg(path)
|
||||
.status()
|
||||
.map_err(|e| Error::Other(format!("could not run chown: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(Error::Other(format!(
|
||||
"chown failed for '{}'",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_group_membership(pair: &DrivePair) -> Result<()> {
|
||||
let Some(owner) = &pair.owner_user else {
|
||||
return Ok(());
|
||||
};
|
||||
if pair.local.kind == MountKind::WebDav || pair.cloud.kind == MountKind::WebDav {
|
||||
let status = Command::new("usermod")
|
||||
.args(["-aG", "davfs2", owner])
|
||||
.status()
|
||||
.map_err(|e| Error::Other(format!("could not run usermod: {e}")))?;
|
||||
if !status.success() {
|
||||
logger_ctdra::warn(
|
||||
"fstab",
|
||||
&format!(
|
||||
"Could not add '{owner}' to group 'davfs2' - does the group exist (package 'davfs2' installed)?"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_managed_block(
|
||||
pairs: &[&DrivePair],
|
||||
settings: &GlobalSettings,
|
||||
owner_scope: &[String],
|
||||
) -> Result<()> {
|
||||
let fstab_path = PathBuf::from(FSTAB_PATH);
|
||||
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
|
||||
|
||||
backup(&fstab_path, &existing)?;
|
||||
|
||||
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
|
||||
let current_block =
|
||||
crate::util::extract_managed_block(&existing, BEGIN_MARKER, END_MARKER).unwrap_or_default();
|
||||
let new_block = merge_managed_block(¤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<String> {
|
||||
let line = fstab_line(pair, side, settings)?;
|
||||
Ok(format!("{line} # {}", line_tag(pair, side)))
|
||||
}
|
||||
|
||||
/// Liest `(pair_id, side, owner)` aus dem von [`line_tag`] angehängten Kommentar einer
|
||||
/// bestehenden fstab-Zeile, falls vorhanden.
|
||||
fn parse_tag(line: &str) -> Option<(String, &'static str, String)> {
|
||||
let marker = "# smart-mount ";
|
||||
let idx = line.find(marker)?;
|
||||
let rest = &line[idx + marker.len()..];
|
||||
|
||||
let mut pair_id = None;
|
||||
let mut side = None;
|
||||
let mut owner = None;
|
||||
for token in rest.split_whitespace() {
|
||||
if let Some(v) = token.strip_prefix("pair=") {
|
||||
pair_id = Some(v.to_string());
|
||||
} else if let Some(v) = token.strip_prefix("side=") {
|
||||
side = match v {
|
||||
"local" => Some("local"),
|
||||
"cloud" => Some("cloud"),
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(v) = token.strip_prefix("owner=") {
|
||||
owner = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Some((pair_id?, side?, owner.unwrap_or_else(|| "-".to_string())))
|
||||
}
|
||||
|
||||
/// Führt den bestehenden verwalteten Block mit den frisch berechneten Zeilen für `pairs`
|
||||
/// zusammen, statt ihn komplett zu ersetzen:
|
||||
/// - eine bestehende Zeile, die zu einem der aktuell verarbeiteten Paare gehört, wird durch die
|
||||
/// frische Version ersetzt (oder, falls deren Neuberechnung fehlschlägt, z. B. weil ein
|
||||
/// MAC-adressiertes lokales Gerät gerade offline ist, unverändert beibehalten statt
|
||||
/// ersatzlos gelöscht - siehe [`fstab_line`]/[`tagged_line`]);
|
||||
/// - eine Zeile eines inzwischen aus der Konfiguration entfernten Paares DESSELBEN Nutzers
|
||||
/// (`owner_scope`) wird entfernt;
|
||||
/// - jede andere Zeile (insbesondere die eines ANDEREN Nutzers) bleibt unangetastet.
|
||||
///
|
||||
/// Ohne diese Unterscheidung würde ein zweiter Nutzer, der `setup fstab` für sein eigenes Konto
|
||||
/// ausführt, versehentlich die vom ersten Nutzer installierten Zeilen löschen, da beide
|
||||
/// denselben verwalteten Block in `/etc/fstab` teilen. Ebenso würde ein einzelnes Paar, dessen
|
||||
/// Neuberechnung gerade fehlschlägt, sonst den gesamten Block-Rebuild für alle anderen,
|
||||
/// gesunden Paare verhindern.
|
||||
fn merge_managed_block(
|
||||
current_block: &str,
|
||||
pairs: &[&DrivePair],
|
||||
settings: &GlobalSettings,
|
||||
owner_scope: &[String],
|
||||
) -> String {
|
||||
let current_pair_ids: HashSet<&str> = pairs.iter().map(|p| p.id.as_str()).collect();
|
||||
|
||||
let mut new_lines = Vec::new();
|
||||
let mut replaced: HashSet<(String, &'static str)> = HashSet::new();
|
||||
for pair in pairs {
|
||||
for side in [Side::Local, Side::Cloud] {
|
||||
match tagged_line(pair, side, settings) {
|
||||
Ok(line) => {
|
||||
replaced.insert((pair.id.clone(), side.as_str()));
|
||||
new_lines.push(line);
|
||||
}
|
||||
Err(e) => {
|
||||
logger_ctdra::warn(
|
||||
"fstab",
|
||||
&format!(
|
||||
"could not compute fstab entry for pair '{}' ({}): {e} - leaving \
|
||||
any existing entry for it untouched",
|
||||
pair.id,
|
||||
side.as_str()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut kept: Vec<String> = current_block
|
||||
.lines()
|
||||
.filter(|line| match parse_tag(line) {
|
||||
Some((pair_id, side, _)) if replaced.contains(&(pair_id.clone(), side)) => false,
|
||||
Some((pair_id, _, owner))
|
||||
if !current_pair_ids.contains(pair_id.as_str())
|
||||
&& owner_scope.iter().any(|o| o == &owner) =>
|
||||
{
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
|
||||
kept.extend(new_lines);
|
||||
kept.join("\n")
|
||||
}
|
||||
|
||||
fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
|
||||
// Wiederverwendet dieselbe Options-Berechnung wie der tatsächliche Mount-Aufruf
|
||||
// (`mount::target::build_target`, inkl. `apply_owner_permissions`) - insbesondere die
|
||||
// dort injizierten uid=/gid= sind hier nicht optional: laut `man mount.davfs`
|
||||
// ("uid=user"/"gid=group") darf ein unprivilegierter Nutzer eine Zeile nur mounten, wenn
|
||||
// uid= auf ihn selbst zeigt und er Mitglied der in gid= genannten Gruppe ist. Ohne diese
|
||||
// Optionen dürfte JEDES Mitglied der Gruppe 'davfs2' JEDES konfigurierte Paar mounten,
|
||||
// nicht nur der vorgesehene Besitzer (`setup()` verweigert daher bereits vorab Paare ohne
|
||||
// `owner_user`).
|
||||
//
|
||||
// Für CIFS ist per `man mount.cifs` BESTÄTIGT, dass dieselbe Beschränkung NICHT existiert:
|
||||
// uid=/gid= betreffen dort ausschließlich die simulierte Datei-Ownership nach dem Mount,
|
||||
// nicht das Mount-*Recht* selbst - mount(8)/mount.cifs bieten keinen Mechanismus, eine
|
||||
// 'user'-fstab-Zeile auf eine bestimmte Person einzuschränken. Für CIFS-Nutzer-Kontext-
|
||||
// Paare bleibt das eine bewusst akzeptierte, strukturelle Lücke (siehe README) statt einer
|
||||
// über Mount-Optionen behebbaren - die Optionen werden trotzdem gesetzt, da korrekte
|
||||
// Ownership unabhängig davon nötig ist.
|
||||
let target = target::build_target(pair, settings, side)?;
|
||||
let kind = target::side_kind(pair, side);
|
||||
|
||||
let (fstype, mut extra_opts) = match kind {
|
||||
MountKind::WebDav => ("davfs", String::new()),
|
||||
MountKind::Smb => {
|
||||
let creds = smb::credentials_path(&pair.id, side);
|
||||
("cifs", format!(",credentials={}", creds.display()))
|
||||
}
|
||||
MountKind::Nfs => ("nfs", String::new()),
|
||||
};
|
||||
|
||||
for opt in &target.options {
|
||||
extra_opts.push_str(&format!(",{opt}"));
|
||||
}
|
||||
|
||||
// Jede Seite bekommt ihr eigenes, eindeutiges Backing-Verzeichnis als Ziel - siehe
|
||||
// Moduldoku. `pair.mount_point` selbst taucht bewusst NICHT in fstab auf.
|
||||
//
|
||||
// WICHTIG: die `user`-Option impliziert laut `man 8 mount` ("Non-superuser mounts") für
|
||||
// JEDES Dateisystem `noexec,nosuid,nodev`, sofern nicht direkt im selben Optionslisten-
|
||||
// Eintrag überschrieben. Ohne das explizite `exec` hier könnten auf einem User-Kontext-
|
||||
// Laufwerk liegende Skripte NICHT ausgeführt werden. `nosuid`/`nodev` bleiben bewusst
|
||||
// implizit (sinnvolle Absicherung, dafür gab es keine Anforderung).
|
||||
Ok(format!(
|
||||
"{source} {mount_point} {fstype} user,exec,noauto{extra_opts} 0 0",
|
||||
source = target.source,
|
||||
mount_point = target.mount_point.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn user_config_path(username: &str) -> PathBuf {
|
||||
let program_name = config_ctdra::get_program_name();
|
||||
let config_name = config_ctdra::get_config_name();
|
||||
let file_name = if config_name.ends_with(".toml") {
|
||||
config_name
|
||||
} else {
|
||||
format!("{config_name}.toml")
|
||||
};
|
||||
user_home_dir(username)
|
||||
.map(|h| h.join(".config").join(&program_name).join(&file_name))
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(format!(
|
||||
"/home/{username}/.config/{program_name}/{file_name}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn user_home_dir(username: &str) -> Option<PathBuf> {
|
||||
if let Ok(output) = Command::new("getent").args(["passwd", username]).output()
|
||||
&& output.status.success()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let fields: Vec<&str> = stdout.trim().split(':').collect();
|
||||
if fields.len() >= 6 && !fields[5].is_empty() {
|
||||
return Some(PathBuf::from(fields[5]));
|
||||
}
|
||||
}
|
||||
if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
|
||||
for line in passwd.lines() {
|
||||
let fields: Vec<&str> = line.split(':').collect();
|
||||
if fields.len() >= 6 && fields[0] == username && !fields[5].is_empty() {
|
||||
return Some(PathBuf::from(fields[5]));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Weder `getent` noch `/etc/passwd` konnten den Nutzer auflösen - das reine Erraten von
|
||||
// `/home/<user>` kann bei einem abweichenden Home-Verzeichnis (oder falsch geschriebenem
|
||||
// Nutzernamen) dazu führen, dass `setup()` anschließend die Konfigurationsdatei am
|
||||
// falschen Pfad lädt und stillschweigend "nichts zu tun" meldet. Warnen statt schweigen.
|
||||
logger_ctdra::warn(
|
||||
"fstab",
|
||||
&format!(
|
||||
"could not resolve home directory for user '{username}' via getent/passwd - \
|
||||
guessing '/home/{username}'"
|
||||
),
|
||||
);
|
||||
Some(PathBuf::from(format!("/home/{username}")))
|
||||
}
|
||||
|
||||
/// Zeigt an, dass diese Konfiguration bereits ein einmaliges `setup fstab` benötigt hat.
|
||||
pub fn requires_setup(cfg: &AppConfig) -> bool {
|
||||
cfg.pairs.iter().any(|p| p.context == MountContext::User)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair() -> DrivePair {
|
||||
// Nutzt den tatsächlich ausführenden Testnutzer statt eines hartkodierten Namens,
|
||||
// da `fstab_line` jetzt `id -u`/`id -g` für `owner_user` aufruft (siehe
|
||||
// `mount::target::apply_owner_permissions`) - ein fester Name wäre auf anderen
|
||||
// Maschinen/CI nicht garantiert vorhanden.
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
context: MountContext::User,
|
||||
owner_user: Some(user.clone()),
|
||||
mount_point: "/home/dragon/smart-mount/pair-1".into(),
|
||||
local: crate::config::LocalSide {
|
||||
kind: MountKind::Smb,
|
||||
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)),
|
||||
share: "share".into(),
|
||||
username: Some("nasuser".into()),
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: crate::config::CloudSide {
|
||||
kind: MountKind::Smb,
|
||||
host_or_url: "cloud.example.com".into(),
|
||||
share: "share".into(),
|
||||
username: Some(user),
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_pairs_have_owner_rejects_missing_owner() {
|
||||
let mut pair = sample_pair();
|
||||
pair.owner_user = None;
|
||||
let err = validate_user_pairs_have_owner(&[&pair]).unwrap_err();
|
||||
assert!(err.to_string().contains("owner_user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_pairs_have_owner_accepts_pair_with_owner() {
|
||||
let pair = sample_pair();
|
||||
assert!(validate_user_pairs_have_owner(&[&pair]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_two_lines_per_pair_each_with_its_own_unique_target() {
|
||||
let pair = sample_pair();
|
||||
let block = merge_managed_block("", &[&pair], &GlobalSettings::default(), &[]);
|
||||
let lines: Vec<&str> = block.lines().collect();
|
||||
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].contains("user,exec,noauto"));
|
||||
assert!(lines[0].contains("uid="));
|
||||
assert!(lines[0].contains("gid="));
|
||||
|
||||
// Der sichtbare pair.mount_point selbst darf in KEINER Zeile als Ziel auftauchen -
|
||||
// das ist der Symlink, den smart-mount zur Laufzeit umschaltet, kein fstab-Ziel.
|
||||
let visible = pair.mount_point.display().to_string();
|
||||
let target_tokens: Vec<&str> = [lines[0], lines[1]]
|
||||
.iter()
|
||||
.map(|l| l.split_whitespace().nth(1).unwrap())
|
||||
.collect();
|
||||
assert!(!target_tokens.contains(&visible.as_str()));
|
||||
|
||||
// Jede Zeile hat ein eigenes, eindeutiges Ziel (Backing-Verzeichnis) - keine zwei
|
||||
// Zeilen mit demselben Mountpoint, auf dessen Disambiguierung sich mount(8) laut
|
||||
// `man 8 mount` nicht verlassen ließe.
|
||||
let target_of = |line: &str| line.split_whitespace().nth(1).unwrap().to_string();
|
||||
assert_ne!(target_of(lines[0]), target_of(lines[1]));
|
||||
assert_eq!(
|
||||
target_of(lines[0]),
|
||||
backing_dir(&pair, Side::Local).display().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
target_of(lines[1]),
|
||||
backing_dir(&pair, Side::Cloud).display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_managed_block_preserves_lines_belonging_to_other_users() {
|
||||
let pair = sample_pair();
|
||||
let other_users_line = "//other/share /backing/other cifs user,exec,noauto 0 0 \
|
||||
# smart-mount pair=other-pair side=local owner=someone-else";
|
||||
let block = merge_managed_block(
|
||||
other_users_line,
|
||||
&[&pair],
|
||||
&GlobalSettings::default(),
|
||||
&[pair.owner_user.clone().unwrap()],
|
||||
);
|
||||
|
||||
assert!(
|
||||
block.contains(other_users_line),
|
||||
"a run scoped to one user must not touch another user's fstab lines"
|
||||
);
|
||||
assert!(block.contains("pair=pair-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_managed_block_drops_stale_lines_for_a_removed_pair_of_the_same_owner() {
|
||||
let pair = sample_pair();
|
||||
let owner = pair.owner_user.clone().unwrap();
|
||||
let stale_line = format!(
|
||||
"//old/share /backing/old cifs user,exec,noauto 0 0 \
|
||||
# smart-mount pair=deleted-pair side=local owner={owner}"
|
||||
);
|
||||
// `deleted-pair` is no longer part of `pairs`, so its line should be dropped since it
|
||||
// belongs to the same owner this run is scoped to - but only then.
|
||||
let block =
|
||||
merge_managed_block(&stale_line, &[&pair], &GlobalSettings::default(), &[owner]);
|
||||
|
||||
assert!(!block.contains("deleted-pair"));
|
||||
assert!(block.contains("pair=pair-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_managed_block_keeps_stale_lines_of_a_different_owner() {
|
||||
let pair = sample_pair();
|
||||
let stale_line = "//old/share /backing/old cifs user,exec,noauto 0 0 \
|
||||
# smart-mount pair=deleted-pair side=local owner=someone-else";
|
||||
// `owner_scope` only covers `pair.owner_user`, not `someone-else` - the stale line must
|
||||
// survive even though its pair is absent from `pairs`.
|
||||
let block = merge_managed_block(
|
||||
stale_line,
|
||||
&[&pair],
|
||||
&GlobalSettings::default(),
|
||||
&[pair.owner_user.clone().unwrap()],
|
||||
);
|
||||
|
||||
assert!(block.contains("deleted-pair"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_managed_block_removes_only_the_marked_section() {
|
||||
let contents = "/dev/sda1 / ext4 defaults 0 1\n# BEGIN smart-mount managed block\nfoo\n# END smart-mount managed block\n";
|
||||
let stripped = crate::util::strip_managed_block(contents, BEGIN_MARKER, END_MARKER);
|
||||
assert!(stripped.contains("/dev/sda1"));
|
||||
assert!(!stripped.contains("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_all_owned_creates_multi_level_path_and_chowns_new_dirs() {
|
||||
// `chown` zu einem ANDEREN Nutzer bräuchte Root - hier wird bewusst auf den eigenen
|
||||
// Nutzer "umgechownt" (funktioniert unprivilegiert, ist ein No-op auf die tatsächliche
|
||||
// Ownership, prüft aber, dass der `chown`-Aufruf pro neu angelegtem Verzeichnis
|
||||
// fehlerfrei durchläuft und die Verzeichnisstruktur korrekt entsteht).
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let target = base.path().join("a").join("b").join("c");
|
||||
|
||||
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
|
||||
|
||||
assert!(target.is_dir());
|
||||
assert!(base.path().join("a").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_all_owned_does_not_touch_already_existing_ancestors() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let target = base.path().join("existing").join("new-child");
|
||||
std::fs::create_dir_all(base.path().join("existing")).expect("pre-create ancestor");
|
||||
|
||||
// Darf nicht versuchen, `base.path()` selbst zu chownen (das existierte schon vorher) -
|
||||
// nur `existing/new-child`. Schlägt fehl, falls die Funktion stattdessen versucht,
|
||||
// einen nicht existierenden Nutzer für einen bereits vorhandenen Ordner zu setzen o. Ä.
|
||||
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
|
||||
assert!(target.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_all_owned_without_owner_just_creates_directories() {
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let target = base.path().join("x").join("y");
|
||||
create_dir_all_owned(&target, None).expect("create_dir_all_owned");
|
||||
assert!(target.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_config_path_resolves_for_user() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let path = user_config_path(&user);
|
||||
let s = path.to_str().unwrap();
|
||||
assert!(s.contains(&format!("/home/{user}/.config/")));
|
||||
assert!(s.ends_with("/config.toml"));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -1,16 +1,15 @@
|
||||
//! smart-mount: bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und
|
||||
//! schaltet automatisch zwischen LAN und Cloud um.
|
||||
|
||||
pub mod cli;
|
||||
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};
|
||||
|
||||
+14
-2
@@ -1,18 +1,30 @@
|
||||
mod cli;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use smart_mount::cli;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
smart_mount::config::init();
|
||||
|
||||
// Persistentes, gut auffindbares Log-Verzeichnis statt logger-ctdras Standard-Fallback
|
||||
// (ein Ordner im System-Temp-Verzeichnis, siehe logger-ctdra-Doku) - smart-mount läuft
|
||||
// ausschließlich als root/System-Dienst (siehe crate::systemd), daher immer derselbe,
|
||||
// feste Pfad. Muss vor dem ersten Log-Aufruf stehen (siehe logger_ctdra::set_log_dir).
|
||||
logger_ctdra::set_log_dir("/var/log/smart-mount");
|
||||
|
||||
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));
|
||||
logger_ctdra::info(
|
||||
"main",
|
||||
&format!(
|
||||
"smart-mount {} starting (log level: {log_level})",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
),
|
||||
);
|
||||
|
||||
let cli = cli::Cli::parse();
|
||||
match cli::dispatch(cli).await {
|
||||
|
||||
+13
-1
@@ -23,9 +23,16 @@ use crate::error::{Error, Result};
|
||||
/// 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 {
|
||||
pair_id: String,
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl Drop for PairLock {
|
||||
fn drop(&mut self) {
|
||||
logger_ctdra::debug("lock", &format!("pair '{}': lock released", self.pair_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -59,8 +66,13 @@ fn acquire_blocking(pair_id: &str) -> Result<PairLock> {
|
||||
// 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.
|
||||
logger_ctdra::debug("lock", &format!("pair '{pair_id}': waiting for lock"));
|
||||
file.lock().map_err(|e| Error::io(&path, e))?;
|
||||
Ok(PairLock { _file: file })
|
||||
logger_ctdra::debug("lock", &format!("pair '{pair_id}': lock acquired"));
|
||||
Ok(PairLock {
|
||||
pair_id: pair_id.to_string(),
|
||||
_file: file,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sperrt ein Laufwerkspaar prozessübergreifend für die Dauer des zurückgegebenen Guards.
|
||||
|
||||
+27
-24
@@ -18,21 +18,9 @@ use crate::config::{DrivePair, GlobalSettings, MountKind};
|
||||
use crate::db::credentials::{Credential, Side};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Wie ein Mount-Aufruf ausgeführt wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MountInvocation {
|
||||
/// Root, volle `-o`-Optionen: `mount -t <type> <source> <target> -o <opts>`.
|
||||
Direct,
|
||||
/// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount <target>` (nur das
|
||||
/// Ziel, genau der in `man 8 mount` dokumentierte Fall `mount /cd`). Da jede Seite ihr
|
||||
/// eigenes, eindeutiges Backing-Verzeichnis hat (siehe [`target::backing_dir`]), gibt es
|
||||
/// dabei nie mehr als eine passende fstab-Zeile. Voraussetzung: `smart-mount setup fstab`
|
||||
/// wurde für dieses Paar bereits ausgeführt.
|
||||
ViaFstab,
|
||||
}
|
||||
|
||||
/// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein-
|
||||
/// bzw. auszuhängen.
|
||||
/// bzw. auszuhängen. Der Mount-Aufruf ist immer `mount -t <type> <source> <target> -o <opts>`
|
||||
/// als root (smart-mount läuft ausschließlich als root/System-Dienst, siehe [`crate::systemd`]).
|
||||
pub struct MountTarget {
|
||||
pub pair_id: String,
|
||||
/// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile
|
||||
@@ -41,12 +29,11 @@ pub struct MountTarget {
|
||||
pub mount_point: PathBuf,
|
||||
/// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`.
|
||||
pub source: String,
|
||||
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`), nur bei
|
||||
/// `MountInvocation::Direct` verwendet - mit Kommas verbindbar für `mount -o`.
|
||||
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`) - mit Kommas
|
||||
/// verbindbar für `mount -o`.
|
||||
pub options: Vec<String>,
|
||||
pub invocation: MountInvocation,
|
||||
/// Für `MountContext::User`-Paare: der Linux-Benutzername, dem Zugangsdaten-/Secrets-
|
||||
/// Dateien gehören sollen.
|
||||
/// Für Paare mit gesetztem `owner_user`: der Linux-Benutzername, dem Zugangsdaten-/
|
||||
/// Secrets-Dateien gehören sollen.
|
||||
pub owner_user: Option<String>,
|
||||
}
|
||||
|
||||
@@ -106,6 +93,17 @@ fn run_tolerating_already_done_with_timeout(
|
||||
tolerate_timeout: bool,
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
logger_ctdra::debug(
|
||||
"mount",
|
||||
&format!(
|
||||
"{context}: running '{} {}' (timeout {timeout_secs}s)",
|
||||
cmd.get_program().to_string_lossy(),
|
||||
cmd.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
);
|
||||
let output =
|
||||
run_with_timeout(cmd, timeout_secs).map_err(|e| crate::error::Error::MountFailed {
|
||||
context: context.to_string(),
|
||||
@@ -113,6 +111,7 @@ fn run_tolerating_already_done_with_timeout(
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
logger_ctdra::debug("mount", &format!("{context}: succeeded"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -186,16 +185,20 @@ fn cleanup_side_credentials(
|
||||
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(
|
||||
if path.exists() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => logger_ctdra::debug(
|
||||
"mount",
|
||||
&format!("deleted credentials file '{}'", path.display()),
|
||||
),
|
||||
Err(e) => logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!(
|
||||
"Could not delete credentials file '{}': {e}",
|
||||
path.display()
|
||||
),
|
||||
);
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
MountKind::WebDav => {
|
||||
|
||||
+1
-10
@@ -5,9 +5,7 @@ use std::process::Command;
|
||||
|
||||
use crate::db::credentials::Credential;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{
|
||||
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
|
||||
};
|
||||
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
|
||||
|
||||
pub struct NfsBackend;
|
||||
|
||||
@@ -34,8 +32,6 @@ impl MountBackend for NfsBackend {
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
match target.invocation {
|
||||
MountInvocation::Direct => {
|
||||
cmd.arg("-t")
|
||||
.arg("nfs")
|
||||
.arg(&target.source)
|
||||
@@ -43,11 +39,6 @@ impl MountBackend for NfsBackend {
|
||||
if !target.options.is_empty() {
|
||||
cmd.arg("-o").arg(target.options.join(","));
|
||||
}
|
||||
}
|
||||
MountInvocation::ViaFstab => {
|
||||
cmd.arg(&target.mount_point);
|
||||
}
|
||||
}
|
||||
run_tolerating_already_done(cmd, "nfs mount", false)
|
||||
}
|
||||
|
||||
|
||||
+9
-13
@@ -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;
|
||||
|
||||
@@ -31,15 +29,18 @@ impl MountBackend for SmbBackend {
|
||||
|
||||
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)?;
|
||||
let path = credentials_path(&target.pair_id, target.side);
|
||||
logger_ctdra::debug(
|
||||
"mount",
|
||||
&format!("cifs: writing credentials file '{}'", path.display()),
|
||||
);
|
||||
write_credentials_file(&path, 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)
|
||||
@@ -50,11 +51,6 @@ impl MountBackend for SmbBackend {
|
||||
credentials_path(&target.pair_id, target.side).display()
|
||||
));
|
||||
cmd.arg("-o").arg(opts.join(","));
|
||||
}
|
||||
MountInvocation::ViaFstab => {
|
||||
cmd.arg(&target.mount_point);
|
||||
}
|
||||
}
|
||||
run_tolerating_already_done(cmd, "cifs mount", false)
|
||||
}
|
||||
|
||||
@@ -65,8 +61,8 @@ impl MountBackend for SmbBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stabiler Pfad (nicht ein Tempfile!), da bei `MountInvocation::ViaFstab` die fstab-Zeile
|
||||
/// (von `setup fstab` einmalig geschrieben) exakt auf diesen `credentials=`-Pfad verweist.
|
||||
/// Stabiler Pfad (nicht ein Tempfile!) - `prepare()` schreibt hierhin, `mount()` referenziert
|
||||
/// denselben Pfad über `-o credentials=...`.
|
||||
pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf {
|
||||
let base = config_ctdra::get_config_path()
|
||||
.parent()
|
||||
|
||||
+2
-1
@@ -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"));
|
||||
}
|
||||
|
||||
+71
-8
@@ -15,12 +15,13 @@
|
||||
//! kurz während eines Umschaltens (siehe [`crate::reconcile`]) mehr als ein Backing-Verzeichnis
|
||||
//! gemountet - im Ruhezustand immer nur eines, wie ursprünglich vorgesehen.
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountContext, MountKind};
|
||||
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountKind};
|
||||
use crate::db::credentials::Side;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{self, MountInvocation, MountTarget};
|
||||
use crate::mount::{self, MountTarget};
|
||||
use crate::network::address;
|
||||
|
||||
/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird
|
||||
@@ -60,10 +61,6 @@ pub fn build_target_with_cached_local_ip(
|
||||
side: Side,
|
||||
cached_local_ip: Option<std::net::Ipv4Addr>,
|
||||
) -> Result<MountTarget> {
|
||||
let invocation = match pair.context {
|
||||
MountContext::System => MountInvocation::Direct,
|
||||
MountContext::User => MountInvocation::ViaFstab,
|
||||
};
|
||||
let kind = side_kind(pair, side);
|
||||
|
||||
let (source, mut options) = match side {
|
||||
@@ -88,7 +85,6 @@ pub fn build_target_with_cached_local_ip(
|
||||
mount_point: backing_dir(pair, side),
|
||||
source,
|
||||
options,
|
||||
invocation,
|
||||
owner_user: pair.owner_user.clone(),
|
||||
})
|
||||
}
|
||||
@@ -164,6 +160,21 @@ fn resolve_uid_gid(username: &str) -> Result<(u32, u32)> {
|
||||
Ok((run_id(username, "-u")?, run_id(username, "-g")?))
|
||||
}
|
||||
|
||||
/// Macht `owner` (Nutzer + dessen primäre Gruppe) zum alleinigen Besitzer von `dir` (`0700`).
|
||||
/// Für das Backing-Verzeichnis eines Paares mit gesetztem `owner_user` - unabhängig vom
|
||||
/// Mount-Typ, also auch für NFS, wo es (anders als bei CIFS/WebDAV) keine `uid=`/`gid=`-
|
||||
/// Mount-Option gibt, die die nach dem Mounten sichtbaren Rechte übersteuern könnte: das
|
||||
/// Verzeichnis selbst gehört so wenigstens `owner` - reicht z. B. aus, um es zu betreten, bevor/
|
||||
/// falls etwas gemountet ist, ersetzt aber nicht die serverseitige NFS-Export-Rechtevergabe
|
||||
/// (siehe README, Abschnitt "Voller Zugriff für einen bestimmten Nutzer").
|
||||
pub(crate) fn ensure_owner_only_dir(dir: &Path, owner: &str) -> Result<()> {
|
||||
let (uid, gid) = resolve_uid_gid(owner)?;
|
||||
std::os::unix::fs::chown(dir, Some(uid), Some(gid)).map_err(|e| Error::io(dir, e))?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|e| Error::io(dir, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_id(username: &str, flag: &str) -> Result<u32> {
|
||||
let output = std::process::Command::new("id")
|
||||
.arg(flag)
|
||||
@@ -268,6 +279,13 @@ pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> {
|
||||
|
||||
if let Some(parent) = link_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
|
||||
// `mount_base_dir` ist von allen Paaren (mit ggf. unterschiedlichem `owner_user`)
|
||||
// gemeinsam genutzt - hart auf `0755` setzen statt sich auf das Umask des root-Prozesses
|
||||
// zu verlassen, damit jeder lokale Nutzer bis zum eigentlichen Mountpoint durchqueren
|
||||
// kann (die eigentliche Zugriffsbeschränkung passiert weiter unten je Paar, siehe
|
||||
// `ensure_owner_only_dir`/die `uid=`/`gid=`-Mount-Optionen).
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| Error::io(parent, e))?;
|
||||
}
|
||||
|
||||
let target = backing_dir(pair, side);
|
||||
@@ -283,6 +301,15 @@ pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> {
|
||||
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))?;
|
||||
logger_ctdra::info(
|
||||
"mount",
|
||||
&format!(
|
||||
"pair '{}': activated '{}' -> '{}'",
|
||||
pair.id,
|
||||
link_path.display(),
|
||||
target.display()
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -296,7 +323,6 @@ mod tests {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
context: MountContext::System,
|
||||
owner_user: None,
|
||||
mount_point: "/media/smart-mount/pair-1".into(),
|
||||
local: LocalSide {
|
||||
@@ -472,4 +498,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);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-25
@@ -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;
|
||||
|
||||
@@ -48,6 +46,10 @@ impl MountBackend for WebDavBackend {
|
||||
"davfs2 credential has an empty username or password".to_string(),
|
||||
));
|
||||
}
|
||||
logger_ctdra::debug(
|
||||
"mount",
|
||||
&format!("davfs2: writing secrets entry for '{}'", target.source),
|
||||
);
|
||||
write_secrets_entry(
|
||||
&davfs2_secrets_path(),
|
||||
&target.source,
|
||||
@@ -60,8 +62,6 @@ impl MountBackend for WebDavBackend {
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
match target.invocation {
|
||||
MountInvocation::Direct => {
|
||||
cmd.arg("-t")
|
||||
.arg("davfs")
|
||||
.arg(&target.source)
|
||||
@@ -69,11 +69,6 @@ impl MountBackend for WebDavBackend {
|
||||
if !target.options.is_empty() {
|
||||
cmd.arg("-o").arg(target.options.join(","));
|
||||
}
|
||||
}
|
||||
MountInvocation::ViaFstab => {
|
||||
cmd.arg(&target.mount_point);
|
||||
}
|
||||
}
|
||||
run_tolerating_already_done(cmd, "davfs2 mount", false)
|
||||
}
|
||||
|
||||
@@ -84,26 +79,14 @@ impl MountBackend for WebDavBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mounten läuft ausschließlich als root/System-Dienst (siehe [`crate::systemd`]), daher immer
|
||||
/// der System-Pfad - kein Nutzerkontext-Zweig mehr nötig.
|
||||
fn davfs2_conf_path() -> PathBuf {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
PathBuf::from("/etc/davfs2/davfs2.conf")
|
||||
} else {
|
||||
home_dir().join(".davfs2/davfs2.conf")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn davfs2_secrets_path() -> PathBuf {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
PathBuf::from("/etc/davfs2/secrets")
|
||||
} else {
|
||||
home_dir().join(".davfs2/secrets")
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen
|
||||
@@ -382,7 +365,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]
|
||||
|
||||
+19
-1
@@ -38,6 +38,12 @@ 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<Ipv4Addr> {
|
||||
logger_ctdra::info(
|
||||
"network",
|
||||
&format!(
|
||||
"resolving MAC '{mac}' via '{binary}' (may take up to {MAC2IP_TIMEOUT_SECS}s, e.g. for an nmap scan)"
|
||||
),
|
||||
);
|
||||
let output = Command::new("timeout")
|
||||
.arg(MAC2IP_TIMEOUT_SECS.to_string())
|
||||
.arg(binary)
|
||||
@@ -49,13 +55,25 @@ pub fn resolve(mac: &str, binary: &str) -> Result<Ipv4Addr> {
|
||||
})?;
|
||||
|
||||
if output.status.code() == Some(124) {
|
||||
logger_ctdra::warn(
|
||||
"network",
|
||||
&format!("'{binary}' did not respond within {MAC2IP_TIMEOUT_SECS}s for MAC '{mac}'"),
|
||||
);
|
||||
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)
|
||||
let result = parse_output(&output.stdout, mac);
|
||||
match &result {
|
||||
Ok(ip) => logger_ctdra::info("network", &format!("MAC '{mac}' resolved to {ip}")),
|
||||
Err(e) => logger_ctdra::debug(
|
||||
"network",
|
||||
&format!("MAC '{mac}' could not be resolved: {e}"),
|
||||
),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Prüft, ob das konfigurierte `mac2ip`-Binary über `PATH` auffindbar ist.
|
||||
|
||||
@@ -17,6 +17,15 @@ use std::process::{Command, Stdio};
|
||||
/// (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 {
|
||||
let reachable = is_reachable_inner(addr);
|
||||
logger_ctdra::debug(
|
||||
"network",
|
||||
&format!("reachability check for '{addr}': {reachable}"),
|
||||
);
|
||||
reachable
|
||||
}
|
||||
|
||||
fn is_reachable_inner(addr: &str) -> bool {
|
||||
if addr.starts_with("http://") || addr.starts_with("https://") {
|
||||
Command::new("curl")
|
||||
.args([
|
||||
|
||||
+85
-12
@@ -50,6 +50,13 @@ pub struct ReconcileOutcome {
|
||||
/// Risiko für einen Effizienzgewinn, den die Reachability-Parallelisierung bereits liefert.
|
||||
pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec<ReconcileOutcome> {
|
||||
let enabled: Vec<&DrivePair> = cfg.pairs.iter().filter(|p| p.enabled).collect();
|
||||
logger_ctdra::debug(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"watch: checking reachability for {} enabled pair(s)",
|
||||
enabled.len()
|
||||
),
|
||||
);
|
||||
|
||||
let mut checks = Vec::with_capacity(enabled.len());
|
||||
for pair in &enabled {
|
||||
@@ -123,16 +130,28 @@ async fn reconcile_pair_checked(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(action) => ReconcileOutcome {
|
||||
Ok(action) => {
|
||||
logger_ctdra::info(
|
||||
"reconcile",
|
||||
&format!("pair '{pair_name}' ({pair_id}): {action:?}"),
|
||||
);
|
||||
ReconcileOutcome {
|
||||
pair_id,
|
||||
pair_name,
|
||||
action,
|
||||
},
|
||||
Err(e) => ReconcileOutcome {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
logger_ctdra::error(
|
||||
"reconcile",
|
||||
&format!("pair '{pair_name}' ({pair_id}): reconcile failed: {e}"),
|
||||
);
|
||||
ReconcileOutcome {
|
||||
pair_id,
|
||||
pair_name,
|
||||
action: Action::Failed(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +166,13 @@ async fn reconcile_pair_inner(
|
||||
let _guard = lock::acquire(&pair.id).await?;
|
||||
|
||||
let active = target::active_side(pair);
|
||||
logger_ctdra::debug(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}': local_reachable={local_reachable} cloud_reachable={cloud_reachable} active={:?}",
|
||||
pair.id, active
|
||||
),
|
||||
);
|
||||
cleanup_orphaned_mounts(pair, settings, active).await;
|
||||
|
||||
if local_reachable {
|
||||
@@ -182,7 +208,13 @@ async fn reconcile_pair_inner(
|
||||
fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> (bool, Option<Ipv4Addr>) {
|
||||
match address::resolve_ip(&local.address, settings) {
|
||||
Ok(ip) => (network::is_reachable(&ip.to_string()), Some(ip)),
|
||||
Err(_) => (false, None),
|
||||
Err(e) => {
|
||||
logger_ctdra::debug(
|
||||
"reconcile",
|
||||
&format!("local address could not be resolved: {e}"),
|
||||
);
|
||||
(false, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +283,15 @@ async fn switch_to(
|
||||
old_active: Option<Side>,
|
||||
cached_local_ip: Option<Ipv4Addr>,
|
||||
) -> Result<()> {
|
||||
logger_ctdra::info(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}': switching to '{}' (previously active: {:?})",
|
||||
pair.id,
|
||||
new_side.as_str(),
|
||||
old_active
|
||||
),
|
||||
);
|
||||
mount_side(pair, settings, new_side, creds, cached_local_ip).await?;
|
||||
|
||||
// Falls das Aktivieren des Symlinks fehlschlägt, muss die gerade gemountete `new_side`
|
||||
@@ -280,13 +321,38 @@ async fn mount_side(
|
||||
) -> Result<()> {
|
||||
let mount_target =
|
||||
target::build_target_with_cached_local_ip(pair, settings, side, cached_local_ip)?;
|
||||
logger_ctdra::debug(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}' ({}): resolved source '{}' -> '{}'",
|
||||
pair.id,
|
||||
side.as_str(),
|
||||
mount_target.source,
|
||||
mount_target.mount_point.display()
|
||||
),
|
||||
);
|
||||
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()?;
|
||||
let cred = creds.get(&pair.id, side).await?;
|
||||
backend.prepare(&mount_target, cred.as_ref())?;
|
||||
logger_ctdra::info(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}' ({}): mounting via {} ...",
|
||||
pair.id,
|
||||
side.as_str(),
|
||||
backend.name()
|
||||
),
|
||||
);
|
||||
backend.mount(&mount_target)
|
||||
}
|
||||
|
||||
@@ -295,6 +361,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<Action> {
|
||||
logger_ctdra::info("reconcile", &format!("pair '{}': unmounting", pair.id));
|
||||
let _guard = lock::acquire(&pair.id).await?;
|
||||
|
||||
// Beide Seiten werden unabhängig voneinander versucht - ein Fehler (auch ein
|
||||
@@ -354,20 +421,27 @@ 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(),
|
||||
}
|
||||
});
|
||||
mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target)
|
||||
let backend = mount::backend_for(target::side_kind(pair, side));
|
||||
logger_ctdra::debug(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}' ({}): unmounting via {} ({})",
|
||||
pair.id,
|
||||
side.as_str(),
|
||||
backend.name(),
|
||||
mount_target.mount_point.display()
|
||||
),
|
||||
);
|
||||
backend.unmount(&mount_target)
|
||||
}
|
||||
|
||||
#[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 +449,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 {
|
||||
|
||||
+118
-295
@@ -1,172 +1,116 @@
|
||||
//! Generiert/installiert systemd-Units (System- und User-Kontext) sowie das Crontab-Äquivalent
|
||||
//! für Systeme ohne systemd.
|
||||
//! Cron-Fallback 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 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. Dieses Modul stellt die
|
||||
//! Logik für den Cron-Fallback bereit - aufgerufen wird sie NICHT interaktiv über ein
|
||||
//! `smart-mount`-Subcommand, sondern automatisch vom separaten `setup-cron`-Hilfsprogramm
|
||||
//! (siehe `src/bin/setup-cron.rs`), das ebenfalls über die postinst-Skripte der Pakete beim
|
||||
//! Installieren läuft, sofern kein systemd verfügbar ist (siehe [`is_available`]). 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,
|
||||
/// Fester Installationspfad der Haupt-Binary (siehe `[package.metadata.deb/generate-rpm].assets`
|
||||
/// bzw. `scripts/package-arch.py`, die immer nach `/usr/bin/<name>` installieren) - smart-mount
|
||||
/// läuft ausschließlich als paketierter System-Dienst, es gibt keinen anderen Installationsort.
|
||||
/// Bewusst NICHT über `std::env::current_exe()`: der Cron-Eintrag wird vom separaten
|
||||
/// `setup-cron`-Hilfsprogramm geschrieben (siehe [`crate::systemd`]-Moduldoku), dessen eigener
|
||||
/// Pfad hier falsch wäre.
|
||||
fn binary_path() -> &'static str {
|
||||
"/usr/bin/smart-mount"
|
||||
}
|
||||
|
||||
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(scope: Scope) -> String {
|
||||
let wanted_by = match scope {
|
||||
Scope::System => "multi-user.target",
|
||||
Scope::User => "default.target",
|
||||
};
|
||||
// `network-online.target` ist ein Ziel des System-Managers - unter `systemctl --user` gibt
|
||||
// es dafür keine sinnvolle Entsprechung (die Unit existiert dort nicht bzw. wird nie
|
||||
// erreicht), die Ordnungsabhängigkeit wäre also für User-Scope-Units wirkungslos statt
|
||||
// schlicht harmlos. Nur im System-Kontext gesetzt.
|
||||
let network_wait = match scope {
|
||||
Scope::System => "After=network-online.target\nWants=network-online.target\n",
|
||||
Scope::User => "",
|
||||
};
|
||||
format!(
|
||||
"[Unit]\nDescription=smart-mount: mount configured drive pairs at boot\n{network_wait}\n[Service]\nType=oneshot\nExecStart={} mount --all\n\n[Install]\nWantedBy={wanted_by}\n",
|
||||
binary_path()
|
||||
)
|
||||
}
|
||||
|
||||
fn watch_service_unit() -> String {
|
||||
format!(
|
||||
"[Unit]\nDescription=smart-mount: check reachability and switch local/cloud if needed\n\n[Service]\nType=oneshot\nExecStart={} watch\n",
|
||||
binary_path()
|
||||
)
|
||||
}
|
||||
|
||||
fn watch_timer_unit(interval_secs: u64) -> String {
|
||||
format!(
|
||||
"[Unit]\nDescription=smart-mount: periodic reconciling\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec={interval_secs}s\nPersistent=true\nUnit={WATCH_SERVICE}\n\n[Install]\nWantedBy=timers.target\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn unit_dir(scope: Scope) -> Result<PathBuf> {
|
||||
match scope {
|
||||
Scope::System => {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
return Err(Error::RequiresRoot("service install --system"));
|
||||
}
|
||||
Ok(PathBuf::from("/etc/systemd/system"))
|
||||
}
|
||||
Scope::User => {
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or(Error::Other("HOME not set".to_string()))?;
|
||||
Ok(home.join(".config/systemd/user"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn systemctl(scope: Scope, args: &[&str]) -> Result<()> {
|
||||
let mut cmd = Command::new("systemctl");
|
||||
if scope == Scope::User {
|
||||
cmd.arg("--user");
|
||||
}
|
||||
cmd.args(args);
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| Error::Other(format!("could not run systemctl: {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Other(format!(
|
||||
"systemctl {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schreibt die Unit-Dateien, lädt systemd neu und aktiviert Mount- und Watch-Timer-Unit.
|
||||
pub fn install(scope: Scope, watch_interval_secs: u64) -> Result<()> {
|
||||
let dir = unit_dir(scope)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
|
||||
|
||||
std::fs::write(dir.join(MOUNT_SERVICE), mount_service_unit(scope))
|
||||
.map_err(|e| Error::io(&dir, e))?;
|
||||
std::fs::write(dir.join(WATCH_SERVICE), watch_service_unit())
|
||||
.map_err(|e| Error::io(&dir, e))?;
|
||||
std::fs::write(dir.join(WATCH_TIMER), watch_timer_unit(watch_interval_secs))
|
||||
.map_err(|e| Error::io(&dir, e))?;
|
||||
|
||||
systemctl(scope, &["daemon-reload"])?;
|
||||
systemctl(scope, &["enable", "--now", MOUNT_SERVICE, WATCH_TIMER])?;
|
||||
|
||||
if scope == Scope::User {
|
||||
logger_ctdra::info(
|
||||
"systemd",
|
||||
"For boot-time operation without an active login session: run 'loginctl enable-linger <user>'. \
|
||||
Note: in that case the OS keyring may not yet be available at boot - \
|
||||
smart-mount then automatically falls back to the key file.",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ob `systemctl` auf diesem System überhaupt vorhanden ist - Voraussetzung, bevor
|
||||
/// [`install`]/[`uninstall`] sinnvoll aufgerufen werden können.
|
||||
/// Ob `systemctl` auf diesem System vorhanden ist - entscheidet für `doctor` UND für
|
||||
/// `setup-cron` (siehe Moduldoku), ob der Cron-Fallback überhaupt installiert werden soll: die
|
||||
/// eigentliche systemd-Einrichtung läuft über die Paketierung, ein zusätzlicher Cron-Eintrag
|
||||
/// wäre dort redundant.
|
||||
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,
|
||||
/// `systemctl is-enabled`/`is-active <unit>` liefert bei Erfolg wie bei Misserfolg exakt eine
|
||||
/// Ausgabezeile auf stdout (z. B. "enabled"/"disabled"/"static"/"not-found" bzw.
|
||||
/// "active"/"inactive"/"failed") - der Exit-Code allein reicht nicht (z. B. ist `is-active`
|
||||
/// auch für eine deaktivierte, aber existierende Unit `1`). `None` bei fehlendem `systemctl`
|
||||
/// oder einem sonstigen Ausführungsfehler.
|
||||
fn systemctl_query(subcommand: &str, unit: &str) -> Option<String> {
|
||||
std::process::Command::new("systemctl")
|
||||
.args([subcommand, unit])
|
||||
.output()
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Deaktiviert und entfernt die Unit-Dateien, falls vorhanden.
|
||||
pub fn uninstall(scope: Scope) -> Result<SystemdUninstallOutcome> {
|
||||
let dir = unit_dir(scope)?;
|
||||
let units = [MOUNT_SERVICE, WATCH_SERVICE, WATCH_TIMER];
|
||||
|
||||
if !units.iter().any(|unit| dir.join(unit).exists()) {
|
||||
return Ok(SystemdUninstallOutcome::NotPresent);
|
||||
}
|
||||
|
||||
let _ = systemctl(scope, &["disable", "--now", MOUNT_SERVICE, WATCH_TIMER]);
|
||||
|
||||
for unit in units {
|
||||
let path = dir.join(unit);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path).map_err(|e| Error::io(&path, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
systemctl(scope, &["daemon-reload"])?;
|
||||
Ok(SystemdUninstallOutcome::Removed)
|
||||
/// Ob `systemctl` die Unit überhaupt kennt (eine geladene Unit-Datei existiert), unabhängig
|
||||
/// davon, ob sie aktiviert/aktiv ist - für `doctor`, um "installiert, aber nicht
|
||||
/// aktiviert/gestartet" von "gar nicht installiert" unterscheiden zu können. Eine komplett
|
||||
/// unbekannte Unit liefert bei `is-enabled` je nach systemd-Version "not-found" oder eine leere
|
||||
/// Ausgabe (Fehler auf stderr) - beides ungleich einem der hier aufgeführten, eine tatsächlich
|
||||
/// geladene Unit-Datei voraussetzenden Zustände.
|
||||
pub fn is_unit_installed(unit: &str) -> bool {
|
||||
matches!(
|
||||
systemctl_query("is-enabled", unit).as_deref(),
|
||||
Some(
|
||||
"enabled"
|
||||
| "disabled"
|
||||
| "static"
|
||||
| "alias"
|
||||
| "linked"
|
||||
| "generated"
|
||||
| "transient"
|
||||
| "masked"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Ob die Unit dauerhaft aktiviert ist (startet automatisch, siehe `man systemctl`,
|
||||
/// Abschnitt "is-enabled").
|
||||
pub fn is_unit_enabled(unit: &str) -> bool {
|
||||
systemctl_query("is-enabled", unit).as_deref() == Some("enabled")
|
||||
}
|
||||
|
||||
/// Ob die Unit aktuell läuft (bei einem Timer: wartet auf die nächste Auslösung). Bei einem
|
||||
/// `oneshot`-Service wie `smart-mount-mount.service` (läuft einmal beim Boot und beendet sich
|
||||
/// danach) ist "inactive" direkt nach einem erfolgreichen Lauf der korrekte, erwartete Zustand -
|
||||
/// dafür ist [`is_unit_installed`]/[`is_unit_enabled`] aussagekräftiger.
|
||||
pub fn is_unit_active(unit: &str) -> bool {
|
||||
systemctl_query("is-active", unit).as_deref() == Some("active")
|
||||
}
|
||||
|
||||
/// Ob ein von [`install_cron`] verwalteter Cron-Eintrag aktuell existiert - für `doctor`, um
|
||||
/// zwischen "kein Cron-Mechanismus vorhanden" und "vorhanden, aber smart-mounts Eintrag fehlt"
|
||||
/// (z. B. weil das Paket nicht sauber installiert wurde) unterscheiden zu können.
|
||||
pub fn is_cron_installed() -> bool {
|
||||
Path::new(CRON_D_PATH).exists()
|
||||
}
|
||||
|
||||
/// Ob aktuell ein Cron-Daemon-Prozess läuft - rein prozessbasiert über `pgrep` geprüft (statt
|
||||
/// z. B. über `systemctl is-active cron.service`), damit es unabhängig davon funktioniert, ob
|
||||
/// systemd überhaupt vorhanden ist: genau im Cron-Fallback-Fall (kein/kein genutztes systemd,
|
||||
/// siehe Moduldoku) ist das der einzige portable Weg. Prüft beide gängigen Prozessnamen
|
||||
/// (Debian/Ubuntu: `cron`, Fedora/Arch: `crond`), ohne selbst eine Distributions-Erkennung zu
|
||||
/// brauchen.
|
||||
pub fn is_cron_daemon_running() -> bool {
|
||||
["cron", "crond"].iter().any(|name| {
|
||||
std::process::Command::new("pgrep")
|
||||
.args(["-x", name])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 +121,22 @@ pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
|
||||
pub enum CronInstallOutcome {
|
||||
/// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`).
|
||||
SystemFile(PathBuf),
|
||||
/// Persönliche Crontab des aufrufenden Nutzers aktualisiert.
|
||||
UserCrontab,
|
||||
/// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer
|
||||
/// sollte stattdessen [`crontab_equivalent`] anzeigen.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Richtet die periodische Ausführung direkt über Cron ein (Alternative zu [`install`] für
|
||||
/// Systeme ohne systemd), sofern ein Cron-Mechanismus gefunden wird - sonst [`CronInstallOutcome::Unavailable`]
|
||||
/// statt eines Fehlers, der Aufrufer zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung.
|
||||
///
|
||||
/// `Scope::System` schreibt `/etc/cron.d/smart-mount` (Standard-Konvention für
|
||||
/// paketverwaltete Cron-Einträge, läuft als root; erfordert Root-Rechte, kein
|
||||
/// Self-Elevate - analog zu `install(Scope::System, ...)`). `Scope::User` aktualisiert die
|
||||
/// persönliche Crontab des aufrufenden Nutzers über `crontab -l`/`crontab -`, mit demselben
|
||||
/// verwalteten-Block-Muster wie `fstab::setup` für `/etc/fstab` - bestehende, unabhängige
|
||||
/// Cron-Einträge bleiben unangetastet.
|
||||
pub fn install_cron(scope: Scope, watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
match scope {
|
||||
Scope::System => install_cron_system(watch_interval_secs),
|
||||
Scope::User => install_cron_user(watch_interval_secs),
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_cron_block(watch_interval_secs: u64, user_field: Option<&str>) -> String {
|
||||
let bin = binary_path();
|
||||
let schedule = cron_schedule_for_interval(watch_interval_secs);
|
||||
let user_prefix = user_field.map(|u| format!("{u} ")).unwrap_or_default();
|
||||
format!(
|
||||
"{CRON_BEGIN_MARKER}\n@reboot {user_prefix}{bin} mount --all\n{schedule} {user_prefix}{bin} watch\n{CRON_END_MARKER}\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn install_cron_system(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
return Err(Error::RequiresRoot("service crontab (system context)"));
|
||||
}
|
||||
/// Richtet die periodische Ausführung direkt über `/etc/cron.d/smart-mount` ein - Fallback für
|
||||
/// Systeme ohne (genutzten) systemd, sofern `/etc/cron.d` existiert - sonst
|
||||
/// [`CronInstallOutcome::Unavailable`] statt eines Fehlers, der Aufrufer (`setup-cron`, siehe
|
||||
/// Moduldoku) zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung. Erfordert Root
|
||||
/// (siehe Moduldoku) - beim Aufruf aus dem postinst-Skript der Pakete ohnehin gegeben.
|
||||
pub fn install_cron(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
if !Path::new("/etc/cron.d").is_dir() {
|
||||
return Ok(CronInstallOutcome::Unavailable);
|
||||
}
|
||||
|
||||
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root,
|
||||
// passend dazu, dass System-Kontext-Paare auch sonst als root gemountet werden.
|
||||
let contents = managed_cron_block(watch_interval_secs, Some("root"));
|
||||
let contents = managed_cron_block(watch_interval_secs);
|
||||
std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -228,22 +144,18 @@ fn install_cron_system(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
std::fs::set_permissions(CRON_D_PATH, std::fs::Permissions::from_mode(0o644))
|
||||
.map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
}
|
||||
logger_ctdra::info("systemd", &format!("cron entry written to '{CRON_D_PATH}'"));
|
||||
Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH)))
|
||||
}
|
||||
|
||||
fn install_cron_user(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
if !crate::mount::binary_available("crontab") {
|
||||
return Ok(CronInstallOutcome::Unavailable);
|
||||
}
|
||||
|
||||
let existing = read_current_user_crontab();
|
||||
let without_block =
|
||||
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER);
|
||||
let block = managed_cron_block(watch_interval_secs, None);
|
||||
let new_contents = format!("{}\n{block}", without_block.trim_end());
|
||||
|
||||
write_user_crontab(&new_contents)?;
|
||||
Ok(CronInstallOutcome::UserCrontab)
|
||||
fn managed_cron_block(watch_interval_secs: u64) -> String {
|
||||
let bin = binary_path();
|
||||
let schedule = cron_schedule_for_interval(watch_interval_secs);
|
||||
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root,
|
||||
// passend dazu, dass Mounts auch sonst immer als root laufen.
|
||||
format!(
|
||||
"{CRON_BEGIN_MARKER}\n@reboot root {bin} mount --all\n{schedule} root {bin} watch\n{CRON_END_MARKER}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ergebnis von [`uninstall_cron`].
|
||||
@@ -255,77 +167,20 @@ pub enum CronUninstallOutcome {
|
||||
}
|
||||
|
||||
/// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten
|
||||
/// Cron-Eintrag wieder, sofern vorhanden. `Scope::User` rührt dabei - wie `install_cron` -
|
||||
/// nur den von smart-mount verwalteten Block in der persönlichen Crontab an, keine
|
||||
/// unabhängigen, bereits vorhandenen Einträge.
|
||||
pub fn uninstall_cron(scope: Scope) -> Result<CronUninstallOutcome> {
|
||||
match scope {
|
||||
Scope::System => uninstall_cron_system(),
|
||||
Scope::User => uninstall_cron_user(),
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_cron_system() -> Result<CronUninstallOutcome> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
return Err(Error::RequiresRoot(
|
||||
"service uninstall (system context, cron)",
|
||||
));
|
||||
}
|
||||
/// Cron-Eintrag wieder, sofern vorhanden.
|
||||
pub fn uninstall_cron() -> Result<CronUninstallOutcome> {
|
||||
let path = Path::new(CRON_D_PATH);
|
||||
if !path.exists() {
|
||||
return Ok(CronUninstallOutcome::NotPresent);
|
||||
}
|
||||
std::fs::remove_file(path).map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
logger_ctdra::info(
|
||||
"systemd",
|
||||
&format!("cron entry removed from '{CRON_D_PATH}'"),
|
||||
);
|
||||
Ok(CronUninstallOutcome::Removed)
|
||||
}
|
||||
|
||||
fn uninstall_cron_user() -> Result<CronUninstallOutcome> {
|
||||
if !crate::mount::binary_available("crontab") {
|
||||
return Ok(CronUninstallOutcome::NotPresent);
|
||||
}
|
||||
let existing = read_current_user_crontab();
|
||||
if !existing.contains(CRON_BEGIN_MARKER) {
|
||||
return Ok(CronUninstallOutcome::NotPresent);
|
||||
}
|
||||
let without_block =
|
||||
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER);
|
||||
write_user_crontab(without_block.trim_end())?;
|
||||
Ok(CronUninstallOutcome::Removed)
|
||||
}
|
||||
|
||||
/// `crontab -l` meldet für einen Nutzer ohne bestehende Crontab einen Fehler ("no crontab for
|
||||
/// ...") - das ist der Normalfall bei der ersten Einrichtung, kein echter Fehler.
|
||||
fn read_current_user_crontab() -> String {
|
||||
Command::new("crontab")
|
||||
.arg("-l")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_user_crontab(contents: &str) -> Result<()> {
|
||||
let mut child = Command::new("crontab")
|
||||
.arg("-")
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| Error::Other(format!("could not start 'crontab': {e}")))?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| Error::Other("stdin of 'crontab -' not available".to_string()))?
|
||||
.write_all(contents.as_bytes())
|
||||
.map_err(|e| Error::Other(format!("writing to 'crontab -' failed: {e}")))?;
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| Error::Other(format!("'crontab -' failed: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(Error::Other("'crontab -' reported an error".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rechnet ein Sekunden-Intervall in einen `*/N`-artigen Cron-Ausdruck um. Crons Granularität
|
||||
/// ist Minuten (keine Sekunden) - es wird auf die nächste Minute gerundet, mindestens 1
|
||||
/// (Cron kann nicht häufiger als minütlich auslösen). Ab 60 Minuten wird auf Stunden
|
||||
@@ -350,44 +205,12 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mount_service_unit_system_scope_uses_multi_user_target() {
|
||||
let unit = mount_service_unit(Scope::System);
|
||||
assert!(unit.contains("WantedBy=multi-user.target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_service_unit_user_scope_uses_default_target() {
|
||||
let unit = mount_service_unit(Scope::User);
|
||||
assert!(unit.contains("WantedBy=default.target"));
|
||||
assert!(!unit.contains("WantedBy=multi-user.target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_service_unit_system_scope_waits_for_network_online() {
|
||||
let unit = mount_service_unit(Scope::System);
|
||||
assert!(unit.contains("network-online.target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_service_unit_user_scope_does_not_reference_network_online_target() {
|
||||
let unit = mount_service_unit(Scope::User);
|
||||
assert!(!unit.contains("network-online.target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_cron_block_for_user_crontab_has_no_user_field() {
|
||||
let block = managed_cron_block(120, None);
|
||||
fn managed_cron_block_includes_root_user_field_and_both_lines() {
|
||||
let block = managed_cron_block(120);
|
||||
assert!(block.starts_with(CRON_BEGIN_MARKER));
|
||||
assert!(block.trim_end().ends_with(CRON_END_MARKER));
|
||||
assert!(block.contains("@reboot") && !block.contains("@reboot root"));
|
||||
assert!(block.contains("mount --all"));
|
||||
assert!(block.contains("*/2 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_cron_block_for_system_cron_d_includes_user_field() {
|
||||
let block = managed_cron_block(120, Some("root"));
|
||||
assert!(block.contains("@reboot root "));
|
||||
assert!(block.contains("mount --all"));
|
||||
assert!(block.contains("*/2 * * * * root "));
|
||||
}
|
||||
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
//! Kleine, modulübergreifend geteilte Hilfsfunktionen.
|
||||
|
||||
/// Entfernt einen durch `begin_marker`/`end_marker` abgegrenzten Abschnitt aus `contents`
|
||||
/// (Marker-Zeilen selbst eingeschlossen). Für das "verwalteter Block"-Muster, mit dem
|
||||
/// smart-mount eigene Zeilen in einer fremden Datei (`/etc/fstab`, Crontab) aktualisiert,
|
||||
/// ohne bestehende, unabhängige Einträge anzurühren - siehe [`crate::fstab`] und
|
||||
/// [`crate::systemd`].
|
||||
pub(crate) fn strip_managed_block(contents: &str, begin_marker: &str, end_marker: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut inside = false;
|
||||
for line in contents.lines() {
|
||||
if line.trim() == begin_marker {
|
||||
inside = true;
|
||||
continue;
|
||||
}
|
||||
if line.trim() == end_marker {
|
||||
inside = false;
|
||||
continue;
|
||||
}
|
||||
if !inside {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Gegenstück zu [`strip_managed_block`]: gibt nur den Inhalt *innerhalb* des Blocks zurück
|
||||
/// (ohne die Marker-Zeilen selbst), oder `None`, falls kein solcher Block vorhanden ist. Für
|
||||
/// ein zeilenweises Zusammenführen (statt komplettem Ersetzen) des verwalteten Blocks.
|
||||
pub(crate) fn extract_managed_block(
|
||||
contents: &str,
|
||||
begin_marker: &str,
|
||||
end_marker: &str,
|
||||
) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut inside = false;
|
||||
let mut found = false;
|
||||
for line in contents.lines() {
|
||||
if line.trim() == begin_marker {
|
||||
inside = true;
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if line.trim() == end_marker {
|
||||
inside = false;
|
||||
continue;
|
||||
}
|
||||
if inside {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
found.then_some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removes_only_the_marked_section() {
|
||||
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
|
||||
let stripped = strip_managed_block(contents, "# BEGIN test", "# END test");
|
||||
assert_eq!(stripped, "line1\nline2\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_a_noop_when_markers_are_absent() {
|
||||
let contents = "line1\nline2\n";
|
||||
assert_eq!(
|
||||
strip_managed_block(contents, "# BEGIN test", "# END test"),
|
||||
contents
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_content_before_the_first_marker_and_no_trailing_content() {
|
||||
let contents = "keep-me\n# BEGIN x\ndrop-me\n# END x\n";
|
||||
assert_eq!(
|
||||
strip_managed_block(contents, "# BEGIN x", "# END x"),
|
||||
"keep-me\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_managed_block_returns_only_the_interior() {
|
||||
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
|
||||
assert_eq!(
|
||||
extract_managed_block(contents, "# BEGIN test", "# END test"),
|
||||
Some("foo\nbar\n".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_managed_block_is_none_when_markers_are_absent() {
|
||||
let contents = "line1\nline2\n";
|
||||
assert_eq!(
|
||||
extract_managed_block(contents, "# BEGIN test", "# END test"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user