Compare commits
14
Commits
v0.3.1
...
2da1ea55a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da1ea55a9 | ||
|
|
2e4cf1cef0 | ||
|
|
1048253497 | ||
|
|
aa65d96434 | ||
|
|
3f3f8f2730 | ||
|
|
650cfc7cd4 | ||
|
|
9d1a4d3ad7 | ||
|
|
aa25719c0d | ||
|
|
34378998c4 | ||
|
|
4ef0c414fa | ||
|
|
1f3afb1e52 | ||
|
|
5f2040f8bf | ||
|
|
da2ddbd6ab | ||
|
|
46f976b353 |
+63
-4
@@ -5,6 +5,69 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument
|
||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
|
||||
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [0.6.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
- **`sanctum upgrade` (Alias: `sanctum update`) - In-Place Self-Upgrade Engine**:
|
||||
- Ermöglicht das direkte Aktualisieren der laufenden Sanctum-Binary ohne externe Paketmanager oder manuelle Download-Schritte.
|
||||
- **Plattformunabhängiger In-Place-Austausch**: Löst das Windows-Dateilock-Problem (`ERROR_ACCESS_DENIED`) via `self-replace` (Umbenennen der laufenden Executable und verzögerte Bereinigung). Auf Linux/Unix wird ein sauberer atomarer Tausch mit Ausführungsrechten (`0o755`) durchgeführt.
|
||||
- **Kryptografische Integritätsprüfung**: Vor dem Austausch wird jedes heruntergeladene Binary zwingend gegen die offizielle `SHA256SUMS.txt` des Gitea-Releases via SHA-256 verifiziert. Bei Abweichungen bleibt die bestehende Installation vollständig unberührt.
|
||||
- **Prüfmodus (`--check` / `-c`)**: Schneller Abgleich mit der Gitea-Releases-API zur Feststellung, ob ein Update vorliegt, ohne Downloads durchzuführen.
|
||||
- **Automatisierungsmodus (`--yes` / `-y`)**: Führt das Upgrade ohne interaktive Bestätigung durch – ideal für geplante Aufgaben und Skripte.
|
||||
- **Force-Option (`--force` / `-f`)**: Erlaubt das Erzwingen einer Neuinstallation / Aktualisierung auch bei gleicher Version oder erkannter Paketmanager-Umgebung.
|
||||
- **Paketmanager-Erkennung**: Erkennt automatisch, ob Sanctum über Scoop oder Winget ausgeführt wird, und empfiehlt den entsprechenden Paketmanager-Befehl (`scoop update sanctum` bzw. `winget upgrade HaraldPansi.Sanctum`).
|
||||
- **Testsuite**:
|
||||
- `tests/upgrade_test.rs`: 5 neue Tests zur Verifikation von Checksum-Parsing, SemVer-Vergleich, Paketmanager-Erkennung, SHA-256 Hash-Validierung und Gitea Release API-JSON-Deserialisierung.
|
||||
|
||||
## [0.5.0] - 2026-09-16
|
||||
|
||||
### Added
|
||||
- **`sanctum sync` - Native rsync-ähnliche Synchronisation**:
|
||||
- Bidirektionale Synchronisation (`Push`: Host -> Tresor, `Pull`: Tresor -> Host) direkt auf SQLite- und VFS-Ebene unter vollständiger Umgehung des WebDAV-Layers.
|
||||
- **Beseitigung des 4-GB-Dateilimits**: Löst das fundamentale Problem des Windows WebClient-Dienstes (`mrxdav.sys`), welcher systembedingt auf 32-Bit Dateigrößen (~4 GB - 1 Byte / Fehler `0x800700DF`) beschränkt ist. Sanctum streamt nun Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+) in 1 MB Chunks mit AES-256-GCM direkt in/aus den Tresor.
|
||||
- **Dry-Run-Simulation (`--dry-run` / `-n`)**: Ermöglicht die risikolose Vorschau aller auszuführenden Aktionen (Hinzufügen, Aktualisieren, Löschen, Überspringen) samt Datenmengen-Berechnung, ohne tatsächliche Änderungen am Ziel durchzuführen.
|
||||
- **Spiegelung & Verwaiste Dateien bereinigen (`--delete`)**: Unterstützt das automatische Löschen von Dateien im Ziel, die in der Quelle nicht mehr existieren, für exakte Ordnerspiegelungen.
|
||||
- **Inhaltsbasierte Hash-Prüfung (`--checksum` / `-c`)**: Optionaler byteweiser SHA-256 Inhaltsvergleich anstelle des schnellen Größen-/mtime-Prüfverfahrens.
|
||||
- **Ausschlussmuster (`--exclude <PATTERN>`)**: Unterstützung für flexible Glob-Muster zum Ignorieren temporärer Dateien, Downloads oder System-Metadaten (z. B. `*.crdownload`, `*.tmp`, `Thumbs.db`).
|
||||
- **Quiet-Modus (`--quiet` / `-q`)**: Ermöglicht lautlose Ausführung für Skripte und geplante Aufgaben mit knapper Zusammenfassungsstatistik.
|
||||
- **Metadaten-Konservierung**: Erhaltung und Übertragung präziser Datei-Modifikationszeitstempel (`mtime`) in beide Richtungen.
|
||||
- **Integrationstests**:
|
||||
- `tests/sync_test.rs`: Umfassende automatisierte Testabdeckung für Push/Pull, Multi-Chunk Großdateien, Fast-Delta-Check, Dry-Run-Invarianz, `--delete` und `--exclude`.
|
||||
|
||||
## [0.4.1] - 2026-09-14
|
||||
|
||||
### Fixed
|
||||
- **Linux Mount-Stabilität & Signal-Handling**:
|
||||
- Behebung eines sofortigen automatischen Aushängens direkt nach erfolgreichem Mount unter Linux: Windows-spezifische Kontrollkanäle (Console-Close, Session-Lock, System-Tray) wurden auf Nicht-Windows-Systemen sofort geschlossen, wodurch `tokio::select!` über `None` vorzeitig ausgelöst wurde.
|
||||
- Plattform-spezifische Aufteilung der `tokio::select!`-Ereignisschleife: Unter Linux/Unix wird nun sauber auf POSIX-Terminierungssignale (`SIGINT`, `SIGTERM`, `SIGHUP`) gelauscht.
|
||||
- Defensives Pattern-Matching mit `Some(()) = rx.recv()` stellt sicher, dass geschlossene Kanäle keine unberechtigten Shutdowns mehr auslösen können.
|
||||
- Automatisches Öffnen via `xdg-open` auf Linux vorerst unterdrückt.
|
||||
|
||||
## [0.4.0] - 2026-09-10
|
||||
|
||||
### Added
|
||||
- **Distribution & Package Ecosystem**:
|
||||
- Offizielles Scoop-Manifest (`packages/scoop/sanctum.json`) für One-Click CLI-Installation und nahtlose Upgrades unter Windows.
|
||||
- Windows Package Manager (Winget) Manifest (`packages/winget/HaraldPansi.Sanctum.yaml`) zur systemweiten Paketverwaltung.
|
||||
- Vollständiger Installations-Leitfaden (`INSTALL.md`) für Scoop, Winget, Standalone-Binaries und `cargo install`.
|
||||
- **Cross-Platform Architektur (Linux & macOS Vorbereitung)**:
|
||||
- Neues Plattform-Abstraktionsmodul `src/platform/mod.rs` für OS-unabhängiges Speicherschutz- und Mount-Handling.
|
||||
- POSIX-Memory-Hardening mit `mlock` und `munlock` Systemaufrufen für Unix-Systeme.
|
||||
- Plattformübergreifendes Datei- und Ordneröffnen (`explorer.exe` unter Windows, `xdg-open` unter Linux, `open` unter macOS).
|
||||
- Native WebDAV-VFS-Einbindung für Linux GNOME/KDE via `gio mount`.
|
||||
- **Chaos Engineering & Live-Crash-Resilienz**:
|
||||
- `tests/live_crash_resilience_test.rs`: Umfassende automatisierte Stresstests mit Multi-Worker Schreibabbrüchen, asynchronen Panic-Simulationen und Validierung der SQLite WAL-Konsistenz (Null Korruption).
|
||||
- **Legal, Compliance & Software Bill of Materials (SBOM)**:
|
||||
- `LEGAL.md`: Rechtliche Leitlinien, US EAR § 742.15(b) & EU Dual-Use Exportkontroll-Klassifizierung, DSGVO Art. 25 Zero-Data-Erklärung und Haftungsausschluss nach § 521 BGB.
|
||||
- `THIRD_PARTY_LICENSES.md`: Lückenloses Lizenzaudit aller 231 Abhängigkeiten (100% permissive Lizenzen, 0% Copyleft) zur strikten Compliance mit Apache-2.0 Section 4.
|
||||
- Aufnahme von `LEGAL.md` und `THIRD_PARTY_LICENSES.md` in das offizielle Release-Distributionsarchiv (`package-release.ps1`).
|
||||
- **Statisches Linux Single-Binary (`x86_64-unknown-linux-musl`)**:
|
||||
- 100% statisch gelinktes Linux-Binary ohne GLIBC-Abhängigkeiten via Zig-Cross-Toolchain.
|
||||
- Neues Linux-Distributionsarchiv `sanctum-v0.4.0-linux-x86_64.tar.gz` inklusive Dokumentation und Prüfsummen.
|
||||
- Native Freedesktop `.desktop`-Datei und MIME-Type-Integration (`application/x-sanctum`) via `sanctum register`.
|
||||
- **OpSec & Disaster Recovery UX**:
|
||||
- `QUICKSTART.md`: Druckbare 24-Wörter BIP-39 Notfallkarte zur sicheren analogen Verwahrung des Notfallschlüssels (Air-Gapped Vault Paper Backup).
|
||||
|
||||
## [0.3.1] - 2026-09-10
|
||||
|
||||
### Added
|
||||
@@ -20,10 +83,6 @@ und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semve
|
||||
- **DFIR & Windows Host-Memory Hardening**:
|
||||
- Win32 `VirtualLock` Speicherschutz: Physische Verriegelung des Root-DEK im RAM zur Unterbindung von Auslagerungen in `pagefile.sys` oder `swapfile.sys`.
|
||||
- Windows Defender Controlled Folder Access (CFA / Ransomware-Schutz) Diagnose mit gezielter Hilfestellung.
|
||||
- **Legal, Compliance & Export-Control (LEGAL-01 bis LEGAL-03)**:
|
||||
- `LEGAL.md`: Umfassende rechtliche Dokumentation inkl. Exportkontroll-Klassifizierung (US EAR § 742.15(b) & EU Dual-Use GSN), Aufklärung über Plausible Deniability bei Key-Disclosure-Gesetzen, EU/DACH-Haftungsbeschränkung gem. § 521 BGB (Schenkungsrecht) sowie DSGVO Art. 25 Zero-Data-Erklärung.
|
||||
- `THIRD_PARTY_LICENSES.md`: Lückenlose Attribution aller ~230 statisch eingebundenen Crates (Apache-2.0, MIT, BSD, SQLite Public Domain) zur strikten Erfüllung von Section 4 der Apache-2.0-Lizenz.
|
||||
- Automatisierte Aufnahme von `LEGAL.md` und `THIRD_PARTY_LICENSES.md` in das Release-Packaging (`package-release.ps1`).
|
||||
|
||||
### Changed
|
||||
- **Visuelle Parität & Plausible Deniability beim Mounten (HF-01)**:
|
||||
|
||||
Generated
+161
-1
@@ -512,6 +512,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1020,6 +1026,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -1334,6 +1346,20 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.4"
|
||||
@@ -1369,6 +1395,54 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -1377,7 +1451,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.3.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -1396,13 +1470,17 @@ dependencies = [
|
||||
"rand",
|
||||
"rpassword",
|
||||
"rusqlite",
|
||||
"self-replace",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tray-item",
|
||||
"ureq",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -1412,6 +1490,23 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "self-replace"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"tempfile",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -1466,6 +1561,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1564,6 +1670,19 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
@@ -1780,6 +1899,29 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1884,6 +2026,24 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.3.1"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||
@@ -36,6 +36,12 @@ dyn-clone = "1.0"
|
||||
lz4_flex = "0.11"
|
||||
bip39 = { version = "2.2", features = ["zeroize"] }
|
||||
hex = "0.4"
|
||||
self-replace = "1.5"
|
||||
ureq = { version = "2.10", default-features = false, features = ["json", "tls"] }
|
||||
sha2 = "0.10"
|
||||
semver = "1.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tray-item = "0.10"
|
||||
|
||||
[profile.release]
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
# Installation & Paketmanager-Guide für Sanctum 🛡️
|
||||
|
||||
Sanctum kann auf verschiedene Arten unter Windows 10/11 installiert und verwaltet werden. Es sind **keine Administratorrechte** erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 1. Installation via Scoop (Empfohlen für Entwickler)
|
||||
|
||||
Wenn du den Windows-Paketmanager [Scoop](https://scoop.sh/) nutzt, kannst du Sanctum direkt über das bereitgestellte Manifest installieren:
|
||||
|
||||
```powershell
|
||||
# Direkt über die URL des Manifests installieren:
|
||||
scoop install https://gitea.pansi.eu/harald/sanctum/raw/branch/main/packages/scoop/sanctum.json
|
||||
```
|
||||
|
||||
Oder falls du ein lokales Repository geklont hast:
|
||||
|
||||
```powershell
|
||||
scoop install .\packages\scoop\sanctum.json
|
||||
```
|
||||
|
||||
Nach der Installation ist der Befehl `sanctum` sofort global in deiner PowerShell verfügbar.
|
||||
|
||||
---
|
||||
|
||||
## 🪟 2. Installation via Windows Package Manager (Winget)
|
||||
|
||||
Sanctum verfügt über ein standardkonformes Winget-Manifest (Schema 1.6.0).
|
||||
|
||||
### Lokale Installation via Manifest:
|
||||
```powershell
|
||||
winget install --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
|
||||
```
|
||||
|
||||
Winget entpackt das portable Binary automatisch in das Benutzer-Anwendungsverzeichnis und verknüpft das Alias `sanctum`.
|
||||
|
||||
---
|
||||
|
||||
## 📦 3. Manuelle Installation (Standalone Portable)
|
||||
|
||||
1. Lade das aktuelle Release-Paket herunter:
|
||||
* **URL:** [https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0](https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.0)
|
||||
* **Datei:** `sanctum-v0.4.0-windows-x86_64.zip`
|
||||
2. Entpacke das Archiv in ein Verzeichnis deiner Wahl (z. B. `C:\Tools\Sanctum\`).
|
||||
3. *(Optional)* Registriere das Windows Explorer Rechtsklick-Kontextmenü:
|
||||
```powershell
|
||||
.\sanctum.exe register
|
||||
```
|
||||
*(Erlaubt das Einhängen per Rechtsklick "In Sanctum öffnen" ohne Adminrechte).*
|
||||
|
||||
---
|
||||
|
||||
## 🐧 4. Installation unter Linux (x86_64)
|
||||
|
||||
Sanctum wird für Linux als **100 % statisches Single-Binary** (`x86_64-unknown-linux-musl`) ohne jegliche GLIBC- oder Bibliotheksabhängigkeiten bereitgestellt. Es läuft direkt auf Ubuntu, Debian, Alpine, Arch, Fedora und weiteren Distributionen:
|
||||
|
||||
1. Lade das Linux-Release herunter und installiere es:
|
||||
```bash
|
||||
curl -LO https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.0/sanctum-v0.4.0-linux-x86_64.tar.gz
|
||||
tar -xzf sanctum-v0.4.0-linux-x86_64.tar.gz
|
||||
chmod +x sanctum
|
||||
sudo install -m 755 sanctum /usr/local/bin/sanctum
|
||||
```
|
||||
2. *(Optional)* Freedesktop-Integration einrichten (Doppelklick in GNOME / KDE / Thunar):
|
||||
```bash
|
||||
sanctum register
|
||||
```
|
||||
*Richtet die `.desktop`-Datei und den MIME-Typ `application/x-sanctum` im Userland (`~/.local/share`) ein.*
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updates
|
||||
|
||||
* **Scoop:**
|
||||
```powershell
|
||||
scoop update sanctum
|
||||
```
|
||||
* **Winget:**
|
||||
```powershell
|
||||
winget upgrade --manifest .\packages\winget\HaraldPansi.Sanctum.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗑️ Deinstallation
|
||||
|
||||
* **Scoop:**
|
||||
```powershell
|
||||
scoop uninstall sanctum
|
||||
```
|
||||
* **Explorer-Kontextmenü rückstandslos bereinigen:**
|
||||
```powershell
|
||||
sanctum.exe unregister
|
||||
```
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Sanctum 🛡️ — Schnellstartanleitung & Notfallhandbuch
|
||||
|
||||
Willkommen bei **Sanctum**! Diese Anleitung führt dich in wenigen Minuten durch die grundlegenden Funktionen zur sicheren Verwaltung deiner verschlüsselten Container unter Windows 10/11 – **100% Userland, ohne Administratorrechte und ohne Treiber**.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ In 3 Minuten startklar
|
||||
|
||||
```powershell
|
||||
# 1. Neuen Tresor mit Notfallkarte anlegen:
|
||||
sanctum.exe init --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
|
||||
|
||||
# 2. Tresor einbinden (wählt automatisch den nächsten freien Buchstaben, z. B. Z:):
|
||||
sanctum.exe mount --path "C:\Users\DeinName\Dokumente\mein_tresor.sanctum"
|
||||
|
||||
# 3. Sicher trennen:
|
||||
# Entweder Ctrl+C im Konsolenfenster ODER Rechtsklick auf das Schild-Icon im Infobereich (Systray) -> "Aushängen & Beenden".
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 Die Kernfunktionen im Überblick
|
||||
|
||||
### 1. Tresor initialisieren (`init`)
|
||||
|
||||
Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
|
||||
|
||||
* **Standard-Tresor (Single-Vault)**:
|
||||
```powershell
|
||||
sanctum.exe init --path "D:\Tresor\daten.sanctum"
|
||||
```
|
||||
* **Plausible Deniability Tresor (Dual-Vault mit Alibi-Carrier)**:
|
||||
```powershell
|
||||
sanctum.exe init --path "D:\Tresor\daten.sanctum" --with-hidden
|
||||
```
|
||||
* Hier legst du **zwei verschiedene Passwörter** fest:
|
||||
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
|
||||
2. **Hidden-Passwort**: Öffnet den geheimen, unnachweisbaren Safe.
|
||||
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
|
||||
|
||||
---
|
||||
|
||||
### 2. Tresor einbinden (`mount`)
|
||||
|
||||
Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- oder Hidden-Safe geöffnet werden soll.
|
||||
|
||||
| Befehl | Zweck |
|
||||
| :--- | :--- |
|
||||
| `sanctum mount --path <DATEI>` | Standard-Mount. Öffnet das Laufwerk automatisch im Explorer. |
|
||||
| `sanctum mount --path <DATEI> --drive S` | Bindet den Tresor fest an den Buchstaben `S:` (statt automatischer Wahl). |
|
||||
| `sanctum mount --path <DATEI> --idle-timeout 300` | Trennt das Laufwerk automatisch nach 5 Minuten (300 Sek.) Inaktivität. |
|
||||
| `sanctum mount --path <DATEI> --no-open` | Verhindert das automatische Öffnen des Windows Explorers (Schutz vor ShellBag-Spuren). |
|
||||
| `sanctum mount --path <DATEI> --stealth` | **Lautloser Stealth-Modus**: Keine Terminal-Ausgaben, keine URLs, kein Explorer-Start. |
|
||||
|
||||
---
|
||||
|
||||
### 3. Notfallrettung & Wartung
|
||||
|
||||
* **Integritätsprüfung (FSCK)**:
|
||||
```powershell
|
||||
sanctum.exe verify --path "D:\Tresor\daten.sanctum"
|
||||
```
|
||||
Überprüft die B-Tree-Struktur der Datenbank und testet sämtliche Chunks gegen ihre kryptografischen AEAD-Authentifizierungs-Tags.
|
||||
|
||||
* **Online-Backup im laufenden Betrieb**:
|
||||
```powershell
|
||||
sanctum.exe backup --path "D:\Tresor\daten.sanctum" --output "E:\Backup\daten_backup.sanctum"
|
||||
```
|
||||
Erzeugt über die SQLite Online Backup API eine konsistente Kopie – selbst während Dateien geöffnet sind.
|
||||
|
||||
* **Passwort vergessen? Wiederherstellung via BIP-39 Notfallschlüssel**:
|
||||
```powershell
|
||||
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
|
||||
```
|
||||
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).*
|
||||
|
||||
---
|
||||
|
||||
## 🖨️ Druckvorlage: BIP-39 Notfallkarte
|
||||
|
||||
Drucke diesen Abschnitt aus oder übertrage die Wörter handschriftlich auf ein Blatt Papier. Bewahre diese Karte physisch getrennt von deinem Computer an einem sicheren Ort (z. B. Tresor, Dokumentenmappe) auf.
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SANCTUM — KRYPTOGRAFISCHE NOTFALL-WIEDERHERSTELLUNGSKARTE (BIP-39) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Container: __________________________________________________________________
|
||||
Erstelldatum: ____.___.202__ Safe: [ ] Standard [ ] Hidden Vault
|
||||
Hinweis: Alle Wörter sind in Kleinbuchstaben aus der offiziellen BIP-39 Liste.
|
||||
|
||||
┌────┬────────────────────────────┬────┬────────────────────────────┐
|
||||
│ # │ WORT │ # │ WORT │
|
||||
├────┼────────────────────────────┼────┼────────────────────────────┤
|
||||
│ 01 │ __________________________ │ 13 │ __________________________ │
|
||||
│ 02 │ __________________________ │ 14 │ __________________________ │
|
||||
│ 03 │ __________________________ │ 15 │ __________________________ │
|
||||
│ 04 │ __________________________ │ 16 │ __________________________ │
|
||||
│ 05 │ __________________________ │ 17 │ __________________________ │
|
||||
│ 06 │ __________________________ │ 18 │ __________________________ │
|
||||
│ 07 │ __________________________ │ 19 │ __________________________ │
|
||||
│ 08 │ __________________________ │ 20 │ __________________________ │
|
||||
│ 09 │ __________________________ │ 21 │ __________________________ │
|
||||
│ 10 │ __________________________ │ 22 │ __________________________ │
|
||||
│ 11 │ __________________________ │ 23 │ __________________________ │
|
||||
│ 12 │ __________________________ │ 24 │ __________________________ │
|
||||
└────┴────────────────────────────┴────┴────────────────────────────┘
|
||||
|
||||
⚠️ SICHERHEITSHINWEISE:
|
||||
1. Wer im Besitz dieser 24 Wörter ist, kann den Tresor ohne Passwort entschlüsseln!
|
||||
2. Niemals abfotografieren, in Cloud-Notizen speichern oder unverschlüsselt versenden.
|
||||
3. Bei Verlust beider Passwörter und dieser Karte sind die Daten unwiederbringlich verloren.
|
||||
```
|
||||
@@ -17,6 +17,8 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
||||
- **Disaster Recovery**: 24-Wort BIP-39 Mnemonic Seed Phrases, konsistente Online-Backups via SQLite Online Backup API und kryptografische Vollprüfung (`sanctum verify`).
|
||||
- **Statisches Single-Binary**: `sanctum.exe` (~5.3 MB) ohne externe DLL-Abhängigkeiten.
|
||||
|
||||
> 💡 **Neu bei Sanctum?** Eine kompakte Schritt-für-Schritt-Anleitung findest du in der [Schnellstartanleitung (QUICKSTART.md)](QUICKSTART.md). Hinweise zur Installation via Scoop oder Winget gibt es im [Installations-Guide (INSTALL.md)](INSTALL.md).
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Kryptografie & Sicherheitsarchitektur
|
||||
@@ -209,7 +211,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
||||
```
|
||||
|
||||
Erzeugt:
|
||||
- `dist/sanctum-v0.3.1-windows-x86_64.zip`
|
||||
- `dist/sanctum-v0.4.0-windows-x86_64.zip` (Windows x86_64 ZIP)
|
||||
- `dist/sanctum-v0.4.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
|
||||
- `dist/SHA256SUMS.txt`
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": "0.6.0",
|
||||
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
|
||||
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
||||
"license": "MIT",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip",
|
||||
"hash": "5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69",
|
||||
"bin": "sanctum.exe"
|
||||
}
|
||||
},
|
||||
"checkver": {
|
||||
"url": "https://gitea.pansi.eu/api/v1/repos/harald/sanctum/releases/latest",
|
||||
"jsonpath": "$.tag_name",
|
||||
"regex": "v([\\d.]+)"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v$version/sanctum-v$version-windows-x86_64.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||
PackageIdentifier: HaraldPansi.Sanctum
|
||||
PackageVersion: 0.6.0
|
||||
PackageName: Sanctum
|
||||
Publisher: Harald Pansi
|
||||
PublisherUrl: https://gitea.pansi.eu/harald
|
||||
PublisherSupportUrl: https://gitea.pansi.eu/harald/sanctum/issues
|
||||
Author: Harald Pansi
|
||||
License: MIT
|
||||
LicenseUrl: https://gitea.pansi.eu/harald/sanctum/src/branch/main/LICENSE
|
||||
ShortDescription: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV
|
||||
Description: Sanctum ist eine eigenständige, speichersichere CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (.sanctum) unter Windows 10/11 im reinen Userland verwaltet. 100% Userland, keine Adminrechte, keine Treiber.
|
||||
Moniker: sanctum
|
||||
Tags:
|
||||
- encryption
|
||||
- security
|
||||
- privacy
|
||||
- webdav
|
||||
- container
|
||||
- plausible-deniability
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: sanctum.exe
|
||||
PortableCommandAlias: sanctum
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.6.0/sanctum-v0.6.0-windows-x86_64.zip
|
||||
InstallerSha256: 5f2a66bd5ff2ab322b92108fde243c2c5c8d331d55ea98b76f0c94cd8c536a69
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.6.0
|
||||
@@ -0,0 +1,119 @@
|
||||
param (
|
||||
[switch]$SkipBuild = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Repository-Wurzelverzeichnis ermitteln
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
# Toolchain-Pfade sicherstellen (Zig + Cargo Bin)
|
||||
$ZigDir = "C:\Users\pansih\AppData\Local\Microsoft\WinGet\Packages\zig.zig_Microsoft.Winget.Source_8wekyb3d8bbwe\zig-x86_64-windows-0.16.0"
|
||||
$env:PATH = "$ZigDir;C:\Users\pansih\.cargo\bin;C:\Windows\System32;" + $env:PATH
|
||||
|
||||
# Version aus Cargo.toml auslesen
|
||||
$CargoToml = Get-Content (Join-Path $ProjectRoot "Cargo.toml") -Raw
|
||||
if ($CargoToml -match 'version\s*=\s*"([^"]+)"') {
|
||||
$Version = $matches[1]
|
||||
} else {
|
||||
Write-Error "Konnte Versionsnummer nicht aus Cargo.toml ermitteln."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " Sanctum Linux Release Packaging v$Version (x86_64 musl)" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
|
||||
# 1. Linux Release-Binary bauen
|
||||
$Target = "x86_64-unknown-linux-musl"
|
||||
$BinaryPath = Join-Path $ProjectRoot "target\$Target\release\sanctum"
|
||||
|
||||
if (-not $SkipBuild -or -not (Test-Path $BinaryPath)) {
|
||||
Write-Host "`n[1/3] Kompiliere statisches Linux-Binary via cargo-zigbuild..." -ForegroundColor Yellow
|
||||
cargo-zigbuild.exe zigbuild --target $Target --release
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Linux-Build fehlgeschlagen!"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Write-Host "[OK] Linux-Binary erfolgreich gebaut." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`n[1/3] Build uebersprungen (-SkipBuild)." -ForegroundColor DarkYellow
|
||||
}
|
||||
|
||||
# 2. Release-Verzeichnisstruktur vorbereiten
|
||||
Write-Host "`n[2/3] Erstelle Linux-Distributionspaket..." -ForegroundColor Yellow
|
||||
|
||||
$DistDir = Join-Path $ProjectRoot "dist"
|
||||
if (-not (Test-Path $DistDir)) {
|
||||
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$PackageName = "sanctum-v$Version-linux-x86_64"
|
||||
$StagingDir = Join-Path $DistDir $PackageName
|
||||
$TarGzFile = Join-Path $DistDir "$PackageName.tar.gz"
|
||||
|
||||
if (Test-Path $StagingDir) {
|
||||
Remove-Item $StagingDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
|
||||
|
||||
# Dateien kopieren
|
||||
Copy-Item $BinaryPath -Destination (Join-Path $StagingDir "sanctum") -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "README.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "LICENSE") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "CHANGELOG.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") -Destination $StagingDir -Force
|
||||
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") -Destination $StagingDir -Force
|
||||
|
||||
# .tar.gz Archiv erstellen mit Windows nativem bsdtar
|
||||
if (Test-Path $TarGzFile) {
|
||||
Remove-Item $TarGzFile -Force
|
||||
}
|
||||
|
||||
tar.exe -czf $TarGzFile -C $DistDir $PackageName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Fehler beim Erstellen des tar.gz-Archivs!"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
# Staging-Verzeichnis bereinigen
|
||||
Remove-Item $StagingDir -Recurse -Force
|
||||
|
||||
Write-Host "[OK] Archiv erstellt: $TarGzFile" -ForegroundColor Green
|
||||
|
||||
# 3. SHA-256 Pruefsummen berechnen und aktualisieren
|
||||
Write-Host "`n[3/3] Aktualisiere SHA-256 Pruefsummen..." -ForegroundColor Yellow
|
||||
|
||||
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
|
||||
$TarHash = (Get-FileHash -Path $TarGzFile -Algorithm SHA256).Hash.ToLower()
|
||||
$ElfHash = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
# Existierende Pruefsummen lesen und um Linux-Assets ergaenzen/aktualisieren
|
||||
$ExistingLines = if (Test-Path $ChecksumFile) {
|
||||
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.tar\.gz" -and $_ -notmatch "\s+sanctum$" }
|
||||
} else {
|
||||
@()
|
||||
}
|
||||
|
||||
$AllLines = @()
|
||||
$AllLines += $ExistingLines
|
||||
$AllLines += "$TarHash $PackageName.tar.gz"
|
||||
$AllLines += "$ElfHash sanctum"
|
||||
|
||||
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||
|
||||
$TarSizeMB = [math]::Round((Get-Item $TarGzFile).Length / 1MB, 2)
|
||||
$ElfSizeMB = [math]::Round((Get-Item $BinaryPath).Length / 1MB, 2)
|
||||
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
Write-Host " Sanctum Linux Release v$Version erfolgreich gepackt!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " Archiv: $TarGzFile ($TarSizeMB MB)"
|
||||
Write-Host " TAR SHA-256: $TarHash"
|
||||
Write-Host " ELF SHA-256: $ElfHash"
|
||||
Write-Host " Checksum-File: $ChecksumFile"
|
||||
Write-Host "`nBereit fuer Gitea Release."
|
||||
@@ -55,14 +55,19 @@ $PackageName = "sanctum-v$Version-windows-x86_64"
|
||||
$StagingDir = Join-Path $DistDir $PackageName
|
||||
$ZipFile = Join-Path $DistDir "$PackageName.zip"
|
||||
|
||||
if (Test-Path $DistDir) {
|
||||
Remove-Item $DistDir -Recurse -Force
|
||||
if (-not (Test-Path $DistDir)) {
|
||||
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
|
||||
}
|
||||
if (Test-Path $StagingDir) {
|
||||
Remove-Item $StagingDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
|
||||
|
||||
$ExeSource = Join-Path $ProjectRoot "target\release\sanctum.exe"
|
||||
Copy-Item $ExeSource (Join-Path $StagingDir "sanctum.exe")
|
||||
Copy-Item (Join-Path $ProjectRoot "README.md") (Join-Path $StagingDir "README.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "QUICKSTART.md") (Join-Path $StagingDir "QUICKSTART.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "INSTALL.md") (Join-Path $StagingDir "INSTALL.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "LICENSE") (Join-Path $StagingDir "LICENSE")
|
||||
Copy-Item (Join-Path $ProjectRoot "LEGAL.md") (Join-Path $StagingDir "LEGAL.md")
|
||||
Copy-Item (Join-Path $ProjectRoot "THIRD_PARTY_LICENSES.md") (Join-Path $StagingDir "THIRD_PARTY_LICENSES.md")
|
||||
@@ -82,7 +87,17 @@ $ZipHash = (Get-FileHash -Path $ZipFile -Algorithm SHA256).Hash.ToLower()
|
||||
$ExeHash = (Get-FileHash -Path $ExeSource -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
$ChecksumFile = Join-Path $DistDir "SHA256SUMS.txt"
|
||||
@("$ZipHash $PackageName.zip", "$ExeHash sanctum.exe") | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||
$ExistingLines = if (Test-Path $ChecksumFile) {
|
||||
Get-Content $ChecksumFile | Where-Object { $_ -notmatch "$PackageName\.zip" -and $_ -notmatch "\s+sanctum\.exe$" }
|
||||
} else {
|
||||
@()
|
||||
}
|
||||
|
||||
$AllLines = @()
|
||||
$AllLines += "$ZipHash $PackageName.zip"
|
||||
$AllLines += "$ExeHash sanctum.exe"
|
||||
$AllLines += $ExistingLines
|
||||
$AllLines | Set-Content -Path $ChecksumFile -Encoding utf8
|
||||
|
||||
Write-Host "[OK] Pruefsummen in SHA256SUMS.txt gespeichert." -ForegroundColor Green
|
||||
|
||||
|
||||
+47
-21
@@ -108,29 +108,55 @@ if (-not $Release) {
|
||||
$UploadUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets"
|
||||
$AuthHeader = "Authorization: token $Token"
|
||||
|
||||
# ZIP Asset hochladen
|
||||
Write-Host "Lade sanctum-$TagName-windows-x86_64.zip hoch..." -ForegroundColor Cyan
|
||||
$ZipPathNorm = $ZipFile.Replace('\', '/')
|
||||
$ZipUrl = "$UploadUrl?name=sanctum-$TagName-windows-x86_64.zip"
|
||||
$ZipResult = & curl.exe -s -X POST $ZipUrl -H $AuthHeader -F "attachment=@$ZipPathNorm"
|
||||
Write-Host "[OK] sanctum-$TagName-windows-x86_64.zip hochgeladen." -ForegroundColor Green
|
||||
# Hilfsfunktion zum sauberen Hochladen/Ersetzen von Assets
|
||||
function Upload-ReleaseAsset {
|
||||
param (
|
||||
[string]$FilePath,
|
||||
[string]$AssetName
|
||||
)
|
||||
|
||||
if (-not (Test-Path $FilePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Lade $AssetName hoch..." -ForegroundColor Cyan
|
||||
|
||||
# Vorhandenes Asset mit gleichem Namen ermitteln und ggf. loeschen
|
||||
$ExistingAssets = try {
|
||||
Invoke-RestMethod -Uri $UploadUrl -Headers $Headers -Method Get
|
||||
} catch {
|
||||
@()
|
||||
}
|
||||
$Duplicate = $ExistingAssets | Where-Object { $_.name -eq $AssetName }
|
||||
if ($Duplicate) {
|
||||
Write-Host " [i] Ersetze existierendes Asset '$AssetName' (ID: $($Duplicate.id))..." -ForegroundColor DarkYellow
|
||||
$DeleteUrl = "$GiteaUrl/api/v1/repos/$Owner/$Repo/releases/$($Release.id)/assets/$($Duplicate.id)"
|
||||
Invoke-RestMethod -Uri $DeleteUrl -Headers $Headers -Method Delete | Out-Null
|
||||
}
|
||||
|
||||
$PathNorm = $FilePath.Replace('\', '/')
|
||||
$TargetUrl = "${UploadUrl}?name=$AssetName"
|
||||
$Result = & curl.exe -sS -X POST $TargetUrl -H $AuthHeader -F "attachment=@$PathNorm"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Upload von $AssetName fehlgeschlagen: $Result"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[OK] $AssetName erfolgreich hochgeladen." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Windows-Assets hochladen
|
||||
Upload-ReleaseAsset -FilePath $ZipFile -AssetName "sanctum-$TagName-windows-x86_64.zip"
|
||||
$ExeFile = Join-Path $ProjectRoot "target\release\sanctum.exe"
|
||||
Upload-ReleaseAsset -FilePath $ExeFile -AssetName "sanctum.exe"
|
||||
|
||||
# Linux-Assets hochladen
|
||||
$LinuxTar = Join-Path $DistDir "sanctum-$TagName-linux-x86_64.tar.gz"
|
||||
Upload-ReleaseAsset -FilePath $LinuxTar -AssetName "sanctum-$TagName-linux-x86_64.tar.gz"
|
||||
$LinuxElf = Join-Path $ProjectRoot "target\x86_64-unknown-linux-musl\release\sanctum"
|
||||
Upload-ReleaseAsset -FilePath $LinuxElf -AssetName "sanctum"
|
||||
|
||||
# SHA256SUMS.txt hochladen
|
||||
Write-Host "Lade SHA256SUMS.txt hoch..." -ForegroundColor Cyan
|
||||
$SumPathNorm = $ChecksumFile.Replace('\', '/')
|
||||
$SumUrl = "$UploadUrl?name=SHA256SUMS.txt"
|
||||
$SumResult = & curl.exe -s -X POST $SumUrl -H $AuthHeader -F "attachment=@$SumPathNorm"
|
||||
Write-Host "[OK] SHA256SUMS.txt hochgeladen." -ForegroundColor Green
|
||||
|
||||
# Standalone EXE hochladen
|
||||
$ExeFile = Join-Path $ProjectRoot "target\release\sanctum.exe"
|
||||
if (Test-Path $ExeFile) {
|
||||
Write-Host "Lade sanctum.exe hoch..." -ForegroundColor Cyan
|
||||
$ExePathNorm = $ExeFile.Replace('\', '/')
|
||||
$ExeUrl = "$UploadUrl?name=sanctum.exe"
|
||||
$ExeResult = & curl.exe -s -X POST $ExeUrl -H $AuthHeader -F "attachment=@$ExePathNorm"
|
||||
Write-Host "[OK] sanctum.exe hochgeladen." -ForegroundColor Green
|
||||
}
|
||||
Upload-ReleaseAsset -FilePath $ChecksumFile -AssetName "SHA256SUMS.txt"
|
||||
|
||||
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
pub mod carrier;
|
||||
pub mod crypto;
|
||||
pub mod mount;
|
||||
pub mod platform;
|
||||
pub mod recovery;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod ui;
|
||||
pub mod upgrade;
|
||||
pub mod verify;
|
||||
pub mod vfs;
|
||||
pub mod windows;
|
||||
|
||||
+263
-1
@@ -68,10 +68,14 @@ enum Commands {
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:', optional; wählt standardmäßig automatisch das nächste freie Laufwerk)
|
||||
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:', optional; wählt unter Windows standardmäßig das nächste freie Laufwerk)
|
||||
#[arg(short, long)]
|
||||
drive: Option<String>,
|
||||
|
||||
/// Optionaler lokaler Mount-Pfad unter Linux/macOS (z. B. '/mnt/sanctum' oder '~/vault')
|
||||
#[arg(short = 'm', long = "mount-point", value_name = "DIR")]
|
||||
mount_point: Option<PathBuf>,
|
||||
|
||||
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
@@ -197,11 +201,73 @@ enum Commands {
|
||||
full: bool,
|
||||
},
|
||||
|
||||
/// Synchronisiert Dateien und Ordner zwischen Host und Container (rsync-artig, ohne Größenbeschränkung)
|
||||
Sync {
|
||||
/// Pfad zur .sanctum Containerdatei
|
||||
#[arg(short, long)]
|
||||
path: PathBuf,
|
||||
|
||||
/// Quellpfad (lokales Verzeichnis/Datei oder Vault-Pfad bei --pull)
|
||||
source: String,
|
||||
|
||||
/// Zielpfad (Vault-Pfad wie z. B. '/Downloads' oder lokaler Pfad bei --pull; Standard: '/')
|
||||
#[arg(default_value = "/")]
|
||||
target: String,
|
||||
|
||||
/// Kehrt die Richtung um: Lädt Daten aus dem Tresor auf die lokale Festplatte (Pull)
|
||||
#[arg(long, default_value_t = false)]
|
||||
pull: bool,
|
||||
|
||||
/// Löscht Dateien im Ziel, die an der Quelle nicht mehr existieren (Spiegelung)
|
||||
#[arg(long, default_value_t = false)]
|
||||
delete: bool,
|
||||
|
||||
/// Führt eine Simulation aus: Zeigt Änderungen an, ohne Dateien zu schreiben oder zu löschen
|
||||
#[arg(short = 'n', long, default_value_t = false)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Vergleicht Dateien anhand kryptografischer Hashes statt nur Zeitstempel und Dateigröße
|
||||
#[arg(short = 'c', long, default_value_t = false)]
|
||||
checksum: bool,
|
||||
|
||||
/// Schließt Dateien oder Ordner anhand von Mustern aus (z. B. '*.tmp', '*.crdownload')
|
||||
#[arg(long)]
|
||||
exclude: Vec<String>,
|
||||
|
||||
/// Unterdrückt Pro-Datei-Ausgaben
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
quiet: bool,
|
||||
|
||||
/// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage)
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||
recovery_key: Option<String>,
|
||||
},
|
||||
|
||||
/// Registriert .sanctum Containerdateien im Windows Explorer (Doppelklick & Kontextmenü, keine Adminrechte)
|
||||
Register,
|
||||
|
||||
/// Entfernt die .sanctum Explorer-Integration aus der Benutzer-Registry
|
||||
Unregister,
|
||||
|
||||
/// Aktualisiert Sanctum auf die neueste Version (In-Place Self-Upgrade)
|
||||
#[command(alias = "update")]
|
||||
Upgrade {
|
||||
/// Prüft nur auf verfügbare Aktualisierungen, ohne Dateien herunterzuladen
|
||||
#[arg(short = 'c', long, default_value_t = false)]
|
||||
check: bool,
|
||||
|
||||
/// Führt die Aktualisierung ohne interaktive Bestätigung durch
|
||||
#[arg(short = 'y', long, default_value_t = false)]
|
||||
yes: bool,
|
||||
|
||||
/// Erzwingt die Aktualisierung (auch bei gleicher Version oder erkannter Paketmanager-Verwaltung)
|
||||
#[arg(short = 'f', long, default_value_t = false)]
|
||||
force: bool,
|
||||
|
||||
/// Benutzerdefinierte Gitea-Basis-URL (Standard: https://gitea.pansi.eu)
|
||||
#[arg(long, default_value = "https://gitea.pansi.eu")]
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn parse_drive_letter(s: &str) -> Result<char> {
|
||||
@@ -880,6 +946,159 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_sync(
|
||||
container_path: &Path,
|
||||
source: &str,
|
||||
target: &str,
|
||||
pull: bool,
|
||||
delete: bool,
|
||||
dry_run: bool,
|
||||
checksum: bool,
|
||||
exclude: Vec<String>,
|
||||
quiet: bool,
|
||||
recovery_key: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
}
|
||||
|
||||
let auth = if let Some(key_arg) = recovery_key {
|
||||
let phrase_str = if key_arg.trim().is_empty() {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ 🔑 Maskierte interaktive Notfallschlüssel-Eingabe │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
rpassword::prompt_password("24-Wort Notfall-Wiederherstellungsschlüssel: ")
|
||||
.context("Fehler beim Einlesen des Schlüssels")?
|
||||
} else {
|
||||
key_arg.to_string()
|
||||
};
|
||||
ContainerAuth::RecoveryKey(Zeroizing::new(phrase_str))
|
||||
} else {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Ordner- & Dateisynchronisation (Sync) │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
let pass = rpassword::prompt_password("Master-Passwort: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?;
|
||||
ContainerAuth::Password(Zeroizing::new(pass))
|
||||
};
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
let (dek, version, vault_id) = match auth {
|
||||
ContainerAuth::Password(ref password) => {
|
||||
let keys = meta
|
||||
.authenticate(password)
|
||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
|
||||
(keys.dek().clone(), meta.version, keys.slot_id())
|
||||
}
|
||||
ContainerAuth::RecoveryKey(ref phrase) => {
|
||||
let dek = mnemonic_to_dek(phrase)
|
||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
let is_hidden = {
|
||||
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
|
||||
!children.is_empty()
|
||||
};
|
||||
let v_id = if is_hidden { 1 } else { 0 };
|
||||
(dek, meta.version, v_id)
|
||||
}
|
||||
};
|
||||
|
||||
// Richtung ermitteln
|
||||
let direction = if pull || source.starts_with('/') {
|
||||
sanctum::sync::SyncDirection::Pull
|
||||
} else {
|
||||
sanctum::sync::SyncDirection::Push
|
||||
};
|
||||
|
||||
let options = sanctum::sync::SyncOptions {
|
||||
direction,
|
||||
delete,
|
||||
dry_run,
|
||||
checksum,
|
||||
exclude_patterns: exclude,
|
||||
quiet,
|
||||
};
|
||||
|
||||
if !quiet {
|
||||
let dir_str = match direction {
|
||||
sanctum::sync::SyncDirection::Push => "Push (Host -> Container)",
|
||||
sanctum::sync::SyncDirection::Pull => "Pull (Container -> Host)",
|
||||
};
|
||||
|
||||
println!();
|
||||
if dry_run {
|
||||
println!(" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!", ui::yellow("[!]"));
|
||||
}
|
||||
println!(" • Richtung: {}", ui::cyan(dir_str));
|
||||
println!(" • Quelle: {}", source);
|
||||
println!(" • Ziel: {}", target);
|
||||
if delete {
|
||||
println!(" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)", ui::red("--delete aktiv"));
|
||||
}
|
||||
if checksum {
|
||||
println!(" • Modus: Kryptografischer Inhaltsabgleich (--checksum aktiv)");
|
||||
}
|
||||
if !options.exclude_patterns.is_empty() {
|
||||
println!(" • Filter: {} Ausschlussmuster", options.exclude_patterns.len());
|
||||
}
|
||||
println!();
|
||||
println!(" {} Starte Synchronisation ...", ui::dim("[-]"));
|
||||
println!();
|
||||
}
|
||||
|
||||
let stats = sanctum::sync::run_sync(
|
||||
&db,
|
||||
vault_id,
|
||||
&dek,
|
||||
version,
|
||||
source,
|
||||
target,
|
||||
&options,
|
||||
)?;
|
||||
|
||||
if !dry_run {
|
||||
let _ = db.checkpoint();
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
println!();
|
||||
if dry_run {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ℹ Dry-Run Simulation erfolgreich abgeschlossen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
|
||||
println!(" • Zu übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
|
||||
println!(" • Bereits aktuell: {} Dateien (übersprungen)", stats.files_skipped);
|
||||
if delete {
|
||||
println!(" • Zu löschen: {} Dateien/Ordner", stats.files_deleted);
|
||||
}
|
||||
println!(" • Hinweis: {} Es wurden keine Änderungen vorgenommen.", ui::yellow("✔"));
|
||||
} else {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Synchronisation erfolgreich abgeschlossen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
|
||||
println!(" • Übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
|
||||
println!(" • Übersprungen: {} Dateien (bereits aktuell)", stats.files_skipped);
|
||||
if delete {
|
||||
println!(" • Gelöscht: {} verwaiste Dateien/Ordner", stats.files_deleted);
|
||||
}
|
||||
let secs = stats.elapsed.as_secs_f64();
|
||||
let mb = (stats.bytes_transferred as f64) / (1024.0 * 1024.0);
|
||||
let speed = if secs > 0.05 { mb / secs } else { 0.0 };
|
||||
println!(" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)", secs, speed);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
@@ -904,6 +1123,7 @@ async fn run() -> Result<()> {
|
||||
Commands::Mount {
|
||||
path,
|
||||
drive,
|
||||
mount_point,
|
||||
port,
|
||||
recovery_key,
|
||||
no_open,
|
||||
@@ -916,6 +1136,8 @@ async fn run() -> Result<()> {
|
||||
let drive_char = match drive {
|
||||
Some(ref d) => parse_drive_letter(d)?,
|
||||
None => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let auto_drive = sanctum::windows::find_next_available_drive()?;
|
||||
if !stealth {
|
||||
println!(
|
||||
@@ -926,6 +1148,11 @@ async fn run() -> Result<()> {
|
||||
}
|
||||
auto_drive
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
'Z'
|
||||
}
|
||||
}
|
||||
};
|
||||
let auth = if let Some(key) = recovery_key {
|
||||
let actual_key = if key.trim().is_empty() {
|
||||
@@ -953,6 +1180,7 @@ async fn run() -> Result<()> {
|
||||
mount_container(
|
||||
&path,
|
||||
drive_char,
|
||||
mount_point.as_deref(),
|
||||
port,
|
||||
auth,
|
||||
open_explorer,
|
||||
@@ -993,6 +1221,31 @@ async fn run() -> Result<()> {
|
||||
Commands::Verify { path, full } => {
|
||||
handle_verify(&path, full)?;
|
||||
}
|
||||
Commands::Sync {
|
||||
path,
|
||||
source,
|
||||
target,
|
||||
pull,
|
||||
delete,
|
||||
dry_run,
|
||||
checksum,
|
||||
exclude,
|
||||
quiet,
|
||||
recovery_key,
|
||||
} => {
|
||||
handle_sync(
|
||||
&path,
|
||||
&source,
|
||||
&target,
|
||||
pull,
|
||||
delete,
|
||||
dry_run,
|
||||
checksum,
|
||||
exclude,
|
||||
quiet,
|
||||
recovery_key.as_deref(),
|
||||
)?;
|
||||
}
|
||||
Commands::Register => {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — Windows Explorer Integration einrichten │");
|
||||
@@ -1013,6 +1266,15 @@ async fn run() -> Result<()> {
|
||||
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔"));
|
||||
println!();
|
||||
}
|
||||
Commands::Upgrade { check, yes, force, url } => {
|
||||
let options = sanctum::upgrade::UpgradeOptions {
|
||||
check_only: check,
|
||||
yes,
|
||||
force,
|
||||
base_url: url,
|
||||
};
|
||||
sanctum::upgrade::run_upgrade(&options)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+170
-15
@@ -32,8 +32,10 @@ pub fn format_drive(drive_letter: char) -> String {
|
||||
format!("{}:", drive_letter.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y`.
|
||||
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y` bzw. Unix-Mountpoint via `gio mount -u`.
|
||||
pub fn unmount_drive(drive_letter: char) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let drive_str = format_drive(drive_letter);
|
||||
|
||||
let output = Command::new("net")
|
||||
@@ -53,10 +55,24 @@ pub fn unmount_drive(drive_letter: char) -> Result<()> {
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = drive_letter;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = drive_letter;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein.
|
||||
fn run_net_use_mount(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
|
||||
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein
|
||||
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/<TOKEN>/`.
|
||||
fn run_mount_command(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
|
||||
|
||||
let mut output = Command::new("net")
|
||||
@@ -107,12 +123,28 @@ fn run_net_use_mount(drive_str: &str, port: u16, session_token: &str) -> Result<
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dav_url = format!("dav://127.0.0.1:{}/{}/", port, session_token);
|
||||
let _ = Command::new("gio")
|
||||
.args(["mount", &dav_url])
|
||||
.output();
|
||||
let _ = drive_str;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = (drive_str, port, session_token);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Startet den WebDAV-Server für den Sanctum-Container und bindet ihn als Netzlaufwerk ein.
|
||||
/// Startet den WebDAV-Server für den Sanctum-Container und bindet ihn als Netzlaufwerk (Windows) bzw. VFS (Linux) ein.
|
||||
pub async fn mount_container(
|
||||
container_path: &Path,
|
||||
drive_letter: char,
|
||||
mount_point: Option<&Path>,
|
||||
requested_port: Option<u16>,
|
||||
auth: ContainerAuth,
|
||||
open_explorer: bool,
|
||||
@@ -123,6 +155,10 @@ pub async fn mount_container(
|
||||
stealth: bool,
|
||||
) -> Result<()> {
|
||||
let drive_str = format_drive(drive_letter);
|
||||
let _ = mount_point;
|
||||
let _ = open_explorer;
|
||||
let _ = enable_tray;
|
||||
let _ = lock_on_screen_lock;
|
||||
|
||||
if !container_path.exists() {
|
||||
bail!(
|
||||
@@ -224,6 +260,7 @@ pub async fn mount_container(
|
||||
let bound_port = bound_addr.port();
|
||||
|
||||
if !stealth {
|
||||
#[cfg(windows)]
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
@@ -233,6 +270,16 @@ pub async fn mount_container(
|
||||
bound_port, drive_str
|
||||
),
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
"🌐",
|
||||
&format!(
|
||||
"Starte WebDAV-Server auf Port {} & initialisiere VFS...",
|
||||
bound_port
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
@@ -245,20 +292,22 @@ pub async fn mount_container(
|
||||
shutdown_rx,
|
||||
));
|
||||
|
||||
// Netzlaufwerk einbinden
|
||||
if let Err(e) = run_net_use_mount(&drive_str, bound_port, &session_token) {
|
||||
// Netzlaufwerk bzw. Verzeichnis einbinden
|
||||
if let Err(e) = run_mount_command(&drive_str, bound_port, &session_token) {
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault)
|
||||
#[cfg(windows)]
|
||||
if open_explorer {
|
||||
let _ = crate::windows::open_in_explorer(drive_letter);
|
||||
}
|
||||
|
||||
// System-Tray Initialisierung
|
||||
let (tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
// System-Tray Initialisierung (nur Windows)
|
||||
#[cfg(windows)]
|
||||
let (_tray_shutdown_tx, mut tray_shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
#[cfg(windows)]
|
||||
let _tray = if enable_tray {
|
||||
let icon_source = crate::windows::get_default_system_icon()
|
||||
@@ -276,7 +325,7 @@ pub async fn mount_container(
|
||||
let _ = tray.add_menu_item("Im Explorer öffnen", move || {
|
||||
let _ = crate::windows::open_in_explorer(dl);
|
||||
});
|
||||
let s_tx = tray_shutdown_tx.clone();
|
||||
let s_tx = _tray_shutdown_tx.clone();
|
||||
let _ = tray.add_menu_item("Trennen & Beenden", move || {
|
||||
let _ = s_tx.blocking_send(());
|
||||
});
|
||||
@@ -315,8 +364,10 @@ pub async fn mount_container(
|
||||
}
|
||||
}
|
||||
|
||||
// Windows-Sitzungssperre (Win + L Auto-Lock)
|
||||
// Windows-Sitzungssperre (Win + L Auto-Lock, nur Windows)
|
||||
#[cfg(windows)]
|
||||
let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
#[cfg(windows)]
|
||||
let _session_monitor = if lock_on_screen_lock {
|
||||
match crate::windows::start_session_lock_monitor(session_lock_tx) {
|
||||
Ok(guard) => Some(guard),
|
||||
@@ -330,7 +381,10 @@ pub async fn mount_container(
|
||||
};
|
||||
|
||||
if stealth {
|
||||
#[cfg(windows)]
|
||||
println!("Sanctum: Netzlaufwerk {} bereit.", drive_str);
|
||||
#[cfg(not(windows))]
|
||||
println!("Sanctum: WebDAV-Server auf Port {} bereit.", bound_port);
|
||||
} else {
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
@@ -338,30 +392,63 @@ pub async fn mount_container(
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
#[cfg(windows)]
|
||||
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(mp) = mount_point {
|
||||
println!(" • Mountpoint: {}", ui::cyan(&mp.display().to_string()));
|
||||
} else {
|
||||
println!(" • Modus: WebDAV Userland-VFS");
|
||||
}
|
||||
}
|
||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/{}/", bound_port, session_token);
|
||||
if let Some(mp) = mount_point {
|
||||
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/{}/ {}", bound_port, session_token, mp.display());
|
||||
}
|
||||
}
|
||||
if let Some(secs) = idle_timeout {
|
||||
println!(" • Auto-Lock: Inaktivität nach {}s", secs);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if lock_on_screen_lock {
|
||||
println!(" • Sitzung: Automatisches Sperren bei Win + L aktiv");
|
||||
}
|
||||
if anti_leak {
|
||||
#[cfg(windows)]
|
||||
println!(" • Anti-Leak: Explorer-Metadatenfilter aktiv (Thumbs.db, desktop.ini blockiert)");
|
||||
#[cfg(not(windows))]
|
||||
println!(" • Anti-Leak: VFS-Metadatenfilter aktiv (.directory, .Trash, desktop.ini blockiert)");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if enable_tray {
|
||||
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
||||
}
|
||||
println!();
|
||||
#[cfg(windows)]
|
||||
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
|
||||
#[cfg(not(windows))]
|
||||
println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp"));
|
||||
println!();
|
||||
}
|
||||
|
||||
// Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT)
|
||||
// Windows Console Close Monitor (CTRL_CLOSE_EVENT / CTRL_SHUTDOWN_EVENT, nur Windows)
|
||||
#[cfg(windows)]
|
||||
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
#[cfg(windows)]
|
||||
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
|
||||
|
||||
// Unix Signale (SIGTERM, SIGHUP)
|
||||
#[cfg(unix)]
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
|
||||
#[cfg(unix)]
|
||||
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
|
||||
|
||||
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen)
|
||||
#[cfg(windows)]
|
||||
tokio::select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
let _ = res;
|
||||
@@ -370,25 +457,81 @@ pub async fn mount_container(
|
||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = console_close_rx.recv() => {
|
||||
Some(()) = console_close_rx.recv() => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Konsolenfenster wird geschlossen — sichere Trennung ausgeführt!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = tray_shutdown_rx.recv() => {
|
||||
Some(()) = tray_shutdown_rx.recv() => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal aus System-Tray empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = session_lock_rx.recv() => {
|
||||
Some(()) = session_lock_rx.recv() => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Windows-Sitzung gesperrt (Win + L) — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = idle_shutdown_rx.recv() => {
|
||||
Some(()) = idle_shutdown_rx.recv() => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
tokio::select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
let _ = res;
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
match sigterm.as_mut() {
|
||||
Some(s) => { s.recv().await; }
|
||||
None => { std::future::pending::<()>().await; }
|
||||
}
|
||||
} => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (SIGTERM) empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
match sighup.as_mut() {
|
||||
Some(s) => { s.recv().await; }
|
||||
None => { std::future::pending::<()>().await; }
|
||||
}
|
||||
} => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (SIGHUP) empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
Some(()) = idle_shutdown_rx.recv() => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
tokio::select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
let _ = res;
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Beendigungssignal (Ctrl+C) empfangen.", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
Some(()) = idle_shutdown_rx.recv() => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
@@ -406,8 +549,13 @@ pub async fn mount_container(
|
||||
}
|
||||
}
|
||||
let _ = db.checkpoint();
|
||||
#[cfg(windows)]
|
||||
println!("Sanctum: Laufwerk {} getrennt und geschlossen.", drive_str);
|
||||
#[cfg(not(windows))]
|
||||
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
|
||||
} else {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
@@ -417,6 +565,13 @@ pub async fn mount_container(
|
||||
} else {
|
||||
println!("{}", ui::green("OK"));
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
print!(" {} Beende WebDAV-Server ... ", ui::dim("[-]"));
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
println!("{}", ui::green("OK"));
|
||||
}
|
||||
|
||||
// HTTP Server beenden
|
||||
let _ = shutdown_tx.send(true);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Plattform-Abstraktionsschicht für Sanctum.
|
||||
//!
|
||||
//! Dieses Modul bündelt alle betriebssystemspezifischen Funktionen
|
||||
//! (Speichersperren, Dateimanager-Aufrufe, Signal-Monitoring, Shell-Integration)
|
||||
//! für Windows, Linux und macOS unter einer einheitlichen, speichersicheren Schnittstelle.
|
||||
|
||||
pub use crate::windows::*;
|
||||
+850
@@ -0,0 +1,850 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
||||
use crate::storage::{Database, NodeRecord};
|
||||
use crate::ui;
|
||||
use crate::vfs::is_leak_file;
|
||||
|
||||
/// Synchronisationsrichtung
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncDirection {
|
||||
/// Host-Dateisystem -> Sanctum Container (Upload / Backup)
|
||||
Push,
|
||||
/// Sanctum Container -> Host-Dateisystem (Download / Restore)
|
||||
Pull,
|
||||
}
|
||||
|
||||
/// Optionen für den Synchronisationslauf
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncOptions {
|
||||
pub direction: SyncDirection,
|
||||
pub delete: bool,
|
||||
pub dry_run: bool,
|
||||
pub checksum: bool,
|
||||
pub exclude_patterns: Vec<String>,
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
impl Default for SyncOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
direction: SyncDirection::Push,
|
||||
delete: false,
|
||||
dry_run: false,
|
||||
checksum: false,
|
||||
exclude_patterns: Vec::new(),
|
||||
quiet: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiken über den Synchronisationslauf
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SyncStats {
|
||||
pub files_scanned: usize,
|
||||
pub files_transferred: usize,
|
||||
pub files_skipped: usize,
|
||||
pub files_deleted: usize,
|
||||
pub bytes_transferred: u64,
|
||||
pub elapsed: Duration,
|
||||
}
|
||||
|
||||
/// Ergebnis einer einzelnen Dateiübertragung
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileTransferResult {
|
||||
Transferred { size: u64 },
|
||||
Skipped { size: u64 },
|
||||
DryRunTransferred { size: u64 },
|
||||
}
|
||||
|
||||
/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht.
|
||||
pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
||||
// 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen
|
||||
if is_leak_file(name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let norm_rel = rel_path.replace('\\', "/");
|
||||
let norm_name = name.to_ascii_lowercase();
|
||||
|
||||
for pat in patterns {
|
||||
let p = pat.trim();
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wildcard-Muster: *.ext
|
||||
if p.starts_with("*.") {
|
||||
let ext = &p[1..].to_ascii_lowercase();
|
||||
if norm_name.ends_with(ext) || norm_rel.to_ascii_lowercase().ends_with(ext) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Wildcard-Muster: prefix*
|
||||
else if p.ends_with('*') && !p[..p.len() - 1].contains('*') {
|
||||
let prefix = p[..p.len() - 1].to_ascii_lowercase();
|
||||
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Exakter Name oder Teilpfad
|
||||
else {
|
||||
let p_lower = p.to_ascii_lowercase();
|
||||
if norm_name == p_lower {
|
||||
return true;
|
||||
}
|
||||
if norm_rel.trim_start_matches('/').to_ascii_lowercase() == p_lower.trim_start_matches('/') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Liest bis zu `buf.len()` Bytes aus einer Datei (verlässliche Chunk-Lesung).
|
||||
fn read_chunk_buffer(file: &mut File, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut total = 0;
|
||||
while total < buf.len() {
|
||||
match file.read(&mut buf[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Setzt den Modifikationszeitstempel einer lokalen Datei via std::fs::FileTimes.
|
||||
fn set_local_file_mtime(file: &File, mtime_secs: u64) {
|
||||
let times = std::fs::FileTimes::new()
|
||||
.set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
|
||||
let _ = file.set_times(times);
|
||||
}
|
||||
|
||||
/// Stellt sicher, dass ein Verzeichnispfad im Tresor existiert und gibt den Ziel-Knoten zurück.
|
||||
pub fn ensure_vault_dir_tree(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
vault_dir_path: &str,
|
||||
) -> Result<NodeRecord> {
|
||||
let normalized = vault_dir_path.trim_matches('/');
|
||||
let root_id = Database::get_root_node_id_for_vault(vault_id);
|
||||
let root_node = db
|
||||
.get_node_by_id_in_vault(root_id, vault_id, dek)?
|
||||
.context("Wurzelknoten im Container nicht gefunden")?;
|
||||
|
||||
if normalized.is_empty() {
|
||||
return Ok(root_node);
|
||||
}
|
||||
|
||||
let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut current_id = root_id;
|
||||
let mut current_node = root_node;
|
||||
|
||||
for segment in segments {
|
||||
let children = db.list_children_in_vault(current_id, vault_id, dek)?;
|
||||
if let Some(existing) = children.into_iter().find(|c| c.name == segment) {
|
||||
if !existing.is_dir {
|
||||
bail!("Pfad-Konflikt: '{}' existiert im Container bereits als Datei", segment);
|
||||
}
|
||||
current_id = existing.id;
|
||||
current_node = existing;
|
||||
} else {
|
||||
let new_dir = db.create_node_in_vault(vault_id, current_id, segment, true, dek)?;
|
||||
current_id = new_dir.id;
|
||||
current_node = new_dir;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(current_node)
|
||||
}
|
||||
|
||||
/// Synchronisiert eine einzelne Host-Datei in den Tresor (Push).
|
||||
pub fn sync_single_file_to_vault(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
version: u32,
|
||||
local_path: &Path,
|
||||
parent_node_id: i64,
|
||||
file_name: &str,
|
||||
checksum: bool,
|
||||
dry_run: bool,
|
||||
) -> Result<FileTransferResult> {
|
||||
let meta = fs::metadata(local_path)
|
||||
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
|
||||
let local_size = meta.len();
|
||||
let local_mtime = meta
|
||||
.modified()
|
||||
.unwrap_or(SystemTime::now())
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let children = db.list_children_in_vault(parent_node_id, vault_id, dek)?;
|
||||
let existing_node = children.into_iter().find(|c| c.name == file_name);
|
||||
|
||||
if let Some(ref node) = existing_node {
|
||||
if node.is_dir {
|
||||
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
|
||||
}
|
||||
|
||||
// Fast Check: Wenn Größe und mtime identisch sind, überspringen
|
||||
if !checksum && node.size == local_size && node.modified_at == local_mtime {
|
||||
return Ok(FileTransferResult::Skipped { size: local_size });
|
||||
}
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
return Ok(FileTransferResult::DryRunTransferred { size: local_size });
|
||||
}
|
||||
|
||||
let node_id = match existing_node {
|
||||
Some(n) => n.id,
|
||||
None => {
|
||||
let new_node = db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
|
||||
new_node.id
|
||||
}
|
||||
};
|
||||
|
||||
// 1-MB-Chunk Streaming direkt in SQLite
|
||||
let mut file = File::open(local_path)
|
||||
.with_context(|| format!("Konnte '{}' nicht zum Lesen öffnen", local_path.display()))?;
|
||||
let mut buffer = vec![0u8; CHUNK_SIZE];
|
||||
let mut chunk_idx = 0u32;
|
||||
let mut bytes_written = 0u64;
|
||||
|
||||
loop {
|
||||
let n = read_chunk_buffer(&mut file, &mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let chunk_data = &buffer[..n];
|
||||
let (ciphertext, nonce, tag) = encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version)?;
|
||||
bytes_written += n as u64;
|
||||
|
||||
db.write_chunk_and_update_size(
|
||||
node_id,
|
||||
chunk_idx,
|
||||
&nonce,
|
||||
&tag,
|
||||
&ciphertext,
|
||||
bytes_written,
|
||||
local_mtime,
|
||||
)?;
|
||||
|
||||
chunk_idx += 1;
|
||||
}
|
||||
|
||||
// Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen
|
||||
db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?;
|
||||
db.update_node_size_and_time(node_id, local_size, local_mtime)?;
|
||||
|
||||
Ok(FileTransferResult::Transferred { size: local_size })
|
||||
}
|
||||
|
||||
/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte (Pull).
|
||||
pub fn sync_single_file_to_host(
|
||||
db: &Database,
|
||||
_vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
version: u32,
|
||||
node: &NodeRecord,
|
||||
local_path: &Path,
|
||||
checksum: bool,
|
||||
dry_run: bool,
|
||||
) -> Result<FileTransferResult> {
|
||||
if local_path.exists() {
|
||||
if let Ok(meta) = fs::metadata(local_path) {
|
||||
let local_size = meta.len();
|
||||
let local_mtime = meta
|
||||
.modified()
|
||||
.unwrap_or(SystemTime::now())
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
if !checksum && local_size == node.size && local_mtime == node.modified_at {
|
||||
return Ok(FileTransferResult::Skipped { size: node.size });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
return Ok(FileTransferResult::DryRunTransferred { size: node.size });
|
||||
}
|
||||
|
||||
if let Some(parent) = local_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut out_file = File::create(local_path)
|
||||
.with_context(|| format!("Konnte Zieldatei '{}' nicht erstellen", local_path.display()))?;
|
||||
|
||||
let total_chunks = if node.size == 0 {
|
||||
0
|
||||
} else {
|
||||
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
|
||||
};
|
||||
|
||||
for idx in 0..total_chunks {
|
||||
if let Some(record) = db.read_chunk(node.id, idx)? {
|
||||
let plaintext = decrypt_chunk(dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, version)?;
|
||||
out_file.write_all(&plaintext)?;
|
||||
} else {
|
||||
bail!("Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", idx, node.name);
|
||||
}
|
||||
}
|
||||
|
||||
out_file.flush()?;
|
||||
set_local_file_mtime(&out_file, node.modified_at);
|
||||
|
||||
Ok(FileTransferResult::Transferred { size: node.size })
|
||||
}
|
||||
|
||||
/// Führt die vollständige Synchronisation zwischen Host und Container aus.
|
||||
pub fn run_sync(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
version: u32,
|
||||
source_arg: &str,
|
||||
target_arg: &str,
|
||||
options: &SyncOptions,
|
||||
) -> Result<SyncStats> {
|
||||
let start_time = Instant::now();
|
||||
let mut stats = SyncStats::default();
|
||||
|
||||
match options.direction {
|
||||
SyncDirection::Push => {
|
||||
sync_push(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
|
||||
}
|
||||
SyncDirection::Pull => {
|
||||
sync_pull(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
|
||||
}
|
||||
}
|
||||
|
||||
stats.elapsed = start_time.elapsed();
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Push: Host -> Tresor
|
||||
fn sync_push(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
version: u32,
|
||||
source_str: &str,
|
||||
target_str: &str,
|
||||
options: &SyncOptions,
|
||||
stats: &mut SyncStats,
|
||||
) -> Result<()> {
|
||||
let local_source = Path::new(source_str);
|
||||
if !local_source.exists() {
|
||||
bail!("Lokale Quelle '{}' existiert nicht.", source_str);
|
||||
}
|
||||
|
||||
let target_vault_dir = if target_str.is_empty() { "/" } else { target_str };
|
||||
|
||||
if local_source.is_file() {
|
||||
let file_name = local_source
|
||||
.file_name()
|
||||
.context("Ungültiger Dateiname")?
|
||||
.to_string_lossy();
|
||||
|
||||
if is_excluded(&file_name, &file_name, &options.exclude_patterns) {
|
||||
if !options.quiet {
|
||||
println!(" {} Ausgeschlossen: {}", ui::dim("[-]"), file_name);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?;
|
||||
stats.files_scanned += 1;
|
||||
|
||||
match sync_single_file_to_vault(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
local_source,
|
||||
parent_node.id,
|
||||
&file_name,
|
||||
options.checksum,
|
||||
options.dry_run,
|
||||
)? {
|
||||
FileTransferResult::Transferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Übertragen: {} ({})", ui::green("[+]"), file_name, ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), file_name, ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
stats.files_skipped += 1;
|
||||
if !options.quiet {
|
||||
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), file_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if local_source.is_dir() {
|
||||
let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?;
|
||||
let mut local_relative_paths = HashSet::new();
|
||||
|
||||
// Rekursiv alle lokalen Dateien und Ordner erfassen
|
||||
collect_and_push_dir(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
local_source,
|
||||
local_source,
|
||||
parent_node.id,
|
||||
options,
|
||||
stats,
|
||||
&mut local_relative_paths,
|
||||
)?;
|
||||
|
||||
// Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen
|
||||
if options.delete {
|
||||
delete_orphans_in_vault(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
parent_node.id,
|
||||
"",
|
||||
&local_relative_paths,
|
||||
options.dry_run,
|
||||
options.quiet,
|
||||
stats,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_and_push_dir(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
version: u32,
|
||||
base_dir: &Path,
|
||||
current_dir: &Path,
|
||||
current_vault_parent_id: i64,
|
||||
options: &SyncOptions,
|
||||
stats: &mut SyncStats,
|
||||
local_relative_paths: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(current_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
let rel_path = path
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
if is_excluded(&file_name, &rel_path, &options.exclude_patterns) {
|
||||
continue;
|
||||
}
|
||||
|
||||
local_relative_paths.insert(rel_path.clone());
|
||||
|
||||
if path.is_dir() {
|
||||
// Ordner im Tresor anlegen falls nötig
|
||||
let children = db.list_children_in_vault(current_vault_parent_id, vault_id, dek)?;
|
||||
let sub_dir_node = match children.into_iter().find(|c| c.name == file_name && c.is_dir) {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
if !options.dry_run {
|
||||
db.create_node_in_vault(vault_id, current_vault_parent_id, &file_name, true, dek)?
|
||||
} else {
|
||||
// Dummy für dry-run
|
||||
NodeRecord {
|
||||
id: -1,
|
||||
parent_id: Some(current_vault_parent_id),
|
||||
name: file_name.clone(),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collect_and_push_dir(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
base_dir,
|
||||
&path,
|
||||
sub_dir_node.id,
|
||||
options,
|
||||
stats,
|
||||
local_relative_paths,
|
||||
)?;
|
||||
} else if path.is_file() {
|
||||
stats.files_scanned += 1;
|
||||
match sync_single_file_to_vault(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
&path,
|
||||
current_vault_parent_id,
|
||||
&file_name,
|
||||
options.checksum,
|
||||
options.dry_run,
|
||||
)? {
|
||||
FileTransferResult::Transferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Übertragen: {} ({})", ui::green("[+]"), rel_path, ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), rel_path, ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
stats.files_skipped += 1;
|
||||
if !options.quiet {
|
||||
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), rel_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_orphans_in_vault(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
current_vault_id: i64,
|
||||
prefix_rel: &str,
|
||||
local_relative_paths: &HashSet<String>,
|
||||
dry_run: bool,
|
||||
quiet: bool,
|
||||
stats: &mut SyncStats,
|
||||
) -> Result<()> {
|
||||
let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?;
|
||||
|
||||
for child in children {
|
||||
let child_rel = if prefix_rel.is_empty() {
|
||||
child.name.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix_rel, child.name)
|
||||
};
|
||||
|
||||
if !local_relative_paths.contains(&child_rel) {
|
||||
stats.files_deleted += 1;
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde aus Tresor löschen: {}", ui::red("[-]"), child_rel);
|
||||
}
|
||||
} else {
|
||||
db.delete_node(child.id)?;
|
||||
if !quiet {
|
||||
println!(" {} Gelöscht aus Tresor: {}", ui::red("[-]"), child_rel);
|
||||
}
|
||||
}
|
||||
} else if child.is_dir {
|
||||
delete_orphans_in_vault(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
child.id,
|
||||
&child_rel,
|
||||
local_relative_paths,
|
||||
dry_run,
|
||||
quiet,
|
||||
stats,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull: Tresor -> Host
|
||||
fn sync_pull(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
version: u32,
|
||||
source_vault_str: &str,
|
||||
target_host_str: &str,
|
||||
options: &SyncOptions,
|
||||
stats: &mut SyncStats,
|
||||
) -> Result<()> {
|
||||
let vault_source_node = db
|
||||
.resolve_path_in_vault(source_vault_str, vault_id, dek)?
|
||||
.with_context(|| format!("Quelle '{}' im Tresor nicht gefunden", source_vault_str))?;
|
||||
|
||||
let local_target_dir = Path::new(target_host_str);
|
||||
|
||||
if !vault_source_node.is_dir {
|
||||
// Einzelne Datei aus Tresor laden
|
||||
let local_file_path = if local_target_dir.is_dir() {
|
||||
local_target_dir.join(&vault_source_node.name)
|
||||
} else {
|
||||
local_target_dir.to_path_buf()
|
||||
};
|
||||
|
||||
stats.files_scanned += 1;
|
||||
match sync_single_file_to_host(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
&vault_source_node,
|
||||
&local_file_path,
|
||||
options.checksum,
|
||||
options.dry_run,
|
||||
)? {
|
||||
FileTransferResult::Transferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), local_file_path.display(), ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), local_file_path.display(), ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
stats.files_skipped += 1;
|
||||
if !options.quiet {
|
||||
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), local_file_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ganzes Verzeichnis aus Tresor laden
|
||||
if !options.dry_run {
|
||||
fs::create_dir_all(local_target_dir)?;
|
||||
}
|
||||
|
||||
let mut vault_relative_paths = HashSet::new();
|
||||
|
||||
collect_and_pull_dir(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
vault_source_node.id,
|
||||
"",
|
||||
local_target_dir,
|
||||
options,
|
||||
stats,
|
||||
&mut vault_relative_paths,
|
||||
)?;
|
||||
|
||||
// Spiegelung mit --delete: Lokale verwaiste Dateien entfernen
|
||||
if options.delete && local_target_dir.exists() {
|
||||
delete_orphans_on_host(
|
||||
local_target_dir,
|
||||
local_target_dir,
|
||||
&vault_relative_paths,
|
||||
options.dry_run,
|
||||
options.quiet,
|
||||
stats,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_and_pull_dir(
|
||||
db: &Database,
|
||||
vault_id: u32,
|
||||
dek: &[u8; 32],
|
||||
version: u32,
|
||||
vault_node_id: i64,
|
||||
rel_prefix: &str,
|
||||
local_target_base: &Path,
|
||||
options: &SyncOptions,
|
||||
stats: &mut SyncStats,
|
||||
vault_relative_paths: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
|
||||
|
||||
for child in children {
|
||||
let child_rel = if rel_prefix.is_empty() {
|
||||
child.name.clone()
|
||||
} else {
|
||||
format!("{}/{}", rel_prefix, child.name)
|
||||
};
|
||||
|
||||
if is_excluded(&child.name, &child_rel, &options.exclude_patterns) {
|
||||
continue;
|
||||
}
|
||||
|
||||
vault_relative_paths.insert(child_rel.clone());
|
||||
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
|
||||
|
||||
if child.is_dir {
|
||||
if !options.dry_run {
|
||||
fs::create_dir_all(&local_child_path)?;
|
||||
}
|
||||
collect_and_pull_dir(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
child.id,
|
||||
&child_rel,
|
||||
local_target_base,
|
||||
options,
|
||||
stats,
|
||||
vault_relative_paths,
|
||||
)?;
|
||||
} else {
|
||||
stats.files_scanned += 1;
|
||||
match sync_single_file_to_host(
|
||||
db,
|
||||
vault_id,
|
||||
dek,
|
||||
version,
|
||||
&child,
|
||||
&local_child_path,
|
||||
options.checksum,
|
||||
options.dry_run,
|
||||
)? {
|
||||
FileTransferResult::Transferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), child_rel, ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), child_rel, ui::format_bytes(size));
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
stats.files_skipped += 1;
|
||||
if !options.quiet {
|
||||
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), child_rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_orphans_on_host(
|
||||
base_dir: &Path,
|
||||
current_dir: &Path,
|
||||
vault_relative_paths: &HashSet<String>,
|
||||
dry_run: bool,
|
||||
quiet: bool,
|
||||
stats: &mut SyncStats,
|
||||
) -> Result<()> {
|
||||
if !current_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(current_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let rel_path = path
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
if !vault_relative_paths.contains(&rel_path) {
|
||||
stats.files_deleted += 1;
|
||||
if path.is_dir() {
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde lokalen Ordner löschen: {}", ui::red("[-]"), rel_path);
|
||||
}
|
||||
} else {
|
||||
fs::remove_dir_all(&path)?;
|
||||
if !quiet {
|
||||
println!(" {} Lokalen Ordner gelöscht: {}", ui::red("[-]"), rel_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde lokale Datei löschen: {}", ui::red("[-]"), rel_path);
|
||||
}
|
||||
} else {
|
||||
fs::remove_file(&path)?;
|
||||
if !quiet {
|
||||
println!(" {} Lokale Datei gelöscht: {}", ui::red("[-]"), rel_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
delete_orphans_on_host(base_dir, &path, vault_relative_paths, dry_run, quiet, stats)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_excluded_patterns() {
|
||||
let patterns = vec![
|
||||
"*.tmp".to_string(),
|
||||
"*.crdownload".to_string(),
|
||||
"Thumbs.db".to_string(),
|
||||
"backup_*".to_string(),
|
||||
"/logs".to_string(),
|
||||
];
|
||||
|
||||
assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns));
|
||||
assert!(is_excluded("download.crdownload", "download.crdownload", &patterns));
|
||||
assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns));
|
||||
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns));
|
||||
assert!(!is_excluded("important.doc", "sub/important.doc", &patterns));
|
||||
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,27 @@ pub fn magenta(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatiert eine Byte-Anzahl lesbar in B, KB, MB, GB oder TB.
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
const TB: f64 = GB * 1024.0;
|
||||
|
||||
let b = bytes as f64;
|
||||
if b >= TB {
|
||||
format!("{:.2} TB", b / TB)
|
||||
} else if b >= GB {
|
||||
format!("{:.2} GB", b / GB)
|
||||
} else if b >= MB {
|
||||
format!("{:.2} MB", b / MB)
|
||||
} else if b >= KB {
|
||||
format!("{:.2} KB", b / KB)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...`
|
||||
pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
|
||||
let tag = cyan(&format!("[{}/{}]", num, total));
|
||||
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::ui;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub const TARGET_ASSET_NAME: &str = "sanctum.exe";
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub const TARGET_ASSET_NAME: &str = "sanctum";
|
||||
|
||||
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
|
||||
|
||||
/// Repräsentiert ein Asset in einem Gitea-Release
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ReleaseAsset {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
/// Repräsentiert ein Release von der Gitea Releases-API
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct GiteaRelease {
|
||||
pub id: u64,
|
||||
pub tag_name: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub html_url: String,
|
||||
pub published_at: Option<String>,
|
||||
pub assets: Vec<ReleaseAsset>,
|
||||
}
|
||||
|
||||
/// Optionen für das Upgrade
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpgradeOptions {
|
||||
pub check_only: bool,
|
||||
pub yes: bool,
|
||||
pub force: bool,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl Default for UpgradeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_only: false,
|
||||
yes: false,
|
||||
force: false,
|
||||
base_url: "https://gitea.pansi.eu".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Erkannter Paketmanager
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PackageManager {
|
||||
Scoop,
|
||||
Winget,
|
||||
}
|
||||
|
||||
impl PackageManager {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
PackageManager::Scoop => "Scoop",
|
||||
PackageManager::Winget => "Winget",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upgrade_command(&self) -> &'static str {
|
||||
match self {
|
||||
PackageManager::Scoop => "scoop update sanctum",
|
||||
PackageManager::Winget => "winget upgrade HaraldPansi.Sanctum",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prüft, ob die laufende Binary durch einen Paketmanager (Scoop oder Winget) verwaltet wird.
|
||||
pub fn detect_package_manager() -> Option<PackageManager> {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
detect_package_manager_from_path(&exe_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hilfsfunktion für die Paketmanager-Erkennung anhand eines Pfads (für Tests isolierbar).
|
||||
pub fn detect_package_manager_from_path(path: &Path) -> Option<PackageManager> {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
if s.contains("scoop\\apps\\sanctum")
|
||||
|| s.contains("scoop/apps/sanctum")
|
||||
|| s.contains("scoop\\shims")
|
||||
|| s.contains("scoop/shims")
|
||||
{
|
||||
return Some(PackageManager::Scoop);
|
||||
}
|
||||
if s.contains("winget\\packages")
|
||||
|| s.contains("winget/packages")
|
||||
|| s.contains("winget\\links")
|
||||
|| s.contains("winget/links")
|
||||
{
|
||||
return Some(PackageManager::Winget);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ruft das neueste Release von der Gitea Releases-API ab.
|
||||
pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base);
|
||||
|
||||
let resp = ureq::get(&api_url)
|
||||
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
|
||||
.set("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
.with_context(|| format!("Netzwerkfehler beim Abrufen der Releases von '{}'", api_url))?;
|
||||
|
||||
let release: GiteaRelease = resp
|
||||
.into_json()
|
||||
.context("Ungültiges JSON-Format von der Gitea Releases-API empfangen")?;
|
||||
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// Lädt eine Datei von einer URL als Text herunter.
|
||||
pub fn fetch_text(url: &str) -> Result<String> {
|
||||
let resp = ureq::get(url)
|
||||
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
|
||||
|
||||
let text = resp
|
||||
.into_string()
|
||||
.with_context(|| format!("Fehler beim Lesen des Texts von '{}'", url))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Extrahiert die SHA-256 Prüfsumme für ein bestimmtes Asset aus dem Inhalt von `SHA256SUMS.txt`.
|
||||
pub fn parse_checksum_for_asset(sha256sums_content: &str, target_asset: &str) -> Option<String> {
|
||||
for line in sha256sums_content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let hash = parts[0];
|
||||
let filename = parts[1].trim_start_matches('*');
|
||||
if filename.eq_ignore_ascii_case(target_asset) && hash.len() == 64 {
|
||||
return Some(hash.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Bereinigt einen Versionsstring von führenden 'v' / 'V' Zeichen und parst ihn als SemVer.
|
||||
pub fn parse_clean_version(ver_str: &str) -> Result<Version> {
|
||||
let clean = ver_str.trim().trim_start_matches(|c| c == 'v' || c == 'V');
|
||||
Version::parse(clean).with_context(|| format!("Ungültiges SemVer-Format: '{}'", ver_str))
|
||||
}
|
||||
|
||||
/// Vergleicht die aktuelle Version mit der entfernten Version.
|
||||
/// Gibt `true` zurück, wenn `remote > current`.
|
||||
pub fn is_newer_version(current: &str, remote: &str) -> bool {
|
||||
let cur = match parse_clean_version(current) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let rem = match parse_clean_version(remote) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
rem > cur
|
||||
}
|
||||
|
||||
/// Lädt das Ziel-Binary in eine temporäre Datei herunter und validiert dabei den SHA-256 Hash.
|
||||
pub fn download_and_verify_binary(
|
||||
download_url: &str,
|
||||
expected_hash: &str,
|
||||
expected_size: u64,
|
||||
) -> Result<PathBuf> {
|
||||
let resp = ureq::get(download_url)
|
||||
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(120))
|
||||
.call()
|
||||
.with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?;
|
||||
|
||||
let mut reader = resp.into_reader();
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_file_path = temp_dir.join(format!(
|
||||
"sanctum_update_{}_{}",
|
||||
std::process::id(),
|
||||
TARGET_ASSET_NAME
|
||||
));
|
||||
|
||||
let mut temp_file = File::create(&temp_file_path).with_context(|| {
|
||||
format!(
|
||||
"Konnte temporäre Update-Datei '{}' nicht erstellen",
|
||||
temp_file_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer
|
||||
let mut downloaded_bytes = 0u64;
|
||||
|
||||
println!(" • Herunterladen von: {}", download_url);
|
||||
print!(" • Fortschritt: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
loop {
|
||||
let n = reader
|
||||
.read(&mut buffer)
|
||||
.context("Fehler beim Lesen des Download-Datenstroms")?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
temp_file
|
||||
.write_all(&buffer[..n])
|
||||
.context("Fehler beim Schreiben in die temporäre Datei")?;
|
||||
hasher.update(&buffer[..n]);
|
||||
|
||||
downloaded_bytes += n as u64;
|
||||
|
||||
if expected_size > 0 {
|
||||
let percent = (downloaded_bytes as f64 / expected_size as f64 * 100.0).min(100.0);
|
||||
print!(
|
||||
"\r • Fortschritt: {} / {} ({:.1}%)",
|
||||
ui::format_bytes(downloaded_bytes),
|
||||
ui::format_bytes(expected_size),
|
||||
percent
|
||||
);
|
||||
} else {
|
||||
print!(
|
||||
"\r • Fortschritt: {}",
|
||||
ui::format_bytes(downloaded_bytes)
|
||||
);
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
println!();
|
||||
|
||||
temp_file
|
||||
.flush()
|
||||
.context("Fehler beim Flush der temporären Datei")?;
|
||||
drop(temp_file);
|
||||
|
||||
// SHA-256 Integritätsprüfung
|
||||
let calculated_hash = hex::encode(hasher.finalize()).to_lowercase();
|
||||
let expected_lower = expected_hash.to_lowercase();
|
||||
|
||||
if calculated_hash != expected_lower {
|
||||
let _ = fs::remove_file(&temp_file_path);
|
||||
bail!(
|
||||
"Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.",
|
||||
expected_lower,
|
||||
calculated_hash
|
||||
);
|
||||
}
|
||||
|
||||
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
|
||||
|
||||
Ok(temp_file_path)
|
||||
}
|
||||
|
||||
/// Führt den In-Place-Austausch des laufenden Binaries durch.
|
||||
pub fn apply_upgrade(temp_binary: &Path) -> Result<PathBuf> {
|
||||
let current_exe = std::env::current_exe()
|
||||
.context("Konnte den Pfad der aktuell ausgeführten Datei nicht ermitteln")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(temp_binary)
|
||||
.context("Konnte Metadaten der Update-Datei nicht lesen")?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(temp_binary, perms)
|
||||
.context("Konnte Ausführungsrechte (chmod +x / 0755) nicht setzen")?;
|
||||
}
|
||||
|
||||
self_replace::self_replace(temp_binary)
|
||||
.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
|
||||
|
||||
let _ = fs::remove_file(temp_binary);
|
||||
|
||||
Ok(current_exe)
|
||||
}
|
||||
|
||||
/// Führt den gesamten Upgrade-Workflow aus.
|
||||
pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Aktuelle Version: {}", ui::cyan(&format!("v{}", current_version)));
|
||||
println!(" • Server: {}", options.base_url);
|
||||
println!();
|
||||
|
||||
// 1. Paketmanager-Prüfung
|
||||
if let Some(pm) = detect_package_manager() {
|
||||
println!(
|
||||
" {} Sanctum wird anscheinend über {} verwaltet!",
|
||||
ui::yellow("[!]"),
|
||||
pm.name()
|
||||
);
|
||||
println!(
|
||||
" Empfohlener Aktualisierungsbefehl: {}",
|
||||
ui::cyan(pm.upgrade_command())
|
||||
);
|
||||
println!();
|
||||
|
||||
if !options.force && !options.yes && !options.check_only {
|
||||
print!(" Möchten Sie das direkte In-Place-Upgrade trotzdem fortsetzen? [j/N]: ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Fehler beim Einlesen der Benutzereingabe")?;
|
||||
let trimmed = answer.trim().to_lowercase();
|
||||
if trimmed != "j" && trimmed != "ja" && trimmed != "y" && trimmed != "yes" {
|
||||
println!(" Upgrade abgebrochen.");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Neuestes Release abfragen
|
||||
println!(" {} Suche nach neuesten Releases auf Gitea ...", ui::dim("[-]"));
|
||||
let release = fetch_latest_release(&options.base_url)?;
|
||||
let remote_tag = &release.tag_name;
|
||||
let is_newer = is_newer_version(current_version, remote_tag);
|
||||
|
||||
println!(" • Neueste Version: {}", if is_newer { ui::green(remote_tag) } else { ui::cyan(remote_tag) });
|
||||
println!(" • Release-Name: {}", release.name);
|
||||
if let Some(ref pub_at) = release.published_at {
|
||||
println!(" • Veröffentlicht: {}", pub_at);
|
||||
}
|
||||
println!(" • Release-URL: {}", release.html_url);
|
||||
println!();
|
||||
|
||||
if !is_newer && !options.force {
|
||||
println!(
|
||||
" {} Sie verwenden bereits die neueste Version von Sanctum.",
|
||||
ui::green("✔")
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if options.check_only {
|
||||
if is_newer {
|
||||
println!(
|
||||
" {} Ein Update auf {} ist verfügbar! Führen Sie 'sanctum upgrade' aus.",
|
||||
ui::yellow("[!]"),
|
||||
remote_tag
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Ziel-Asset und Checksumme identifizieren
|
||||
let target_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(TARGET_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde kein passendes Binary für Ihr Betriebssystem ('{}') gefunden!",
|
||||
remote_tag,
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_ASSET_NAME))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Im Release '{}' wurde keine Prüfsummendatei ('{}') gefunden!",
|
||||
remote_tag,
|
||||
CHECKSUM_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
// 4. Prüfsummen herunterladen und extrahieren
|
||||
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
|
||||
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
|
||||
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
|
||||
if !options.yes {
|
||||
println!(" • Ziel-Binary: {} ({})", target_asset.name, ui::format_bytes(target_asset.size));
|
||||
println!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
|
||||
println!();
|
||||
print!(
|
||||
" Möchten Sie Sanctum jetzt von v{} auf {} aktualisieren? [J/n]: ",
|
||||
current_version, remote_tag
|
||||
);
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Fehler beim Einlesen der Benutzereingabe")?;
|
||||
let trimmed = answer.trim().to_lowercase();
|
||||
if trimmed == "n" || trimmed == "nein" || trimmed == "no" {
|
||||
println!(" Upgrade abgebrochen.");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// 6. Download & Verifikation
|
||||
println!(" {} Lade neues Binary herunter ...", ui::dim("[-]"));
|
||||
let temp_file = download_and_verify_binary(
|
||||
&target_asset.browser_download_url,
|
||||
&expected_hash,
|
||||
target_asset.size,
|
||||
)?;
|
||||
|
||||
// 7. In-Place-Austausch
|
||||
println!(" {} Führe In-Place-Austausch durch ...", ui::dim("[-]"));
|
||||
let updated_exe = apply_upgrade(&temp_file)?;
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag);
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Pfad: {}", updated_exe.display());
|
||||
println!(" • Neue Version: {}", ui::green(remote_tag));
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+133
-17
@@ -1,5 +1,6 @@
|
||||
use std::process::Command;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D').
|
||||
@@ -32,14 +33,68 @@ pub fn find_next_available_drive() -> Result<char> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet das eingebundene Netzlaufwerk direkt im Windows Explorer.
|
||||
/// Öffnet das eingebundene Netzlaufwerk oder Verzeichnis direkt im systemeigenen Dateimanager
|
||||
/// (Windows: Explorer, macOS: open, Linux: xdg-open).
|
||||
pub fn open_in_explorer(drive_char: char) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let drive_path = format!("{}:\\", drive_char.to_ascii_uppercase());
|
||||
Command::new("explorer.exe")
|
||||
.arg(&drive_path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = drive_char;
|
||||
let _ = Command::new("open").arg(".").spawn();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let _ = drive_char;
|
||||
let _ = Command::new("xdg-open").arg(".").spawn();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = drive_char;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet einen beliebigen Pfad im nativen Dateimanager der Plattform.
|
||||
pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Command::new("explorer.exe")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Benachrichtigt die Windows-Shell (Explorer) über geänderte Dateiverknüpfungen (SHCNE_ASSOCCHANGED).
|
||||
@@ -67,10 +122,19 @@ pub fn notify_shell_associations_changed() {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let _ = Command::new("update-desktop-database").spawn();
|
||||
}
|
||||
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer (HKCU, 100% Userland, keine Adminrechte).
|
||||
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer bzw. unter Linux via Freedesktop.
|
||||
pub fn register_explorer_integration() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let current_exe = std::env::current_exe()
|
||||
.context("Konnte den Pfad zur aktuellen sanctum.exe nicht ermitteln")?;
|
||||
let exe_str = current_exe.display().to_string();
|
||||
@@ -98,14 +162,14 @@ pub fn register_explorer_integration() -> Result<()> {
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\open",
|
||||
"",
|
||||
"Als Laufwerk einbinden",
|
||||
"In Sanctum öffnen",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\open\command",
|
||||
"",
|
||||
&format!("\"{exe_str}\" mount --path \"%1\""),
|
||||
&format!("\"{exe_str}\" mount \"%1\""),
|
||||
),
|
||||
// 5. Kontextmenü-Aktion: Integritätsprüfung
|
||||
// 5. Kontextmenü-Aktion: Verify / FSCK
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\verify",
|
||||
"",
|
||||
@@ -114,18 +178,18 @@ pub fn register_explorer_integration() -> Result<()> {
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\verify\command",
|
||||
"",
|
||||
&format!("\"{exe_str}\" verify --path \"%1\""),
|
||||
&format!("cmd /k \"\"{exe_str}\" verify \"%1\"\""),
|
||||
),
|
||||
// 6. Kontextmenü-Aktion: Header sichern
|
||||
// 6. Kontextmenü-Aktion: Header-Backup
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header",
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup",
|
||||
"",
|
||||
"Header sichern",
|
||||
"Header sichern (Disaster Recovery)",
|
||||
),
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup_header\command",
|
||||
r"HKCU\Software\Classes\Sanctum.Container\shell\backup\command",
|
||||
"",
|
||||
&format!("\"{exe_str}\" backup-header --path \"%1\""),
|
||||
&format!("cmd /k \"\"{exe_str}\" backup-header \"%1\"\""),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -148,10 +212,32 @@ pub fn register_explorer_integration() -> Result<()> {
|
||||
|
||||
notify_shell_associations_changed();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home_path = std::path::PathBuf::from(home);
|
||||
let apps_dir = home_path.join(".local/share/applications");
|
||||
let mime_dir = home_path.join(".local/share/mime/packages");
|
||||
let _ = std::fs::create_dir_all(&apps_dir);
|
||||
let _ = std::fs::create_dir_all(&mime_dir);
|
||||
|
||||
let desktop_content = "[Desktop Entry]\nType=Application\nName=Sanctum\nComment=Verschlüsselter Ein-Datei-Container\nExec=sanctum mount %f\nIcon=security-high\nTerminal=true\nMimeType=application/x-sanctum;\nCategories=Utility;Security;\n";
|
||||
let _ = std::fs::write(apps_dir.join("sanctum.desktop"), desktop_content);
|
||||
|
||||
let mime_content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">\n <mime-type type=\"application/x-sanctum\">\n <comment>Sanctum Verschlüsselter Container</comment>\n <glob pattern=\"*.sanctum\"/>\n </mime-type>\n</mime-info>\n";
|
||||
let _ = std::fs::write(mime_dir.join("application-x-sanctum.xml"), mime_content);
|
||||
|
||||
notify_shell_associations_changed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU).
|
||||
/// Entfernt die Windows-Explorer-Verknüpfungen aus der Benutzer-Registry (HKCU) bzw. unter Linux aus Freedesktop.
|
||||
pub fn unregister_explorer_integration() -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let keys_to_delete = [
|
||||
r"HKCU\Software\Classes\.sanctum",
|
||||
r"HKCU\Software\Classes\Sanctum.Container",
|
||||
@@ -165,6 +251,17 @@ pub fn unregister_explorer_integration() -> Result<()> {
|
||||
|
||||
notify_shell_associations_changed();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home_path = std::path::PathBuf::from(home);
|
||||
let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
|
||||
let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml"));
|
||||
notify_shell_associations_changed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt das Windows-Sicherheitsschild-Icon (IDI_SHIELD) oder Anwendungs-Icon für den System-Tray.
|
||||
@@ -198,10 +295,9 @@ pub fn get_default_system_icon() -> Option<tray_item::IconSource> {
|
||||
|
||||
/// Guard zur Verwaltung des Hintergrundthreads für die Windows-Sitzungssperre.
|
||||
/// Beim Droppen wird das Win32-Nachrichtenfenster geschlossen und der Thread sauber beendet.
|
||||
#[cfg(windows)]
|
||||
pub struct SessionLockGuard {
|
||||
#[cfg(windows)]
|
||||
hwnd: isize,
|
||||
#[cfg(windows)]
|
||||
join_handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -527,7 +623,7 @@ pub fn start_console_ctrl_monitor(
|
||||
}
|
||||
}
|
||||
|
||||
/// Verriegelt einen Speicherbereich im physischen RAM (verhindert Paging in pagefile.sys / swapfile.sys).
|
||||
/// Verriegelt einen Speicherbereich im physischen RAM (verhindert Paging in pagefile.sys / swapfile.sys unter Windows bzw. Swap unter Linux/macOS).
|
||||
pub fn lock_memory(ptr: *const u8, len: usize) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -539,7 +635,17 @@ pub fn lock_memory(ptr: *const u8, len: usize) -> bool {
|
||||
}
|
||||
unsafe { VirtualLock(ptr as *const std::ffi::c_void, len) != 0 }
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(unix)]
|
||||
{
|
||||
extern "C" {
|
||||
fn mlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
|
||||
}
|
||||
if ptr.is_null() || len == 0 {
|
||||
return false;
|
||||
}
|
||||
unsafe { mlock(ptr as *const std::ffi::c_void, len) == 0 }
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = (ptr, len);
|
||||
false
|
||||
@@ -558,7 +664,17 @@ pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
|
||||
}
|
||||
unsafe { VirtualUnlock(ptr as *const std::ffi::c_void, len) != 0 }
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(unix)]
|
||||
{
|
||||
extern "C" {
|
||||
fn munlock(addr: *const std::ffi::c_void, len: usize) -> std::ffi::c_int;
|
||||
}
|
||||
if ptr.is_null() || len == 0 {
|
||||
return false;
|
||||
}
|
||||
unsafe { munlock(ptr as *const std::ffi::c_void, len) == 0 }
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
let _ = (ptr, len);
|
||||
false
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use dav_server::{
|
||||
davpath::DavPath,
|
||||
fs::{DavFileSystem, OpenOptions},
|
||||
};
|
||||
use rand::RngCore;
|
||||
use sanctum::{
|
||||
crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION},
|
||||
storage::Database,
|
||||
verify::verify_container,
|
||||
vfs::SanctumFs,
|
||||
};
|
||||
|
||||
/// Live-Crash- und Stresstest: Simuliert harten Verbindungsabbruch und Power-Cut
|
||||
/// während intensiver paralleler Schreibvorgänge im VFS.
|
||||
#[tokio::test]
|
||||
async fn test_live_crash_and_recovery_stress() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let container_path: PathBuf = temp_dir.join(format!("sanctum_live_stress_{}.sanctum", std::process::id()));
|
||||
if container_path.exists() {
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
let password = "LiveStressPassword2026!";
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("DEK wrapping");
|
||||
|
||||
// 1. Initialisierung des Containers
|
||||
{
|
||||
let db = Database::open(&container_path).expect("Open database");
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||
.expect("Init schema");
|
||||
db.checkpoint().expect("Initial Checkpoint");
|
||||
}
|
||||
|
||||
// 2. Parallele Schreiblast mit SanctumFs erzeugen
|
||||
let stop_signal = Arc::new(AtomicBool::new(false));
|
||||
let db = Database::open(&container_path).expect("Open database for VFS");
|
||||
let fs = SanctumFs::new(db, dek.clone(), FORMAT_VERSION);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Spawn 4 parallele Schreiber
|
||||
for worker_id in 0..4 {
|
||||
let fs_clone = fs.clone();
|
||||
let stop_clone = stop_signal.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut file_idx = 0;
|
||||
while !stop_clone.load(Ordering::Relaxed) && file_idx < 10 {
|
||||
let file_path_str = format!("/worker_{}_file_{}.dat", worker_id, file_idx);
|
||||
let dav_path = DavPath::new(&file_path_str).unwrap();
|
||||
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create = true;
|
||||
opts.truncate = true;
|
||||
|
||||
// Datei anlegen
|
||||
let mut file = match fs_clone.open(&dav_path, opts).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
// Mehrere 256-KB Blöcke schreiben (über mehrere Chunks hinweg)
|
||||
let mut payload = vec![0u8; 256 * 1024];
|
||||
rand::thread_rng().fill_bytes(&mut payload);
|
||||
|
||||
for _ in 0..6 {
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let _ = file.write_bytes(Bytes::copy_from_slice(&payload)).await;
|
||||
}
|
||||
let _ = file.flush().await;
|
||||
file_idx += 1;
|
||||
}
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Lass die Worker 500ms unter Volllast schreiben
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// 3. Simuliere abrupten Prozessabbruch (Hard Kill / Power Cut)
|
||||
// Wir brechen die Worker hart ab (Cancel) und verwerfen das FS-Handle ohne sauberen Unmount
|
||||
stop_signal.store(true, Ordering::SeqCst);
|
||||
for h in handles {
|
||||
h.abort(); // Simuliert Kill
|
||||
}
|
||||
|
||||
// FS ohne Checkpoint/Drop-Finalisierung freigeben
|
||||
drop(fs);
|
||||
|
||||
// 4. Recovery & Integritätsprüfung nach Crash
|
||||
// Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten
|
||||
let verify_result = verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
|
||||
assert!(
|
||||
verify_result.is_healthy(),
|
||||
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
|
||||
verify_result.errors
|
||||
);
|
||||
assert_eq!(verify_result.corrupted_chunks, 0, "Keine korrupten Chunks erlaubt");
|
||||
|
||||
// 5. Konsistentes Weiterarbeiten nach dem Absturz
|
||||
let db_recovered = Database::open(&container_path).expect("Open database after crash");
|
||||
let fs_recovered = SanctumFs::new(db_recovered, dek.clone(), FORMAT_VERSION);
|
||||
|
||||
// Neue Datei im wiederhergestellten Dateisystem anlegen und lesen
|
||||
let recovery_test_path = DavPath::new("/post_crash_verification.txt").unwrap();
|
||||
{
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.write = true;
|
||||
opts.create = true;
|
||||
opts.truncate = true;
|
||||
let mut file = fs_recovered
|
||||
.open(&recovery_test_path, opts)
|
||||
.await
|
||||
.expect("Create post-crash file");
|
||||
file.write_bytes(Bytes::from_static(b"Sanctum Crash Consistency Verified!"))
|
||||
.await
|
||||
.expect("Write post crash file");
|
||||
file.flush().await.expect("Flush post crash file");
|
||||
}
|
||||
|
||||
// Datei wieder einlesen
|
||||
{
|
||||
let mut opts = OpenOptions::default();
|
||||
opts.read = true;
|
||||
let mut file = fs_recovered
|
||||
.open(&recovery_test_path, opts)
|
||||
.await
|
||||
.expect("Read post-crash file");
|
||||
let bytes = file.read_bytes(1024).await.expect("Read bytes");
|
||||
assert_eq!(&bytes[..], b"Sanctum Crash Consistency Verified!");
|
||||
}
|
||||
|
||||
// Sauberes Aufräumen der Testdatei
|
||||
drop(fs_recovered);
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use sanctum::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION,
|
||||
};
|
||||
use sanctum::storage::Database;
|
||||
use sanctum::sync::{run_sync, SyncDirection, SyncOptions};
|
||||
|
||||
fn create_test_container(path: &Path) -> (Database, [u8; 32]) {
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
let db = Database::open(path).expect("Open database");
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: 1024,
|
||||
time_cost: 1,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
|
||||
(db, *dek)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_push_and_pull_basic() {
|
||||
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::<u64>()));
|
||||
let container_path = temp_root.join("test.sanctum");
|
||||
let source_dir = temp_root.join("source");
|
||||
let target_dir = temp_root.join("restored");
|
||||
|
||||
fs::create_dir_all(&source_dir).unwrap();
|
||||
fs::create_dir_all(source_dir.join("sub")).unwrap();
|
||||
|
||||
// Testdateien anlegen
|
||||
fs::write(source_dir.join("file1.txt"), b"Hello Sanctum Sync!").unwrap();
|
||||
fs::write(source_dir.join("sub").join("file2.bin"), vec![0x42u8; 100_000]).unwrap();
|
||||
|
||||
let (db, dek) = create_test_container(&container_path);
|
||||
|
||||
// 1. Push
|
||||
let mut opts = SyncOptions::default();
|
||||
opts.direction = SyncDirection::Push;
|
||||
opts.quiet = true;
|
||||
|
||||
let stats = run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
source_dir.to_str().unwrap(),
|
||||
"/Backup",
|
||||
&opts,
|
||||
).expect("Sync push");
|
||||
|
||||
assert_eq!(stats.files_scanned, 2);
|
||||
assert_eq!(stats.files_transferred, 2);
|
||||
assert_eq!(stats.files_skipped, 0);
|
||||
|
||||
// 2. Erneuter Push (Fast check / Delta): Muss 0 übertragen, 2 überspringen
|
||||
let stats_delta = run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
source_dir.to_str().unwrap(),
|
||||
"/Backup",
|
||||
&opts,
|
||||
).expect("Sync push delta");
|
||||
|
||||
assert_eq!(stats_delta.files_transferred, 0);
|
||||
assert_eq!(stats_delta.files_skipped, 2);
|
||||
|
||||
// 3. Dry-Run mit neuer Datei
|
||||
fs::write(source_dir.join("new_file.txt"), b"Brand new file").unwrap();
|
||||
let mut dry_opts = opts.clone();
|
||||
dry_opts.dry_run = true;
|
||||
|
||||
let stats_dry = run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
source_dir.to_str().unwrap(),
|
||||
"/Backup",
|
||||
&dry_opts,
|
||||
).expect("Sync push dry-run");
|
||||
|
||||
assert_eq!(stats_dry.files_transferred, 1);
|
||||
assert_eq!(stats_dry.files_skipped, 2);
|
||||
|
||||
// Verifizieren, dass new_file.txt im Tresor tatsächlich NICHT existiert
|
||||
let node = db.resolve_path_in_vault("/Backup/new_file.txt", 0, &dek).unwrap();
|
||||
assert!(node.is_none());
|
||||
|
||||
// 4. Pull
|
||||
let mut pull_opts = SyncOptions::default();
|
||||
pull_opts.direction = SyncDirection::Pull;
|
||||
pull_opts.quiet = true;
|
||||
|
||||
let stats_pull = run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
"/Backup",
|
||||
target_dir.to_str().unwrap(),
|
||||
&pull_opts,
|
||||
).expect("Sync pull");
|
||||
|
||||
assert_eq!(stats_pull.files_transferred, 2);
|
||||
|
||||
// Inhalt vergleichen
|
||||
let c1 = fs::read(target_dir.join("file1.txt")).unwrap();
|
||||
assert_eq!(c1, b"Hello Sanctum Sync!");
|
||||
let c2 = fs::read(target_dir.join("sub").join("file2.bin")).unwrap();
|
||||
assert_eq!(c2, vec![0x42u8; 100_000]);
|
||||
|
||||
// Aufräumen
|
||||
let _ = fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_multi_megabyte_large_file() {
|
||||
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::<u64>()));
|
||||
let container_path = temp_root.join("test_large.sanctum");
|
||||
let source_dir = temp_root.join("source");
|
||||
let target_dir = temp_root.join("restored");
|
||||
|
||||
fs::create_dir_all(&source_dir).unwrap();
|
||||
|
||||
// 3.5 MB große Datei erzeugen (geht über 4 Chunks: 0, 1, 2, 3)
|
||||
let large_file_path = source_dir.join("large_payload.bin");
|
||||
let mut large_file = File::create(&large_file_path).unwrap();
|
||||
let chunk_sample = vec![0xA5u8; CHUNK_SIZE];
|
||||
for _ in 0..3 {
|
||||
large_file.write_all(&chunk_sample).unwrap();
|
||||
}
|
||||
large_file.write_all(&vec![0x5Au8; 512 * 1024]).unwrap(); // 3.5 MB
|
||||
large_file.flush().unwrap();
|
||||
drop(large_file);
|
||||
|
||||
let (db, dek) = create_test_container(&container_path);
|
||||
|
||||
// Push
|
||||
let mut opts = SyncOptions::default();
|
||||
opts.direction = SyncDirection::Push;
|
||||
opts.quiet = true;
|
||||
|
||||
let stats = run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
source_dir.to_str().unwrap(),
|
||||
"/LargeTest",
|
||||
&opts,
|
||||
).expect("Sync push large");
|
||||
|
||||
assert_eq!(stats.files_transferred, 1);
|
||||
assert_eq!(stats.bytes_transferred, 3 * (CHUNK_SIZE as u64) + 512 * 1024);
|
||||
|
||||
// Pull
|
||||
let mut pull_opts = SyncOptions::default();
|
||||
pull_opts.direction = SyncDirection::Pull;
|
||||
pull_opts.quiet = true;
|
||||
|
||||
run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
"/LargeTest",
|
||||
target_dir.to_str().unwrap(),
|
||||
&pull_opts,
|
||||
).expect("Sync pull large");
|
||||
|
||||
let restored = fs::read(target_dir.join("large_payload.bin")).unwrap();
|
||||
let original = fs::read(&large_file_path).unwrap();
|
||||
assert_eq!(restored.len(), original.len());
|
||||
assert_eq!(restored, original);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_delete_and_exclude_flags() {
|
||||
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::<u64>()));
|
||||
let container_path = temp_root.join("test_flags.sanctum");
|
||||
let source_dir = temp_root.join("source");
|
||||
|
||||
fs::create_dir_all(&source_dir).unwrap();
|
||||
fs::write(source_dir.join("keep.txt"), b"Keep this").unwrap();
|
||||
fs::write(source_dir.join("remove_me.txt"), b"Will be deleted later").unwrap();
|
||||
fs::write(source_dir.join("ignore.tmp"), b"Temporary file").unwrap();
|
||||
|
||||
let (db, dek) = create_test_container(&container_path);
|
||||
|
||||
let mut opts = SyncOptions::default();
|
||||
opts.direction = SyncDirection::Push;
|
||||
opts.exclude_patterns = vec!["*.tmp".to_string()];
|
||||
opts.quiet = true;
|
||||
|
||||
// 1. Initialer Push mit Exclude
|
||||
let stats = run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
source_dir.to_str().unwrap(),
|
||||
"/Files",
|
||||
&opts,
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(stats.files_transferred, 2); // keep.txt und remove_me.txt
|
||||
assert!(db.resolve_path_in_vault("/Files/ignore.tmp", 0, &dek).unwrap().is_none());
|
||||
|
||||
// 2. Lokale Datei löschen und Sync mit --delete ausführen
|
||||
fs::remove_file(source_dir.join("remove_me.txt")).unwrap();
|
||||
opts.delete = true;
|
||||
|
||||
let stats_del = run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek,
|
||||
FORMAT_VERSION,
|
||||
source_dir.to_str().unwrap(),
|
||||
"/Files",
|
||||
&opts,
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(stats_del.files_deleted, 1);
|
||||
assert!(db.resolve_path_in_vault("/Files/remove_me.txt", 0, &dek).unwrap().is_none());
|
||||
assert!(db.resolve_path_in_vault("/Files/keep.txt", 0, &dek).unwrap().is_some());
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use sanctum::upgrade::{
|
||||
detect_package_manager_from_path, is_newer_version, parse_checksum_for_asset,
|
||||
parse_clean_version, GiteaRelease, PackageManager,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[test]
|
||||
fn test_parse_checksum_for_asset() {
|
||||
let sample_checksums = r#"
|
||||
# Offizielle SHA-256 Prüfsummen für Sanctum Release
|
||||
2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e sanctum-v0.5.0-windows-x86_64.zip
|
||||
e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63 sanctum.exe
|
||||
557dd84f339336899fdd67e69d894dcd6e32f3e0bff2ae8a3057df596eddf4b4 sanctum-v0.5.0-linux-x86_64.tar.gz
|
||||
2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d *sanctum
|
||||
"#;
|
||||
|
||||
// Windows Binary
|
||||
let win_hash = parse_checksum_for_asset(sample_checksums, "sanctum.exe");
|
||||
assert_eq!(
|
||||
win_hash.as_deref(),
|
||||
Some("e0c64ecc12c7f49180487b638f1152eccaf5c90270a93880db52ca56eb1c1d63")
|
||||
);
|
||||
|
||||
// Linux Binary (mit führendem Asterisk im Format)
|
||||
let linux_hash = parse_checksum_for_asset(sample_checksums, "sanctum");
|
||||
assert_eq!(
|
||||
linux_hash.as_deref(),
|
||||
Some("2816f00cb15825d92f35f93fd667ed04d36501763a70b9399dd0ab9355c9448d")
|
||||
);
|
||||
|
||||
// Nicht existentes Asset
|
||||
let non_existent = parse_checksum_for_asset(sample_checksums, "nonexistent.exe");
|
||||
assert_eq!(non_existent, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_semver_comparisons() {
|
||||
// Normaler Versionssprung
|
||||
assert!(is_newer_version("0.5.0", "0.6.0"));
|
||||
assert!(is_newer_version("0.5.0", "v0.6.0"));
|
||||
assert!(is_newer_version("v0.5.0", "V0.5.1"));
|
||||
assert!(is_newer_version("0.5.0", "1.0.0"));
|
||||
|
||||
// Gleiche Version
|
||||
assert!(!is_newer_version("0.6.0", "0.6.0"));
|
||||
assert!(!is_newer_version("0.6.0", "v0.6.0"));
|
||||
assert!(!is_newer_version("v0.6.0", "0.6.0"));
|
||||
|
||||
// Ältere Version
|
||||
assert!(!is_newer_version("0.6.0", "0.5.0"));
|
||||
assert!(!is_newer_version("0.6.0", "v0.4.1"));
|
||||
|
||||
// Parsing mit führenden 'v' / 'V'
|
||||
let v1 = parse_clean_version("v0.6.0").expect("Parse v0.6.0");
|
||||
let v2 = parse_clean_version("V1.2.3").expect("Parse V1.2.3");
|
||||
assert_eq!(v1.major, 0);
|
||||
assert_eq!(v1.minor, 6);
|
||||
assert_eq!(v2.major, 1);
|
||||
assert_eq!(v2.minor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_package_manager_detection() {
|
||||
// Scoop Pfade
|
||||
let scoop_path = Path::new(r"C:\Users\harald\scoop\apps\sanctum\current\sanctum.exe");
|
||||
assert_eq!(
|
||||
detect_package_manager_from_path(scoop_path),
|
||||
Some(PackageManager::Scoop)
|
||||
);
|
||||
|
||||
let scoop_shim = Path::new(r"C:\Users\harald\scoop\shims\sanctum.exe");
|
||||
assert_eq!(
|
||||
detect_package_manager_from_path(scoop_shim),
|
||||
Some(PackageManager::Scoop)
|
||||
);
|
||||
|
||||
// Winget Pfade
|
||||
let winget_path = Path::new(
|
||||
r"C:\Users\harald\AppData\Local\Microsoft\WinGet\Packages\HaraldPansi.Sanctum_Microsoft.Winget.Source_8wekyb3d8bbwe\sanctum.exe",
|
||||
);
|
||||
assert_eq!(
|
||||
detect_package_manager_from_path(winget_path),
|
||||
Some(PackageManager::Winget)
|
||||
);
|
||||
|
||||
// Standard Standalone Pfade (kein Paketmanager)
|
||||
let standalone = Path::new(r"C:\Tools\sanctum\sanctum.exe");
|
||||
assert_eq!(detect_package_manager_from_path(standalone), None);
|
||||
|
||||
let linux_usr = Path::new("/usr/local/bin/sanctum");
|
||||
assert_eq!(detect_package_manager_from_path(linux_usr), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_integrity_calculation() {
|
||||
let test_file = std::env::temp_dir().join(format!("sanctum_test_{}.bin", std::process::id()));
|
||||
|
||||
let payload = b"Sanctum Standalone Binary Payload for Integrity Testing";
|
||||
{
|
||||
let mut f = File::create(&test_file).expect("create file");
|
||||
f.write_all(payload).expect("write");
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload);
|
||||
let expected = hex::encode(hasher.finalize());
|
||||
|
||||
// Datei erneut lesen und hashen
|
||||
let mut reader = File::open(&test_file).expect("open");
|
||||
let mut file_hasher = Sha256::new();
|
||||
std::io::copy(&mut reader, &mut file_hasher).expect("copy");
|
||||
let calculated = hex::encode(file_hasher.finalize());
|
||||
|
||||
assert_eq!(expected, calculated);
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_deserialization_of_gitea_release() {
|
||||
let gitea_json = r##"{
|
||||
"id": 27,
|
||||
"tag_name": "v0.5.0",
|
||||
"name": "Sanctum v0.5.0 (Windows x86_64)",
|
||||
"body": "Release v0.5.0 - sanctum sync",
|
||||
"html_url": "https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.5.0",
|
||||
"published_at": "2026-09-16T10:44:18+02:00",
|
||||
"assets": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "sanctum.exe",
|
||||
"size": 5467136,
|
||||
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum.exe"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"name": "sanctum",
|
||||
"size": 5033952,
|
||||
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"name": "SHA256SUMS.txt",
|
||||
"size": 563,
|
||||
"browser_download_url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/SHA256SUMS.txt"
|
||||
}
|
||||
]
|
||||
}"##;
|
||||
|
||||
let release: GiteaRelease = serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
|
||||
assert_eq!(release.id, 27);
|
||||
assert_eq!(release.tag_name, "v0.5.0");
|
||||
assert_eq!(release.assets.len(), 3);
|
||||
assert_eq!(release.assets[0].name, "sanctum.exe");
|
||||
assert_eq!(release.assets[1].name, "sanctum");
|
||||
assert_eq!(release.assets[2].name, "SHA256SUMS.txt");
|
||||
}
|
||||
Reference in New Issue
Block a user