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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+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