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
+850
View File
@@ -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));
}
}