feat(carrier): implement carrier format V2 paged manifest and secondary index

This commit is contained in:
2026-09-20 00:15:11 +02:00
parent 02f731f57d
commit 167e4b65fc
6 changed files with 1241 additions and 137 deletions
+444 -106
View File
@@ -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_MAGIC: &[u8; 8] = b"SANCTCAR";
pub const CARRIER_VERSION: u32 = 1; 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_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) 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<u32>,
pub next_inode_id: i64,
pub page_block_indices: Vec<u32>, // 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<CarrierInode>,
}
/// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarrierManifest { pub struct CarrierManifest {
pub magic: [u8; 8], pub magic: [u8; 8],
@@ -40,6 +70,9 @@ pub struct CarrierManifest {
pub total_blocks: u32, pub total_blocks: u32,
pub free_blocks: Vec<u32>, pub free_blocks: Vec<u32>,
pub next_inode_id: i64, pub next_inode_id: i64,
#[serde(default)]
pub page_block_indices: Vec<u32>,
#[serde(default)]
pub inodes: HashMap<i64, CarrierInode>, pub inodes: HashMap<i64, CarrierInode>,
} }
@@ -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. // Block 0 und Block 1 sind für den Superblock reserviert (C-02).
let free_blocks = (2..total_blocks).collect(); // 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 { Self {
magic: *CARRIER_MAGIC, magic: *CARRIER_MAGIC,
version: CARRIER_VERSION, version: CARRIER_VERSION_V2,
manifest_generation: 0, manifest_generation: 0,
total_blocks, total_blocks,
free_blocks, free_blocks,
next_inode_id: 2, next_inode_id: 2,
page_block_indices,
inodes, inodes,
} }
} }
@@ -279,6 +324,8 @@ pub struct CarrierFsInner {
pub custom_leak_rules: Arc<Vec<String>>, pub custom_leak_rules: Arc<Vec<String>>,
pub leak_counter: Arc<AtomicU64>, pub leak_counter: Arc<AtomicU64>,
pub manifest: CarrierManifest, pub manifest: CarrierManifest,
pub children_index: HashMap<i64, Vec<i64>>, // 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_dirty: bool,
pub manifest_save_count: u64, pub manifest_save_count: u64,
pub dirty_ops_count: u32, pub dirty_ops_count: u32,
@@ -291,13 +338,31 @@ impl CarrierFsInner {
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules) 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<i64, CarrierInode>) -> HashMap<i64, Vec<i64>> {
let mut index: HashMap<i64, Vec<i64>> = 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). /// 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) { pub fn mark_dirty(&mut self) {
self.manifest_dirty = true; self.manifest_dirty = true;
self.dirty_ops_count += 1; self.dirty_ops_count += 1;
@@ -314,15 +379,37 @@ impl CarrierFsInner {
Ok(()) 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) { pub fn manifest_usage(&self) -> (usize, usize, f64) {
let len = serde_json::to_vec(&self.manifest) let pages_count = self.manifest.page_block_indices.len().max(1);
.map(|v| v.len()) let total_page_capacity = pages_count * CARRIER_MANIFEST_CAPACITY;
.unwrap_or(0); let mut total_bytes = 0usize;
let pct = (len as f64 / CARRIER_MANIFEST_CAPACITY as f64) * 100.0; for inode in self.manifest.inodes.values() {
(len, CARRIER_MANIFEST_CAPACITY, pct) 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<()> { pub fn save_manifest(&mut self) -> Result<()> {
self.manifest_save_count += 1; self.manifest_save_count += 1;
self.manifest_dirty = false; self.manifest_dirty = false;
@@ -332,26 +419,119 @@ impl CarrierFsInner {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
self.manifest.manifest_generation += 1; self.manifest.manifest_generation += 1;
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
if manifest_bytes.len() > CARRIER_MANIFEST_CAPACITY { // D-04: Inodes auf Seiten aufteilen (deterministisch nach Inode-ID sortiert)
bail!( let mut sorted_inodes: Vec<CarrierInode> = self.manifest.inodes.values().cloned().collect();
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes). Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse beachten.", sorted_inodes.sort_by_key(|i| i.id);
manifest_bytes.len(),
CARRIER_MANIFEST_CAPACITY let mut pages: Vec<CarrierInodePage> = 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 { if !current_entries.is_empty() || pages.is_empty() {
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY; pages.push(CarrierInodePage {
warn!( magic: *CARRIER_PAGE_MAGIC,
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).", entries: current_entries,
pct, });
manifest_bytes.len(), }
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 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) { let target_block = if self.manifest.manifest_generation.is_multiple_of(2) {
0 0
} else { } else {
@@ -364,9 +544,20 @@ impl CarrierFsInner {
target_block, target_block,
&self.dek_outer, &self.dek_outer,
&self.dek_inner, &self.dek_inner,
&manifest_bytes, &sb_bytes,
self.format_version, 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(()) Ok(())
} }
@@ -389,6 +580,7 @@ impl CarrierFsInner {
Ok(()) 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<CarrierInode> { pub fn resolve_path(&self, raw_path: &str) -> Option<CarrierInode> {
let trimmed = raw_path.trim_matches('/'); let trimmed = raw_path.trim_matches('/');
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -399,10 +591,17 @@ impl CarrierFsInner {
let mut current_id = 1i64; let mut current_id = 1i64;
for (idx, segment) in segments.iter().enumerate() { for (idx, segment) in segments.iter().enumerate() {
let child = self.manifest.inodes.values().find(|inode| { let child_ids = self.children_index.get(&current_id)?;
inode.parent_id == Some(current_id) && inode.name.as_str() == *segment 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 { if idx + 1 < segments.len() && !child.is_dir {
return None; 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). /// WebDAV-Filesystem-Treiber für den steganografischen Alibi-Carrier (Hidden Vault).
#[derive(Clone)] #[derive(Clone)]
pub struct CarrierFs { pub struct CarrierFs {
inner: Arc<Mutex<CarrierFsInner>>, pub inner: Arc<Mutex<CarrierFsInner>>,
} }
impl CarrierFs { 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). /// und benutzerdefinierten Anti-Leak-Regeln (V-04).
pub fn load_with_leak_rules( pub fn load_with_leak_rules(
db: Database, db: Database,
@@ -454,68 +668,62 @@ impl CarrierFs {
anti_leak: bool, anti_leak: bool,
custom_leak_rules: Vec<String>, custom_leak_rules: Vec<String>,
) -> Result<Self> { ) -> Result<Self> {
// C-02: Versuche beide redundante Blöcke (Block 0 und Block 1) zu lesen und zu verifizieren let read_and_parse_block = |block_idx: u32| -> Result<LoadedBlock> {
let block_0_res = read_carrier_block( let bytes = read_carrier_block(
&db, &db,
carrier_node_id, carrier_node_id,
0, block_idx,
&dek_outer, &dek_outer,
&dek_inner, &dek_inner,
format_version, format_version,
) )?;
.and_then(|bytes| { let probe: VersionProbe = serde_json::from_slice(&bytes)?;
let m: CarrierManifest = serde_json::from_slice(&bytes)?; if probe.magic != *CARRIER_MAGIC {
if m.magic != *CARRIER_MAGIC { bail!("Ungültige Carrier-Magic-Bytes in Block {block_idx}");
bail!("Ungültige Carrier-Magic-Bytes in Block 0");
} }
Ok(m) if probe.version == CARRIER_VERSION {
}); let m: CarrierManifest = serde_json::from_slice(&bytes)?;
Ok(LoadedBlock::V1(m))
let block_1_res = read_carrier_block( } else if probe.version == CARRIER_VERSION_V2 {
&db, let sb: CarrierSuperblock = serde_json::from_slice(&bytes)?;
carrier_node_id, Ok(LoadedBlock::V2(sb))
1, } else {
&dek_outer, bail!("Unbekannte Carrier-Formatversion: {}", probe.version);
&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");
} }
Ok(m) };
});
let mut manifest = match (block_0_res, block_1_res) { let block_0_res = read_and_parse_block(0);
(Ok(m0), Ok(m1)) => { let block_1_res = read_and_parse_block(1);
if m1.manifest_generation > m0.manifest_generation {
let loaded_block = match (block_0_res, block_1_res) {
(Ok(b0), Ok(b1)) => {
if b1.generation() > b0.generation() {
debug!( debug!(
"C-02: Beide Manifest-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})", "C-02: Beide Superblock-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})",
m1.manifest_generation, m0.manifest_generation b1.generation(), b0.generation()
); );
m1 b1
} else { } else {
debug!( debug!(
"C-02: Beide Manifest-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})", "C-02: Beide Superblock-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})",
m0.manifest_generation, m1.manifest_generation b0.generation(), b1.generation()
); );
m0 b0
} }
} }
(Ok(m0), Err(e1)) => { (Ok(b0), Err(e1)) => {
debug!( debug!(
"C-02: Verwende primäres Manifest aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}", "C-02: Verwende primären Superblock aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}",
m0.manifest_generation, e1 b0.generation(), e1
); );
m0 b0
} }
(Err(e0), Ok(m1)) => { (Err(e0), Ok(b1)) => {
warn!( warn!(
"C-02: Block 0 korrupt oder unlesbar ({})! Erfolgreiche Wiederherstellung aus redundanter Kopie in Block 1 (Gen {}).", "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)) => { (Err(e0), Err(e1)) => {
bail!( bail!(
@@ -525,20 +733,101 @@ impl CarrierFs {
} }
}; };
// Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Manifest-Kopie reservieren let mut corrupted_pages = 0usize;
manifest.free_blocks.retain(|&b| b >= 2); let manifest = match loaded_block {
LoadedBlock::V1(mut m) => {
// C-04: Warnung bei Füllstand über 80% beim Mounten // Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Kopie reservieren
if let Ok(manifest_bytes) = serde_json::to_vec(&manifest) { m.free_blocks.retain(|&b| b >= 2);
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD { m
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
);
} }
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::<CarrierInodePage>(&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() let now = SystemTime::now()
@@ -560,6 +849,8 @@ impl CarrierFs {
custom_leak_rules: Arc::new(custom_leak_rules), custom_leak_rules: Arc::new(custom_leak_rules),
leak_counter: Arc::new(AtomicU64::new(0)), leak_counter: Arc::new(AtomicU64::new(0)),
manifest, manifest,
children_index,
corrupted_pages,
manifest_dirty: false, manifest_dirty: false,
manifest_save_count: 0, manifest_save_count: 0,
dirty_ops_count: 0, dirty_ops_count: 0,
@@ -612,6 +903,26 @@ impl CarrierFs {
inner.manifest_usage() 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<CarrierInode> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.resolve_path(raw_path)
}
pub fn leak_counter(&self) -> Arc<AtomicU64> { pub fn leak_counter(&self) -> Arc<AtomicU64> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.leak_counter.clone() inner.leak_counter.clone()
@@ -721,6 +1032,7 @@ impl DavFileSystem for CarrierFs {
}; };
inner.manifest.inodes.insert(new_id, new_inode.clone()); inner.manifest.inodes.insert(new_id, new_inode.clone());
inner.index_add_child(parent.id, new_id);
inner.mark_dirty(); inner.mark_dirty();
new_inode new_inode
} else { } else {
@@ -750,11 +1062,14 @@ impl DavFileSystem for CarrierFs {
return Err(FsError::Forbidden); return Err(FsError::Forbidden);
} }
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = inner let child_ids = inner
.manifest .children_index
.inodes .get(&node.id)
.values() .cloned()
.filter(|child| child.parent_id == Some(node.id)) .unwrap_or_default();
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = child_ids
.into_iter()
.filter_map(|cid| inner.manifest.inodes.get(&cid))
.filter(|child| { .filter(|child| {
if inner.is_leak(&child.name) { if inner.is_leak(&child.name) {
inner.leak_counter.fetch_add(1, Ordering::Relaxed); 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.manifest.inodes.insert(new_id, new_dir);
inner.index_add_child(parent.id, new_id);
inner.mark_dirty(); inner.mark_dirty();
Ok(()) Ok(())
@@ -864,17 +1180,20 @@ impl DavFileSystem for CarrierFs {
return Err(FsError::Forbidden); 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 let has_children = inner
.manifest .children_index
.inodes .get(&node.id)
.values() .is_some_and(|children| !children.is_empty());
.any(|child| child.parent_id == Some(node.id));
if has_children { if has_children {
return Err(FsError::Forbidden); 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.manifest.inodes.remove(&node.id);
inner.mark_dirty(); inner.mark_dirty();
@@ -904,6 +1223,9 @@ impl DavFileSystem for CarrierFs {
inner.manifest.free_blocks.push(b); 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.manifest.inodes.remove(&node.id);
inner.mark_dirty(); inner.mark_dirty();
@@ -948,6 +1270,10 @@ impl DavFileSystem for CarrierFs {
); );
inner.manifest.free_blocks.push(b); 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); inner.manifest.inodes.remove(&dest.id);
} }
@@ -956,8 +1282,15 @@ impl DavFileSystem for CarrierFs {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .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) { 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.name = to_name.to_string();
inode.modified_at = now; inode.modified_at = now;
} }
@@ -1013,6 +1346,10 @@ impl DavFileSystem for CarrierFs {
); );
inner.manifest.free_blocks.push(b); 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); inner.manifest.inodes.remove(&dest.id);
} }
@@ -1091,6 +1428,7 @@ impl DavFileSystem for CarrierFs {
}; };
inner.manifest.inodes.insert(new_id, new_inode); inner.manifest.inodes.insert(new_id, new_inode);
inner.index_add_child(to_parent.id, new_id);
inner.mark_dirty(); inner.mark_dirty();
Ok(()) Ok(())
+15 -8
View File
@@ -423,17 +423,24 @@ pub async fn mount_container(
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)"); println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
} }
if vault_id == 1 { if vault_id == 1 {
if let Some((len, cap, pct)) = fs_clone.carrier_manifest_usage() { if let Some(corrupted) = fs_clone.carrier_corrupted_pages() {
if len >= crate::carrier::CARRIER_MANIFEST_WARN_THRESHOLD { if corrupted > 0 {
println!( println!(
" {} Warnung (C-04): Hidden-Vault Manifest zu {:.1}% gefüllt ({}/{} Bytes).", " {} Warnung (D-01): {} beschädigte Inode-Tabellenseite(n) im Hidden Vault übersprungen!",
ui::yellow("[!]"), ui::red("[!]"),
pct, corrupted
len,
cap
); );
}
}
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!( 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
); );
} }
} }
+66 -12
View File
@@ -713,8 +713,8 @@ impl Database {
)?; )?;
let c_id = conn.last_insert_rowid(); let c_id = conn.last_insert_rowid();
// Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten) // 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(2) as u32; let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(3) as u32;
// Slot 1 (Hidden Vault) einfügen // Slot 1 (Hidden Vault) einfügen
let params_json_1 = serde_json::to_string(h_params)?; 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 = 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) = 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 inner_ct_len_0 = inner_ct_0.len() as u32;
let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE]; 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()); outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes());
let ct_end_0 = 32 + inner_ct_0.len(); let ct_end_0 = 32 + inner_ct_0.len();
if ct_end_0 > CHUNK_SIZE { 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); 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) = 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 inner_ct_len_1 = inner_ct_1.len() as u32;
let mut outer_plaintext_1 = vec![0u8; CHUNK_SIZE]; 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()); outer_plaintext_1[28..32].copy_from_slice(&inner_ct_len_1.to_le_bytes());
let ct_end_1 = 32 + inner_ct_1.len(); let ct_end_1 = 32 + inner_ct_1.len();
if ct_end_1 > CHUNK_SIZE { 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); 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( let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, 0, ?3, ?4, ?5)", VALUES (?1, ?2, 0, ?3, ?4, ?5)",
@@ -820,7 +874,7 @@ impl Database {
OsRng.fill_bytes(&mut dummy_noise); OsRng.fill_bytes(&mut dummy_noise);
conn.execute_batch("BEGIN TRANSACTION;")?; conn.execute_batch("BEGIN TRANSACTION;")?;
for b in 2..total_blocks { for b in 3..total_blocks {
let (ct, nonce, tag) = let (ct, nonce, tag) =
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?; 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,])?; chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
+12
View File
@@ -799,6 +799,18 @@ impl SanctumFs {
self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage()) self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage())
} }
pub fn carrier_corrupted_pages(&self) -> Option<usize> {
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<usize> {
self.carrier_fs.as_ref().map(|cfs| cfs.page_count())
}
pub fn leak_count(&self) -> u64 { pub fn leak_count(&self) -> u64 {
self.leak_counter().load(Ordering::Relaxed) self.leak_counter().load(Ordering::Relaxed)
} }
+18 -11
View File
@@ -1436,21 +1436,28 @@ async fn test_c04_manifest_capacity_limit_and_warning() {
} }
} }
// sync_manifest() MUSS jetzt mit Fehler abbrechen // In Format V2 (v0.9.0): Die 1-MB-Grenze bricht NICHT mehr ab, sondern allokiert eine zusätzliche Seite
let err = cfs_loaded.sync_manifest().unwrap_err(); cfs_loaded
let err_str = err.to_string(); .sync_manifest()
.expect("sync_manifest with > 1 MB must succeed in V2 via paged manifest");
assert!( assert!(
err_str.contains("Carrier-Manifest überschreitet die maximale Blockgröße"), cfs_loaded.page_count() >= 2,
"Error message must specify carrier capacity limit: {}", "Page count must expand to at least 2 pages"
err_str
);
assert!(
err_str.contains("7.000 Dateien/Verzeichnisse"),
"Error message must recommend file limit: {}",
err_str
); );
// Reloaden und prüfen, ob beide Seiten geladen werden
drop(cfs_loaded); 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); drop(db);
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
+686
View File
@@ -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());
}