use std::fmt::Debug; use std::io::SeekFrom; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use bytes::{Buf, Bytes, BytesMut}; use dav_server::{ davpath::DavPath, fs::{ DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsResult, FsStream, OpenOptions, ReadDirMeta, }, }; use futures_util::stream; use std::path::Path; use tracing::{debug, error, warn}; use zeroize::{Zeroize, Zeroizing}; use crate::carrier::CarrierFs; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::storage::{Database, NodeRecord}; /// Exakte Namen von Explorer-, OS- und Desktop-Metadaten (V-04). pub const LEAK_EXACT_NAMES: &[&str] = &[ "thumbs.db", "ehthumbs.db", "ehthumbs_vista.db", "desktop.ini", "folder.jpg", "albumartsmall.jpg", "autorun.inf", ".ds_store", ".directory", ".fseventsd", ".spotlight-v100", ]; /// Präfixe bekannter temporärer Metadaten und Lock-Dateien (V-04). pub const LEAK_PREFIXES: &[&str] = &[ "~$", // MS Office temporäre Lock-Dateien (z. B. ~$Document.docx) "._", // macOS AppleDouble Metadaten-Dateien (z. B. ._Document.pdf) ]; /// Suffixe und Dateiendungen temporärer Caches und unvollständiger Downloads (V-04). pub const LEAK_SUFFIXES: &[&str] = &[ ".tmp", ".temp", ".crdownload", // Google Chrome temporärer Download ".part", // Mozilla Firefox unvollständiger Download ".partial", "~", // Linux/UNIX Editor-Backups (Vim, Emacs, Gedit) ]; /// Prüft, ob ein Dateiname zu den typischen Windows Explorer-, OS- oder Anwendungs- /// Metadaten-, Cache- oder Lock-Dateien gehört, die standardmäßig im Container /// blockiert und verborgen werden (Anti-Leak Shield, V-04). pub fn is_leak_file(filename: &str) -> bool { is_leak_file_with_custom(filename, &[]) } /// Prüft, ob ein Dateiname gemäß der Standardregeln oder benutzerdefinierten Regeln (V-04) /// als Leak-Datei blockiert werden soll. pub fn is_leak_file_with_custom(filename: &str, custom_rules: &[String]) -> bool { let trimmed = filename.trim(); if trimmed.is_empty() { return false; } // 1. NTFS Alternate Data Streams (ADS) wie "file.txt:Zone.Identifier" if trimmed.contains(':') { return true; } let lower = trimmed.to_ascii_lowercase(); // 2. Exakte Namen for &exact in LEAK_EXACT_NAMES { if lower == exact { return true; } } // 3. Präfixe for &prefix in LEAK_PREFIXES { if lower.starts_with(prefix) { return true; } } // 4. Suffixe for &suffix in LEAK_SUFFIXES { if lower.ends_with(suffix) { return true; } } // 5. Spezielle Muster if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) { return true; } if lower.starts_with(".trash") { return true; } // 6. Benutzerdefinierte Regeln for custom in custom_rules { let pat = custom.trim().to_ascii_lowercase(); if pat.is_empty() || pat.starts_with('#') { continue; } if pat.starts_with('*') && pat.ends_with('*') && pat.len() > 2 { let sub = &pat[1..pat.len() - 1]; if lower.contains(sub) { return true; } } else if pat.starts_with('*') { if lower.ends_with(&pat[1..]) { return true; } } else if pat.ends_with('*') { if lower.starts_with(&pat[..pat.len() - 1]) { return true; } } else if lower == pat { return true; } } false } /// Lädt eine benutzerdefinierte Liste von Anti-Leak Filterregeln aus einer Textdatei (V-04). pub fn load_anti_leak_list>(path: P) -> Result> { let p = path.as_ref(); let content = std::fs::read_to_string(p) .with_context(|| format!("Konnte Anti-Leak-Listendatei '{}' nicht lesen", p.display()))?; let mut rules = Vec::new(); for line in content.lines() { let trimmed = line.trim(); if !trimmed.is_empty() && !trimmed.starts_with('#') { rules.push(trimmed.to_string()); } } Ok(rules) } // --------------------------------------------------------------------------- // Metadaten // --------------------------------------------------------------------------- #[derive(Debug, Clone)] pub struct SanctumMetaData { pub is_dir: bool, pub size: u64, pub modified_at: SystemTime, pub created_at: SystemTime, } impl DavMetaData for SanctumMetaData { fn len(&self) -> u64 { self.size } fn modified(&self) -> FsResult { Ok(self.modified_at) } fn is_dir(&self) -> bool { self.is_dir } fn created(&self) -> FsResult { Ok(self.created_at) } fn is_file(&self) -> bool { !self.is_dir } } // --------------------------------------------------------------------------- // Verzeichniseintrag // --------------------------------------------------------------------------- #[derive(Debug, Clone)] pub struct SanctumDirEntry { pub name: String, pub meta: SanctumMetaData, } impl DavDirEntry for SanctumDirEntry { fn name(&self) -> Vec { self.name.as_bytes().to_vec() } fn metadata(&self) -> FsFuture<'_, Box> { let meta = self.meta.clone(); Box::pin(async move { Ok(Box::new(meta) as Box) }) } } /// RAII-Guard für im physischen RAM verriegelte Schlüssel (VirtualLock / mlock). /// Entriegelt den Speicherbereich via VirtualUnlock / munlock erst beim Drop der letzten verbleibenden Referenz (V-03). #[derive(Debug)] pub struct MemoryLockGuard(Zeroizing<[u8; 32]>); impl MemoryLockGuard { pub fn new(key: Zeroizing<[u8; 32]>) -> Self { crate::windows::lock_memory(key.as_ptr(), 32); Self(key) } pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.0 } } impl std::ops::Deref for MemoryLockGuard { type Target = Zeroizing<[u8; 32]>; fn deref(&self) -> &Self::Target { &self.0 } } impl AsRef<[u8; 32]> for MemoryLockGuard { fn as_ref(&self) -> &[u8; 32] { &self.0 } } impl Drop for MemoryLockGuard { fn drop(&mut self) { crate::windows::unlock_memory(self.0.as_ptr(), 32); } } // --------------------------------------------------------------------------- // Datei-Handle mit Streaming & Chunk-Pufferung // --------------------------------------------------------------------------- pub struct SanctumFile { node_id: i64, file_size: u64, cursor: u64, db: Database, dek: Arc, meta: SanctumMetaData, // (chunk_index, decrypted_payload, is_dirty) cached_chunk: Option<(u32, Vec, bool)>, format_version: u32, last_activity: Arc, } impl Debug for SanctumFile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SanctumFile") .field("node_id", &self.node_id) .field("file_size", &self.file_size) .field("cursor", &self.cursor) .field("format_version", &self.format_version) .finish() } } impl SanctumFile { pub fn new( node: NodeRecord, db: Database, dek: Arc, format_version: u32, last_activity: Arc, append: bool, ) -> Self { let meta = SanctumMetaData { is_dir: node.is_dir, size: node.size, created_at: UNIX_EPOCH + Duration::from_secs(node.created_at), modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at), }; let cursor = if append { node.size } else { 0 }; Self { node_id: node.id, file_size: node.size, cursor, db, dek, meta, cached_chunk: None, format_version, last_activity, } } fn touch(&self) { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); self.last_activity.store(now, Ordering::Relaxed); } /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück /// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01). /// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03). fn flush_cached_chunk_and_size(&mut self) -> Result<(), FsError> { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); if let Some((idx, ref data, true)) = self.cached_chunk { let gen = self .db .next_chunk_generation(self.node_id, idx) .unwrap_or(0); let (ciphertext, nonce, tag) = encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version, gen) .map_err(|e| { error!("Verschlüsselungsfehler beim Chunk-Flush: {e}"); FsError::GeneralFailure })?; if let Err(e) = self.db.write_chunk_and_update_size( self.node_id, idx, gen, &nonce, &tag, &ciphertext, self.file_size, now, ) { error!("DB-Fehler beim atomaren Chunk- und Size-Write #{idx}: {e}"); // CHAOS-03: Bei I/O- oder Disk-Full-Fehlern den Cache sauber invalidieren, // um Folgefehler und Panic-/Warnungsschleifen beim Drop zu unterbinden! self.cached_chunk = None; return Err(FsError::GeneralFailure); } if let Some((_, _, ref mut dirty)) = self.cached_chunk { *dirty = false; } self.meta.size = self.file_size; self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now); } else if self.meta.size != self.file_size { // Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat if let Err(e) = self .db .update_node_size_and_time(self.node_id, self.file_size, now) { error!("Fehler beim Aktualisieren der Knotengröße: {e}"); return Err(FsError::GeneralFailure); } self.meta.size = self.file_size; self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now); } Ok(()) } /// Stellt sicher, dass der angeforderte Chunk im Cache geladen und entschlüsselt ist. fn ensure_chunk_loaded(&mut self, chunk_index: u32) -> Result<&mut Vec, FsError> { let is_current = match &self.cached_chunk { Some((idx, _, _)) => *idx == chunk_index, None => false, }; if !is_current { self.flush_cached_chunk_and_size()?; if let Some((_, ref mut data, _)) = self.cached_chunk { data.zeroize(); } let expected_chunks = if self.file_size == 0 { 0 } else { self.file_size.div_ceil(CHUNK_SIZE as u64) as u32 }; let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| { error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}"); FsError::GeneralFailure })? { Some(record) => decrypt_chunk( &self.dek, self.node_id, chunk_index, &record.ciphertext, &record.nonce, &record.tag, self.format_version, record.generation, ) .map_err(|e| { error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}"); FsError::GeneralFailure })?, None => { if chunk_index < expected_chunks { error!( "Integritätsfehler: Fehlender Chunk #{} bei Knoten id={} (erwartete Chunks: {})", chunk_index, self.node_id, expected_chunks ); return Err(FsError::GeneralFailure); } Vec::new() } }; self.cached_chunk = Some((chunk_index, payload, false)); } match &mut self.cached_chunk { Some((_, ref mut data, _)) => Ok(data), None => unreachable!(), } } } impl Drop for SanctumFile { fn drop(&mut self) { if let Err(e) = self.flush_cached_chunk_and_size() { error!( "SanctumFile::drop: Fehler beim automatischen Flush von Knoten {}: {:?}", self.node_id, e ); } if let Some((_, ref mut data, _)) = self.cached_chunk { data.zeroize(); } } } impl DavFile for SanctumFile { fn metadata(&mut self) -> FsFuture<'_, Box> { self.meta.size = self.file_size; let meta = self.meta.clone(); Box::pin(async move { Ok(Box::new(meta) as Box) }) } fn read_bytes(&mut self, mut count: usize) -> FsFuture<'_, Bytes> { self.touch(); Box::pin(async move { if self.cursor >= self.file_size || count == 0 { return Ok(Bytes::new()); } let remaining_file = (self.file_size - self.cursor) as usize; if count > remaining_file { count = remaining_file; } let mut result = BytesMut::with_capacity(count); while count > 0 && self.cursor < self.file_size { let chunk_idx = (self.cursor / CHUNK_SIZE as u64) as u32; let offset_in_chunk = (self.cursor % CHUNK_SIZE as u64) as usize; let bytes_in_chunk_left = CHUNK_SIZE - offset_in_chunk; let to_read = count .min(bytes_in_chunk_left) .min((self.file_size - self.cursor) as usize); let chunk_data = self.ensure_chunk_loaded(chunk_idx)?; if offset_in_chunk >= chunk_data.len() { if self.cursor < self.file_size { error!( "Integritätsfehler: Unerwartetes Chunk-Ende bei Offset {} in Chunk #{} (Dateigröße: {}, Cursor: {})", offset_in_chunk, chunk_idx, self.file_size, self.cursor ); return Err(FsError::GeneralFailure); } break; } let available = (chunk_data.len() - offset_in_chunk).min(to_read); result.extend_from_slice(&chunk_data[offset_in_chunk..offset_in_chunk + available]); self.cursor += available as u64; count -= available; if available < to_read { if self.cursor < self.file_size { error!( "Integritätsfehler: Vorzeitiges Chunk-Ende (verfügbar: {}, benötigt: {}, Dateigröße: {}, Cursor: {})", available, to_read, self.file_size, self.cursor ); return Err(FsError::GeneralFailure); } break; } } Ok(result.freeze()) }) } fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> { self.touch(); Box::pin(async move { // V-06: Sparse Writes — Lücke zwischen bisherigem Dateiende und Cursor mit Nullen füllen if self.cursor > self.file_size { let target = self.cursor; while self.file_size < target { let chunk_idx = (self.file_size / CHUNK_SIZE as u64) as u32; let offset_in_chunk = (self.file_size % CHUNK_SIZE as u64) as usize; let space_in_chunk = CHUNK_SIZE - offset_in_chunk; let to_pad = ((target - self.file_size) as usize).min(space_in_chunk); let chunk_data = self.ensure_chunk_loaded(chunk_idx)?; if chunk_data.len() < offset_in_chunk + to_pad { chunk_data.resize(offset_in_chunk + to_pad, 0); } let mut is_full = false; if let Some((_, ref d, ref mut dirty)) = self.cached_chunk { *dirty = true; is_full = d.len() >= CHUNK_SIZE; } self.file_size += to_pad as u64; if is_full { self.flush_cached_chunk_and_size()?; } } } let mut src = &buf[..]; while !src.is_empty() { let chunk_idx = (self.cursor / CHUNK_SIZE as u64) as u32; let offset_in_chunk = (self.cursor % CHUNK_SIZE as u64) as usize; let space_in_chunk = CHUNK_SIZE - offset_in_chunk; let to_write = src.len().min(space_in_chunk); let chunk_data = self.ensure_chunk_loaded(chunk_idx)?; if chunk_data.len() < offset_in_chunk { chunk_data.resize(offset_in_chunk, 0); } if chunk_data.len() < offset_in_chunk + to_write { chunk_data.resize(offset_in_chunk + to_write, 0); } chunk_data[offset_in_chunk..offset_in_chunk + to_write] .copy_from_slice(&src[..to_write]); if let Some((_, _, ref mut dirty)) = self.cached_chunk { *dirty = true; } self.cursor += to_write as u64; if self.cursor > self.file_size { self.file_size = self.cursor; } // Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen if self .cached_chunk .as_ref() .map(|(_, d, _)| d.len() >= CHUNK_SIZE) .unwrap_or(false) { self.flush_cached_chunk_and_size()?; } src = &src[to_write..]; } Ok(()) }) } fn write_buf(&mut self, mut buf: Box) -> FsFuture<'_, ()> { let bytes = buf.copy_to_bytes(buf.remaining()); self.write_bytes(bytes) } fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> { self.touch(); Box::pin(async move { let new_cursor = match pos { SeekFrom::Start(offset) => offset as i64, SeekFrom::End(offset) => self.file_size as i64 + offset, SeekFrom::Current(offset) => self.cursor as i64 + offset, }; if new_cursor < 0 { return Err(FsError::GeneralFailure); } self.cursor = new_cursor as u64; Ok(self.cursor) }) } fn flush(&mut self) -> FsFuture<'_, ()> { self.touch(); Box::pin(async move { self.flush_cached_chunk_and_size()?; Ok(()) }) } } // --------------------------------------------------------------------------- // DavFileSystem Implementierung // --------------------------------------------------------------------------- #[derive(Clone)] pub struct SanctumFs { db: Database, dek: Arc, #[allow(dead_code)] carrier_dek: Option>, carrier_node_id: Option, carrier_fs: Option, format_version: u32, anti_leak: bool, custom_leak_rules: Arc>, leak_counter: Arc, vault_id: u32, last_activity: Arc, } impl SanctumFs { pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self { Self::with_vault(db, dek, format_version, true, 0) } pub fn with_options( db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32, anti_leak: bool, ) -> Self { Self::with_vault(db, dek, format_version, anti_leak, 0) } pub fn with_options_and_leak_rules( db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32, anti_leak: bool, custom_leak_rules: Vec, ) -> Self { Self::with_carrier_and_leak_rules( db, dek, None, None, format_version, anti_leak, custom_leak_rules, 0, ) } pub fn with_vault( db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32, anti_leak: bool, vault_id: u32, ) -> Self { Self::with_carrier(db, dek, None, None, format_version, anti_leak, vault_id) } pub fn with_carrier( db: Database, dek: Zeroizing<[u8; 32]>, carrier_dek: Option>, carrier_node_id: Option, format_version: u32, anti_leak: bool, vault_id: u32, ) -> Self { Self::with_carrier_and_leak_rules( db, dek, carrier_dek, carrier_node_id, format_version, anti_leak, Vec::new(), vault_id, ) } pub fn with_carrier_and_leak_rules( db: Database, dek: Zeroizing<[u8; 32]>, carrier_dek: Option>, carrier_node_id: Option, format_version: u32, anti_leak: bool, custom_leak_rules: Vec, vault_id: u32, ) -> Self { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let dek_guard = Arc::new(MemoryLockGuard::new(dek)); let carrier_dek_guard = carrier_dek.map(|k| Arc::new(MemoryLockGuard::new(k))); let db = db.with_session(vault_id, (*dek_guard.key()).clone()); // Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist, // um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen. let carrier_node_id = carrier_node_id.or_else(|| { if vault_id == 0 { db.find_carrier_node_id().ok().flatten() } else { None } }); let carrier_fs = if vault_id == 1 { if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_guard, carrier_node_id) { match CarrierFs::load_with_leak_rules( db.clone(), c_nid, Arc::new((*c_dek.key()).clone()), Arc::new((*dek_guard.key()).clone()), format_version, anti_leak, custom_leak_rules.clone(), ) { Ok(cfs) => Some(cfs), Err(e) => { warn!("CarrierFs konnte nicht initialisiert werden: {e}"); None } } } else { None } } else { None }; Self { db, dek: dek_guard, carrier_dek: carrier_dek_guard, carrier_node_id, carrier_fs, format_version, anti_leak, custom_leak_rules: Arc::new(custom_leak_rules), leak_counter: Arc::new(AtomicU64::new(0)), vault_id, last_activity: Arc::new(AtomicU64::new(now)), } } pub fn dek_strong_count(&self) -> usize { Arc::strong_count(&self.dek) } pub fn vault_id(&self) -> u32 { self.vault_id } pub fn last_activity(&self) -> Arc { if let Some(ref cfs) = self.carrier_fs { cfs.last_activity() } else { self.last_activity.clone() } } pub fn is_anti_leak_enabled(&self) -> bool { self.anti_leak } pub fn leak_counter(&self) -> Arc { if let Some(ref cfs) = self.carrier_fs { cfs.leak_counter() } else { self.leak_counter.clone() } } pub fn sync_carrier_manifest(&self) -> Result<()> { if let Some(ref cfs) = self.carrier_fs { cfs.sync_manifest()?; } Ok(()) } pub fn carrier_manifest_usage(&self) -> Option<(usize, usize, f64)> { self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage()) } pub fn leak_count(&self) -> u64 { self.leak_counter().load(Ordering::Relaxed) } pub fn is_leak(&self, name: &str) -> bool { self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules) } pub fn touch(&self) { if let Some(ref cfs) = self.carrier_fs { cfs.touch(); } else { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); self.last_activity.store(now, Ordering::Relaxed); } } fn path_to_str(path: &DavPath) -> String { String::from_utf8_lossy(path.as_bytes()).to_string() } fn split_parent_and_name<'a>(&self, path: &'a str) -> (&'a str, &'a str) { let trimmed = path.trim_matches('/'); match trimmed.rfind('/') { Some(pos) => (&trimmed[..pos], &trimmed[pos + 1..]), None => ("", trimmed), } } fn resolve_path(&self, path: &str) -> Result, FsError> { validate_path_safety(path)?; self.db .resolve_path_in_vault(path, self.vault_id, &self.dek) .map_err(|_| FsError::GeneralFailure) } fn list_children(&self, parent_id: i64) -> Result, FsError> { self.db .list_children_in_vault(parent_id, self.vault_id, &self.dek) .map_err(|_| FsError::GeneralFailure) } fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result { validate_path_safety(name)?; crate::pathutil::validate_node_name(name).map_err(|_| FsError::Forbidden)?; self.db .create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek) .map_err(|e| { error!("Fehler beim Erstellen des Knotens '{}': {e}", name); FsError::GeneralFailure }) } fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> { validate_path_safety(new_name)?; crate::pathutil::validate_node_name(new_name).map_err(|_| FsError::Forbidden)?; self.db .rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek) .map_err(|_| FsError::GeneralFailure) } } /// Validiert, dass ein Pfad oder Dateiname keine Null-Bytes oder unzulässige Steuerzeichen enthält (CHAOS-02). pub fn validate_path_safety(path: &str) -> Result<(), FsError> { if path.contains('\0') { return Err(FsError::Forbidden); } for c in path.chars() { if (c as u32) < 0x20 { return Err(FsError::Forbidden); } } Ok(()) } impl DavFileSystem for SanctumFs { fn open<'a>( &'a self, path: &'a DavPath, options: OpenOptions, ) -> FsFuture<'a, Box> { if let Some(ref cfs) = self.carrier_fs { return cfs.open(path, options); } Box::pin(async move { let path_str = Self::path_to_str(path); let (parent_path, file_name) = self.split_parent_and_name(&path_str); // Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten if self.is_leak(file_name) { if options.create || options.create_new || options.write || options.append || options.truncate { self.leak_counter.fetch_add(1, Ordering::Relaxed); debug!( "Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'", file_name ); return Err(FsError::Forbidden); } } self.touch(); debug!( "VFS open aufgerufen: path='{}', options={:?}", path_str, options ); let existing_node = self.resolve_path(&path_str)?; let node = match existing_node { Some(n) => { // Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt! if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) { return Err(FsError::Forbidden); } if n.is_dir && (options.write || options.append) { return Err(FsError::Forbidden); } if options.create_new { return Err(FsError::Exists); } if options.truncate { self.db .truncate_chunks_after(n.id, 0) .map_err(|_| FsError::GeneralFailure)?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); self.db .update_node_size_and_time(n.id, 0, now) .map_err(|_| FsError::GeneralFailure)?; NodeRecord { size: 0, modified_at: now, ..n } } else { n } } None => { if options.create || options.create_new { let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?; if !parent.is_dir { return Err(FsError::Forbidden); } self.create_node(parent.id, file_name, false)? } else { return Err(FsError::NotFound); } } }; let file = SanctumFile::new( node, self.db.clone(), self.dek.clone(), self.format_version, self.last_activity.clone(), options.append, ); Ok(Box::new(file) as Box) }) } fn read_dir<'a>( &'a self, path: &'a DavPath, meta: ReadDirMeta, ) -> FsFuture<'a, FsStream>> { if let Some(ref cfs) = self.carrier_fs { return cfs.read_dir(path, meta); } Box::pin(async move { let path_str = Self::path_to_str(path); let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; if !node.is_dir { return Err(FsError::Forbidden); } let children = self.list_children(node.id)?; let entries: Vec, FsError>> = children .into_iter() .filter(|child| { if self.is_leak(&child.name) { self.leak_counter.fetch_add(1, Ordering::Relaxed); false } else { true } }) .map(|child| { Ok(Box::new(SanctumDirEntry { name: child.name, meta: SanctumMetaData { is_dir: child.is_dir, size: child.size, created_at: UNIX_EPOCH + Duration::from_secs(child.created_at), modified_at: UNIX_EPOCH + Duration::from_secs(child.modified_at), }, }) as Box) }) .collect(); Ok(Box::pin(stream::iter(entries)) as FsStream>) }) } fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { if let Some(ref cfs) = self.carrier_fs { return cfs.metadata(path); } Box::pin(async move { let path_str = Self::path_to_str(path); let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; let meta = SanctumMetaData { is_dir: node.is_dir, size: node.size, created_at: UNIX_EPOCH + Duration::from_secs(node.created_at), modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at), }; Ok(Box::new(meta) as Box) }) } fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box> { if let Some(ref cfs) = self.carrier_fs { return cfs.symlink_metadata(path); } self.metadata(path) } fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { if let Some(ref cfs) = self.carrier_fs { return cfs.create_dir(path); } Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); let (parent_path, dir_name) = self.split_parent_and_name(&path_str); if self.is_leak(dir_name) { self.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } if self.resolve_path(&path_str)?.is_some() { return Err(FsError::Exists); } let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?; if !parent.is_dir { return Err(FsError::Forbidden); } self.create_node(parent.id, dir_name, true)?; Ok(()) }) } fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { if let Some(ref cfs) = self.carrier_fs { return cfs.remove_dir(path); } Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; if !node.is_dir { return Err(FsError::Forbidden); } let root_id = Database::get_root_node_id_for_vault(self.vault_id); if node.id == root_id { // Root-Verzeichnis darf nicht gelöscht werden return Err(FsError::Forbidden); } // Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält! if let Some(carrier_id) = self.carrier_node_id { if self .db .is_descendant_of(carrier_id, node.id) .map_err(|_| FsError::GeneralFailure)? { return Err(FsError::Forbidden); } } self.db .delete_node(node.id) .map_err(|_| FsError::GeneralFailure)?; Ok(()) }) } fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> { if let Some(ref cfs) = self.carrier_fs { return cfs.remove_file(path); } Box::pin(async move { self.touch(); let path_str = Self::path_to_str(path); let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?; if node.is_dir { return Err(FsError::Forbidden); } // Schutz der Trägerdatei im Decoy Vault: Löschen verboten! if self.carrier_node_id == Some(node.id) { return Err(FsError::Forbidden); } self.db .delete_node(node.id) .map_err(|_| FsError::GeneralFailure)?; Ok(()) }) } fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> { if let Some(ref cfs) = self.carrier_fs { return cfs.rename(from, to); } Box::pin(async move { self.touch(); let from_str = Self::path_to_str(from); let to_str = Self::path_to_str(to); let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?; // Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten! if self.carrier_node_id == Some(node.id) { return Err(FsError::Forbidden); } let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); if self.is_leak(to_name) { self.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } let to_parent = self .resolve_path(to_parent_path)? .ok_or(FsError::NotFound)?; if !to_parent.is_dir { return Err(FsError::Forbidden); } // Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen if let Some(dest) = self.resolve_path(&to_str)? { if dest.is_dir { return Err(FsError::Forbidden); } // Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Rename verboten! if self.carrier_node_id == Some(dest.id) { return Err(FsError::Forbidden); } self.db .delete_node(dest.id) .map_err(|_| FsError::GeneralFailure)?; } self.rename_node(node.id, to_parent.id, to_name)?; Ok(()) }) } fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> { if let Some(ref cfs) = self.carrier_fs { return cfs.copy(from, to); } Box::pin(async move { self.touch(); let from_str = Self::path_to_str(from); let to_str = Self::path_to_str(to); if from_str == to_str { return Ok(()); } let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?; if node.is_dir { return Err(FsError::NotImplemented); } // R-02: Schutz der Trägerdatei: Kopieren der Trägerdatei (Quelle) ist strikt verboten! if self.carrier_node_id == Some(node.id) { return Err(FsError::Forbidden); } let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); if self.is_leak(to_name) { self.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } let to_parent = self .resolve_path(to_parent_path)? .ok_or(FsError::NotFound)?; if !to_parent.is_dir { return Err(FsError::Forbidden); } // RFC-4918 (V-07): Falls Zieldatei bereits existiert: // - Wenn Verzeichnis: 403 Forbidden // - Wenn Datei: bestehende Datei vor dem Kopieren löschen & shredden if let Some(dest) = self.resolve_path(&to_str)? { if dest.is_dir { return Err(FsError::Forbidden); } // Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten! if self.carrier_node_id == Some(dest.id) { return Err(FsError::Forbidden); } self.db .delete_node(dest.id) .map_err(|_| FsError::GeneralFailure)?; } let dest_node = self.create_node(to_parent.id, to_name, false)?; // Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!) let total_chunks = if node.size == 0 { 0 } else { ((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32 }; for idx in 0..total_chunks { if let Some(record) = self .db .read_chunk(node.id, idx) .map_err(|_| FsError::GeneralFailure)? { let plaintext = decrypt_chunk( &self.dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, self.format_version, record.generation, ) .map_err(|_| FsError::GeneralFailure)?; let gen = self .db .next_chunk_generation(dest_node.id, idx) .map_err(|_| FsError::GeneralFailure)?; let (new_ct, new_nonce, new_tag) = encrypt_chunk( &self.dek, dest_node.id, idx, &plaintext, self.format_version, gen, ) .map_err(|_| FsError::GeneralFailure)?; self.db .write_chunk(dest_node.id, idx, gen, &new_nonce, &new_tag, &new_ct) .map_err(|_| FsError::GeneralFailure)?; } } let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); self.db .update_node_size_and_time(dest_node.id, node.size, now) .map_err(|_| FsError::GeneralFailure)?; Ok(()) }) } fn get_quota(&self) -> FsFuture<'_, (u64, Option)> { if let Some(ref cfs) = self.carrier_fs { return cfs.get_quota(); } Box::pin(async move { self.touch(); // Z-04: Echte Containergröße (physisch auf Disk) und freier Host-Speicher let container_size = self.db.get_container_file_size().unwrap_or(0); let used_bytes = if container_size > 0 { container_size } else { self.db.get_total_used_size().unwrap_or(0) }; let free_host_space = self .db .container_path() .and_then(|p| crate::windows::get_available_disk_space(&p)) .unwrap_or(1024 * 1024 * 1024 * 1024); // Fallback: 1 TB let total_capacity = used_bytes.saturating_add(free_host_space); Ok((used_bytes, Some(total_capacity))) }) } } #[cfg(test)] mod tests { use super::*; use crate::crypto::{ derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION, }; use dav_server::fs::OpenOptions; use futures_util::StreamExt; #[test] fn test_is_leak_file() { // Exakte Namen assert!(is_leak_file("Thumbs.db")); assert!(is_leak_file("thumbs.db")); assert!(is_leak_file("THUMBS.DB")); assert!(is_leak_file("ehthumbs.db")); assert!(is_leak_file("ehthumbs_vista.db")); assert!(is_leak_file("desktop.ini")); assert!(is_leak_file("Desktop.ini")); assert!(is_leak_file("Folder.jpg")); assert!(is_leak_file("albumartsmall.jpg")); assert!(is_leak_file("AlbumArt_{12345}_Large.jpg")); assert!(is_leak_file("AlbumArt_{12345}_Small.jpg")); assert!(is_leak_file("autorun.inf")); assert!(is_leak_file(".ds_store")); assert!(is_leak_file(".DS_Store")); assert!(is_leak_file(".directory")); assert!(is_leak_file(".fseventsd")); assert!(is_leak_file(".spotlight-v100")); // Präfixe (V-04) assert!(is_leak_file("~$MyDocument.docx")); assert!(is_leak_file("._Document.pdf")); // Suffixe (V-04) assert!(is_leak_file("temp_file.tmp")); assert!(is_leak_file("cache.temp")); assert!(is_leak_file("video.crdownload")); assert!(is_leak_file("archive.tar.gz.part")); assert!(is_leak_file("bigfile.partial")); assert!(is_leak_file("notes.txt~")); // NTFS Alternate Data Streams (ADS, V-04) assert!(is_leak_file("document.pdf:Zone.Identifier")); assert!(is_leak_file("file.exe:$DATA")); // Wildcards (.trash*, V-04) assert!(is_leak_file(".trash")); assert!(is_leak_file(".Trash-1000")); assert!(is_leak_file(".trashes")); // Harmlos: assert!(!is_leak_file("secret.txt")); assert!(!is_leak_file("passwords.kdbx")); assert!(!is_leak_file("my_folder.jpg.txt")); assert!(!is_leak_file("desktop_notes.ini.bak")); assert!(!is_leak_file("temp_report.docx")); assert!(!is_leak_file("part1_chapter.txt")); } #[test] fn test_v04_custom_anti_leak_rules_and_loader() { let custom_rules = vec![ "*.secret_log".to_string(), "debug_*".to_string(), "*_temp_*".to_string(), "custom_exact.bin".to_string(), ]; assert!(is_leak_file_with_custom("audit.secret_log", &custom_rules)); assert!(is_leak_file_with_custom("debug_dump.txt", &custom_rules)); assert!(is_leak_file_with_custom( "app_temp_cache.dat", &custom_rules )); assert!(is_leak_file_with_custom("custom_exact.bin", &custom_rules)); // Normale Datei wird nicht blockiert assert!(!is_leak_file_with_custom("regular_file.txt", &custom_rules)); // Test load_anti_leak_list let temp_dir = tempfile::tempdir().unwrap(); let rule_file = temp_dir.path().join("anti_leak_rules.txt"); std::fs::write( &rule_file, "# Kommentarzeile\n*.bak\n\n # Noch ein Kommentar\nprivate_*\n", ) .unwrap(); let loaded = load_anti_leak_list(&rule_file).unwrap(); assert_eq!(loaded.len(), 2); assert_eq!(loaded[0], "*.bak"); assert_eq!(loaded[1], "private_*"); assert!(is_leak_file_with_custom("data.bak", &loaded)); assert!(is_leak_file_with_custom("private_keys.pem", &loaded)); assert!(!is_leak_file_with_custom("public_data.txt", &loaded)); } fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) { let temp_dir = tempfile_placeholder::TempDir::new(); let db_path = temp_dir.path().join("test_vfs.sanctum"); let db = Database::open(&db_path).unwrap(); let salt = generate_salt(); let kdf_params = KdfParams { memory_cost: crate::crypto::MIN_MEMORY_COST_KIB, time_cost: crate::crypto::MIN_TIME_COST, parallelism: 1, }; let kek = derive_kek("TestMasterPassword123!", &salt, &kdf_params).unwrap(); let dek = generate_dek(); let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap(); db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) .unwrap(); let fs = SanctumFs::with_options(db, dek, FORMAT_VERSION, anti_leak); (fs, temp_dir) } mod tempfile_placeholder { use std::path::{Path, PathBuf}; pub struct TempDir(PathBuf); impl TempDir { pub fn new() -> Self { let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::())); std::fs::create_dir_all(&p).unwrap(); Self(p) } pub fn path(&self) -> &Path { &self.0 } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } } #[tokio::test] async fn test_anti_leak_blocks_creation() { let (fs, _dir) = create_test_fs(true); let path = DavPath::new("/desktop.ini").unwrap(); let mut opts = OpenOptions::default(); opts.write = true; opts.create_new = true; // desktop.ini muss blockiert werden mit Forbidden let res = fs.open(&path, opts).await; assert!(matches!(res, Err(FsError::Forbidden))); // create_dir mit Thumbs.db muss auch blockiert werden let dir_path = DavPath::new("/Thumbs.db").unwrap(); let res_dir = fs.create_dir(&dir_path).await; assert!(matches!(res_dir, Err(FsError::Forbidden))); // Normale Datei muss erlaubt sein let valid_path = DavPath::new("/notes.txt").unwrap(); let mut valid_opts = OpenOptions::default(); valid_opts.write = true; valid_opts.create_new = true; let res_valid = fs.open(&valid_path, valid_opts).await; assert!(res_valid.is_ok()); } #[tokio::test] async fn test_anti_leak_filters_read_dir() { let (fs_shielded, _dir) = create_test_fs(true); // Erstelle eine normale Datei let normal_path = DavPath::new("/legit.txt").unwrap(); let mut opts = OpenOptions::default(); opts.write = true; opts.create_new = true; let res = fs_shielded.open(&normal_path, opts).await; assert!(res.is_ok()); // Erzwinge direkt in die DB eine Thumbs.db Datei fs_shielded.db.create_node(1, "Thumbs.db", false).unwrap(); // read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen let root_path = DavPath::new("/").unwrap(); let mut stream = fs_shielded .read_dir(&root_path, ReadDirMeta::None) .await .unwrap(); let mut names = Vec::new(); while let Some(entry) = stream.next().await { let entry = entry.unwrap(); names.push(String::from_utf8_lossy(&entry.name()).to_string()); } assert!(names.contains(&"legit.txt".to_string())); assert!(!names.contains(&"Thumbs.db".to_string())); // Mit unshielded FS (anti_leak = false) muss Thumbs.db sichtbar sein let fs_unshielded = SanctumFs::with_options( fs_shielded.db.clone(), zeroize::Zeroizing::new([0u8; 32]), FORMAT_VERSION, false, ); let mut stream_unshielded = fs_unshielded .read_dir(&root_path, ReadDirMeta::None) .await .unwrap(); let mut names_unshielded = Vec::new(); while let Some(entry) = stream_unshielded.next().await { let entry = entry.unwrap(); names_unshielded.push(String::from_utf8_lossy(&entry.name()).to_string()); } assert!(names_unshielded.contains(&"Thumbs.db".to_string())); } #[tokio::test] async fn test_vfs_activity_tracking() { let (fs, _dir) = create_test_fs(true); let act_arc = fs.last_activity(); let initial_time = act_arc.load(Ordering::Relaxed); assert!(initial_time > 0); // Manuell zurückdatieren act_arc.store(1000, Ordering::Relaxed); assert_eq!(act_arc.load(Ordering::Relaxed), 1000); // Nach einem VFS-Zugriff muss die Zeit aktualisiert sein let path = DavPath::new("/test_activity.txt").unwrap(); let mut opts = OpenOptions::default(); opts.write = true; opts.create_new = true; let _ = fs.open(&path, opts).await; let new_time = act_arc.load(Ordering::Relaxed); assert!(new_time > 1000); } #[tokio::test] async fn test_carrier_protection_in_decoy_vault() { let (fs, _dir) = create_test_fs(true); // Erstelle eine Carrier-Datei "system_backup.dat" let carrier_node = fs.db.create_node(1, "system_backup.dat", false).unwrap(); let carrier_id = carrier_node.id; // Erstelle FS mit bekanntem carrier_node_id let protected_fs = SanctumFs::with_carrier( fs.db.clone(), (*fs.dek).clone(), None, Some(carrier_id), FORMAT_VERSION, true, 0, ); let carrier_path = DavPath::new("/system_backup.dat").unwrap(); // 1. Lesen muss erlaubt sein let mut read_opts = OpenOptions::default(); read_opts.read = true; assert!(protected_fs.open(&carrier_path, read_opts).await.is_ok()); // 2. Schreiben / Truncate muss verboten sein (FsError::Forbidden) let mut write_opts = OpenOptions::default(); write_opts.write = true; assert!(matches!( protected_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden) )); let mut trunc_opts = OpenOptions::default(); trunc_opts.truncate = true; assert!(matches!( protected_fs.open(&carrier_path, trunc_opts).await, Err(FsError::Forbidden) )); // 3. Löschen der Carrier-Datei muss verboten sein assert!(matches!( protected_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden) )); // 4. Umbenennen der Carrier-Datei muss verboten sein let rename_target = DavPath::new("/renamed_backup.dat").unwrap(); assert!(matches!( protected_fs.rename(&carrier_path, &rename_target).await, Err(FsError::Forbidden) )); // 5. Überschreiben der Carrier-Datei durch Rename einer anderen Datei muss verboten sein let other_path = DavPath::new("/other.txt").unwrap(); let mut other_opts = OpenOptions::default(); other_opts.write = true; other_opts.create_new = true; protected_fs.open(&other_path, other_opts).await.unwrap(); assert!(matches!( protected_fs.rename(&other_path, &carrier_path).await, Err(FsError::Forbidden) )); // 6. Überschreiben der Carrier-Datei durch Copy einer anderen Datei muss verboten sein assert!(matches!( protected_fs.copy(&other_path, &carrier_path).await, Err(FsError::Forbidden) )); } #[tokio::test] async fn test_path_safety_rejects_null_bytes_and_control_chars() { let (fs, _dir) = create_test_fs(true); // 1. Null-Byte im Pfad let null_path = DavPath::new("/bad\0file.txt"); assert!( null_path.is_err() || fs .open(&null_path.unwrap(), OpenOptions::default()) .await .is_err() ); assert!(validate_path_safety("/bad\0file.txt").is_err()); // 2. Steuerzeichen < 0x20 assert!(validate_path_safety("/bad\x01file.txt").is_err()); assert!(validate_path_safety("/bad\rfile.txt").is_err()); assert!(validate_path_safety("/bad\nfile.txt").is_err()); assert!(validate_path_safety("/bad\tfile.txt").is_err()); // 3. Gültiger Pfad assert!(validate_path_safety("/normal_file_123.txt").is_ok()); assert!(validate_path_safety("/path/to/subfolder/file.pdf").is_ok()); } #[tokio::test] async fn test_write_atomic_and_cache_invalidation() { let (fs, _dir) = create_test_fs(true); let path = DavPath::new("/atomic_test.bin").unwrap(); let mut opts = OpenOptions::default(); opts.write = true; opts.create_new = true; let mut file = fs.open(&path, opts).await.unwrap(); // 1. Schreibe 500 Bytes let payload = Bytes::from(vec![42u8; 500]); file.write_buf(Box::new(std::io::Cursor::new(payload))) .await .unwrap(); // 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren file.flush().await.unwrap(); let node = fs.resolve_path("/atomic_test.bin").unwrap().unwrap(); assert_eq!(node.size, 500); // Chunk in DB prüfen let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap(); assert!(!chunk.ciphertext.is_empty()); } #[tokio::test] async fn test_v02_missing_chunk_returns_error_instead_of_silent_truncation() { let (fs, _dir) = create_test_fs(true); let path = DavPath::new("/corrupt_test.bin").unwrap(); // 1. Datei mit 3 Chunks anlegen (3 * 1 MB = 3 MB) let mut opts = OpenOptions::default(); opts.write = true; opts.create_new = true; let mut file = fs.open(&path, opts).await.unwrap(); let chunk_data = vec![0xABu8; CHUNK_SIZE]; for _ in 0..3 { file.write_bytes(Bytes::copy_from_slice(&chunk_data)) .await .unwrap(); } file.flush().await.unwrap(); drop(file); let node = fs.resolve_path("/corrupt_test.bin").unwrap().unwrap(); assert_eq!(node.size, 3 * CHUNK_SIZE as u64); // 2. Chunk 1 direkt per SQL aus der chunks-Tabelle löschen (stille Datenkorruption) { let conn = fs.db.conn_for_test(); let deleted = conn .execute( "DELETE FROM chunks WHERE node_id = ?1 AND chunk_index = 1", [node.id], ) .unwrap(); assert_eq!(deleted, 1, "Chunk 1 muss gelöscht worden sein"); } // 3. Datei im VFS zum Lesen öffnen let mut read_opts = OpenOptions::default(); read_opts.read = true; let mut read_file = fs.open(&path, read_opts).await.unwrap(); // Erstes Megabyte (Chunk 0) lesen -> muss klappen let c0 = read_file.read_bytes(CHUNK_SIZE).await.unwrap(); assert_eq!(c0.len(), CHUNK_SIZE); // Zweites Megabyte (fehlender Chunk 1) lesen -> V-02 MUSS FsError::GeneralFailure zurückgeben! let c1_res = read_file.read_bytes(CHUNK_SIZE).await; assert!( matches!(c1_res, Err(FsError::GeneralFailure)), "Lesen eines gelöschten/fehlenden Chunks muss mit GeneralFailure fehlschlagen, nicht stillschweigend gekürzt werden! Erhalten: {:?}", c1_res ); // 4. Test jenseits der Dateigröße bei leeren Dateien: kein Regress let empty_path = DavPath::new("/empty.txt").unwrap(); let mut empty_opts = OpenOptions::default(); empty_opts.create_new = true; empty_opts.write = true; let mut empty_file = fs.open(&empty_path, empty_opts).await.unwrap(); empty_file.flush().await.unwrap(); drop(empty_file); let mut read_empty_opts = OpenOptions::default(); read_empty_opts.read = true; let mut read_empty = fs.open(&empty_path, read_empty_opts).await.unwrap(); let bytes = read_empty.read_bytes(100).await.unwrap(); assert!(bytes.is_empty(), "Leere Datei liefert 0 Bytes ohne Fehler"); } #[tokio::test] async fn test_v05_sanctum_file_drop_flushes_dirty_chunk_automatically() { let (fs, _dir) = create_test_fs(true); let path = DavPath::new("/drop_flush_test.bin").unwrap(); let mut opts = OpenOptions::default(); opts.write = true; opts.create_new = true; let mut file = fs.open(&path, opts).await.unwrap(); let test_data = b"Autoflush on drop without explicit flush() call (V-05)"; file.write_bytes(Bytes::copy_from_slice(test_data)) .await .unwrap(); // Absichtlich KEIN file.flush().await aufrufen! // Drop des Handles muss Daten & Größe zwingend automatisch persistieren. drop(file); // Prüfe, ob Datei in DB existiert und korrekte Größe hat let node = fs.resolve_path("/drop_flush_test.bin").unwrap().unwrap(); assert_eq!(node.size, test_data.len() as u64); // Lese Datei neu ein und verifiziere Inhalt let mut read_opts = OpenOptions::default(); read_opts.read = true; let mut read_file = fs.open(&path, read_opts).await.unwrap(); let read_bytes = read_file.read_bytes(test_data.len()).await.unwrap(); assert_eq!(&read_bytes[..], test_data); } #[tokio::test] async fn test_v06_sparse_write_zero_fills_gap() { use std::io::SeekFrom; let (fs, _dir) = create_test_fs(true); let path = DavPath::new("/sparse_test.bin").unwrap(); let mut opts = OpenOptions::default(); opts.write = true; opts.create_new = true; let mut file = fs.open(&path, opts).await.unwrap(); // 1. Schreibe 5 Bytes am Anfang (Chunk 0) file.write_bytes(Bytes::copy_from_slice(b"START")) .await .unwrap(); // 2. Springe weit über das Dateiende hinaus in Chunk 2 (2,5 MB) let seek_pos = 2 * CHUNK_SIZE as u64 + 500; file.seek(SeekFrom::Start(seek_pos)).await.unwrap(); // 3. Schreibe 3 Bytes an Position seek_pos file.write_bytes(Bytes::copy_from_slice(b"END")) .await .unwrap(); file.flush().await.unwrap(); drop(file); // 4. Verifiziere Dateigröße let node = fs.resolve_path("/sparse_test.bin").unwrap().unwrap(); assert_eq!(node.size, seek_pos + 3); // 5. Lese Datei vollständig ein und prüfe Nullen in der Lücke let mut read_opts = OpenOptions::default(); read_opts.read = true; let mut read_file = fs.open(&path, read_opts).await.unwrap(); // Anfang lesen let start_bytes = read_file.read_bytes(5).await.unwrap(); assert_eq!(&start_bytes[..], b"START"); // Lücke in Chunk 0 und 1 muss mit Nullen gefüllt sein let zero_gap = read_file.read_bytes(1000).await.unwrap(); assert_eq!(zero_gap.len(), 1000); assert!(zero_gap.iter().all(|&b| b == 0)); // Springe zum Ende und lies die geschriebenen Daten read_file.seek(SeekFrom::Start(seek_pos)).await.unwrap(); let end_bytes = read_file.read_bytes(3).await.unwrap(); assert_eq!(&end_bytes[..], b"END"); } #[tokio::test] async fn test_v07_copy_overwrite_existing_file_semantics() { let (fs, _dir) = create_test_fs(true); let src_path = DavPath::new("/source.txt").unwrap(); let dest_path = DavPath::new("/dest.txt").unwrap(); // 1. Erstelle Quelldatei let mut opts1 = OpenOptions::default(); opts1.write = true; opts1.create_new = true; let mut src_file = fs.open(&src_path, opts1).await.unwrap(); let src_data = b"Freshly copied source data (RFC-4918)"; src_file .write_bytes(Bytes::copy_from_slice(src_data)) .await .unwrap(); src_file.flush().await.unwrap(); drop(src_file); // 2. Erstelle Zieldatei mit abweichendem Inhalt und Größe let mut opts2 = OpenOptions::default(); opts2.write = true; opts2.create_new = true; let mut dest_file = fs.open(&dest_path, opts2).await.unwrap(); let old_dest_data = b"Old obsolete destination data that MUST be overwritten"; dest_file .write_bytes(Bytes::copy_from_slice(old_dest_data)) .await .unwrap(); dest_file.flush().await.unwrap(); drop(dest_file); // 3. Kopiere Quelle auf existierendes Ziel (RFC-4918 Copy Overwrite) fs.copy(&src_path, &dest_path) .await .expect("Copy with overwrite must succeed per RFC-4918"); // 4. Verifiziere Zielinhalt let mut read_opts = OpenOptions::default(); read_opts.read = true; let mut read_dest = fs.open(&dest_path, read_opts).await.unwrap(); let read_bytes = read_dest.read_bytes(src_data.len() + 100).await.unwrap(); assert_eq!(&read_bytes[..], src_data); let dest_node = fs.resolve_path("/dest.txt").unwrap().unwrap(); assert_eq!(dest_node.size, src_data.len() as u64); // 5. Test: Kopieren auf existierendes Verzeichnis muss mit Forbidden fehlschlagen let dir_path = DavPath::new("/sub_dir").unwrap(); fs.create_dir(&dir_path).await.unwrap(); let copy_to_dir_res = fs.copy(&src_path, &dir_path).await; assert!(matches!(copy_to_dir_res, Err(FsError::Forbidden))); } }