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:
+98
-29
@@ -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<Option<NodeRecord>, 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<NodeRecord, FsError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user