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(())
|
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
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user