fix(sync): S-09 — pull overwrite protection and conflict safety

This commit is contained in:
2026-09-19 09:40:00 +02:00
parent 92db21d47e
commit c7c0ed85f5
4 changed files with 332 additions and 44 deletions
+28
View File
@@ -257,6 +257,14 @@ enum Commands {
#[arg(short = 'f', long, default_value_t = false)]
force: bool,
/// Erstellt Sicherungskopien (.bak) überschriebener lokaler Dateien beim Pull (S-09)
#[arg(short = 'b', long, default_value_t = false)]
backup: bool,
/// Verhindert das Überschreiben neuerer lokaler Zieldateien beim Pull (S-09)
#[arg(short = 'u', long, default_value_t = false)]
update: bool,
/// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage)
#[arg(long, num_args = 0..=1, default_missing_value = "")]
recovery_key: Option<String>,
@@ -1367,6 +1375,8 @@ fn handle_sync(
exclude: Vec<String>,
quiet: bool,
force: bool,
backup: bool,
update: bool,
recovery_key: Option<&str>,
) -> Result<()> {
if !container_path.exists() {
@@ -1424,6 +1434,8 @@ fn handle_sync(
exclude_patterns: exclude,
quiet,
force,
backup,
update,
};
if !quiet {
@@ -1499,6 +1511,12 @@ fn handle_sync(
stats.files_skipped_invalid
);
}
if stats.files_backed_up > 0 {
println!(
" • Backups: {} Sicherheitskopien (.bak)",
stats.files_backed_up
);
}
if delete {
println!(
" • Zu löschen: {} Dateien/Ordner",
@@ -1538,6 +1556,12 @@ fn handle_sync(
stats.files_skipped_invalid
);
}
if stats.files_backed_up > 0 {
println!(
" • Backups: {} Sicherheitskopien (.bak)",
stats.files_backed_up
);
}
if delete {
println!(
" • Gelöscht: {} verwaiste Dateien/Ordner",
@@ -1705,6 +1729,8 @@ async fn run() -> Result<()> {
exclude,
quiet,
force,
backup,
update,
recovery_key,
} => {
handle_sync(
@@ -1719,6 +1745,8 @@ async fn run() -> Result<()> {
exclude,
quiet,
force,
backup,
update,
recovery_key.as_deref(),
)?;
}
+177 -44
View File
@@ -84,6 +84,8 @@ pub struct SyncOptions {
pub exclude_patterns: Vec<String>,
pub quiet: bool,
pub force: bool,
pub backup: bool,
pub update: bool,
}
impl Default for SyncOptions {
@@ -97,6 +99,8 @@ impl Default for SyncOptions {
exclude_patterns: Vec::new(),
quiet: false,
force: false,
backup: false,
update: false,
}
}
}
@@ -109,6 +113,7 @@ pub struct SyncStats {
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,
@@ -441,21 +446,54 @@ pub fn sync_single_file_to_vault(
})
}
/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte (Pull).
pub fn sync_single_file_to_host(
/// 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,
checksum: bool,
dry_run: bool,
options: &SyncOptions,
stats: &mut SyncStats,
) -> Result<FileTransferResult> {
db.assert_not_carrier(node.id)?;
validate_node_name(&node.name)?;
if local_path.exists() {
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
@@ -465,12 +503,13 @@ pub fn sync_single_file_to_host(
.unwrap_or_default()
.as_secs();
if !checksum && local_size == node.size && local_mtime == node.modified_at {
// 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 checksum && local_size == node.size {
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),
@@ -480,10 +519,28 @@ pub fn sync_single_file_to_host(
}
}
}
// 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 dry_run {
if options.dry_run {
if local_exists && options.backup {
stats.files_backed_up += 1;
}
return Ok(FileTransferResult::DryRunTransferred { size: node.size });
}
@@ -491,47 +548,123 @@ pub fn sync_single_file_to_host(
fs::create_dir_all(parent)?;
}
let mut out_file = File::create(local_path).with_context(|| {
format!(
"Konnte Zieldatei '{}' nicht erstellen",
local_path.display()
)
})?;
// 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 total_chunks = if node.size == 0 {
0
} else {
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
};
let write_res = (|| -> Result<()> {
let mut out_file = File::create(&temp_path).with_context(|| {
format!(
"Konnte temporäre Zieldatei '{}' nicht erstellen",
temp_path.display()
)
})?;
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)?;
let total_chunks = if node.size == 0 {
0
} else {
bail!(
"Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt",
idx,
node.name
((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()
);
}
}
out_file.flush()?;
set_local_file_mtime(&out_file, node.modified_at);
// 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,
@@ -988,15 +1121,15 @@ fn sync_pull(
};
stats.files_scanned += 1;
match sync_single_file_to_host(
match sync_single_file_to_host_opts(
db,
vault_id,
dek,
version,
&vault_source_node,
&local_file_path,
options.checksum,
options.dry_run,
options,
stats,
)? {
FileTransferResult::Transferred { size } => {
stats.files_transferred += 1;
@@ -1174,15 +1307,15 @@ fn collect_and_pull_dir(
)?;
} else {
stats.files_scanned += 1;
match sync_single_file_to_host(
match sync_single_file_to_host_opts(
db,
vault_id,
dek,
version,
&child,
&local_child_path,
options.checksum,
options.dry_run,
options,
stats,
)? {
FileTransferResult::Transferred { size } => {
stats.files_transferred += 1;