feat(sync): add rsync-like sync command with dry-run and bump version to v0.5.0

This commit is contained in:
2026-09-16 10:40:38 +02:00
parent 3f3f8f2730
commit aa65d96434
8 changed files with 1350 additions and 2 deletions
+220
View File
@@ -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 │");