From 167e4b65fc59a3fb136feeba680ebb90cac87f95 Mon Sep 17 00:00:00 2001 From: harald Date: Sun, 20 Sep 2026 00:15:11 +0200 Subject: [PATCH] feat(carrier): implement carrier format V2 paged manifest and secondary index --- src/carrier.rs | 550 +++++++++++++++---- src/mount.rs | 23 +- src/storage.rs | 78 ++- src/vfs.rs | 12 + tests/carrier_model_a_test.rs | 29 +- tests/carrier_v2_paged_manifest_test.rs | 686 ++++++++++++++++++++++++ 6 files changed, 1241 insertions(+), 137 deletions(-) create mode 100644 tests/carrier_v2_paged_manifest_test.rs diff --git a/src/carrier.rs b/src/carrier.rs index f35323d..5fd30bd 100644 --- a/src/carrier.rs +++ b/src/carrier.rs @@ -27,10 +27,40 @@ use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData}; pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR"; pub const CARRIER_VERSION: u32 = 1; +pub const CARRIER_VERSION_V2: u32 = 2; +pub const CARRIER_PAGE_MAGIC: &[u8; 8] = b"SANCTPAG"; pub const CARRIER_MANIFEST_CAPACITY: usize = CHUNK_SIZE - 64; // 1,048,512 Bytes pub const CARRIER_MANIFEST_WARN_THRESHOLD: usize = CARRIER_MANIFEST_CAPACITY * 8 / 10; // 838,809 Bytes (80%, C-04) -/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0 und 1, C-02). +/// Superblock: globaler Zustand, redundant auf Block 0 und 1 (C-02, Format V2). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CarrierSuperblock { + pub magic: [u8; 8], + pub version: u32, + pub manifest_generation: u64, + pub total_blocks: u32, + pub free_blocks: Vec, + pub next_inode_id: i64, + pub page_block_indices: Vec, // Reihenfolge = Seitenreihenfolge +} + +/// Eine Inode-Tabellenseite (~1 MB Budget, serialisiert in einen Carrier-Block). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CarrierInodePage { + pub magic: [u8; 8], // eigenes Sub-Magic b"SANCTPAG" + pub entries: Vec, +} + +/// Hilfsstruktur zur Typerkennung (V1 vs V2) in Block 0/1. +#[derive(Debug, Deserialize)] +struct VersionProbe { + #[allow(dead_code)] + pub magic: [u8; 8], + pub version: u32, +} + +/// In-Memory-Zustand des Carrier-Manifests. +/// Hält Superblock-Felder und die vollständige Inode-Map für schnellen Zugriff. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CarrierManifest { pub magic: [u8; 8], @@ -40,6 +70,9 @@ pub struct CarrierManifest { pub total_blocks: u32, pub free_blocks: Vec, pub next_inode_id: i64, + #[serde(default)] + pub page_block_indices: Vec, + #[serde(default)] pub inodes: HashMap, } @@ -66,16 +99,28 @@ impl CarrierManifest { }, ); - // Block 0 und Block 1 sind für das redundante Manifest reserviert (C-02). Nutzblöcke sind 2..total_blocks-1. - let free_blocks = (2..total_blocks).collect(); + // Block 0 und Block 1 sind für den Superblock reserviert (C-02). + // Block 2 wird für die erste Inode-Tabellenseite allokiert (falls total_blocks > 2). + // Nutzblöcke starten ab Block 3. + let page_block_indices = if total_blocks > 2 { + vec![2] + } else { + Vec::new() + }; + let free_blocks = if total_blocks > 3 { + (3..total_blocks).collect() + } else { + Vec::new() + }; Self { magic: *CARRIER_MAGIC, - version: CARRIER_VERSION, + version: CARRIER_VERSION_V2, manifest_generation: 0, total_blocks, free_blocks, next_inode_id: 2, + page_block_indices, inodes, } } @@ -279,6 +324,8 @@ pub struct CarrierFsInner { pub custom_leak_rules: Arc>, pub leak_counter: Arc, pub manifest: CarrierManifest, + pub children_index: HashMap>, // parent_id -> [child_id, ...] (D-02) + pub corrupted_pages: usize, // Zähler für übersprungene/beschädigte Seiten (D-01) pub manifest_dirty: bool, pub manifest_save_count: u64, pub dirty_ops_count: u32, @@ -291,13 +338,31 @@ impl CarrierFsInner { self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules) } + /// Baut den Sekundärindex (parent_id -> [child_id, ...]) aus der Inode-Map auf (D-02). + pub fn build_children_index(inodes: &HashMap) -> HashMap> { + let mut index: HashMap> = HashMap::new(); + for (&id, inode) in inodes { + if let Some(parent_id) = inode.parent_id { + index.entry(parent_id).or_default().push(id); + } + } + index + } + + pub fn index_add_child(&mut self, parent_id: i64, child_id: i64) { + let entry = self.children_index.entry(parent_id).or_default(); + if !entry.contains(&child_id) { + entry.push(child_id); + } + } + + pub fn index_remove_child(&mut self, parent_id: i64, child_id: i64) { + if let Some(children) = self.children_index.get_mut(&parent_id) { + children.retain(|&id| id != child_id); + } + } + /// 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; @@ -314,15 +379,37 @@ impl CarrierFsInner { Ok(()) } - /// Ermittelt die aktuelle Bytegröße des Manifests, die Kapazität und den Füllstand in Prozent (C-04). + /// Ermittelt die aktuelle Bytegröße des Manifests, die Kapazität und den Füllstand in Prozent. pub fn manifest_usage(&self) -> (usize, usize, f64) { - let len = serde_json::to_vec(&self.manifest) - .map(|v| v.len()) - .unwrap_or(0); - let pct = (len as f64 / CARRIER_MANIFEST_CAPACITY as f64) * 100.0; - (len, CARRIER_MANIFEST_CAPACITY, pct) + let pages_count = self.manifest.page_block_indices.len().max(1); + let total_page_capacity = pages_count * CARRIER_MANIFEST_CAPACITY; + let mut total_bytes = 0usize; + for inode in self.manifest.inodes.values() { + total_bytes += serde_json::to_vec(inode) + .map(|v| v.len() + 2) + .unwrap_or(200); + } + let pct = if total_page_capacity > 0 { + (total_bytes as f64 / total_page_capacity as f64) * 100.0 + } else { + 0.0 + }; + (total_bytes, total_page_capacity, pct) } + pub fn free_blocks_count(&self) -> usize { + self.manifest.free_blocks.len() + } + + pub fn total_blocks_count(&self) -> u32 { + self.manifest.total_blocks + } + + pub fn page_count(&self) -> usize { + self.manifest.page_block_indices.len() + } + + /// Speichert das Carrier-Manifest im Format V2 (Superblock + Inode-Tabellenseiten, D-04). pub fn save_manifest(&mut self) -> Result<()> { self.manifest_save_count += 1; self.manifest_dirty = false; @@ -332,26 +419,119 @@ impl CarrierFsInner { .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() > CARRIER_MANIFEST_CAPACITY { - bail!( - "Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes). Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse beachten.", - manifest_bytes.len(), - CARRIER_MANIFEST_CAPACITY - ); + + // D-04: Inodes auf Seiten aufteilen (deterministisch nach Inode-ID sortiert) + let mut sorted_inodes: Vec = self.manifest.inodes.values().cloned().collect(); + sorted_inodes.sort_by_key(|i| i.id); + + let mut pages: Vec = Vec::new(); + let mut current_entries = Vec::new(); + let mut current_bytes = 64usize; // Header-Overhead für CarrierInodePage JSON + + for inode in sorted_inodes { + let entry_len = serde_json::to_vec(&inode) + .map(|v| v.len() + 2) + .unwrap_or(256); + if current_bytes + entry_len > CARRIER_MANIFEST_CAPACITY && !current_entries.is_empty() + { + pages.push(CarrierInodePage { + magic: *CARRIER_PAGE_MAGIC, + entries: std::mem::take(&mut current_entries), + }); + current_bytes = 64; + } + current_bytes += entry_len; + current_entries.push(inode); } - if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD { - let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY; - warn!( - "Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).", - pct, - manifest_bytes.len(), + if !current_entries.is_empty() || pages.is_empty() { + pages.push(CarrierInodePage { + magic: *CARRIER_PAGE_MAGIC, + entries: current_entries, + }); + } + + let needed_pages = pages.len(); + let old_page_blocks = self.manifest.page_block_indices.clone(); + let old_page_count = old_page_blocks.len(); + + let mut new_page_blocks = old_page_blocks; + + if needed_pages > old_page_count { + let additional_needed = needed_pages - old_page_count; + if self.manifest.free_blocks.len() < additional_needed { + bail!( + "Nicht genügend freie Blöcke für die Inode-Tabelle (benötigt: {}, frei: {}). Bitte Speicherplatz freigeben oder Container vergrößern.", + additional_needed, + self.manifest.free_blocks.len() + ); + } + for _ in 0..additional_needed { + let block = self.manifest.free_blocks.pop().ok_or_else(|| { + anyhow::anyhow!("Keine freien Blöcke mehr für Inode-Seitenallokation") + })?; + new_page_blocks.push(block); + } + } else if needed_pages < old_page_count { + // D-05: Überzählige Seitenblöcke freigeben und schreddern + let surplus = new_page_blocks.split_off(needed_pages); + for b in surplus { + let _ = shred_carrier_block( + &self.db, + self.carrier_node_id, + b, + &self.dek_outer, + self.format_version, + ); + self.manifest.free_blocks.push(b); + } + } + + // Alle Inode-Tabellenseiten schreiben + for (page_idx, page) in pages.iter().enumerate() { + let block_idx = new_page_blocks[page_idx]; + let page_bytes = serde_json::to_vec(page)?; + if page_bytes.len() > CARRIER_MANIFEST_CAPACITY { + bail!( + "Inode-Tabellenseite #{} überschreitet Blockgröße ({} > {} Bytes)", + page_idx, + page_bytes.len(), + CARRIER_MANIFEST_CAPACITY + ); + } + write_carrier_block( + &self.db, + self.carrier_node_id, + block_idx, + &self.dek_outer, + &self.dek_inner, + &page_bytes, + self.format_version, + )?; + } + + self.manifest.page_block_indices = new_page_blocks.clone(); + self.manifest.version = CARRIER_VERSION_V2; + + // Superblock schreiben (C-02 Dual-Block Rolling Scheme) + let superblock = CarrierSuperblock { + magic: *CARRIER_MAGIC, + version: CARRIER_VERSION_V2, + manifest_generation: self.manifest.manifest_generation, + total_blocks: self.manifest.total_blocks, + free_blocks: self.manifest.free_blocks.clone(), + next_inode_id: self.manifest.next_inode_id, + page_block_indices: new_page_blocks, + }; + + let sb_bytes = serde_json::to_vec(&superblock)?; + if sb_bytes.len() > CARRIER_MANIFEST_CAPACITY { + bail!( + "Superblock überschreitet Blockgröße ({} > {} Bytes)", + sb_bytes.len(), CARRIER_MANIFEST_CAPACITY ); } - // C-02: Rollierendes Dual-Block-Manifest. - // Gerade Generation -> Block 0, ungerade Generation -> Block 1. let target_block = if self.manifest.manifest_generation.is_multiple_of(2) { 0 } else { @@ -364,9 +544,20 @@ impl CarrierFsInner { target_block, &self.dek_outer, &self.dek_inner, - &manifest_bytes, + &sb_bytes, self.format_version, )?; + + // Warnung bei knappem freiem Blockpool + let free_len = self.manifest.free_blocks.len(); + let total = self.manifest.total_blocks; + if total > 0 && (free_len < 20 || (free_len as f64 / total as f64) < 0.05) { + warn!( + "Warnung: Hidden-Vault hat nur noch {} freie Blöcke (von {}).", + free_len, total + ); + } + Ok(()) } @@ -389,6 +580,7 @@ impl CarrierFsInner { Ok(()) } + /// Löst einen Pfad anhand des Sekundärindex auf (D-02, O(Kindknoten) statt O(alle Inodes)). pub fn resolve_path(&self, raw_path: &str) -> Option { let trimmed = raw_path.trim_matches('/'); if trimmed.is_empty() { @@ -399,10 +591,17 @@ impl CarrierFsInner { let mut current_id = 1i64; for (idx, segment) in segments.iter().enumerate() { - let child = self.manifest.inodes.values().find(|inode| { - inode.parent_id == Some(current_id) && inode.name.as_str() == *segment + let child_ids = self.children_index.get(¤t_id)?; + let child_id = child_ids.iter().find(|&&cid| { + self.manifest + .inodes + .get(&cid) + .map(|inode| inode.name.as_str() == *segment) + .unwrap_or(false) })?; + let child = self.manifest.inodes.get(child_id)?; + if idx + 1 < segments.len() && !child.is_dir { return None; } @@ -436,14 +635,29 @@ impl Drop for CarrierFsInner { } } +enum LoadedBlock { + V1(CarrierManifest), + V2(CarrierSuperblock), +} + +impl LoadedBlock { + fn generation(&self) -> u64 { + match self { + LoadedBlock::V1(m) => m.manifest_generation, + LoadedBlock::V2(sb) => sb.manifest_generation, + } + } +} + /// WebDAV-Filesystem-Treiber für den steganografischen Alibi-Carrier (Hidden Vault). #[derive(Clone)] pub struct CarrierFs { - inner: Arc>, + pub inner: Arc>, } impl CarrierFs { - /// Lädt ein bestehendes Carrier-Dateisystem mit redundanter Block-0/Block-1 Prüfung (C-02) + /// Lädt ein bestehendes Carrier-Dateisystem mit redundanter Superblock-Prüfung (C-02), + /// seitenbasierter Inode-Tabelle (D-01), V1->V2 Migrationsunterstützung (D-03) /// und benutzerdefinierten Anti-Leak-Regeln (V-04). pub fn load_with_leak_rules( db: Database, @@ -454,68 +668,62 @@ impl CarrierFs { anti_leak: bool, custom_leak_rules: Vec, ) -> Result { - // C-02: Versuche beide redundante Blöcke (Block 0 und Block 1) zu lesen und zu verifizieren - let block_0_res = read_carrier_block( - &db, - carrier_node_id, - 0, - &dek_outer, - &dek_inner, - format_version, - ) - .and_then(|bytes| { - let m: CarrierManifest = serde_json::from_slice(&bytes)?; - if m.magic != *CARRIER_MAGIC { - bail!("Ungültige Carrier-Magic-Bytes in Block 0"); + let read_and_parse_block = |block_idx: u32| -> Result { + let bytes = read_carrier_block( + &db, + carrier_node_id, + block_idx, + &dek_outer, + &dek_inner, + format_version, + )?; + let probe: VersionProbe = serde_json::from_slice(&bytes)?; + if probe.magic != *CARRIER_MAGIC { + bail!("Ungültige Carrier-Magic-Bytes in Block {block_idx}"); } - Ok(m) - }); - - let block_1_res = read_carrier_block( - &db, - carrier_node_id, - 1, - &dek_outer, - &dek_inner, - format_version, - ) - .and_then(|bytes| { - let m: CarrierManifest = serde_json::from_slice(&bytes)?; - if m.magic != *CARRIER_MAGIC { - bail!("Ungültige Carrier-Magic-Bytes in Block 1"); + if probe.version == CARRIER_VERSION { + let m: CarrierManifest = serde_json::from_slice(&bytes)?; + Ok(LoadedBlock::V1(m)) + } else if probe.version == CARRIER_VERSION_V2 { + let sb: CarrierSuperblock = serde_json::from_slice(&bytes)?; + Ok(LoadedBlock::V2(sb)) + } else { + bail!("Unbekannte Carrier-Formatversion: {}", probe.version); } - Ok(m) - }); + }; - let mut manifest = match (block_0_res, block_1_res) { - (Ok(m0), Ok(m1)) => { - if m1.manifest_generation > m0.manifest_generation { + let block_0_res = read_and_parse_block(0); + let block_1_res = read_and_parse_block(1); + + let loaded_block = match (block_0_res, block_1_res) { + (Ok(b0), Ok(b1)) => { + if b1.generation() > b0.generation() { debug!( - "C-02: Beide Manifest-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})", - m1.manifest_generation, m0.manifest_generation + "C-02: Beide Superblock-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})", + b1.generation(), b0.generation() ); - m1 + b1 } else { debug!( - "C-02: Beide Manifest-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})", - m0.manifest_generation, m1.manifest_generation + "C-02: Beide Superblock-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})", + b0.generation(), b1.generation() ); - m0 + b0 } } - (Ok(m0), Err(e1)) => { + (Ok(b0), Err(e1)) => { debug!( - "C-02: Verwende primäres Manifest aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}", - m0.manifest_generation, e1 + "C-02: Verwende primären Superblock aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}", + b0.generation(), e1 ); - m0 + b0 } - (Err(e0), Ok(m1)) => { + (Err(e0), Ok(b1)) => { warn!( "C-02: Block 0 korrupt oder unlesbar ({})! Erfolgreiche Wiederherstellung aus redundanter Kopie in Block 1 (Gen {}).", - e0, m1.manifest_generation + e0, b1.generation() ); - m1 + b1 } (Err(e0), Err(e1)) => { bail!( @@ -525,20 +733,101 @@ impl CarrierFs { } }; - // Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Manifest-Kopie reservieren - manifest.free_blocks.retain(|&b| b >= 2); - - // C-04: Warnung bei Füllstand über 80% beim Mounten - if let Ok(manifest_bytes) = serde_json::to_vec(&manifest) { - if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD { - let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY; - warn!( - "Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).", - pct, - manifest_bytes.len(), - CARRIER_MANIFEST_CAPACITY - ); + let mut corrupted_pages = 0usize; + let manifest = match loaded_block { + LoadedBlock::V1(mut m) => { + // Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Kopie reservieren + m.free_blocks.retain(|&b| b >= 2); + m } + LoadedBlock::V2(sb) => { + let mut inodes = HashMap::new(); + for &page_block_idx in &sb.page_block_indices { + match read_carrier_block( + &db, + carrier_node_id, + page_block_idx, + &dek_outer, + &dek_inner, + format_version, + ) { + Ok(page_bytes) => { + match serde_json::from_slice::(&page_bytes) { + Ok(page) => { + if page.magic != *CARRIER_PAGE_MAGIC { + error!( + "D-01: Ungültige Magic in Inode-Tabellenseite auf Block {} (erwartet SANCTPAG)", + page_block_idx + ); + corrupted_pages += 1; + } else { + for entry in page.entries { + inodes.insert(entry.id, entry); + } + } + } + Err(e) => { + error!( + "D-01: Deserialisierungsfehler in Inode-Tabellenseite auf Block {}: {}", + page_block_idx, e + ); + corrupted_pages += 1; + } + } + } + Err(e) => { + error!( + "D-01: Lesefehler auf Inode-Tabellenseite Block {}: {}", + page_block_idx, e + ); + corrupted_pages += 1; + } + } + } + + // Sicherstellen, dass das Wurzelverzeichnis '/' (Inode 1) vorhanden ist + inodes.entry(1).or_insert_with(|| { + warn!("Wurzelknoten (Inode 1) fehlt in geladenen Seiten, wird rekonstruiert."); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + CarrierInode { + id: 1, + parent_id: None, + name: String::new(), + is_dir: true, + size: 0, + created_at: now, + modified_at: now, + blocks: Vec::new(), + } + }); + + CarrierManifest { + magic: sb.magic, + version: sb.version, + manifest_generation: sb.manifest_generation, + total_blocks: sb.total_blocks, + free_blocks: sb.free_blocks, + next_inode_id: sb.next_inode_id, + page_block_indices: sb.page_block_indices, + inodes, + } + } + }; + + // D-02: Sekundärindex aufbauen + let children_index = CarrierFsInner::build_children_index(&manifest.inodes); + + // Warnung bei knappem freiem Blockpool + let free_len = manifest.free_blocks.len(); + let total = manifest.total_blocks; + if total > 0 && (free_len < 20 || (free_len as f64 / total as f64) < 0.05) { + warn!( + "Warnung: Hidden-Vault hat nur noch {} freie Blöcke (von {}).", + free_len, total + ); } let now = SystemTime::now() @@ -560,6 +849,8 @@ impl CarrierFs { custom_leak_rules: Arc::new(custom_leak_rules), leak_counter: Arc::new(AtomicU64::new(0)), manifest, + children_index, + corrupted_pages, manifest_dirty: false, manifest_save_count: 0, dirty_ops_count: 0, @@ -612,6 +903,26 @@ impl CarrierFs { inner.manifest_usage() } + pub fn corrupted_pages(&self) -> usize { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.corrupted_pages + } + + pub fn block_stats(&self) -> (usize, u32) { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + (inner.free_blocks_count(), inner.total_blocks_count()) + } + + pub fn page_count(&self) -> usize { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.page_count() + } + + pub fn resolve_path(&self, raw_path: &str) -> Option { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.resolve_path(raw_path) + } + pub fn leak_counter(&self) -> Arc { let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); inner.leak_counter.clone() @@ -721,6 +1032,7 @@ impl DavFileSystem for CarrierFs { }; inner.manifest.inodes.insert(new_id, new_inode.clone()); + inner.index_add_child(parent.id, new_id); inner.mark_dirty(); new_inode } else { @@ -750,11 +1062,14 @@ impl DavFileSystem for CarrierFs { return Err(FsError::Forbidden); } - let entries: Vec, FsError>> = inner - .manifest - .inodes - .values() - .filter(|child| child.parent_id == Some(node.id)) + let child_ids = inner + .children_index + .get(&node.id) + .cloned() + .unwrap_or_default(); + let entries: Vec, FsError>> = child_ids + .into_iter() + .filter_map(|cid| inner.manifest.inodes.get(&cid)) .filter(|child| { if inner.is_leak(&child.name) { inner.leak_counter.fetch_add(1, Ordering::Relaxed); @@ -843,6 +1158,7 @@ impl DavFileSystem for CarrierFs { }; inner.manifest.inodes.insert(new_id, new_dir); + inner.index_add_child(parent.id, new_id); inner.mark_dirty(); Ok(()) @@ -864,17 +1180,20 @@ impl DavFileSystem for CarrierFs { return Err(FsError::Forbidden); } - // Prüfe, ob das Verzeichnis leer ist + // Prüfe, ob das Verzeichnis leer ist (via Sekundärindex) let has_children = inner - .manifest - .inodes - .values() - .any(|child| child.parent_id == Some(node.id)); + .children_index + .get(&node.id) + .is_some_and(|children| !children.is_empty()); if has_children { return Err(FsError::Forbidden); } + if let Some(p_id) = node.parent_id { + inner.index_remove_child(p_id, node.id); + } + inner.children_index.remove(&node.id); inner.manifest.inodes.remove(&node.id); inner.mark_dirty(); @@ -904,6 +1223,9 @@ impl DavFileSystem for CarrierFs { inner.manifest.free_blocks.push(b); } + if let Some(p_id) = node.parent_id { + inner.index_remove_child(p_id, node.id); + } inner.manifest.inodes.remove(&node.id); inner.mark_dirty(); @@ -948,6 +1270,10 @@ impl DavFileSystem for CarrierFs { ); inner.manifest.free_blocks.push(b); } + if let Some(p_id) = dest.parent_id { + inner.index_remove_child(p_id, dest.id); + } + inner.children_index.remove(&dest.id); inner.manifest.inodes.remove(&dest.id); } @@ -956,8 +1282,15 @@ impl DavFileSystem for CarrierFs { .map(|d| d.as_secs()) .unwrap_or(0); + let old_parent_id = node.parent_id; + let new_parent_id = to_parent.id; + if let Some(old_p) = old_parent_id { + inner.index_remove_child(old_p, node.id); + } + inner.index_add_child(new_parent_id, node.id); + if let Some(inode) = inner.manifest.inodes.get_mut(&node.id) { - inode.parent_id = Some(to_parent.id); + inode.parent_id = Some(new_parent_id); inode.name = to_name.to_string(); inode.modified_at = now; } @@ -1013,6 +1346,10 @@ impl DavFileSystem for CarrierFs { ); inner.manifest.free_blocks.push(b); } + if let Some(p_id) = dest.parent_id { + inner.index_remove_child(p_id, dest.id); + } + inner.children_index.remove(&dest.id); inner.manifest.inodes.remove(&dest.id); } @@ -1091,6 +1428,7 @@ impl DavFileSystem for CarrierFs { }; inner.manifest.inodes.insert(new_id, new_inode); + inner.index_add_child(to_parent.id, new_id); inner.mark_dirty(); Ok(()) diff --git a/src/mount.rs b/src/mount.rs index e8a1ff2..21047d0 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -423,17 +423,24 @@ pub async fn mount_container( println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)"); } if vault_id == 1 { - if let Some((len, cap, pct)) = fs_clone.carrier_manifest_usage() { - if len >= crate::carrier::CARRIER_MANIFEST_WARN_THRESHOLD { + if let Some(corrupted) = fs_clone.carrier_corrupted_pages() { + if corrupted > 0 { println!( - " {} Warnung (C-04): Hidden-Vault Manifest zu {:.1}% gefüllt ({}/{} Bytes).", - ui::yellow("[!]"), - pct, - len, - cap + " {} Warnung (D-01): {} beschädigte Inode-Tabellenseite(n) im Hidden Vault übersprungen!", + ui::red("[!]"), + corrupted ); + } + } + if let Some((free_blocks, total_blocks)) = fs_clone.carrier_block_stats() { + if total_blocks > 0 + && (free_blocks < 20 || (free_blocks as f64 / total_blocks as f64) < 0.05) + { println!( - " Empfohlene Obergrenze beachten: ca. 7.000 Dateien/Verzeichnisse im Hidden Vault." + " {} Warnung: Hidden Vault hat nur noch {} freie Blöcke (von {}).", + ui::yellow("[!]"), + free_blocks, + total_blocks ); } } diff --git a/src/storage.rs b/src/storage.rs index a11b5bf..2e488da 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -713,8 +713,8 @@ impl Database { )?; let c_id = conn.last_insert_rowid(); - // Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten) - let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(2) as u32; + // Berechne Blockanzahl (min. 3 Blöcke: Block 0/1 für Superblock, Block 2 für Inode-Seite) + let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(3) as u32; // Slot 1 (Hidden Vault) einfügen let params_json_1 = serde_json::to_string(h_params)?; @@ -732,13 +732,67 @@ impl Database { ], )?; - // Initialisiere CarrierManifest für redundante Blöcke 0 und 1 (C-02) + // Initialisiere Carrier-Format V2 (Superblock auf Block 0/1, Inode-Seite auf Block 2) let manifest = crate::carrier::CarrierManifest::new(total_blocks); - let manifest_bytes = serde_json::to_vec(&manifest)?; - // 1. Block 0 schreiben (Primärkopie, Gen 0) + // 1. Block 2 schreiben: Erste Inode-Tabellenseite mit Wurzelverzeichnis '/' + let root_page = crate::carrier::CarrierInodePage { + magic: *crate::carrier::CARRIER_PAGE_MAGIC, + entries: manifest.inodes.values().cloned().collect(), + }; + let root_page_bytes = serde_json::to_vec(&root_page)?; + + let (inner_ct_2, inner_nonce_2, inner_tag_2) = + crate::crypto::encrypt_chunk(dek_1, c_id, 2, &root_page_bytes, FORMAT_VERSION, 0)?; + let inner_ct_len_2 = inner_ct_2.len() as u32; + + let mut outer_plaintext_2 = vec![0u8; CHUNK_SIZE]; + OsRng.fill_bytes(&mut outer_plaintext_2); + + outer_plaintext_2[0..12].copy_from_slice(&inner_nonce_2); + outer_plaintext_2[12..28].copy_from_slice(&inner_tag_2); + outer_plaintext_2[28..32].copy_from_slice(&inner_ct_len_2.to_le_bytes()); + let ct_end_2 = 32 + inner_ct_2.len(); + if ct_end_2 > CHUNK_SIZE { + bail!("Inode-Page-Payload zu groß für Block 2"); + } + outer_plaintext_2[32..ct_end_2].copy_from_slice(&inner_ct_2); + + let (outer_ct_2, outer_nonce_2, outer_tag_2) = crate::crypto::encrypt_chunk( + dek_0, + c_id, + 2, + &outer_plaintext_2, + FORMAT_VERSION, + 0, + )?; + + conn.execute( + "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) + VALUES (?1, 2, 0, ?2, ?3, ?4)", + params![ + c_id, + outer_nonce_2.as_slice(), + outer_tag_2.as_slice(), + outer_ct_2 + ], + )?; + + // 2. Superblock (Block 0 & 1, redundante Zweitkopie C-02, Format V2) + let superblock = crate::carrier::CarrierSuperblock { + magic: *crate::carrier::CARRIER_MAGIC, + version: crate::carrier::CARRIER_VERSION_V2, + manifest_generation: 0, + total_blocks, + free_blocks: manifest.free_blocks.clone(), + next_inode_id: manifest.next_inode_id, + page_block_indices: vec![2], + }; + let sb_bytes = serde_json::to_vec(&superblock)?; + + // Block 0 schreiben (Primärkopie) let (inner_ct_0, inner_nonce_0, inner_tag_0) = - crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, 0)?; + crate::crypto::encrypt_chunk(dek_1, c_id, 0, &sb_bytes, FORMAT_VERSION, 0)?; let inner_ct_len_0 = inner_ct_0.len() as u32; let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE]; @@ -749,7 +803,7 @@ impl Database { outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes()); let ct_end_0 = 32 + inner_ct_0.len(); if ct_end_0 > CHUNK_SIZE { - bail!("Manifest-Payload zu groß für Block 0"); + bail!("Superblock-Payload zu groß für Block 0"); } outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0); @@ -773,9 +827,9 @@ impl Database { ], )?; - // 2. Block 1 schreiben (Redundante Zweitkopie, Gen 0, C-02) + // Block 1 schreiben (Redundante Zweitkopie, C-02) let (inner_ct_1, inner_nonce_1, inner_tag_1) = - crate::crypto::encrypt_chunk(dek_1, c_id, 1, &manifest_bytes, FORMAT_VERSION, 0)?; + crate::crypto::encrypt_chunk(dek_1, c_id, 1, &sb_bytes, FORMAT_VERSION, 0)?; let inner_ct_len_1 = inner_ct_1.len() as u32; let mut outer_plaintext_1 = vec![0u8; CHUNK_SIZE]; @@ -786,7 +840,7 @@ impl Database { outer_plaintext_1[28..32].copy_from_slice(&inner_ct_len_1.to_le_bytes()); let ct_end_1 = 32 + inner_ct_1.len(); if ct_end_1 > CHUNK_SIZE { - bail!("Manifest-Payload zu groß für Block 1"); + bail!("Superblock-Payload zu groß für Block 1"); } outer_plaintext_1[32..ct_end_1].copy_from_slice(&inner_ct_1); @@ -810,7 +864,7 @@ impl Database { ], )?; - // Blöcke 2..total_blocks-1 mit DEK_0 vorallokieren (C-02) + // 3. Blöcke 3..total_blocks-1 mit DEK_0 vorallokieren let mut chunk_stmt = conn.prepare( "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, ?2, 0, ?3, ?4, ?5)", @@ -820,7 +874,7 @@ impl Database { OsRng.fill_bytes(&mut dummy_noise); conn.execute_batch("BEGIN TRANSACTION;")?; - for b in 2..total_blocks { + for b in 3..total_blocks { let (ct, nonce, tag) = crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?; chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?; diff --git a/src/vfs.rs b/src/vfs.rs index 52aa913..dc81034 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -799,6 +799,18 @@ impl SanctumFs { self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage()) } + pub fn carrier_corrupted_pages(&self) -> Option { + self.carrier_fs.as_ref().map(|cfs| cfs.corrupted_pages()) + } + + pub fn carrier_block_stats(&self) -> Option<(usize, u32)> { + self.carrier_fs.as_ref().map(|cfs| cfs.block_stats()) + } + + pub fn carrier_page_count(&self) -> Option { + self.carrier_fs.as_ref().map(|cfs| cfs.page_count()) + } + 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 54518f8..bfae705 100644 --- a/tests/carrier_model_a_test.rs +++ b/tests/carrier_model_a_test.rs @@ -1436,21 +1436,28 @@ async fn test_c04_manifest_capacity_limit_and_warning() { } } - // sync_manifest() MUSS jetzt mit Fehler abbrechen - let err = cfs_loaded.sync_manifest().unwrap_err(); - let err_str = err.to_string(); + // In Format V2 (v0.9.0): Die 1-MB-Grenze bricht NICHT mehr ab, sondern allokiert eine zusätzliche Seite + cfs_loaded + .sync_manifest() + .expect("sync_manifest with > 1 MB must succeed in V2 via paged manifest"); assert!( - err_str.contains("Carrier-Manifest überschreitet die maximale Blockgröße"), - "Error message must specify carrier capacity limit: {}", - err_str - ); - assert!( - err_str.contains("7.000 Dateien/Verzeichnisse"), - "Error message must recommend file limit: {}", - err_str + cfs_loaded.page_count() >= 2, + "Page count must expand to at least 2 pages" ); + // Reloaden und prüfen, ob beide Seiten geladen werden drop(cfs_loaded); + let cfs_paged = 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 paged manifest"); + assert!(cfs_paged.page_count() >= 2); + drop(cfs_paged); drop(db); let _ = std::fs::remove_file(&path); } diff --git a/tests/carrier_v2_paged_manifest_test.rs b/tests/carrier_v2_paged_manifest_test.rs new file mode 100644 index 0000000..44539c2 --- /dev/null +++ b/tests/carrier_v2_paged_manifest_test.rs @@ -0,0 +1,686 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Instant; + +use bytes::Bytes; +use dav_server::davpath::DavPath; +use dav_server::fs::{DavFileSystem, OpenOptions}; +use rand::rngs::OsRng; +use rand::RngCore; + +use sanctum::carrier::{ + write_carrier_block, CarrierFs, CarrierFsInner, CarrierInode, CarrierManifest, CARRIER_MAGIC, + CARRIER_VERSION, CARRIER_VERSION_V2, +}; +use sanctum::crypto::{ + derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams, + MIN_MEMORY_COST_KIB, MIN_TIME_COST, +}; +use sanctum::storage::{Database, UnlockedKeys}; + +fn temp_db_path(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let id: u64 = OsRng.next_u64(); + path.push(format!("sanctum_test_v2_{}_{}.sanctum", prefix, id)); + path +} + +struct TestEnv { + pub path: PathBuf, + pub db: Database, + pub carrier_node_id: i64, + pub auth_hidden: UnlockedKeys, +} + +impl Drop for TestEnv { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn setup_carrier_env(prefix: &str, size_mb: usize) -> TestEnv { + let path = temp_db_path(prefix); + let carrier_size_bytes = (size_mb * 1024 * 1024) as u64; + let carrier_name = "carrier_v2.dat"; + let pass_decoy = "DecoyPassword2026!"; + let pass_hidden = "SuperSecretHiddenPassword2026!"; + + 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"); + + TestEnv { + path, + db, + carrier_node_id, + auth_hidden, + } +} + +fn load_carrier_fs(env: &TestEnv) -> CarrierFs { + CarrierFs::load( + env.db.clone(), + env.carrier_node_id, + std::sync::Arc::new(env.auth_hidden.carrier_dek().unwrap().clone()), + std::sync::Arc::new(env.auth_hidden.dek().clone()), + env.auth_hidden.version(), + true, + ) + .expect("Load CarrierFs") +} + +/// 1. Kapazitätstest: >10.000 Inodes künstlich erzeugen, über mehrere Seiten speichern und neu laden +#[tokio::test] +async fn test_capacity_large_inode_table() { + let env = setup_carrier_env("capacity_large", 25); + let cfs = load_carrier_fs(&env); + + // Initial hat ein frischer Container 1 Seite (Block 2) + assert_eq!(cfs.page_count(), 1); + + // 10.000 Inodes direkt in den Speicher einfügen + { + let mut inner = cfs.inner.lock().unwrap(); + for i in 0..10_000 { + let id = inner.manifest.next_inode_id; + inner.manifest.next_inode_id += 1; + let inode = CarrierInode { + id, + parent_id: Some(1), + name: format!("file_{:05}.txt", i), + is_dir: false, + size: i % 500, + created_at: 1_700_000_000 + i, + modified_at: 1_700_000_000 + i, + blocks: Vec::new(), + }; + inner.manifest.inodes.insert(id, inode); + inner.index_add_child(1, id); + } + inner.mark_dirty(); + } + + // Speichern erzwingen + cfs.sync_manifest().expect("Sync 10.000 inodes"); + + // In V2 müssen die 10.000 Inodes auf mindestens 2 Seiten aufgeteilt worden sein + let pages_after_save = cfs.page_count(); + assert!( + pages_after_save >= 2, + "10.000 Inodes müssen auf mindestens 2 Seiten aufgeteilt werden, tatsächlich: {}", + pages_after_save + ); + + drop(cfs); + + // Neu aus Trägerdatei laden + let cfs_reloaded = load_carrier_fs(&env); + assert_eq!(cfs_reloaded.page_count(), pages_after_save); + assert_eq!(cfs_reloaded.corrupted_pages(), 0); + + // Prüfe, dass beliebige Dateien korrekt existieren und Pfade auflösen + let test_file_1 = cfs_reloaded.resolve_path("/file_00000.txt"); + assert!(test_file_1.is_some()); + assert_eq!(test_file_1.unwrap().name, "file_00000.txt"); + + let test_file_mid = cfs_reloaded.resolve_path("/file_04999.txt"); + assert!(test_file_mid.is_some()); + assert_eq!(test_file_mid.unwrap().name, "file_04999.txt"); + + let test_file_end = cfs_reloaded.resolve_path("/file_09999.txt"); + assert!(test_file_end.is_some()); + assert_eq!(test_file_end.unwrap().name, "file_09999.txt"); + + let nonexistent = cfs_reloaded.resolve_path("/file_10000.txt"); + assert!(nonexistent.is_none()); +} + +/// 2. Migrationstest: Einen V1-Container laden, modifizieren, als V2 speichern und verifizieren +#[tokio::test] +async fn test_v1_to_v2_migration() { + let env = setup_carrier_env("migration_v1_v2", 10); + + // Einen V1-Container konstruieren: Block 0 und Block 1 halten CarrierManifest mit version = 1 + let mut v1_manifest = CarrierManifest { + magic: *CARRIER_MAGIC, + version: CARRIER_VERSION, // 1 + manifest_generation: 1, + total_blocks: 10, + free_blocks: (2..10).collect(), + next_inode_id: 3, + page_block_indices: Vec::new(), + inodes: HashMap::new(), + }; + v1_manifest.inodes.insert( + 1, + CarrierInode { + id: 1, + parent_id: None, + name: String::new(), + is_dir: true, + size: 0, + created_at: 1000, + modified_at: 1000, + blocks: Vec::new(), + }, + ); + v1_manifest.inodes.insert( + 2, + CarrierInode { + id: 2, + parent_id: Some(1), + name: "original_v1.txt".to_string(), + is_dir: false, + size: 128, + created_at: 1000, + modified_at: 1000, + blocks: Vec::new(), + }, + ); + + let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap(); + let dek_outer = env.auth_hidden.carrier_dek().unwrap(); + let dek_inner = env.auth_hidden.dek(); + write_carrier_block( + &env.db, + env.carrier_node_id, + 0, + &dek_outer, + &dek_inner, + &v1_bytes, + env.auth_hidden.version(), + ) + .unwrap(); + write_carrier_block( + &env.db, + env.carrier_node_id, + 1, + &dek_outer, + &dek_inner, + &v1_bytes, + env.auth_hidden.version(), + ) + .unwrap(); + + // 1. Unter v0.9.0 laden: V1-Format wird transparent erkannt + let cfs = load_carrier_fs(&env); + { + let inner = cfs.inner.lock().unwrap(); + assert_eq!(inner.manifest.version, CARRIER_VERSION); + } + let orig = cfs.resolve_path("/original_v1.txt"); + assert!(orig.is_some()); + assert_eq!(orig.unwrap().id, 2); + + // 2. Änderung vornehmen: Datei hinzufügen (löst Dirty-Tracking aus) + let new_file_path = DavPath::new("/new_v2.txt").unwrap(); + let mut file = cfs + .open( + &new_file_path, + OpenOptions { + create_new: true, + write: true, + ..Default::default() + }, + ) + .await + .expect("Create new file in migrated container"); + file.write_bytes(Bytes::from_static(b"migrated to v2")) + .await + .unwrap(); + file.flush().await.unwrap(); + drop(file); + + // Explizit synchronisieren + cfs.sync_manifest().expect("Sync manifest during migration"); + drop(cfs); + + // 3. Neu laden: Jetzt muss das Dateisystem im Format V2 vorliegen + let cfs_reloaded = load_carrier_fs(&env); + { + let inner = cfs_reloaded.inner.lock().unwrap(); + assert_eq!(inner.manifest.version, CARRIER_VERSION_V2); + assert!(!inner.manifest.page_block_indices.is_empty()); + } + + // Beide Dateien müssen vorhanden sein + assert!(cfs_reloaded.resolve_path("/original_v1.txt").is_some()); + assert!(cfs_reloaded.resolve_path("/new_v2.txt").is_some()); +} + +/// 3. Teilausfalltest (D-01 Fail-Soft): Beschädigung einer einzelnen Inode-Seite führt nicht zum Totalverlust +#[tokio::test] +async fn test_partial_page_corruption_resilience() { + let env = setup_carrier_env("partial_corruption", 20); + let cfs = load_carrier_fs(&env); + + // Genügend Inodes erzeugen, um 2 Seiten zu belegen + { + let mut inner = cfs.inner.lock().unwrap(); + for i in 0..1500 { + let id = inner.manifest.next_inode_id; + inner.manifest.next_inode_id += 1; + let name = if i < 750 { + format!("alpha_{:04}_{}", i, "a".repeat(800)) + } else { + format!("omega_{:04}_{}", i, "z".repeat(800)) + }; + let inode = CarrierInode { + id, + parent_id: Some(1), + name, + is_dir: false, + size: 10, + created_at: 2000, + modified_at: 2000, + blocks: Vec::new(), + }; + inner.manifest.inodes.insert(id, inode); + inner.index_add_child(1, id); + } + inner.mark_dirty(); + } + + cfs.sync_manifest().expect("Sync 2 pages"); + let page_blocks = { + let inner = cfs.inner.lock().unwrap(); + inner.manifest.page_block_indices.clone() + }; + assert!( + page_blocks.len() >= 2, + "Benötigt mindestens 2 Seiten, hat: {}", + page_blocks.len() + ); + + let second_page_block = page_blocks[1]; + drop(cfs); + + // 2. Gezielte Beschädigung: Zweite Seite in SQLite mit Rauschen überschreiben + { + let conn = rusqlite::Connection::open(&env.path).unwrap(); + let mut noise = vec![0u8; 500]; + OsRng.fill_bytes(&mut noise); + conn.execute( + "UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = ?3", + rusqlite::params![noise, env.carrier_node_id, second_page_block], + ) + .unwrap(); + } + + // 3. Neu laden: Darf NICHT abbrechen (Fail-Soft), sondern überspringt die defekte Seite + let cfs_reloaded = load_carrier_fs(&env); + assert_eq!( + cfs_reloaded.corrupted_pages(), + 1, + "Muss genau 1 beschädigte Seite protokollieren" + ); + + // Wurzelverzeichnis '/' muss intakt sein + assert!(cfs_reloaded.resolve_path("/").is_some()); + + // Einträge der ersten, unbeschädigten Seite müssen weiterhin vollständig auffindbar sein + let alpha_0 = cfs_reloaded.resolve_path(&format!("/alpha_0000_{}", "a".repeat(800))); + assert!( + alpha_0.is_some(), + "Dateien aus der intakten Seite 0 müssen lesbar bleiben" + ); +} + +/// 4. Index-Konsistenztest (D-02): Operationsfolge gegen frisch aufgebauten Referenzindex abgleichen +#[tokio::test] +async fn test_children_index_consistency() { + let env = setup_carrier_env("children_consistency", 10); + let cfs = load_carrier_fs(&env); + + // 1. Verzeichnisse anlegen + cfs.create_dir(&DavPath::new("/docs").unwrap()) + .await + .unwrap(); + cfs.create_dir(&DavPath::new("/docs/work").unwrap()) + .await + .unwrap(); + cfs.create_dir(&DavPath::new("/photos").unwrap()) + .await + .unwrap(); + + // 2. Dateien anlegen + let opt = OpenOptions { + create_new: true, + write: true, + ..Default::default() + }; + let mut f1 = cfs + .open(&DavPath::new("/docs/report.txt").unwrap(), opt.clone()) + .await + .unwrap(); + f1.write_bytes(Bytes::from_static(b"report")).await.unwrap(); + f1.flush().await.unwrap(); + drop(f1); + + let mut f2 = cfs + .open(&DavPath::new("/docs/work/notes.txt").unwrap(), opt.clone()) + .await + .unwrap(); + f2.write_bytes(Bytes::from_static(b"notes")).await.unwrap(); + f2.flush().await.unwrap(); + drop(f2); + + let mut f3 = cfs + .open(&DavPath::new("/photos/pic.jpg").unwrap(), opt.clone()) + .await + .unwrap(); + f3.write_bytes(Bytes::from_static(b"pic")).await.unwrap(); + f3.flush().await.unwrap(); + drop(f3); + + // 3. Verschieben/Umbenennen + cfs.rename( + &DavPath::new("/docs/work/notes.txt").unwrap(), + &DavPath::new("/photos/notes_moved.txt").unwrap(), + ) + .await + .unwrap(); + + // 4. Datei löschen + cfs.remove_file(&DavPath::new("/docs/report.txt").unwrap()) + .await + .unwrap(); + + // 5. Verzeichnis löschen + cfs.remove_dir(&DavPath::new("/docs/work").unwrap()) + .await + .unwrap(); + + // 6. Abgleich: children_index gegen frisch aufgebauten Referenzindex prüfen + { + let inner = cfs.inner.lock().unwrap(); + let reference_index = CarrierFsInner::build_children_index(&inner.manifest.inodes); + + for (parent_id, ref_children) in &reference_index { + let actual_children = inner.children_index.get(parent_id); + assert!( + actual_children.is_some(), + "Parent {} fehlt im children_index", + parent_id + ); + let mut ref_sorted = ref_children.clone(); + ref_sorted.sort(); + let mut act_sorted = actual_children.unwrap().clone(); + act_sorted.sort(); + assert_eq!( + ref_sorted, act_sorted, + "Inkonsistenz der Kinder für Parent {}", + parent_id + ); + } + + // Auch die Umkehrung prüfen (keine Geister-Einträge) + for (parent_id, actual_children) in &inner.children_index { + if actual_children.is_empty() { + continue; + } + assert!( + reference_index.contains_key(parent_id), + "children_index enthält verwaisten Parent {}", + parent_id + ); + } + } +} + +/// 5. Performance-Sanity-Check: Pfadauflösung bei 5.000 Geschwistern muss sub-millisekündlich sein (D-02) +#[tokio::test] +async fn test_secondary_index_path_resolution_speed() { + let env = setup_carrier_env("perf_resolution", 15); + let cfs = load_carrier_fs(&env); + + cfs.create_dir(&DavPath::new("/big_dir").unwrap()) + .await + .unwrap(); + + let big_dir_node = cfs.resolve_path("/big_dir").expect("big_dir node"); + + // 5.000 Kindknoten in /big_dir anlegen + { + let mut inner = cfs.inner.lock().unwrap(); + for i in 0..5_000 { + let id = inner.manifest.next_inode_id; + inner.manifest.next_inode_id += 1; + let inode = CarrierInode { + id, + parent_id: Some(big_dir_node.id), + name: format!("child_{:04}.txt", i), + is_dir: false, + size: 10, + created_at: 3000, + modified_at: 3000, + blocks: Vec::new(), + }; + inner.manifest.inodes.insert(id, inode); + inner.index_add_child(big_dir_node.id, id); + } + inner.mark_dirty(); + } + + // Messung: Pfadauflösung für den letzten Eintrag + let start = Instant::now(); + let target = cfs.resolve_path("/big_dir/child_4999.txt"); + let elapsed = start.elapsed(); + + assert!(target.is_some()); + assert_eq!(target.unwrap().name, "child_4999.txt"); + + // Der Sekundärindex muss den Eintrag in unter 5 Millisekunden finden (typisch < 0.2 ms) + assert!( + elapsed.as_millis() < 5, + "Pfadauflösung dauerte zu lange: {:?}", + elapsed + ); +} + +/// 6. Platzmangel-Migrationstest: Migration eines Containers ohne freie Blöcke bricht sauber ab (D-03) +#[tokio::test] +async fn test_out_of_space_migration_aborts_cleanly() { + let env = setup_carrier_env("migration_no_space", 5); + + // V1-Manifest mit 0 freien Blöcken (free_blocks ist leer) + let mut v1_manifest = CarrierManifest { + magic: *CARRIER_MAGIC, + version: CARRIER_VERSION, + manifest_generation: 1, + total_blocks: 2, + free_blocks: Vec::new(), // Keine freien Blöcke! + next_inode_id: 2, + page_block_indices: Vec::new(), + inodes: HashMap::new(), + }; + v1_manifest.inodes.insert( + 1, + CarrierInode { + id: 1, + parent_id: None, + name: String::new(), + is_dir: true, + size: 0, + created_at: 1000, + modified_at: 1000, + blocks: Vec::new(), + }, + ); + + let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap(); + let dek_outer = env.auth_hidden.carrier_dek().unwrap(); + let dek_inner = env.auth_hidden.dek(); + write_carrier_block( + &env.db, + env.carrier_node_id, + 0, + &dek_outer, + &dek_inner, + &v1_bytes, + env.auth_hidden.version(), + ) + .unwrap(); + write_carrier_block( + &env.db, + env.carrier_node_id, + 1, + &dek_outer, + &dek_inner, + &v1_bytes, + env.auth_hidden.version(), + ) + .unwrap(); + + let cfs = load_carrier_fs(&env); + + // Versuch zu speichern muss mit klarer Fehlermeldung fehlschlagen + let res = { + let mut inner = cfs.inner.lock().unwrap(); + inner.mark_dirty(); + inner.save_manifest() + }; + + assert!(res.is_err()); + let err_msg = res.unwrap_err().to_string(); + assert!( + err_msg.contains("Nicht genügend freie Blöcke"), + "Fehlermeldung muss auf Speichermangel hinweisen: {}", + err_msg + ); + + drop(cfs); + + // Trägerdatei muss weiterhin intakt als V1 ladbar sein + let cfs_recheck = load_carrier_fs(&env); + { + let inner = cfs_recheck.inner.lock().unwrap(); + assert_eq!(inner.manifest.version, CARRIER_VERSION); + } +} + +/// 7. Seitenfreigabe (D-05): Überzählige Seitenblöcke werden bei Schrumpfung geschreddert und freigegeben +#[tokio::test] +async fn test_page_block_reclaiming_on_shrink() { + let env = setup_carrier_env("page_reclaiming", 25); + let cfs = load_carrier_fs(&env); + + let initial_free = { + let inner = cfs.inner.lock().unwrap(); + inner.manifest.free_blocks.len() + }; + + // 1. Viele Inodes erzeugen, sodass mindestens 3 Seiten belegt werden + let mut added_ids = Vec::new(); + { + let mut inner = cfs.inner.lock().unwrap(); + for i in 0..2500 { + let id = inner.manifest.next_inode_id; + inner.manifest.next_inode_id += 1; + added_ids.push(id); + let inode = CarrierInode { + id, + parent_id: Some(1), + name: format!("large_item_{:04}_{}", i, "x".repeat(800)), + is_dir: false, + size: 10, + created_at: 4000, + modified_at: 4000, + blocks: Vec::new(), + }; + inner.manifest.inodes.insert(id, inode); + inner.index_add_child(1, id); + } + inner.mark_dirty(); + } + + cfs.sync_manifest().expect("Sync 3 pages"); + let pages_before = cfs.page_count(); + assert!( + pages_before >= 3, + "Muss mindestens 3 Seiten belegen, hat: {}", + pages_before + ); + + let free_after_expand = { + let inner = cfs.inner.lock().unwrap(); + inner.manifest.free_blocks.len() + }; + assert_eq!(free_after_expand, initial_free - (pages_before - 1)); + + // 2. Fast alle Inodes entfernen, sodass nur noch 1 Seite benötigt wird + { + let mut inner = cfs.inner.lock().unwrap(); + for id in added_ids { + inner.manifest.inodes.remove(&id); + inner.index_remove_child(1, id); + } + inner.mark_dirty(); + } + + cfs.sync_manifest().expect("Sync after shrink"); + + // 3. Verifizieren: page_count ist wieder 1, freie Blöcke wurden zurückgegeben + let pages_after = cfs.page_count(); + assert_eq!( + pages_after, 1, + "Nach Schrumpfung darf nur noch 1 Seite belegt sein" + ); + + let free_after_shrink = { + let inner = cfs.inner.lock().unwrap(); + inner.manifest.free_blocks.len() + }; + assert_eq!( + free_after_shrink, initial_free, + "Alle überzähligen Seitenblöcke müssen an free_blocks zurückgegeben worden sein" + ); + + drop(cfs); + + // Trägerdatei neu laden und Integrität prüfen + let cfs_reloaded = load_carrier_fs(&env); + assert_eq!(cfs_reloaded.page_count(), 1); + assert_eq!(cfs_reloaded.corrupted_pages(), 0); + assert!(cfs_reloaded.resolve_path("/").is_some()); +}