fix(sync): S-09 — pull overwrite protection and conflict safety
This commit is contained in:
+28
@@ -257,6 +257,14 @@ enum Commands {
|
|||||||
#[arg(short = 'f', long, default_value_t = false)]
|
#[arg(short = 'f', long, default_value_t = false)]
|
||||||
force: bool,
|
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)
|
/// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage)
|
||||||
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
#[arg(long, num_args = 0..=1, default_missing_value = "")]
|
||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
@@ -1367,6 +1375,8 @@ fn handle_sync(
|
|||||||
exclude: Vec<String>,
|
exclude: Vec<String>,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
|
backup: bool,
|
||||||
|
update: bool,
|
||||||
recovery_key: Option<&str>,
|
recovery_key: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
@@ -1424,6 +1434,8 @@ fn handle_sync(
|
|||||||
exclude_patterns: exclude,
|
exclude_patterns: exclude,
|
||||||
quiet,
|
quiet,
|
||||||
force,
|
force,
|
||||||
|
backup,
|
||||||
|
update,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !quiet {
|
if !quiet {
|
||||||
@@ -1499,6 +1511,12 @@ fn handle_sync(
|
|||||||
stats.files_skipped_invalid
|
stats.files_skipped_invalid
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if stats.files_backed_up > 0 {
|
||||||
|
println!(
|
||||||
|
" • Backups: {} Sicherheitskopien (.bak)",
|
||||||
|
stats.files_backed_up
|
||||||
|
);
|
||||||
|
}
|
||||||
if delete {
|
if delete {
|
||||||
println!(
|
println!(
|
||||||
" • Zu löschen: {} Dateien/Ordner",
|
" • Zu löschen: {} Dateien/Ordner",
|
||||||
@@ -1538,6 +1556,12 @@ fn handle_sync(
|
|||||||
stats.files_skipped_invalid
|
stats.files_skipped_invalid
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if stats.files_backed_up > 0 {
|
||||||
|
println!(
|
||||||
|
" • Backups: {} Sicherheitskopien (.bak)",
|
||||||
|
stats.files_backed_up
|
||||||
|
);
|
||||||
|
}
|
||||||
if delete {
|
if delete {
|
||||||
println!(
|
println!(
|
||||||
" • Gelöscht: {} verwaiste Dateien/Ordner",
|
" • Gelöscht: {} verwaiste Dateien/Ordner",
|
||||||
@@ -1705,6 +1729,8 @@ async fn run() -> Result<()> {
|
|||||||
exclude,
|
exclude,
|
||||||
quiet,
|
quiet,
|
||||||
force,
|
force,
|
||||||
|
backup,
|
||||||
|
update,
|
||||||
recovery_key,
|
recovery_key,
|
||||||
} => {
|
} => {
|
||||||
handle_sync(
|
handle_sync(
|
||||||
@@ -1719,6 +1745,8 @@ async fn run() -> Result<()> {
|
|||||||
exclude,
|
exclude,
|
||||||
quiet,
|
quiet,
|
||||||
force,
|
force,
|
||||||
|
backup,
|
||||||
|
update,
|
||||||
recovery_key.as_deref(),
|
recovery_key.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-44
@@ -84,6 +84,8 @@ pub struct SyncOptions {
|
|||||||
pub exclude_patterns: Vec<String>,
|
pub exclude_patterns: Vec<String>,
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
|
pub backup: bool,
|
||||||
|
pub update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SyncOptions {
|
impl Default for SyncOptions {
|
||||||
@@ -97,6 +99,8 @@ impl Default for SyncOptions {
|
|||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
quiet: false,
|
quiet: false,
|
||||||
force: false,
|
force: false,
|
||||||
|
backup: false,
|
||||||
|
update: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,7 @@ pub struct SyncStats {
|
|||||||
pub files_skipped: usize,
|
pub files_skipped: usize,
|
||||||
pub files_skipped_symlinks: usize,
|
pub files_skipped_symlinks: usize,
|
||||||
pub files_skipped_invalid: usize,
|
pub files_skipped_invalid: usize,
|
||||||
|
pub files_backed_up: usize,
|
||||||
pub files_deleted: usize,
|
pub files_deleted: usize,
|
||||||
pub bytes_transferred: u64,
|
pub bytes_transferred: u64,
|
||||||
pub elapsed: Duration,
|
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).
|
/// Erstellt einen sicheren Pfad für Sicherungskopien (.bak) überschriebener Dateien (S-09).
|
||||||
pub fn sync_single_file_to_host(
|
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,
|
db: &Database,
|
||||||
_vault_id: u32,
|
_vault_id: u32,
|
||||||
dek: &[u8; 32],
|
dek: &[u8; 32],
|
||||||
version: u32,
|
version: u32,
|
||||||
node: &NodeRecord,
|
node: &NodeRecord,
|
||||||
local_path: &Path,
|
local_path: &Path,
|
||||||
checksum: bool,
|
options: &SyncOptions,
|
||||||
dry_run: bool,
|
stats: &mut SyncStats,
|
||||||
) -> Result<FileTransferResult> {
|
) -> Result<FileTransferResult> {
|
||||||
db.assert_not_carrier(node.id)?;
|
db.assert_not_carrier(node.id)?;
|
||||||
validate_node_name(&node.name)?;
|
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) {
|
if let Ok(meta) = fs::metadata(local_path) {
|
||||||
let local_size = meta.len();
|
let local_size = meta.len();
|
||||||
let local_mtime = meta
|
let local_mtime = meta
|
||||||
@@ -465,12 +503,13 @@ pub fn sync_single_file_to_host(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.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 });
|
return Ok(FileTransferResult::Skipped { size: node.size });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
|
// 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)) = (
|
if let (Ok(local_hash), Ok(vault_hash)) = (
|
||||||
calc_local_file_sha256(local_path),
|
calc_local_file_sha256(local_path),
|
||||||
calc_vault_node_sha256(db, node, dek, version),
|
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 });
|
return Ok(FileTransferResult::DryRunTransferred { size: node.size });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,47 +548,123 @@ pub fn sync_single_file_to_host(
|
|||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out_file = File::create(local_path).with_context(|| {
|
// S-09: Atomares Schreiben über temporäre Datei im selben Verzeichnis
|
||||||
format!(
|
// Verhindert inkonsistente / beschädigte Zieldateien bei Abbruch oder I/O-Fehlern
|
||||||
"Konnte Zieldatei '{}' nicht erstellen",
|
let parent_dir = local_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
local_path.display()
|
let temp_path = parent_dir.join(format!(
|
||||||
)
|
".sanctum_pull_{}_{}.tmp",
|
||||||
})?;
|
std::process::id(),
|
||||||
|
rand::random::<u64>()
|
||||||
|
));
|
||||||
|
|
||||||
let total_chunks = if node.size == 0 {
|
let write_res = (|| -> Result<()> {
|
||||||
0
|
let mut out_file = File::create(&temp_path).with_context(|| {
|
||||||
} else {
|
format!(
|
||||||
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
|
"Konnte temporäre Zieldatei '{}' nicht erstellen",
|
||||||
};
|
temp_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
for idx in 0..total_chunks {
|
let total_chunks = if node.size == 0 {
|
||||||
if let Some(record) = db.read_chunk(node.id, idx)? {
|
0
|
||||||
let plaintext = decrypt_chunk(
|
|
||||||
dek,
|
|
||||||
node.id,
|
|
||||||
idx,
|
|
||||||
&record.ciphertext,
|
|
||||||
&record.nonce,
|
|
||||||
&record.tag,
|
|
||||||
version,
|
|
||||||
record.generation,
|
|
||||||
)?;
|
|
||||||
out_file.write_all(&plaintext)?;
|
|
||||||
} else {
|
} else {
|
||||||
bail!(
|
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
|
||||||
"Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt",
|
};
|
||||||
idx,
|
|
||||||
node.name
|
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()?;
|
// Atomares Ersetzen der Zieldatei
|
||||||
set_local_file_mtime(&out_file, node.modified_at);
|
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 })
|
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.
|
/// Führt die vollständige Synchronisation zwischen Host und Container aus.
|
||||||
pub fn run_sync(
|
pub fn run_sync(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
@@ -988,15 +1121,15 @@ fn sync_pull(
|
|||||||
};
|
};
|
||||||
|
|
||||||
stats.files_scanned += 1;
|
stats.files_scanned += 1;
|
||||||
match sync_single_file_to_host(
|
match sync_single_file_to_host_opts(
|
||||||
db,
|
db,
|
||||||
vault_id,
|
vault_id,
|
||||||
dek,
|
dek,
|
||||||
version,
|
version,
|
||||||
&vault_source_node,
|
&vault_source_node,
|
||||||
&local_file_path,
|
&local_file_path,
|
||||||
options.checksum,
|
options,
|
||||||
options.dry_run,
|
stats,
|
||||||
)? {
|
)? {
|
||||||
FileTransferResult::Transferred { size } => {
|
FileTransferResult::Transferred { size } => {
|
||||||
stats.files_transferred += 1;
|
stats.files_transferred += 1;
|
||||||
@@ -1174,15 +1307,15 @@ fn collect_and_pull_dir(
|
|||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
stats.files_scanned += 1;
|
stats.files_scanned += 1;
|
||||||
match sync_single_file_to_host(
|
match sync_single_file_to_host_opts(
|
||||||
db,
|
db,
|
||||||
vault_id,
|
vault_id,
|
||||||
dek,
|
dek,
|
||||||
version,
|
version,
|
||||||
&child,
|
&child,
|
||||||
&local_child_path,
|
&local_child_path,
|
||||||
options.checksum,
|
options,
|
||||||
options.dry_run,
|
stats,
|
||||||
)? {
|
)? {
|
||||||
FileTransferResult::Transferred { size } => {
|
FileTransferResult::Transferred { size } => {
|
||||||
stats.files_transferred += 1;
|
stats.files_transferred += 1;
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
quiet: true,
|
quiet: true,
|
||||||
force: false,
|
force: false,
|
||||||
|
backup: false,
|
||||||
|
update: false,
|
||||||
};
|
};
|
||||||
let pull_res = sanctum::sync::run_sync(
|
let pull_res = sanctum::sync::run_sync(
|
||||||
&db,
|
&db,
|
||||||
|
|||||||
@@ -1064,3 +1064,128 @@ fn test_s08_refined_glob_and_directory_exclusions() {
|
|||||||
let _ = fs::remove_dir_all(&temp_root);
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s09_pull_backup_and_update_conflict_safety() {
|
||||||
|
let temp_root =
|
||||||
|
std::env::temp_dir().join(format!("sanctum_s09_test_{}", rand::random::<u64>()));
|
||||||
|
let container_path = temp_root.join("s09.sanctum");
|
||||||
|
let source_dir = temp_root.join("source");
|
||||||
|
let local_dest_dir = temp_root.join("dest");
|
||||||
|
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::create_dir_all(&local_dest_dir).unwrap();
|
||||||
|
|
||||||
|
let (db, dek) = create_test_container(&container_path);
|
||||||
|
|
||||||
|
// Datei in den Tresor laden
|
||||||
|
fs::write(source_dir.join("document.txt"), b"Vault Version 2.0").unwrap();
|
||||||
|
fs::write(source_dir.join("notes.txt"), b"Older Vault Notes").unwrap();
|
||||||
|
|
||||||
|
let mut push_opts = SyncOptions::default();
|
||||||
|
push_opts.direction = SyncDirection::Push;
|
||||||
|
push_opts.quiet = true;
|
||||||
|
run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
source_dir.to_str().unwrap(),
|
||||||
|
"/",
|
||||||
|
&push_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Test Backup-Erstellung (--backup):
|
||||||
|
// Lokale Datei anlegen mit eigenem Inhalt
|
||||||
|
let local_doc = local_dest_dir.join("document.txt");
|
||||||
|
fs::write(&local_doc, b"Local Version 1.0 (Must be backed up)").unwrap();
|
||||||
|
|
||||||
|
let mut pull_opts = SyncOptions::default();
|
||||||
|
pull_opts.direction = SyncDirection::Pull;
|
||||||
|
pull_opts.quiet = true;
|
||||||
|
pull_opts.backup = true;
|
||||||
|
|
||||||
|
let pull_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
local_dest_dir.to_str().unwrap(),
|
||||||
|
&pull_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(pull_stats.files_backed_up, 1);
|
||||||
|
assert_eq!(fs::read(&local_doc).unwrap(), b"Vault Version 2.0");
|
||||||
|
|
||||||
|
let backup_doc = local_dest_dir.join("document.txt.bak");
|
||||||
|
assert!(
|
||||||
|
backup_doc.exists(),
|
||||||
|
"Backup-Datei .bak muss angelegt worden sein"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&backup_doc).unwrap(),
|
||||||
|
b"Local Version 1.0 (Must be backed up)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Test Konfliktschutz (--update):
|
||||||
|
// Erzeuge lokale notes.txt mit neuerem Timestamp
|
||||||
|
let local_notes = local_dest_dir.join("notes.txt");
|
||||||
|
fs::write(&local_notes, b"Newer Local Notes").unwrap();
|
||||||
|
let future_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000);
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(&local_notes)
|
||||||
|
.unwrap()
|
||||||
|
.set_times(std::fs::FileTimes::new().set_modified(future_time))
|
||||||
|
.expect("Set modified time on local_notes");
|
||||||
|
|
||||||
|
let mut update_opts = SyncOptions::default();
|
||||||
|
update_opts.direction = SyncDirection::Pull;
|
||||||
|
update_opts.quiet = true;
|
||||||
|
update_opts.update = true;
|
||||||
|
|
||||||
|
let update_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
local_dest_dir.to_str().unwrap(),
|
||||||
|
&update_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// notes.txt darf NICHT überschrieben worden sein
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&local_notes).unwrap(),
|
||||||
|
b"Newer Local Notes",
|
||||||
|
"Neuere lokale Datei darf mit --update nicht überschrieben werden"
|
||||||
|
);
|
||||||
|
assert!(update_stats.files_skipped >= 1);
|
||||||
|
|
||||||
|
// Mit --force muss sie überschrieben werden
|
||||||
|
update_opts.force = true;
|
||||||
|
let force_stats = run_sync(
|
||||||
|
&db,
|
||||||
|
0,
|
||||||
|
&dek,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
"/",
|
||||||
|
local_dest_dir.to_str().unwrap(),
|
||||||
|
&update_opts,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&local_notes).unwrap(),
|
||||||
|
b"Older Vault Notes",
|
||||||
|
"Mit --force muss die neuere Datei dennoch überschrieben werden"
|
||||||
|
);
|
||||||
|
assert_eq!(force_stats.files_transferred, 1);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user