From c606fa8132eaa9eed68bde9a42ba25a18d78561b Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 23:13:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(carrier):=20C-03=20=E2=80=94=20decouple=20m?= =?UTF-8?q?anifest=20re-encryption=20with=20dirty=20tracking=20and=20unmou?= =?UTF-8?q?nt=20flush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/carrier.rs | 85 +++++++++++++-- src/mount.rs | 4 + src/storage.rs | 34 ++++-- src/vfs.rs | 7 ++ tests/carrier_model_a_test.rs | 189 +++++++++++++++++++++++++++++++++- 5 files changed, 298 insertions(+), 21 deletions(-) diff --git a/src/carrier.rs b/src/carrier.rs index 010e119..05c6c2d 100644 --- a/src/carrier.rs +++ b/src/carrier.rs @@ -277,6 +277,10 @@ pub struct CarrierFsInner { pub custom_leak_rules: Arc>, pub leak_counter: Arc, pub manifest: CarrierManifest, + pub manifest_dirty: bool, + pub manifest_save_count: u64, + pub dirty_ops_count: u32, + pub last_manifest_save: u64, pub last_activity: Arc, } @@ -284,7 +288,38 @@ impl CarrierFsInner { pub fn is_leak(&self, name: &str) -> bool { self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules) } + + /// Markiert das Manifest als modifiziert (dirty) ohne teure synchrone Neuverschlüsselung (C-03). + /// Erst bei flush(), close(), unmount/drop oder nach Überschreiten von 50 Operationen wird + /// das vollständige 1-MB-Manifest tatsächlich neu verschlüsselt und geschrieben. + /// + /// Konsistenzhinweis: + /// Bei einem abrupten Prozessabbruch gilt der zuletzt gespeicherte Zustand. Dank C-02 + /// (rollierendes Manifest auf Block 0 und Block 1) ist die Integrität stets gewährleistet. + pub fn mark_dirty(&mut self) { + self.manifest_dirty = true; + self.dirty_ops_count += 1; + if self.dirty_ops_count >= 50 { + let _ = self.flush_manifest_if_dirty(); + } + } + + /// Schreibt das Manifest nur, wenn ungespeicherte Änderungen anstehen (C-03). + pub fn flush_manifest_if_dirty(&mut self) -> Result<()> { + if self.manifest_dirty { + self.save_manifest()?; + } + Ok(()) + } + pub fn save_manifest(&mut self) -> Result<()> { + self.manifest_save_count += 1; + self.manifest_dirty = false; + self.dirty_ops_count = 0; + self.last_manifest_save = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); self.manifest.manifest_generation += 1; let manifest_bytes = serde_json::to_vec(&self.manifest)?; if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) { @@ -304,7 +339,7 @@ impl CarrierFsInner { // C-02: Rollierendes Dual-Block-Manifest. // Gerade Generation -> Block 0, ungerade Generation -> Block 1. - let target_block = if self.manifest.manifest_generation % 2 == 0 { + let target_block = if self.manifest.manifest_generation.is_multiple_of(2) { 0 } else { 1 @@ -375,6 +410,14 @@ impl CarrierFsInner { impl Drop for CarrierFsInner { fn drop(&mut self) { + if self.manifest_dirty { + if let Err(e) = self.save_manifest() { + error!( + "Fehler beim finalen Speichern des Carrier-Manifests im Drop: {:?}", + e + ); + } + } crate::windows::unlock_memory(self.dek_outer.as_ptr(), 32); crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32); } @@ -491,6 +534,10 @@ impl CarrierFs { custom_leak_rules: Arc::new(custom_leak_rules), leak_counter: Arc::new(AtomicU64::new(0)), manifest, + manifest_dirty: false, + manifest_save_count: 0, + dirty_ops_count: 0, + last_manifest_save: now, last_activity: Arc::new(AtomicU64::new(now)), }; @@ -519,6 +566,21 @@ impl CarrierFs { ) } + pub fn sync_manifest(&self) -> Result<()> { + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.flush_manifest_if_dirty() + } + + pub fn manifest_save_count(&self) -> u64 { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.manifest_save_count + } + + pub fn is_manifest_dirty(&self) -> bool { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.manifest_dirty + } + pub fn leak_counter(&self) -> Arc { let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); inner.leak_counter.clone() @@ -597,7 +659,7 @@ impl DavFileSystem for CarrierFs { n.size = 0; n.modified_at = now; inner.manifest.inodes.insert(n.id, n.clone()); - let _ = inner.save_manifest(); + inner.mark_dirty(); } n } @@ -628,7 +690,7 @@ impl DavFileSystem for CarrierFs { }; inner.manifest.inodes.insert(new_id, new_inode.clone()); - let _ = inner.save_manifest(); + inner.mark_dirty(); new_inode } else { return Err(FsError::NotFound); @@ -750,7 +812,7 @@ impl DavFileSystem for CarrierFs { }; inner.manifest.inodes.insert(new_id, new_dir); - inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; + inner.mark_dirty(); Ok(()) }) @@ -783,7 +845,7 @@ impl DavFileSystem for CarrierFs { } inner.manifest.inodes.remove(&node.id); - inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; + inner.mark_dirty(); Ok(()) }) @@ -812,7 +874,7 @@ impl DavFileSystem for CarrierFs { } inner.manifest.inodes.remove(&node.id); - inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; + inner.mark_dirty(); Ok(()) }) @@ -869,7 +931,7 @@ impl DavFileSystem for CarrierFs { inode.modified_at = now; } - inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; + inner.mark_dirty(); Ok(()) }) @@ -998,7 +1060,7 @@ impl DavFileSystem for CarrierFs { }; inner.manifest.inodes.insert(new_id, new_inode); - inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; + inner.mark_dirty(); Ok(()) }) @@ -1305,7 +1367,10 @@ impl DavFile for CarrierFile { inode.modified_at = now; } - inner.save_manifest().map_err(|_| FsError::GeneralFailure)?; + inner.mark_dirty(); + inner + .flush_manifest_if_dirty() + .map_err(|_| FsError::GeneralFailure)?; self.meta.size = self.file_size; self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now); @@ -1335,7 +1400,7 @@ impl Drop for CarrierFile { inode.blocks = self.blocks.clone(); inode.modified_at = now; } - let _ = inner.save_manifest(); + inner.mark_dirty(); } } } diff --git a/src/mount.rs b/src/mount.rs index f8503c3..66178b4 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -225,6 +225,7 @@ pub async fn mount_container( ); let fs_leak_counter = fs.leak_counter(); let last_activity = fs.last_activity(); + let fs_clone = fs.clone(); let dav_server = DavHandler::builder() .filesystem(Box::new(fs)) .locksystem(FakeLs::new()) @@ -282,6 +283,7 @@ pub async fn mount_container( // Netzlaufwerk bzw. Verzeichnis einbinden if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) { + let _ = fs_clone.sync_carrier_manifest(); let _ = shutdown_tx.send(true); let _ = server_handle.await; return Err(e); @@ -541,6 +543,7 @@ pub async fn mount_container( } if stealth { + let _ = fs_clone.sync_carrier_manifest(); let _ = unmount_drive(drive_letter); let _ = shutdown_tx.send(true); let _ = server_handle.await; @@ -555,6 +558,7 @@ pub async fn mount_container( #[cfg(not(windows))] println!("Sanctum: Container geschlossen und WebDAV-Server beendet."); } else { + let _ = fs_clone.sync_carrier_manifest(); #[cfg(windows)] { print!( diff --git a/src/storage.rs b/src/storage.rs index 6f3e370..6567621 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -752,13 +752,24 @@ impl Database { } outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0); - let (outer_ct_0, outer_nonce_0, outer_tag_0) = - crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext_0, FORMAT_VERSION, 0)?; + let (outer_ct_0, outer_nonce_0, outer_tag_0) = crate::crypto::encrypt_chunk( + dek_0, + c_id, + 0, + &outer_plaintext_0, + FORMAT_VERSION, + 0, + )?; conn.execute( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 0, 0, ?2, ?3, ?4)", - params![c_id, outer_nonce_0.as_slice(), outer_tag_0.as_slice(), outer_ct_0], + params![ + c_id, + outer_nonce_0.as_slice(), + outer_tag_0.as_slice(), + outer_ct_0 + ], )?; // 2. Block 1 schreiben (Redundante Zweitkopie, Gen 0, C-02) @@ -778,13 +789,24 @@ impl Database { } outer_plaintext_1[32..ct_end_1].copy_from_slice(&inner_ct_1); - let (outer_ct_1, outer_nonce_1, outer_tag_1) = - crate::crypto::encrypt_chunk(dek_0, c_id, 1, &outer_plaintext_1, FORMAT_VERSION, 0)?; + let (outer_ct_1, outer_nonce_1, outer_tag_1) = crate::crypto::encrypt_chunk( + dek_0, + c_id, + 1, + &outer_plaintext_1, + FORMAT_VERSION, + 0, + )?; conn.execute( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 1, 0, ?2, ?3, ?4)", - params![c_id, outer_nonce_1.as_slice(), outer_tag_1.as_slice(), outer_ct_1], + params![ + c_id, + outer_nonce_1.as_slice(), + outer_tag_1.as_slice(), + outer_ct_1 + ], )?; // Blöcke 2..total_blocks-1 mit DEK_0 vorallokieren (C-02) diff --git a/src/vfs.rs b/src/vfs.rs index df96d3b..5d89cd9 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -788,6 +788,13 @@ impl SanctumFs { } } + pub fn sync_carrier_manifest(&self) -> Result<()> { + if let Some(ref cfs) = self.carrier_fs { + cfs.sync_manifest()?; + } + Ok(()) + } + pub fn leak_count(&self) -> u64 { self.leak_counter().load(Ordering::Relaxed) } diff --git a/tests/carrier_model_a_test.rs b/tests/carrier_model_a_test.rs index 4dc6014..f561a4a 100644 --- a/tests/carrier_model_a_test.rs +++ b/tests/carrier_model_a_test.rs @@ -993,8 +993,14 @@ async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() { write: true, ..Default::default() }; - let mut file1 = cfs.open(&file1_path, open_write).await.expect("Create file1"); - file1.write_bytes(Bytes::from_static(b"generation 1 content")).await.unwrap(); + let mut file1 = cfs + .open(&file1_path, open_write) + .await + .expect("Create file1"); + file1 + .write_bytes(Bytes::from_static(b"generation 1 content")) + .await + .unwrap(); file1.flush().await.unwrap(); drop(file1); drop(cfs); @@ -1017,8 +1023,14 @@ async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() { write: true, ..Default::default() }; - let mut file2 = cfs_reloaded.open(&file2_path, open_write2).await.expect("Create file2"); - file2.write_bytes(Bytes::from_static(b"generation 2 content")).await.unwrap(); + let mut file2 = cfs_reloaded + .open(&file2_path, open_write2) + .await + .expect("Create file2"); + file2 + .write_bytes(Bytes::from_static(b"generation 2 content")) + .await + .unwrap(); file2.flush().await.unwrap(); drop(file2); drop(cfs_reloaded); @@ -1071,7 +1083,10 @@ async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() { ) .await .unwrap(); - file3.write_bytes(Bytes::from_static(b"gen 3")).await.unwrap(); + file3 + .write_bytes(Bytes::from_static(b"gen 3")) + .await + .unwrap(); file3.flush().await.unwrap(); drop(file3); drop(cfs_repair); @@ -1133,3 +1148,167 @@ async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() { let _ = std::fs::remove_file(&path); } +#[tokio::test] +async fn test_c03_manifest_dirty_decoupling_and_unmount_flush() { + let path = temp_db_path("c03_dirty"); + let carrier_size_bytes = 5 * 1024 * 1024; // 5 MB + let carrier_name = "carrier_c03.bin"; + + let pass_decoy = "DecoyPass2026!"; + let pass_hidden = "HiddenPass2026!"; + + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + + let salt_0 = generate_salt(); + let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap(); + let dek_0 = generate_dek(); + + let salt_1 = generate_salt(); + let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap(); + let dek_1 = generate_dek(); + + let carrier_node_id = 3i64; + let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap(); + let (wrapped_1, nonce_1, tag_1) = + wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap(); + + let db = Database::open(&path).expect("Open database"); + db.init_schema_with_carrier( + &salt_0, + &kdf_params, + &wrapped_0, + &nonce_0, + &tag_0, + Some(( + carrier_name, + carrier_size_bytes, + &salt_1, + &kdf_params, + &wrapped_1, + &nonce_1, + &tag_1, + &dek_0, + &dek_1, + )), + ) + .expect("Init carrier schema"); + db.checkpoint().unwrap(); + + let meta = db.read_meta().unwrap(); + let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden"); + let cfs = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ) + .expect("CarrierFs load"); + + // 1. Unmittelbar nach Mounten: nicht dirty, 0 Saves + assert_eq!(cfs.manifest_save_count(), 0); + assert!(!cfs.is_manifest_dirty()); + + // 2. Metadaten-Operationen: 3 Ordner anlegen + let dir1 = DavPath::new("/dir1").unwrap(); + let dir2 = DavPath::new("/dir2").unwrap(); + let dir3 = DavPath::new("/dir3").unwrap(); + cfs.create_dir(&dir1).await.expect("create_dir dir1"); + cfs.create_dir(&dir2).await.expect("create_dir dir2"); + cfs.create_dir(&dir3).await.expect("create_dir dir3"); + + // C-03 Verifikation: Manifest ist dirty, aber save_count ist IMMER NOCH 0! + assert!( + cfs.is_manifest_dirty(), + "Manifest must be dirty after create_dir" + ); + assert_eq!( + cfs.manifest_save_count(), + 0, + "No synchronous save_manifest on create_dir (C-03 decoupling)" + ); + + // Datei im Verzeichnis erstellen + let file_path = DavPath::new("/dir1/note.txt").unwrap(); + let open_write = OpenOptions { + create_new: true, + write: true, + ..Default::default() + }; + let mut file = cfs.open(&file_path, open_write).await.expect("open create"); + assert_eq!( + cfs.manifest_save_count(), + 0, + "No synchronous save_manifest on file create" + ); + + // 3. Expliziter Sync über sync_manifest() + cfs.sync_manifest().expect("sync_manifest"); + assert!( + !cfs.is_manifest_dirty(), + "Manifest must be clean after sync" + ); + assert_eq!( + cfs.manifest_save_count(), + 1, + "Save count incremented to 1 after sync" + ); + + // Nochmal sync: da nicht dirty, kein unnötiger Save + cfs.sync_manifest().expect("sync_manifest 2"); + assert_eq!( + cfs.manifest_save_count(), + 1, + "Save count remains 1 when not dirty" + ); + + // 4. In Datei schreiben und flush() ausführen -> führt zu Manifest-Flush + file.write_bytes(Bytes::from_static(b"carrier dirty test payload")) + .await + .unwrap(); + file.flush().await.expect("file flush"); + assert_eq!( + cfs.manifest_save_count(), + 2, + "File flush must flush manifest" + ); + assert!(!cfs.is_manifest_dirty()); + drop(file); + + // 5. Weiteren Ordner anlegen (mark_dirty), dann cfs droppen + let dir4 = DavPath::new("/dir4").unwrap(); + cfs.create_dir(&dir4).await.expect("create_dir dir4"); + assert!(cfs.is_manifest_dirty()); + assert_eq!(cfs.manifest_save_count(), 2); + + // Drop von CarrierFs (Drop-Garantie bei Unmount) + drop(cfs); + + // 6. Neu laden und prüfen, dass dir4 persistiert wurde + let cfs_reloaded = sanctum::carrier::CarrierFs::load( + db.clone(), + carrier_node_id, + std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(auth_hidden.dek().clone()), + auth_hidden.version(), + true, + ) + .expect("CarrierFs reload"); + + assert!( + cfs_reloaded + .read_dir(&dir4, ReadDirMeta::None) + .await + .is_ok(), + "Directory created before drop must exist" + ); + + drop(cfs_reloaded); + drop(db); + let _ = std::fs::remove_file(&path); +}