diff --git a/src/storage.rs b/src/storage.rs index b4298d7..fa83e56 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1200,6 +1200,43 @@ impl Database { Ok(()) } + /// Schreibt einen verschlüsselten Chunk und aktualisiert Dateigröße und Modifikationszeitstempel atomar + /// in einer einzigen SQLite-Transaktion (Crash-Konsistenz / Power-Loss Schutz / CHAOS-01). + pub fn write_chunk_and_update_size( + &self, + node_id: i64, + chunk_index: u32, + nonce: &[u8; 12], + tag: &[u8; 16], + ciphertext: &[u8], + new_size: u64, + modified_at: u64, + ) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(node_id, chunk_index) DO UPDATE SET + nonce = excluded.nonce, + tag = excluded.tag, + ciphertext = excluded.ciphertext", + params![ + node_id, + chunk_index, + nonce.as_slice(), + tag.as_slice(), + ciphertext, + ], + )?; + tx.execute( + "UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3", + params![new_size as i64, modified_at as i64, node_id], + )?; + tx.commit()?; + Ok(()) + } + /// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei) /// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen. pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> { @@ -1709,4 +1746,48 @@ mod tests { ).unwrap(); assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren"); } + + #[test] + fn test_atomic_chunk_write_and_size_update() { + let db = Database::open_in_memory().unwrap(); + let (salt, kdf, wrapped_dek, nonce, tag) = ( + [1u8; 16], + KdfParams::default(), + vec![2u8; 40], + [3u8; 12], + [4u8; 16], + ); + db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap(); + + let file = db.create_node(1, "crash_test.bin", false).unwrap(); + assert_eq!(file.size, 0); + + let chunk_nonce = [5u8; 12]; + let chunk_tag = [6u8; 16]; + let ciphertext = vec![7u8; 1024]; + let new_size = 1024u64; + let modified_at = 2000000u64; + + db.write_chunk_and_update_size( + file.id, + 0, + &chunk_nonce, + &chunk_tag, + &ciphertext, + new_size, + modified_at, + ).expect("Atomic write"); + + // Chunk verifizieren + let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist"); + assert_eq!(chunk.ciphertext, ciphertext); + assert_eq!(chunk.nonce, chunk_nonce); + assert_eq!(chunk.tag, chunk_tag); + + // Inode verifizieren + let nodes = db.list_children(1).unwrap(); + let updated_file = nodes.iter().find(|n| n.id == file.id).unwrap(); + assert_eq!(updated_file.size, new_size); + assert_eq!(updated_file.modified_at, modified_at); + } } diff --git a/src/vfs.rs b/src/vfs.rs index 97fe88a..dd4b47a 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -168,8 +168,15 @@ impl SanctumFile { } - /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück. - fn flush_cached_chunk(&mut self) -> Result<(), FsError> { + /// 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 (ciphertext, nonce, tag) = encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| { @@ -177,16 +184,35 @@ impl SanctumFile { FsError::GeneralFailure })?; - self.db - .write_chunk(self.node_id, idx, &nonce, &tag, &ciphertext) - .map_err(|e| { - error!("DB-Fehler beim Schreiben des Chunks: {e}"); - FsError::GeneralFailure - })?; + if let Err(e) = self.db.write_chunk_and_update_size( + self.node_id, + idx, + &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(()) } @@ -199,7 +225,7 @@ impl SanctumFile { }; if !is_current { - self.flush_cached_chunk()?; + self.flush_cached_chunk_and_size()?; if let Some((_, ref mut data, _)) = self.cached_chunk { data.zeroize(); } @@ -236,17 +262,12 @@ impl SanctumFile { impl Drop for SanctumFile { fn drop(&mut self) { - if let Err(e) = self.flush_cached_chunk() { + if let Err(e) = self.flush_cached_chunk_and_size() { warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e); } if let Some((_, ref mut data, _)) = self.cached_chunk { data.zeroize(); } - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let _ = self.db.update_node_size_and_time(self.node_id, self.file_size, now); } } @@ -334,7 +355,7 @@ impl DavFile for SanctumFile { // 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()?; + self.flush_cached_chunk_and_size()?; } src = &src[to_write..]; @@ -370,19 +391,7 @@ impl DavFile for SanctumFile { fn flush(&mut self) -> FsFuture<'_, ()> { self.touch(); Box::pin(async move { - self.flush_cached_chunk()?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - self.db - .update_node_size_and_time(self.node_id, self.file_size, now) - .map_err(|e| { - error!("Fehler beim Aktualisieren der Knotengröße: {e}"); - FsError::GeneralFailure - })?; - self.meta.size = self.file_size; - self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now); + self.flush_cached_chunk_and_size()?; Ok(()) }) } @@ -539,6 +548,7 @@ impl SanctumFs { } 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) @@ -551,6 +561,7 @@ impl SanctumFs { } fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result { + validate_path_safety(name)?; self.db .create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek) .map_err(|e| { @@ -560,12 +571,26 @@ impl SanctumFs { } fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> { + validate_path_safety(new_name)?; 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 && c != '\t' { + return Err(FsError::Forbidden); + } + } + Ok(()) +} + impl Drop for SanctumFs { fn drop(&mut self) { crate::windows::unlock_memory(self.dek.as_ptr(), 32); @@ -1224,4 +1249,48 @@ mod tests { 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()); + + // 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()); + } }