1518 lines
47 KiB
Rust
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));
|
|
}
|
|
}
|