feat(resilience): implement CHAOS-01 to CHAOS-03 crash consistency and input guards

- CHAOS-01: write chunk and update node size atomically via write_chunk_and_update_size in a single SQLite transaction
- CHAOS-02: reject null-bytes and ASCII control characters in VFS paths and node names via validate_path_safety
- CHAOS-03: invalidate in-memory chunk cache on I/O and disk-full errors to prevent drop failure cascades
This commit is contained in:
2026-09-10 13:58:39 +02:00
parent 541190cff4
commit dae0b3b7c3
2 changed files with 179 additions and 29 deletions
+81
View File
@@ -1200,6 +1200,43 @@ impl Database {
Ok(()) 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) /// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen. /// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> { pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
@@ -1709,4 +1746,48 @@ mod tests {
).unwrap(); ).unwrap();
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren"); 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);
}
} }
+98 -29
View File
@@ -168,8 +168,15 @@ impl SanctumFile {
} }
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück. /// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
fn flush_cached_chunk(&mut self) -> Result<(), FsError> { /// 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 { if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) = let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| { encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
@@ -177,16 +184,35 @@ impl SanctumFile {
FsError::GeneralFailure FsError::GeneralFailure
})?; })?;
self.db if let Err(e) = self.db.write_chunk_and_update_size(
.write_chunk(self.node_id, idx, &nonce, &tag, &ciphertext) self.node_id,
.map_err(|e| { idx,
error!("DB-Fehler beim Schreiben des Chunks: {e}"); &nonce,
FsError::GeneralFailure &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 { if let Some((_, _, ref mut dirty)) = self.cached_chunk {
*dirty = false; *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(()) Ok(())
} }
@@ -199,7 +225,7 @@ impl SanctumFile {
}; };
if !is_current { if !is_current {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
if let Some((_, ref mut data, _)) = self.cached_chunk { if let Some((_, ref mut data, _)) = self.cached_chunk {
data.zeroize(); data.zeroize();
} }
@@ -236,17 +262,12 @@ impl SanctumFile {
impl Drop for SanctumFile { impl Drop for SanctumFile {
fn drop(&mut self) { 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); warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
} }
if let Some((_, ref mut data, _)) = self.cached_chunk { if let Some((_, ref mut data, _)) = self.cached_chunk {
data.zeroize(); 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 // 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) { 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..]; src = &src[to_write..];
@@ -370,19 +391,7 @@ impl DavFile for SanctumFile {
fn flush(&mut self) -> FsFuture<'_, ()> { fn flush(&mut self) -> FsFuture<'_, ()> {
self.touch(); self.touch();
Box::pin(async move { Box::pin(async move {
self.flush_cached_chunk()?; self.flush_cached_chunk_and_size()?;
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);
Ok(()) Ok(())
}) })
} }
@@ -539,6 +548,7 @@ impl SanctumFs {
} }
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> { fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
validate_path_safety(path)?;
self.db self.db
.resolve_path_in_vault(path, self.vault_id, &self.dek) .resolve_path_in_vault(path, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .map_err(|_| FsError::GeneralFailure)
@@ -551,6 +561,7 @@ impl SanctumFs {
} }
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> { fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
validate_path_safety(name)?;
self.db self.db
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek) .create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
.map_err(|e| { .map_err(|e| {
@@ -560,12 +571,26 @@ impl SanctumFs {
} }
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> { fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
validate_path_safety(new_name)?;
self.db self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek) .rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure) .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 { impl Drop for SanctumFs {
fn drop(&mut self) { fn drop(&mut self) {
crate::windows::unlock_memory(self.dek.as_ptr(), 32); crate::windows::unlock_memory(self.dek.as_ptr(), 32);
@@ -1224,4 +1249,48 @@ mod tests {
Err(FsError::Forbidden) 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());
}
} }