Files
sanctum/src/sync.rs
T

1518 lines
47 KiB
Rust

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 sha2::{Digest, Sha256};
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord};
use crate::ui;
use crate::vfs::is_leak_file;
pub use crate::pathutil::{is_path_traversal, validate_node_name};
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
fn calc_local_file_sha256(path: &Path) -> Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
/// Berechnet den SHA-256 Hash der entschlüsselten Nutzdaten eines Vault-Knotens (S-10).
fn calc_vault_node_sha256(
db: &Database,
node: &NodeRecord,
dek: &[u8; 32],
version: u32,
) -> Result<String> {
let mut hasher = Sha256::new();
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 {
let record = db
.read_chunk(node.id, idx)?
.ok_or_else(|| anyhow::anyhow!("Fehlender Chunk {} für Knoten {}", idx, node.id))?;
let decrypted = decrypt_chunk(
dek,
node.id,
idx,
&record.ciphertext,
&record.nonce,
&record.tag,
version,
record.generation,
)?;
hasher.update(&decrypted);
}
Ok(hex::encode(hasher.finalize()))
}
/// 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 delete_excluded: bool,
pub dry_run: bool,
pub checksum: bool,
pub exclude_patterns: Vec<String>,
pub quiet: bool,
pub force: bool,
pub backup: bool,
pub update: bool,
}
impl Default for SyncOptions {
fn default() -> Self {
Self {
direction: SyncDirection::Push,
delete: false,
delete_excluded: false,
dry_run: false,
checksum: false,
exclude_patterns: Vec::new(),
quiet: false,
force: false,
backup: false,
update: 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_skipped_symlinks: usize,
pub files_skipped_invalid: usize,
pub files_backed_up: 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 },
}
/// Einfacher, effizienter Glob-Matcher ohne externe Abhängigkeiten (unterstützt `*`, `**` und `?`).
pub fn glob_match(pattern: &str, text: &str) -> bool {
glob_match_slice(pattern.as_bytes(), text.as_bytes())
}
fn glob_match_slice(pat: &[u8], text: &[u8]) -> bool {
if pat.is_empty() {
return text.is_empty();
}
// Double-Star '**': Entspricht 0 oder mehr beliebigen Zeichen inklusive Verzeichnistrenner
if pat.starts_with(b"**") {
let rest_pat = if pat.len() > 2 && pat[2] == b'/' {
&pat[3..]
} else {
&pat[2..]
};
if rest_pat.is_empty() {
return true;
}
for i in 0..=text.len() {
if glob_match_slice(rest_pat, &text[i..]) {
return true;
}
}
return false;
}
// Single-Star '*': Entspricht 0 oder mehr Zeichen innerhalb desselben Pfadsegments (stoppt an '/')
if pat[0] == b'*' {
let rest_pat = &pat[1..];
for i in 0..=text.len() {
if i > 0 && (text[i - 1] == b'/' || text[i - 1] == b'\\') {
break;
}
if glob_match_slice(rest_pat, &text[i..]) {
return true;
}
}
return false;
}
if text.is_empty() {
return false;
}
// Question mark '?': Einzelzeichen-Platzhalter (außer Pfadtrenner)
if pat[0] == b'?' {
if text[0] == b'/' || text[0] == b'\\' {
return false;
}
return glob_match_slice(&pat[1..], &text[1..]);
}
if pat[0] == text[0] {
return glob_match_slice(&pat[1..], &text[1..]);
}
false
}
/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht (S-08).
///
/// Unterstützte Musterformate:
/// 1. Anti-Leak Shield: OS- und Explorer-Metadaten (z. B. Thumbs.db, .DS_Store) werden stets ausgeschlossen.
/// 2. Glob-Muster mit Wildcards:
/// - `*.ext`: Schließt alle Dateien mit dieser Endung im gesamten Baum aus (z. B. `*.tmp`, `*.bak`).
/// - `prefix*`: Schließt alle Dateien aus, deren Name mit dem Präfix beginnt (z. B. `temp_*`, `backup*`).
/// - `*middle*`: Schließt Dateien/Pfade aus, die die Zeichenkette enthalten.
/// - `?`: Einzelzeichen-Platzhalter (z. B. `file?.txt`).
/// 3. Verzeichnis-Ausschlüsse:
/// - `dirname/` oder `dirname`: Schließt das Verzeichnis und alle darin enthaltenen Dateien/Unterordner aus.
/// - `/path/to/dir`: Verankert den Ausschluss relativ zum Synchronisations-Wurzelverzeichnis.
/// 4. Pfadspezifische Muster:
/// - `build/*.bin`: Schließt `.bin`-Dateien im Ordner `build` aus.
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('\\', "/")
.trim_start_matches('/')
.to_ascii_lowercase();
let norm_name = name.to_ascii_lowercase();
for pat in patterns {
let p = pat.trim();
if p.is_empty() {
continue;
}
let p_norm = p.replace('\\', "/").to_ascii_lowercase();
// Verzeichnis-Ausschluss mit nachgestelltem Slash (z. B. "logs/" oder "build/temp/")
if p_norm.ends_with('/') {
let dir_prefix = p_norm.trim_matches('/');
if norm_rel == dir_prefix
|| norm_rel.starts_with(&format!("{}/", dir_prefix))
|| norm_rel.contains(&format!("/{}/", dir_prefix))
{
return true;
}
}
// Muster mit Pfadtrennzeichen (relativ verankert oder spezifischer Unterpfad, z. B. "/logs", "sub/*.txt")
if p_norm.contains('/') {
let p_clean = p_norm.trim_start_matches('/');
if glob_match(p_clean, &norm_rel) {
return true;
}
// Wenn p_clean ein Verzeichnis ohne Wildcards ist, auch alle Unterpfade erfassen
if !p_clean.contains('*')
&& !p_clean.contains('?')
&& (norm_rel == p_clean || norm_rel.starts_with(&format!("{}/", p_clean)))
{
return true;
}
} else {
// Reines Dateinamen- oder Segment-Muster ohne '/' (z. B. "*.tmp", "temp_*", "node_modules")
if glob_match(&p_norm, &norm_name) || glob_match(&p_norm, &norm_rel) {
return true;
}
// Wenn p_norm ein exakter Ordnername ist (z. B. "node_modules", ".git"), alle Pfade mit diesem Segment erfassen
if !p_norm.contains('*') && !p_norm.contains('?') {
for seg in norm_rel.split('/') {
if seg == p_norm {
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> {
validate_node_name(file_name)?;
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 {
db.assert_not_carrier(node.id)?;
if node.is_dir {
bail!(
"Pfad-Konflikt: '{}' existiert im Tresor als Ordner",
file_name
);
}
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
if !checksum && node.size == local_size && node.modified_at == local_mtime {
return Ok(FileTransferResult::Skipped { size: local_size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if checksum && node.size == local_size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
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 gen = db.next_chunk_generation(node_id, chunk_idx)?;
let (ciphertext, nonce, tag) =
encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version, gen)?;
bytes_written += n as u64;
db.write_chunk_and_update_size(
node_id,
chunk_idx,
gen,
&nonce,
&tag,
&ciphertext,
bytes_written,
local_mtime,
)?;
chunk_idx += 1;
}
// Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen
if bytes_written == 0 {
// S-05: 0-Byte Datei: Alle Chunks (inkl. Chunk 0) restlos löschen und shreddern
db.delete_all_chunks(node_id)?;
} else {
db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?;
}
// S-04: Verwende bytes_written statt local_size zur Vermeidung von TOCTOU-Diskrepanzen
db.update_node_size_and_time(node_id, bytes_written, local_mtime)?;
Ok(FileTransferResult::Transferred {
size: bytes_written,
})
}
/// Erstellt einen sicheren Pfad für Sicherungskopien (.bak) überschriebener Dateien (S-09).
pub fn make_backup_path(path: &Path) -> std::path::PathBuf {
let mut bak = path.to_path_buf();
let ext = bak
.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default();
let new_ext = if ext.is_empty() {
"bak".to_string()
} else {
format!("{}.bak", ext)
};
bak.set_extension(new_ext);
if !bak.exists() {
return bak;
}
for i in 1..=1000 {
let mut numbered = path.to_path_buf();
let numbered_ext = if ext.is_empty() {
format!("bak.{}", i)
} else {
format!("{}.bak.{}", ext, i)
};
numbered.set_extension(numbered_ext);
if !numbered.exists() {
return numbered;
}
}
bak
}
/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte mit atomarem Schreiben,
/// optionalem .bak-Sicherheits-Backup und Konfliktschutz (S-09, Pull).
pub fn sync_single_file_to_host_opts(
db: &Database,
_vault_id: u32,
dek: &[u8; 32],
version: u32,
node: &NodeRecord,
local_path: &Path,
options: &SyncOptions,
stats: &mut SyncStats,
) -> Result<FileTransferResult> {
db.assert_not_carrier(node.id)?;
validate_node_name(&node.name)?;
let local_exists = local_path.exists();
if local_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();
// Identische Datei: Überspringen
if !options.checksum && local_size == node.size && local_mtime == node.modified_at {
return Ok(FileTransferResult::Skipped { size: node.size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if options.checksum && local_size == node.size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
return Ok(FileTransferResult::Skipped { size: node.size });
}
}
}
// S-09: Konfliktschutz (--update)
// Wenn die lokale Datei neuer ist als die Version im Tresor,
// verweigere das Überschreiben, sofern nicht --force gesetzt ist.
if options.update && !options.force && local_mtime > node.modified_at {
if !options.quiet {
println!(
" {} Lokale Datei ist neuer ({}) - übersprungen mit --update: {}",
ui::yellow("[!]"),
local_mtime,
local_path.display()
);
}
return Ok(FileTransferResult::Skipped { size: node.size });
}
}
}
if options.dry_run {
if local_exists && options.backup {
stats.files_backed_up += 1;
}
return Ok(FileTransferResult::DryRunTransferred { size: node.size });
}
if let Some(parent) = local_path.parent() {
fs::create_dir_all(parent)?;
}
// S-09: Atomares Schreiben über temporäre Datei im selben Verzeichnis
// Verhindert inkonsistente / beschädigte Zieldateien bei Abbruch oder I/O-Fehlern
let parent_dir = local_path.parent().unwrap_or_else(|| Path::new("."));
let temp_path = parent_dir.join(format!(
".sanctum_pull_{}_{}.tmp",
std::process::id(),
rand::random::<u64>()
));
let write_res = (|| -> Result<()> {
let mut out_file = File::create(&temp_path).with_context(|| {
format!(
"Konnte temporäre Zieldatei '{}' nicht erstellen",
temp_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,
record.generation,
)?;
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(())
})();
if let Err(e) = write_res {
let _ = fs::remove_file(&temp_path);
return Err(e);
}
// S-09: Backup-Erstellung bei bestehender Zieldatei (--backup)
if local_exists && options.backup {
let backup_path = make_backup_path(local_path);
fs::rename(local_path, &backup_path).with_context(|| {
format!(
"Konnte bestehende Zieldatei nicht nach '{}' sichern",
backup_path.display()
)
})?;
stats.files_backed_up += 1;
if !options.quiet {
println!(
" {} Backup erstellt: {}",
ui::cyan("[*]"),
backup_path.display()
);
}
}
// Atomares Ersetzen der Zieldatei
if local_path.exists() {
fs::remove_file(local_path).with_context(|| {
format!(
"Konnte bestehende Zieldatei '{}' vor atomarem Verschieben nicht entfernen",
local_path.display()
)
})?;
}
fs::rename(&temp_path, local_path).with_context(|| {
format!(
"Konnte temporäre Datei '{}' nicht nach '{}' verschieben",
temp_path.display(),
local_path.display()
)
})?;
Ok(FileTransferResult::Transferred { size: node.size })
}
/// Abwärtskompatibler Wrapper für sync_single_file_to_host.
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> {
let mut stats = SyncStats::default();
let options = SyncOptions {
checksum,
dry_run,
..Default::default()
};
sync_single_file_to_host_opts(
db, vault_id, dek, version, node, local_path, &options, &mut stats,
)
}
/// 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();
// S-06: Advisory-Lock gegen parallelen Mount / gleichzeitigen Zugriff
if let Some((pid, host, time)) = db.check_advisory_lock()? {
if !options.force {
bail!(
"Container ist gesperrt: Wird aktuell von Prozess {} auf Host '{}' verwendet (seit UNIX-Zeit {}). Verwenden Sie --force zum Überschreiben.",
pid, host, time
);
} else if !options.quiet {
println!(
" {} Warnung: Aktiver Advisory-Lock (Prozess {} auf '{}') wird durch --force überschrieben.",
ui::yellow("[!]"),
pid,
host
);
}
}
db.set_active_dek(zeroize::Zeroizing::new(*dek));
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
};
let sym_meta = fs::symlink_metadata(local_source)?;
if sym_meta.file_type().is_symlink() {
stats.files_scanned += 1;
stats.files_skipped += 1;
stats.files_skipped_symlinks += 1;
if !options.quiet {
println!(
" {} Symlink übersprungen: {}",
ui::yellow("[!]"),
source_str
);
}
return Ok(());
}
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();
let mut excluded_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,
&mut excluded_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,
&excluded_relative_paths,
&options.exclude_patterns,
options.delete_excluded,
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>,
excluded_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();
if is_path_traversal(&file_name) {
bail!(
"Path traversal Versuch erkannt in Dateinamen: '{}'",
file_name
);
}
if let Err(e) = validate_node_name(&file_name) {
stats.files_scanned += 1;
stats.files_skipped += 1;
stats.files_skipped_invalid += 1;
if !options.quiet {
println!(
" {} Ungültiger Dateiname übersprungen: {} ({})",
ui::yellow("[!]"),
file_name,
e
);
}
continue;
}
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) {
excluded_relative_paths.insert(rel_path.clone());
continue;
}
// S-03: Symlinks standardmäßig überspringen (Schutz vor Zyklen und Rekursion)
let sym_meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if sym_meta.file_type().is_symlink() {
stats.files_scanned += 1;
stats.files_skipped += 1;
stats.files_skipped_symlinks += 1;
if !options.quiet {
println!(" {} Symlink übersprungen: {}", ui::yellow("[!]"), rel_path);
}
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,
excluded_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(())
}
pub 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>,
excluded_relative_paths: &HashSet<String>,
exclude_patterns: &[String],
delete_excluded: bool,
dry_run: bool,
quiet: bool,
stats: &mut SyncStats,
) -> Result<()> {
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?;
for child in children {
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
if carrier_id > 0
&& (child.id == carrier_id
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
{
continue;
}
let child_rel = if prefix_rel.is_empty() {
child.name.clone()
} else {
format!("{}/{}", prefix_rel, child.name)
};
// S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen
if !delete_excluded {
if is_excluded(&child.name, &child_rel, exclude_patterns) {
continue;
}
if excluded_relative_paths
.iter()
.any(|ex| child_rel == *ex || child_rel.starts_with(&format!("{}/", ex)))
{
continue;
}
}
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,
excluded_relative_paths,
exclude_patterns,
delete_excluded,
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_opts(
db,
vault_id,
dek,
version,
&vault_source_node,
&local_file_path,
options,
stats,
)? {
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();
let mut excluded_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,
&mut excluded_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,
&excluded_relative_paths,
&options.exclude_patterns,
options.delete_excluded,
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>,
excluded_relative_paths: &mut HashSet<String>,
) -> Result<()> {
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
for child in children {
// R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen
if carrier_id > 0
&& (child.id == carrier_id
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
{
continue;
}
if is_path_traversal(&child.name) {
bail!(
"Path traversal Versuch erkannt in Knotennamen: '{}'",
child.name
);
}
if let Err(e) = validate_node_name(&child.name) {
stats.files_scanned += 1;
stats.files_skipped += 1;
stats.files_skipped_invalid += 1;
if !options.quiet {
println!(
" {} Ungültiger Dateiname im Tresor übersprungen: {} ({})",
ui::yellow("[!]"),
child.name,
e
);
}
continue;
}
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) {
excluded_relative_paths.insert(child_rel.clone());
continue;
}
// S-02 & S-08 Path Traversal Guard:
// Plattformunabhängiger Pfadaufbau mit Segment-Validierung
let mut local_child_path = local_target_base.to_path_buf();
for seg in child_rel.split('/') {
if seg.is_empty() || seg == "." || seg == ".." || seg.contains('\\') {
bail!(
"Path traversal Versuch erkannt in relativem Pfad: '{}'",
child_rel
);
}
local_child_path.push(seg);
}
if !local_child_path.starts_with(local_target_base) {
bail!(
"Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus",
child_rel
);
}
vault_relative_paths.insert(child_rel.clone());
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,
excluded_relative_paths,
)?;
} else {
stats.files_scanned += 1;
match sync_single_file_to_host_opts(
db,
vault_id,
dek,
version,
&child,
&local_child_path,
options,
stats,
)? {
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>,
excluded_relative_paths: &HashSet<String>,
exclude_patterns: &[String],
delete_excluded: bool,
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 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('\\', "/");
// S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen
if !delete_excluded {
if is_excluded(&file_name, &rel_path, exclude_patterns) {
continue;
}
if excluded_relative_paths
.iter()
.any(|ex| rel_path == *ex || rel_path.starts_with(&format!("{}/", ex)))
{
continue;
}
}
let is_symlink = match fs::symlink_metadata(&path) {
Ok(m) => m.file_type().is_symlink(),
Err(_) => false,
};
if !vault_relative_paths.contains(&rel_path) {
stats.files_deleted += 1;
if path.is_dir() && !is_symlink {
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() && !is_symlink {
delete_orphans_on_host(
base_dir,
&path,
vault_relative_paths,
excluded_relative_paths,
exclude_patterns,
delete_excluded,
dry_run,
quiet,
stats,
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_match() {
assert!(glob_match("*.txt", "hello.txt"));
assert!(glob_match("*.txt", ".txt"));
assert!(!glob_match("*.txt", "hello.doc"));
assert!(glob_match("temp_*", "temp_file.dat"));
assert!(!glob_match("temp_*", "other_temp_file.dat"));
assert!(glob_match("build/*.bin", "build/app.bin"));
assert!(!glob_match("build/*.bin", "build/debug/app.bin"));
assert!(glob_match("file?.txt", "file1.txt"));
assert!(!glob_match("file?.txt", "file12.txt"));
assert!(glob_match("*test*", "my_test_case"));
}
#[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(),
"target/".to_string(),
"node_modules".to_string(),
"build/*.bin".to_string(),
"file?.doc".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("app.log", "logs/app.log", &patterns));
assert!(is_excluded("deep.log", "logs/sub/deep.log", &patterns));
assert!(is_excluded("cache.bin", "target/cache.bin", &patterns));
assert!(is_excluded(
"package.json",
"node_modules/pkg/package.json",
&patterns
));
assert!(is_excluded(
"package.json",
"client/node_modules/pkg/package.json",
&patterns
));
assert!(is_excluded("app.bin", "build/app.bin", &patterns));
assert!(is_excluded("file1.doc", "sub/file1.doc", &patterns));
assert!(!is_excluded("file12.doc", "sub/file12.doc", &patterns));
assert!(!is_excluded(
"important.doc",
"sub/important.doc",
&patterns
));
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
assert!(!is_excluded("app.bin", "other/app.bin", &patterns));
}
}