Compare commits
5
Commits
3f3f8f2730
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a3bd1911 | ||
|
|
2da1ea55a9 | ||
|
|
2e4cf1cef0 | ||
|
|
1048253497 | ||
|
|
aa65d96434 |
@@ -5,6 +5,35 @@ 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
|
||||
|
||||
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.4.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"
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.4.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,10 @@ 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"
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
|
||||
# Sanctum 🛡️
|
||||
|
||||
**Verschlüsselter Ein-Datei-Container unter Windows 10/11 im reinen Userland**
|
||||
**Verschlüsselter Ein-Datei-Container unter Windows & Linux im reinen Userland**
|
||||
|
||||
Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (`.sanctum`) unter Windows 10/11 im reinen Userland verwaltet.
|
||||
Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendung in Rust, die verschlüsselte Ein-Datei-Container (`.sanctum`) unter Windows 10/11 und Linux im reinen Userland verwaltet.
|
||||
|
||||
- **100% Userland**: Keine Administratorrechte erforderlich, keine Kernel-Treiber (weder WinFsp noch Dokan).
|
||||
- **Windows Explorer-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon.
|
||||
- **Windows Explorer- & Desktop-Integration**: Einhängen als nativer Netzlaufwerk-Buchstabe (Z: abwärts) via lokalem WebDAV (`127.0.0.1`), Explorer-Kontextmenü und System-Tray-Icon unter Windows sowie `gio mount` / `.desktop`-MIME unter Linux.
|
||||
- **Native Synchronisation (`sanctum sync`)**: RSync-artiger bidirektionaler Dateiabgleich (`Push` & `Pull`) mit 1-MB-Chunk-Streaming direkt in SQLite. Umgeht das fundamentale 4-GB-Dateilimit des Windows WebClient-Dienstes (`0x800700DF`) für Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+). Inklusive `--dry-run` Simulation, `--delete` Spiegelung und `--checksum` Hashprüfung.
|
||||
- **In-Place Self-Upgrade (`sanctum upgrade`)**: Aktualisiert das laufende Binary per Knopfdruck direkt über Gitea Releases. Löst Windows File-Locking via `self-replace` und verifiziert jedes Release kryptografisch gegen `SHA256SUMS.txt`.
|
||||
- **Transparente LZ4-Kompression**: Automatische Kompression von Chunks mit On-the-Fly-Dekompression und adaptivem Fallback.
|
||||
- **Anti-Forensik & Storage Compaction**: Inkrementelles Auto-Vacuum zur physikalischen Freigabe von Speicherplatz und kryptografisches Überschreiben (Shredding) von Datenblöcken mit CSPRNG-Rauschen vor dem Löschen.
|
||||
- **Plausible Deniability (Hidden Safe)**: Dual-Slot-Header. Ein unbenutzter Slot enthält uniformes CSPRNG-Rauschen, das sich nicht von einem echten Hidden Vault unterscheiden lässt. Volle Dateinamen-Verschlüsselung im Hidden Vault.
|
||||
- **OpSec & Explorer Anti-Leak Shield**: Blockiert Explorer-Spuren (`Thumbs.db`, `desktop.ini`, `*.tmp`, `:Zone.Identifier`), automatischer Unmount bei Inaktivität oder Windows-Sitzungssperre (`Win + L`).
|
||||
- **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.
|
||||
- **Statisches Single-Binary**: Standalone-Executables ohne externe DLL-Abhängigkeiten (`sanctum.exe` für Windows, statisches musl-ELF für Linux).
|
||||
|
||||
> 💡 **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).
|
||||
|
||||
@@ -169,6 +171,59 @@ sanctum.exe unregister
|
||||
|
||||
---
|
||||
|
||||
### 8. Native Synchronisation (`sanctum sync`)
|
||||
|
||||
Sanctum bietet eine native, rsync-ähnliche Synchronisations-Engine, die Dateien direkt auf Datenbank- und Chunk-Ebene überträgt. Dies löst das fundamentale Problem des Windows WebClient-Dienstes (`mrxdav.sys`), welcher beim Kopieren über gemountete WebDAV-Netzlaufwerke konstruktionsbedingt auf maximal 4 GB pro Datei beschränkt ist (`FileSizeLimitInBytes` / Fehler `0x800700DF`).
|
||||
|
||||
```powershell
|
||||
# 1. Vorab-Simulation ausführen (Dry-Run / risikolos prüfen):
|
||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --dry-run
|
||||
|
||||
# 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen):
|
||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp"
|
||||
|
||||
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel):
|
||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete
|
||||
|
||||
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
|
||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
|
||||
|
||||
# 5. Daten aus dem Tresor wiederherstellen (Pull / Container -> Host):
|
||||
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" /Downloads "D:\Wiederhergestellt\Downloads" --pull
|
||||
```
|
||||
|
||||
- **Dateigrößen**: Dateien beliebiger Größe (**5 GB, 50 GB, 500 GB+**) werden in 1-MB-Streaming-Chunks mit AES-256-GCM direkt übertragen.
|
||||
- **Fast-Delta Check**: Dateien mit identischer Größe und Modifikationszeitstempel (`mtime`) werden sofort übersprungen.
|
||||
- **Metadaten-Erhaltung**: Datei-Zeitstempel (`mtime`) werden in beide Richtungen exakt konserviert.
|
||||
|
||||
---
|
||||
|
||||
### 9. In-Place Self-Upgrade (`sanctum upgrade`)
|
||||
|
||||
Aktualisiert die laufende Sanctum-Executable direkt über die offizielle Gitea-Releases-API auf die neueste Version, ohne manuelle Browser-Downloads oder separate Paketmanager:
|
||||
|
||||
```powershell
|
||||
# Nur prüfen, ob ein Update verfügbar ist:
|
||||
sanctum.exe upgrade --check
|
||||
# oder mit dem Alias:
|
||||
sanctum.exe update -c
|
||||
|
||||
# Interaktives Upgrade durchführen (zeigt Release-Details und fragt nach Bestätigung):
|
||||
sanctum.exe upgrade
|
||||
|
||||
# Vollautomatisches Upgrade ohne Rückfragen (z. B. in Skripten):
|
||||
sanctum.exe upgrade -y
|
||||
|
||||
# Upgrade erzwingen (Neuinstallation / Reinstall derselben Version):
|
||||
sanctum.exe upgrade --force
|
||||
```
|
||||
|
||||
- **Plattformunabhängig**: Löst das Windows-Dateilock (`ERROR_ACCESS_DENIED`) via `self-replace` und unterstützt ebenso Linux (statisches musl ELF).
|
||||
- **Kryptografische Integrität**: Das heruntergeladene Binary wird vor dem Ersetzen zwingend gegen die offizielle `SHA256SUMS.txt` via SHA-256 verifiziert.
|
||||
- **Paketmanager-Harmonie**: Erkennt automatisch Installationen via Scoop (`scoop update sanctum`) oder Winget (`winget upgrade HaraldPansi.Sanctum`) und warnt den Benutzer.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ OpSec & Explorer Anti-Leak Shield
|
||||
|
||||
Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
|
||||
@@ -211,8 +266,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\package-release.ps1
|
||||
```
|
||||
|
||||
Erzeugt:
|
||||
- `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/sanctum-v0.6.0-windows-x86_64.zip` (Windows x86_64 ZIP)
|
||||
- `dist/sanctum-v0.6.0-linux-x86_64.tar.gz` (Linux x86_64 musl static TAR.GZ via `package-release-linux.ps1`)
|
||||
- `dist/SHA256SUMS.txt`
|
||||
|
||||
---
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "0.4.1",
|
||||
"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.4.1/sanctum-v0.4.1-windows-x86_64.zip",
|
||||
"hash": "dc0980312857505a078a3693d81a5d16853c3a94152f9104004dc6c4fd929bf7",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||
PackageIdentifier: HaraldPansi.Sanctum
|
||||
PackageVersion: 0.4.1
|
||||
PackageVersion: 0.6.0
|
||||
PackageName: Sanctum
|
||||
Publisher: Harald Pansi
|
||||
PublisherUrl: https://gitea.pansi.eu/harald
|
||||
@@ -18,7 +18,7 @@ Tags:
|
||||
- webdav
|
||||
- container
|
||||
- plausible-deniability
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.4.1
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.6.0
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
@@ -26,7 +26,7 @@ Installers:
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: sanctum.exe
|
||||
PortableCommandAlias: sanctum
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.4.1/sanctum-v0.4.1-windows-x86_64.zip
|
||||
InstallerSha256: dc0980312857505a078a3693d81a5d16853c3a94152f9104004dc6c4fd929bf7
|
||||
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
|
||||
|
||||
@@ -4,7 +4,9 @@ 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;
|
||||
|
||||
+249
@@ -201,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> {
|
||||
@@ -884,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();
|
||||
|
||||
@@ -1006,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 │");
|
||||
@@ -1026,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(())
|
||||
|
||||
+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(())
|
||||
}
|
||||
@@ -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