Compare commits
4
Commits
v0.4.0
..
1048253497
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1048253497 | ||
|
|
aa65d96434 | ||
|
|
3f3f8f2730 | ||
|
|
650cfc7cd4 |
@@ -5,6 +5,30 @@ 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.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
|
||||
|
||||
Generated
+1
-1
@@ -1377,7 +1377,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.4.0"
|
||||
version = "0.5.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"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.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.0/sanctum-v0.4.0-windows-x86_64.zip",
|
||||
"hash": "0602173dceeb082fdbe7f64b73a37de1e3f7b021390386a9efba8f43aa65e76d",
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum-v0.5.0-windows-x86_64.zip",
|
||||
"hash": "2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e",
|
||||
"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.0
|
||||
PackageVersion: 0.5.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.0
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.5.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.0/sanctum-v0.4.0-windows-x86_64.zip
|
||||
InstallerSha256: 0602173dceeb082fdbe7f64b73a37de1e3f7b021390386a9efba8f43aa65e76d
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.5.0/sanctum-v0.5.0-windows-x86_64.zip
|
||||
InstallerSha256: 2f069a8cc41f9fa6a2559e2952e5669523c21c175df15f6d80f0473bbe2fa60e
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.6.0
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod mount;
|
||||
pub mod platform;
|
||||
pub mod recovery;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod ui;
|
||||
pub mod verify;
|
||||
pub mod vfs;
|
||||
|
||||
+220
@@ -201,6 +201,48 @@ 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,
|
||||
|
||||
@@ -884,6 +926,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 +1201,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 │");
|
||||
|
||||
+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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user