8 Commits
13 changed files with 1295 additions and 111 deletions
+28
View File
@@ -5,6 +5,34 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
## [0.8.1] - 2026-09-19
### Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
Dieses Release behebt sechs Findings aus der dritten Audit-Runde in `carrier.rs` (steganografisches Dateisystem des Hidden Vault) und `storage.rs`, schließt alle verbleibenden Punkte und erreicht 39/39 (100%) behobene Audit-Findings.
#### Steganografischer Hidden Vault & Carrier-Dateisystem (C-02, C-03, C-04)
- **C-02 (HOCH): Rollierendes Dual-Block-Manifest & Failover-Recovery**:
- Beseitigung des Single Point of Failure: Das Dateisystem-Manifest wird nun abwechselnd auf Block 0 und Block 1 geschrieben (`manifest_generation: u64`).
- Beim Mounten liest Sanctum beide Blöcke, wählt die neuere Generation und stellt den Tresor selbst bei Beschädigung (z. B. Bitrot) eines Blocks transparent wieder her.
- Vollständige Abwärtskompatibilität: Block 1 wird für bestehende Container automatisch reserviert.
- **C-03 (MITTEL): Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking**:
- Metadatenänderungen (`create_dir`, `create_file`, `remove_file`, `remove_dir`, `rename`, `copy`, `drop`) markieren das Manifest als `dirty`, anstatt jedes Mal synchron 1 MB per AES-256-GCM neu zu verschlüsseln.
- Synchrone Sicherung erfolgt gebündelt bei Datei-`flush()`, unmount/drop oder nach 50 Operationen.
- Gewährleistet massive Performancesteigerung bei Massenoperationen im Hidden Vault.
- **C-04 (NIEDRIG): Kapazitätsgrenze dokumentiert & Warnung bei Füllstand**:
- Dokumentation der Obergrenze (~7.000 Dateien/Verzeichnisse pro 1-MB-Manifest-Block) in `README.md` und `QUICKSTART.md`.
- Automatische Warnung beim Mounten und Speichern, wenn das Manifest mehr als 80% Füllstand (~838 KB) erreicht.
#### Storage-Hygiene & Fehlerbehandlung (ST-01, ST-02, ST-03)
- **ST-01 (MITTEL): Fail-Closed bei Entschlüsselungsfehlern von Knotennamen**:
- Beseitigung des Hex-Ciphertext-Leaks (`unwrap_or(enc_name)`): Bei Entschlüsselungsfehlern (z. B. Bitrot oder unlesbare Knoten) werden betroffene Einträge in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` übersprungen und geloggt (`warn!`), anstatt rohe Hex-Strings auszuliefern.
- **ST-02 (NIEDRIG): Fehleraggregation bei rekursivem Löschen**:
- `delete_node` verschluckt Fehler bei Kindknoten nicht mehr (`let _ =`).
- Fehler werden gesammelt, protokolliert (`error!`) und führen zum Abbruch mit detaillierter Zusammenfassung, wodurch verwaiste Knoten (Orphans) verhindert werden.
- **ST-03 (NIEDRIG): Atomare SQLite-Transaktion für rekursives Löschen**:
- Das gesamte rekursive Löschen eines Baumes (inklusive kryptografischem Chunk-Shredding) wird nun in einer einzigen atomaren SQLite-Transaktion ausgeführt.
- Schlägt das Löschen eines Teilbaums fehl, wird ein automatischer Rollback ausgeführt; Teilbaumlöschungen und inkonsistente Zustände sind ausgeschlossen.
## [0.8.0] - 2026-09-19
### Security Audit Remediation & Format V3 Release
Generated
+1 -1
View File
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sanctum"
version = "0.8.0"
version = "0.8.1"
dependencies = [
"aes-gcm",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sanctum"
version = "0.8.0"
version = "0.8.1"
edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
+1
View File
@@ -37,6 +37,7 @@ Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
* **Kapazitätsgrenze (C-04)**: Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse im Hidden Vault (~1 MB Manifest-Block). Sanctum warnt beim Mounten automatisch bei über 80% Füllstand.
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
---
+1
View File
@@ -37,6 +37,7 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
- **Dual-Vault (Multi-Slot & Carrier)**:
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
*Kapazitätsgrenze des Hidden Vault (C-04)*: Das Dateisystem des Hidden Vaults verwaltet seine Inodes in einem dual-block rollierenden Manifest (~1 MB pro Block). Daraus ergibt sich eine empfohlene Obergrenze von ca. 7.000 Dateien und Verzeichnissen im Hidden Vault. Bei Überschreiten von 80% Füllstand (~838 KB) warnt Sanctum beim Einbinden automatisch vor Erreichen der Blockgrenze.
- **Dateinamen-Verschlüsselung**:
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
+29 -7
View File
@@ -1,16 +1,17 @@
# Sanctum Security Audit & Remediation Log (v0.8.0)
# Sanctum Security Audit & Remediation Log (v0.8.1)
Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests.
Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests.
---
## Audit-Zusammenfassung
- **Zielversion:** Sanctum v0.8.0
- **Behobene Findings:** 33 / 33 (100%)
- **Test-Ergebnis:** 117 / 117 Tests erfolgreich (100% Pass Rate)
- **Zielversion:** Sanctum v0.8.1
- **Behobene Findings:** 39 / 39 (100%)
- **Test-Ergebnis:** 123 / 123 Tests erfolgreich (100% Pass Rate)
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
- **Container-Format:** Upgrade auf Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) und Chunk-Replay-Schutz (Generation-gebundenes AAD).
- **Hidden Vault & Storage-Resilienz:** Dual-Block rollierendes Manifest (C-02), Manifest-Entkopplung mit Dirty-Tracking (C-03), Kapazitäts-Warnung (C-04), Fail-Closed Node-Name-Decryption (ST-01), rekursive Fehleraggregation (ST-02) und atomare SQLite-Transaktionen für Baum-Löschungen (ST-03).
---
@@ -18,6 +19,12 @@ Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheits
| Finding | Priorität | Modul | Kurzbeschreibung | Commit | Status & Regressionstest |
|---|---|---|---|---|---|
| **C-02** | HOCH | `carrier` | Single Point of Failure beseitigt: Rollierendes Dual-Block-Manifest (Block 0 & 1, `manifest_generation`) mit transparenter Failover-Wiederherstellung | `8455dc0` | ✅ Bestanden (`test_c02_dual_block_rolling_manifest_redundancy_and_recovery`) |
| **C-03** | MITTEL | `carrier`, `vfs`, `mount` | Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking; synchrone Sicherung bei File-Flush und Unmount | `c606fa8` | ✅ Bestanden (`test_c03_manifest_dirty_decoupling_and_unmount_flush`) |
| **C-04** | NIEDRIG | `carrier`, `mount`, `doc` | Dokumentation der Hidden-Vault-Kapazität (~7.000 Inodes) & automatische 80%-Füllstandswarnung beim Mounten | `c21d63a` | ✅ Bestanden (`test_c04_manifest_capacity_limit_and_warning`) |
| **ST-01** | MITTEL | `storage` | Fail-Closed bei Knotennamen-Entschlüsselung; Beseitigung von Hex-Ciphertext-Leaks bei Bitrot/Korruption | `dd6b6ff` | ✅ Bestanden (`test_st01_fail_closed_name_decryption_no_hex_leak`) |
| **ST-02** | NIEDRIG | `storage` | Fehleraggregation beim rekursiven Löschen in `delete_node` ohne stilles Verschlucken | `efddc2c` | ✅ Bestanden (`test_st02_recursive_delete_error_propagation_not_swallowed`) |
| **ST-03** | NIEDRIG | `storage` | Atomare SQLite-Transaktion (`tx.commit()` / Rollback) beim rekursiven Löschen in `delete_node` | `8ff426d` | ✅ Bestanden (`test_st03_recursive_delete_transaction_rollback`) |
| **S-01** | HOCH | `sync` | `--delete` löscht keine ausgeschlossenen Pfade mehr; Schutz ganzer Verzeichnisbäume; `--delete-excluded` eingeführt | `374150e` | ✅ Bestanden (`test_s01_delete_preserves_excluded_files_and_directories`, `test_s01_pull_delete_preserves_local_excluded_and_leak_files`) |
| **V-02** | HOCH | `vfs` | Fehlender Chunk innerhalb erwarteter Dateigröße löst E/A-Fehler aus statt stiller Dateikürzung | `5337644` | ✅ Bestanden (`test_v02_missing_chunk_returns_error_instead_of_silent_truncation`) |
| **V-01** | HOCH | `vfs`, `carrier` | `copy()` delegiert im Carrier-Modus korrekt an `CarrierFs` | `151fdba` | ✅ Bestanden (`test_v01_carrier_fs_copy_delegation_and_functionality`) |
@@ -86,10 +93,25 @@ Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheits
- NTFS Alternate Data Streams (ADS): Alle Zugriffe mit `:` (z. B. `Zone.Identifier`).
- Wildcards: `.trash*`.
- Option `--anti-leak-list <FILE>` für benutzerdefinierte Ausschlussmuster.
- Statistische Zählung aller blockierten Schreib-/Lesezugriffe und transparente Zusammenfassung beim Unmount.
### 4. Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
- **Rollierendes Dual-Block-Manifest (C-02):**
- Das Carrier-Manifest wird abwechselnd auf Block 0 und Block 1 geschrieben, geschützt durch ein monotones Generation-Attribut (`manifest_generation: u64`).
- Beim Mounten liest Sanctum beide Blöcke. Ist ein Block korrumpiert (z. B. Bitrot oder unvollständiges Schreiben), stellt Sanctum den Hidden Vault transparent aus der intakten Kopie wieder her.
- **Entkopplung der Manifest-Neuverschlüsselung (C-03):**
- Vor v0.8.1 wurde bei jeder Metadatenänderung (Dateierstellung, Ordnererstellung, Löschen, Umbenennen, Drop) das gesamte 1-MB-Manifest synchron per AES-256-GCM neu verschlüsselt.
- v0.8.1 führt Dirty-Tracking ein: Änderungen markieren das Manifest als `dirty`. Die tatsächliche Verschlüsselung erfolgt gebündelt bei Datei-`flush()`, Unmount/Drop oder nach 50 Operationen.
- **Kapazitätsgrenze & Warnschwelle (C-04):**
- Durch den 1-MB-Block für das Manifest ergibt sich eine empfohlene Obergrenze von ~7.000 Inodes im Hidden Vault.
- Ab 80% Füllstand (~838 KB) warnt Sanctum beim Mounten und Speichern unübersehbar.
- **Fail-Closed bei Knotennamen (ST-01):**
- Bei Fehlschlagen der Entschlüsselung von Knotennamen werden keine Hex-Ciphertext-Fragmente mehr exponiert (`unwrap_or(enc_name)` eliminiert).
- Unlesbare Knoten werden mit `warn!` geloggt und in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` fail-closed übersprungen.
- **Fehleraggregation & Transaktions-Rollback bei rekursivem Löschen (ST-02 & ST-03):**
- Fehler beim Löschen von Kindknoten werden nicht mehr ignoriert (`let _ =` eliminiert), sondern aggregiert und protokolliert.
- Das gesamte Löschen eines Baumes samt kryptografischem Chunk-Shredding läuft in einer atomaren SQLite-Transaktion. Schlägt ein Knoten fehl, wird der gesamte Vorgang per Rollback zurückgesetzt; verwaiste Knoten (Orphans) sind ausgeschlossen.
---
## Verifikationsnachweis
Alle 117 Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt. Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
Alle Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt. Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
+3 -3
View File
@@ -1,12 +1,12 @@
{
"version": "0.8.0",
"version": "0.8.1",
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
"homepage": "https://gitea.pansi.eu/harald/sanctum",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip",
"hash": "11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419",
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip",
"hash": "11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c",
"bin": "sanctum.exe"
}
},
+4 -4
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
PackageIdentifier: HaraldPansi.Sanctum
PackageVersion: 0.8.0
PackageVersion: 0.8.1
PackageName: Sanctum
Publisher: Harald Pansi
PublisherUrl: https://gitea.pansi.eu/harald
@@ -18,7 +18,7 @@ Tags:
- webdav
- container
- plausible-deniability
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.8.0
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.8.1
Installers:
- Architecture: x64
InstallerType: zip
@@ -26,7 +26,7 @@ Installers:
NestedInstallerFiles:
- RelativeFilePath: sanctum.exe
PortableCommandAlias: sanctum
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip
InstallerSha256: 11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip
InstallerSha256: 11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c
ManifestType: singleton
ManifestVersion: 1.6.0
+196 -26
View File
@@ -18,7 +18,7 @@ use futures_util::stream;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use tracing::{debug, error, warn};
use zeroize::{Zeroize, Zeroizing};
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
@@ -27,12 +27,16 @@ use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData};
pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR";
pub const CARRIER_VERSION: u32 = 1;
pub const CARRIER_MANIFEST_CAPACITY: usize = CHUNK_SIZE - 64; // 1,048,512 Bytes
pub const CARRIER_MANIFEST_WARN_THRESHOLD: usize = CARRIER_MANIFEST_CAPACITY * 8 / 10; // 838,809 Bytes (80%, C-04)
/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0).
/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0 und 1, C-02).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarrierManifest {
pub magic: [u8; 8],
pub version: u32,
#[serde(default)]
pub manifest_generation: u64,
pub total_blocks: u32,
pub free_blocks: Vec<u32>,
pub next_inode_id: i64,
@@ -62,12 +66,13 @@ impl CarrierManifest {
},
);
// Block 0 ist für das Manifest reserviert. Nutzblöcke sind 1..total_blocks-1.
let free_blocks = (1..total_blocks).collect();
// Block 0 und Block 1 sind für das redundante Manifest reserviert (C-02). Nutzblöcke sind 2..total_blocks-1.
let free_blocks = (2..total_blocks).collect();
Self {
magic: *CARRIER_MAGIC,
version: CARRIER_VERSION,
manifest_generation: 0,
total_blocks,
free_blocks,
next_inode_id: 2,
@@ -274,6 +279,10 @@ pub struct CarrierFsInner {
pub custom_leak_rules: Arc<Vec<String>>,
pub leak_counter: Arc<AtomicU64>,
pub manifest: CarrierManifest,
pub manifest_dirty: bool,
pub manifest_save_count: u64,
pub dirty_ops_count: u32,
pub last_manifest_save: u64,
pub last_activity: Arc<AtomicU64>,
}
@@ -281,26 +290,78 @@ impl CarrierFsInner {
pub fn is_leak(&self, name: &str) -> bool {
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
}
/// Markiert das Manifest als modifiziert (dirty) ohne teure synchrone Neuverschlüsselung (C-03).
/// Erst bei flush(), close(), unmount/drop oder nach Überschreiten von 50 Operationen wird
/// das vollständige 1-MB-Manifest tatsächlich neu verschlüsselt und geschrieben.
///
/// Konsistenzhinweis:
/// Bei einem abrupten Prozessabbruch gilt der zuletzt gespeicherte Zustand. Dank C-02
/// (rollierendes Manifest auf Block 0 und Block 1) ist die Integrität stets gewährleistet.
pub fn mark_dirty(&mut self) {
self.manifest_dirty = true;
self.dirty_ops_count += 1;
if self.dirty_ops_count >= 50 {
let _ = self.flush_manifest_if_dirty();
}
}
/// Schreibt das Manifest nur, wenn ungespeicherte Änderungen anstehen (C-03).
pub fn flush_manifest_if_dirty(&mut self) -> Result<()> {
if self.manifest_dirty {
self.save_manifest()?;
}
Ok(())
}
/// Ermittelt die aktuelle Bytegröße des Manifests, die Kapazität und den Füllstand in Prozent (C-04).
pub fn manifest_usage(&self) -> (usize, usize, f64) {
let len = serde_json::to_vec(&self.manifest)
.map(|v| v.len())
.unwrap_or(0);
let pct = (len as f64 / CARRIER_MANIFEST_CAPACITY as f64) * 100.0;
(len, CARRIER_MANIFEST_CAPACITY, pct)
}
pub fn save_manifest(&mut self) -> Result<()> {
self.manifest_save_count += 1;
self.manifest_dirty = false;
self.dirty_ops_count = 0;
self.last_manifest_save = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.manifest.manifest_generation += 1;
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) {
if manifest_bytes.len() > CARRIER_MANIFEST_CAPACITY {
bail!(
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes).",
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes). Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse beachten.",
manifest_bytes.len(),
crate::crypto::CHUNK_SIZE - 64
CARRIER_MANIFEST_CAPACITY
);
}
if manifest_bytes.len() > 800_000 {
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
warn!(
"Carrier-Manifest erreicht 80% der Blockkapazität ({}/{} Bytes)",
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
pct,
manifest_bytes.len(),
crate::crypto::CHUNK_SIZE - 64
CARRIER_MANIFEST_CAPACITY
);
}
// C-02: Rollierendes Dual-Block-Manifest.
// Gerade Generation -> Block 0, ungerade Generation -> Block 1.
let target_block = if self.manifest.manifest_generation.is_multiple_of(2) {
0
} else {
1
};
write_carrier_block(
&self.db,
self.carrier_node_id,
0,
target_block,
&self.dek_outer,
&self.dek_inner,
&manifest_bytes,
@@ -362,6 +423,14 @@ impl CarrierFsInner {
impl Drop for CarrierFsInner {
fn drop(&mut self) {
if self.manifest_dirty {
if let Err(e) = self.save_manifest() {
error!(
"Fehler beim finalen Speichern des Carrier-Manifests im Drop: {:?}",
e
);
}
}
crate::windows::unlock_memory(self.dek_outer.as_ptr(), 32);
crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32);
}
@@ -374,7 +443,8 @@ pub struct CarrierFs {
}
impl CarrierFs {
/// Lädt ein bestehendes Carrier-Dateisystem mit benutzerdefinierten Anti-Leak-Regeln (V-04).
/// Lädt ein bestehendes Carrier-Dateisystem mit redundanter Block-0/Block-1 Prüfung (C-02)
/// und benutzerdefinierten Anti-Leak-Regeln (V-04).
pub fn load_with_leak_rules(
db: Database,
carrier_node_id: i64,
@@ -384,19 +454,92 @@ impl CarrierFs {
anti_leak: bool,
custom_leak_rules: Vec<String>,
) -> Result<Self> {
let manifest_bytes = read_carrier_block(
// C-02: Versuche beide redundante Blöcke (Block 0 und Block 1) zu lesen und zu verifizieren
let block_0_res = read_carrier_block(
&db,
carrier_node_id,
0,
&dek_outer,
&dek_inner,
format_version,
)?;
let manifest: CarrierManifest = serde_json::from_slice(&manifest_bytes)?;
if manifest.magic != *CARRIER_MAGIC {
)
.and_then(|bytes| {
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
if m.magic != *CARRIER_MAGIC {
bail!("Ungültige Carrier-Magic-Bytes in Block 0");
}
Ok(m)
});
let block_1_res = read_carrier_block(
&db,
carrier_node_id,
1,
&dek_outer,
&dek_inner,
format_version,
)
.and_then(|bytes| {
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
if m.magic != *CARRIER_MAGIC {
bail!("Ungültige Carrier-Magic-Bytes in Block 1");
}
Ok(m)
});
let mut manifest = match (block_0_res, block_1_res) {
(Ok(m0), Ok(m1)) => {
if m1.manifest_generation > m0.manifest_generation {
debug!(
"C-02: Beide Manifest-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})",
m1.manifest_generation, m0.manifest_generation
);
m1
} else {
debug!(
"C-02: Beide Manifest-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})",
m0.manifest_generation, m1.manifest_generation
);
m0
}
}
(Ok(m0), Err(e1)) => {
debug!(
"C-02: Verwende primäres Manifest aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}",
m0.manifest_generation, e1
);
m0
}
(Err(e0), Ok(m1)) => {
warn!(
"C-02: Block 0 korrupt oder unlesbar ({})! Erfolgreiche Wiederherstellung aus redundanter Kopie in Block 1 (Gen {}).",
e0, m1.manifest_generation
);
m1
}
(Err(e0), Err(e1)) => {
bail!(
"Kritischer Datenverlust (C-02): Weder Block 0 ({}) noch Block 1 ({}) enthalten ein gültiges Carrier-Manifest! Hidden Vault nicht wiederherstellbar.",
e0, e1
);
}
};
// Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Manifest-Kopie reservieren
manifest.free_blocks.retain(|&b| b >= 2);
// C-04: Warnung bei Füllstand über 80% beim Mounten
if let Ok(manifest_bytes) = serde_json::to_vec(&manifest) {
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
warn!(
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
pct,
manifest_bytes.len(),
CARRIER_MANIFEST_CAPACITY
);
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -417,6 +560,10 @@ impl CarrierFs {
custom_leak_rules: Arc::new(custom_leak_rules),
leak_counter: Arc::new(AtomicU64::new(0)),
manifest,
manifest_dirty: false,
manifest_save_count: 0,
dirty_ops_count: 0,
last_manifest_save: now,
last_activity: Arc::new(AtomicU64::new(now)),
};
@@ -445,6 +592,26 @@ impl CarrierFs {
)
}
pub fn sync_manifest(&self) -> Result<()> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.flush_manifest_if_dirty()
}
pub fn manifest_save_count(&self) -> u64 {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.manifest_save_count
}
pub fn is_manifest_dirty(&self) -> bool {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.manifest_dirty
}
pub fn manifest_usage(&self) -> (usize, usize, f64) {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.manifest_usage()
}
pub fn leak_counter(&self) -> Arc<AtomicU64> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.leak_counter.clone()
@@ -523,7 +690,7 @@ impl DavFileSystem for CarrierFs {
n.size = 0;
n.modified_at = now;
inner.manifest.inodes.insert(n.id, n.clone());
let _ = inner.save_manifest();
inner.mark_dirty();
}
n
}
@@ -554,7 +721,7 @@ impl DavFileSystem for CarrierFs {
};
inner.manifest.inodes.insert(new_id, new_inode.clone());
let _ = inner.save_manifest();
inner.mark_dirty();
new_inode
} else {
return Err(FsError::NotFound);
@@ -676,7 +843,7 @@ impl DavFileSystem for CarrierFs {
};
inner.manifest.inodes.insert(new_id, new_dir);
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
inner.mark_dirty();
Ok(())
})
@@ -709,7 +876,7 @@ impl DavFileSystem for CarrierFs {
}
inner.manifest.inodes.remove(&node.id);
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
inner.mark_dirty();
Ok(())
})
@@ -738,7 +905,7 @@ impl DavFileSystem for CarrierFs {
}
inner.manifest.inodes.remove(&node.id);
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
inner.mark_dirty();
Ok(())
})
@@ -795,7 +962,7 @@ impl DavFileSystem for CarrierFs {
inode.modified_at = now;
}
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
inner.mark_dirty();
Ok(())
})
@@ -924,7 +1091,7 @@ impl DavFileSystem for CarrierFs {
};
inner.manifest.inodes.insert(new_id, new_inode);
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
inner.mark_dirty();
Ok(())
})
@@ -1231,7 +1398,10 @@ impl DavFile for CarrierFile {
inode.modified_at = now;
}
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
inner.mark_dirty();
inner
.flush_manifest_if_dirty()
.map_err(|_| FsError::GeneralFailure)?;
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
@@ -1261,7 +1431,7 @@ impl Drop for CarrierFile {
inode.blocks = self.blocks.clone();
inode.modified_at = now;
}
let _ = inner.save_manifest();
inner.mark_dirty();
}
}
}
+20
View File
@@ -225,6 +225,7 @@ pub async fn mount_container(
);
let fs_leak_counter = fs.leak_counter();
let last_activity = fs.last_activity();
let fs_clone = fs.clone();
let dav_server = DavHandler::builder()
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
@@ -282,6 +283,7 @@ pub async fn mount_container(
// Netzlaufwerk bzw. Verzeichnis einbinden
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
let _ = fs_clone.sync_carrier_manifest();
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
return Err(e);
@@ -420,6 +422,22 @@ pub async fn mount_container(
if enable_tray {
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
if vault_id == 1 {
if let Some((len, cap, pct)) = fs_clone.carrier_manifest_usage() {
if len >= crate::carrier::CARRIER_MANIFEST_WARN_THRESHOLD {
println!(
" {} Warnung (C-04): Hidden-Vault Manifest zu {:.1}% gefüllt ({}/{} Bytes).",
ui::yellow("[!]"),
pct,
len,
cap
);
println!(
" Empfohlene Obergrenze beachten: ca. 7.000 Dateien/Verzeichnisse im Hidden Vault."
);
}
}
}
println!();
#[cfg(windows)]
println!(
@@ -541,6 +559,7 @@ pub async fn mount_container(
}
if stealth {
let _ = fs_clone.sync_carrier_manifest();
let _ = unmount_drive(drive_letter);
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
@@ -555,6 +574,7 @@ pub async fn mount_container(
#[cfg(not(windows))]
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
} else {
let _ = fs_clone.sync_carrier_manifest();
#[cfg(windows)]
{
print!(
+469 -69
View File
@@ -6,6 +6,7 @@ use anyhow::{bail, Result};
use rand::rngs::OsRng;
use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension};
use tracing::{error, warn};
use crate::crypto::{
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
@@ -731,36 +732,85 @@ impl Database {
],
)?;
// Initialisiere CarrierManifest für Block 0
// Initialisiere CarrierManifest für redundante Blöcke 0 und 1 (C-02)
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
let manifest_bytes = serde_json::to_vec(&manifest)?;
let (inner_ct, inner_nonce, inner_tag) =
// 1. Block 0 schreiben (Primärkopie, Gen 0)
let (inner_ct_0, inner_nonce_0, inner_tag_0) =
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, 0)?;
let inner_ct_len = inner_ct.len() as u32;
let inner_ct_len_0 = inner_ct_0.len() as u32;
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut outer_plaintext);
let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut outer_plaintext_0);
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
outer_plaintext[12..28].copy_from_slice(&inner_tag);
outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes());
let ct_end = 32 + inner_ct.len();
if ct_end > CHUNK_SIZE {
outer_plaintext_0[0..12].copy_from_slice(&inner_nonce_0);
outer_plaintext_0[12..28].copy_from_slice(&inner_tag_0);
outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes());
let ct_end_0 = 32 + inner_ct_0.len();
if ct_end_0 > CHUNK_SIZE {
bail!("Manifest-Payload zu groß für Block 0");
}
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0);
let (outer_ct, outer_nonce, outer_tag) =
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION, 0)?;
let (outer_ct_0, outer_nonce_0, outer_tag_0) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
0,
&outer_plaintext_0,
FORMAT_VERSION,
0,
)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, 0, 0, ?2, ?3, ?4)",
params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct],
params![
c_id,
outer_nonce_0.as_slice(),
outer_tag_0.as_slice(),
outer_ct_0
],
)?;
// Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren
// 2. Block 1 schreiben (Redundante Zweitkopie, Gen 0, C-02)
let (inner_ct_1, inner_nonce_1, inner_tag_1) =
crate::crypto::encrypt_chunk(dek_1, c_id, 1, &manifest_bytes, FORMAT_VERSION, 0)?;
let inner_ct_len_1 = inner_ct_1.len() as u32;
let mut outer_plaintext_1 = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut outer_plaintext_1);
outer_plaintext_1[0..12].copy_from_slice(&inner_nonce_1);
outer_plaintext_1[12..28].copy_from_slice(&inner_tag_1);
outer_plaintext_1[28..32].copy_from_slice(&inner_ct_len_1.to_le_bytes());
let ct_end_1 = 32 + inner_ct_1.len();
if ct_end_1 > CHUNK_SIZE {
bail!("Manifest-Payload zu groß für Block 1");
}
outer_plaintext_1[32..ct_end_1].copy_from_slice(&inner_ct_1);
let (outer_ct_1, outer_nonce_1, outer_tag_1) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
1,
&outer_plaintext_1,
FORMAT_VERSION,
0,
)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, 1, 0, ?2, ?3, ?4)",
params![
c_id,
outer_nonce_1.as_slice(),
outer_tag_1.as_slice(),
outer_ct_1
],
)?;
// Blöcke 2..total_blocks-1 mit DEK_0 vorallokieren (C-02)
let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, 0, ?3, ?4, ?5)",
@@ -770,7 +820,7 @@ impl Database {
OsRng.fill_bytes(&mut dummy_noise);
conn.execute_batch("BEGIN TRANSACTION;")?;
for b in 1..total_blocks {
for b in 2..total_blocks {
let (ct, nonce, tag) =
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?;
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
@@ -1384,8 +1434,16 @@ impl Database {
let mut matched_record = None;
for r in rows {
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
let dec_name =
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
let dec_name = match decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name) {
Some(name) => name,
None => {
warn!(
"Knoten {} in Pfadauflösung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
id
);
continue;
}
};
if dec_name == *segment {
matched_record = Some(NodeRecord {
id,
@@ -1433,29 +1491,49 @@ impl Database {
FROM nodes WHERE id = ?1",
)?;
let record = stmt
let raw = stmt
.query_row(params![id], |row| {
let enc_name: String = row.get(2)?;
let p_id: Option<i64> = row.get(1)?;
let name = if vault_id == 1 {
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name)
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<i64>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i32>(3)? != 0,
row.get::<_, i64>(4)? as u64,
row.get::<_, i64>(5)? as u64,
row.get::<_, i64>(6)? as u64,
))
})
.optional()?;
match raw {
Some((id, parent_id, enc_name, is_dir, size, created_at, modified_at)) => {
let name = if vault_id == 1 && parent_id.is_some() {
match decrypt_node_name(dek, parent_id.unwrap_or(0), &enc_name) {
Some(dec) => dec,
None => {
warn!(
"Knoten {} kann nicht geladen werden: Name konnte nicht entschlüsselt werden (ST-01)",
id
);
return Ok(None);
}
}
} else {
enc_name
};
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
Ok(Some(NodeRecord {
id,
parent_id,
name,
is_dir: row.get::<_, i32>(3)? != 0,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get::<_, i64>(5)? as u64,
modified_at: row.get::<_, i64>(6)? as u64,
})
})
.optional()?;
Ok(record)
is_dir,
size,
created_at,
modified_at,
}))
}
None => Ok(None),
}
}
pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> {
@@ -1478,27 +1556,44 @@ impl Database {
)?;
let rows = stmt.query_map(params![parent_id], |row| {
let enc_name: String = row.get(2)?;
let name = if vault_id == 1 {
decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name)
} else {
enc_name
};
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name,
is_dir: row.get::<_, i32>(3)? != 0,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get::<_, i64>(5)? as u64,
modified_at: row.get::<_, i64>(6)? as u64,
})
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<i64>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i32>(3)? != 0,
row.get::<_, i64>(4)? as u64,
row.get::<_, i64>(5)? as u64,
row.get::<_, i64>(6)? as u64,
))
})?;
let mut entries = Vec::new();
for r in rows {
entries.push(r?);
let (id, p_id, enc_name, is_dir, size, created_at, modified_at) = r?;
let name = if vault_id == 1 {
match decrypt_node_name(dek, parent_id, &enc_name) {
Some(dec) => dec,
None => {
warn!(
"Knoten {} in Verzeichnisauflistung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
id
);
continue;
}
}
} else {
enc_name
};
entries.push(NodeRecord {
id,
parent_id: p_id,
name,
is_dir,
size,
created_at,
modified_at,
});
}
Ok(entries)
}
@@ -1586,24 +1681,54 @@ impl Database {
Ok(())
}
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
pub fn delete_node(&self, id: i64) -> Result<()> {
self.assert_not_carrier(id)?;
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
fn delete_subtree_tx(
tx: &rusqlite::Transaction<'_>,
id: i64,
carrier_id: Option<i64>,
) -> Result<()> {
if let Some(cid) = carrier_id {
if id == cid {
bail!(
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt.",
id
);
}
}
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
let _ = self.shred_chunks_for_node(id);
// 1. Chunks für diesen Knoten kryptografisch shreddern
{
let mut stmt = tx
.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
let chunks: Vec<(u32, usize)> = stmt
.query_map(params![id], |row| Ok((row.get(0)?, row.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
// 2. Shredde auch rekursiv alle Unterknoten
let mut update_stmt = tx.prepare(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
)?;
for (chunk_idx, ct_len) in chunks {
let mut noise = vec![0u8; ct_len];
let mut nonce_noise = [0u8; 12];
let mut tag_noise = [0u8; 16];
OsRng.fill_bytes(&mut noise);
OsRng.fill_bytes(&mut nonce_noise);
OsRng.fill_bytes(&mut tag_noise);
let _ = update_stmt.execute(params![
nonce_noise.as_slice(),
tag_noise.as_slice(),
noise,
id,
chunk_idx
]);
}
}
// 2. Kindknoten ermitteln und rekursiv innerhalb derselben Transaktion löschen
let child_ids: Vec<i64> = {
let conn = self.conn();
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
let mut stmt = tx.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
let ids = stmt
.query_map(params![id], |row| row.get(0))?
.filter_map(|r| r.ok())
@@ -1611,14 +1736,47 @@ impl Database {
ids
};
let mut errors = Vec::new();
for child_id in child_ids {
let _ = self.delete_node(child_id);
if let Err(e) = Self::delete_subtree_tx(tx, child_id, carrier_id) {
error!("Konnte Kindknoten {} nicht löschen: {e}", child_id);
errors.push(e);
}
}
if !errors.is_empty() {
bail!(
"Fehler beim rekursiven Löschen: {} Kindknoten konnten nicht gelöscht werden (ST-02)",
errors.len()
);
}
let conn = self.conn();
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
// 3. Chunks und Knoten aus der Datenbank löschen
tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
Ok(())
}
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar in einer SQLite-Transaktion (ST-03).
pub fn delete_node(&self, id: i64) -> Result<()> {
self.assert_not_carrier(id)?;
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
let carrier_id = self.find_carrier_node_id().ok().flatten();
if let Some(cid) = carrier_id {
if self.is_descendant_of(cid, id).unwrap_or(false) {
bail!(
"Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.",
cid
);
}
}
let mut conn = self.conn();
let tx = conn.transaction()?;
Self::delete_subtree_tx(&tx, id, carrier_id)?;
tx.commit()?;
drop(conn);
let _ = self.update_metadata_mac();
Ok(())
}
@@ -2923,4 +3081,246 @@ mod tests {
assert!(read_dek.is_some());
assert_eq!(read_dek.unwrap()[0], 42);
}
#[test]
fn test_st01_fail_closed_name_decryption_no_hex_leak() {
let db = Database::open_in_memory().unwrap();
let (salt0, kdf0) = ([1u8; 16], KdfParams::default());
let (salt1, kdf1) = ([2u8; 16], KdfParams::default());
let (dek0, dek1) = ([10u8; 32], [20u8; 32]);
let carrier_name = "backup.iso";
let carrier_size = 5 * 1024 * 1024;
db.init_schema_with_carrier(
&salt0,
&kdf0,
&[0u8; 40],
&[0u8; 12],
&[0u8; 16],
Some((
carrier_name,
carrier_size,
&salt1,
&kdf1,
&[1u8; 40],
&[1u8; 12],
&[1u8; 16],
&dek0,
&dek1,
)),
)
.unwrap();
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().unwrap();
assert_eq!(root1.id, 2);
// 1. Zwei Dateien in Vault 1 anlegen
let node_valid = db
.create_node_in_vault(1, root1.id, "secret_valid.txt", false, &dek1)
.unwrap();
let node_corrupt = db
.create_node_in_vault(1, root1.id, "secret_corrupt.txt", false, &dek1)
.unwrap();
// 2. Vor Korruption: Beide Dateien sichtbar
let children_before = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
assert_eq!(children_before.len(), 2);
// 3. Simuliere Bitrot / Manipulation: Überschreibe den verschlüsselten Namen in der DB
{
let conn = db.conn();
conn.execute(
"UPDATE nodes SET name = 'deadbeef_invalid_ciphertext_without_valid_tag' WHERE id = ?1",
params![node_corrupt.id],
)
.unwrap();
}
// 4. ST-01 Prüfungen (Fail-Closed):
// a) list_children_in_vault filtert den korrupten Knoten heraus
let children_after = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
assert_eq!(
children_after.len(),
1,
"ST-01: Korrupter Knoten muss aus der Liste herausgefiltert werden"
);
assert_eq!(children_after[0].id, node_valid.id);
assert_eq!(children_after[0].name, "secret_valid.txt");
assert!(
!children_after.iter().any(|c| c.name.contains("deadbeef")),
"Kein Hex-Ciphertext-Leak"
);
// b) resolve_path_in_vault gibt None zurück (nicht den Hex-Namen)
let resolved = db
.resolve_path_in_vault("/secret_corrupt.txt", 1, &dek1)
.unwrap();
assert!(
resolved.is_none(),
"ST-01: Pfad mit korruptem Namen darf nicht aufgelöst werden"
);
// c) get_node_by_id_in_vault gibt None zurück (fail-closed statt Hex-String)
let by_id = db
.get_node_by_id_in_vault(node_corrupt.id, 1, &dek1)
.unwrap();
assert!(
by_id.is_none(),
"ST-01: get_node_by_id_in_vault muss None zurückgeben, wenn Name unlesbar ist"
);
// d) Gültiger Knoten kann normal geladen werden
let valid_by_id = db.get_node_by_id_in_vault(node_valid.id, 1, &dek1).unwrap();
assert!(valid_by_id.is_some());
assert_eq!(valid_by_id.unwrap().name, "secret_valid.txt");
}
#[test]
fn test_st02_recursive_delete_error_propagation_not_swallowed() {
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();
// Verzeichnisstruktur erstellen:
// /parent (id=3)
// /parent/normal.txt (id=4)
// /parent/protected.txt (id=5)
let parent = db.create_node(1, "parent", true).unwrap();
let _child_normal = db.create_node(parent.id, "normal.txt", false).unwrap();
let _child_protected = db.create_node(parent.id, "protected.txt", false).unwrap();
// SQLite-Trigger einrichten, der das Löschen von 'protected.txt' blockiert
{
let conn = db.conn();
conn.execute(
"CREATE TRIGGER block_delete_protected BEFORE DELETE ON nodes
WHEN OLD.name = 'protected.txt'
BEGIN
SELECT RAISE(FAIL, 'Löschen von protected.txt blockiert');
END;",
[],
)
.unwrap();
}
// ST-02: delete_node(parent.id) darf den Fehler nicht mehr mit `let _ =` verschlucken!
let res = db.delete_node(parent.id);
assert!(
res.is_err(),
"Löschen muss fehlschlagen, wenn ein Kindknoten nicht gelöscht werden kann"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains(
"Fehler beim rekursiven Löschen: 1 Kindknoten konnten nicht gelöscht werden"
),
"Fehlermeldung muss die Anzahl fehlgeschlagener Kindknoten exakt ausweisen: {}",
err_msg
);
}
#[test]
fn test_st03_recursive_delete_transaction_rollback() {
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();
// 1. Ordner mit zwei Dateien anlegen
let folder = db.create_node(1, "folder", true).unwrap();
let file1 = db.create_node(folder.id, "file1.txt", false).unwrap();
let file2 = db.create_node(folder.id, "file2.txt", false).unwrap();
// Chunks für file1 und file2 anlegen
let chunk_data = vec![0x42u8; 1024];
let nonce = [1u8; 12];
let tag = [2u8; 16];
db.write_chunk(file1.id, 0, 1, &nonce, &tag, &chunk_data)
.unwrap();
db.write_chunk(file2.id, 0, 1, &nonce, &tag, &chunk_data)
.unwrap();
// 2. Trigger erstellen, der Löschen von file2 verhindert
{
let conn = db.conn();
conn.execute(
"CREATE TRIGGER prevent_del_file2 BEFORE DELETE ON nodes
WHEN OLD.name = 'file2.txt'
BEGIN
SELECT RAISE(FAIL, 'file2 ist schreibgeschützt');
END;",
[],
)
.unwrap();
}
// 3. Löschen des gesamten Ordners aufrufen -> schlägt fehl
let res = db.delete_node(folder.id);
assert!(res.is_err(), "delete_node muss fehlschlagen");
// 4. ST-03 Atomaritätsprüfung (Rollback):
// Weder folder noch file1 noch file2 dürfen gelöscht worden sein!
assert!(
db.get_node_by_id(folder.id).unwrap().is_some(),
"Folder darf nach Rollback nicht gelöscht sein"
);
assert!(
db.get_node_by_id(file1.id).unwrap().is_some(),
"file1 darf nach Rollback nicht gelöscht sein"
);
assert!(
db.get_node_by_id(file2.id).unwrap().is_some(),
"file2 darf nach Rollback nicht gelöscht sein"
);
// Chunk von file1 muss unversehrt existieren
let chunk1 = db.read_chunk(file1.id, 0).unwrap();
assert!(
chunk1.is_some(),
"Chunk von file1 darf nach Rollback nicht gelöscht sein"
);
assert_eq!(
chunk1.unwrap().ciphertext,
chunk_data,
"Chunk von file1 muss unversehrt sein"
);
// 5. Trigger entfernen und erneut löschen -> muss jetzt vollständig gelingen
{
let conn = db.conn();
conn.execute("DROP TRIGGER prevent_del_file2", []).unwrap();
}
db.delete_node(folder.id)
.expect("delete_node nach Trigger-Entfernung muss gelingen");
assert!(
db.get_node_by_id(folder.id).unwrap().is_none(),
"Folder muss gelöscht sein"
);
assert!(
db.get_node_by_id(file1.id).unwrap().is_none(),
"file1 muss gelöscht sein"
);
assert!(
db.get_node_by_id(file2.id).unwrap().is_none(),
"file2 muss gelöscht sein"
);
assert!(
db.read_chunk(file1.id, 0).unwrap().is_none(),
"Chunk von file1 muss gelöscht sein"
);
}
}
+11
View File
@@ -788,6 +788,17 @@ impl SanctumFs {
}
}
pub fn sync_carrier_manifest(&self) -> Result<()> {
if let Some(ref cfs) = self.carrier_fs {
cfs.sync_manifest()?;
}
Ok(())
}
pub fn carrier_manifest_usage(&self) -> Option<(usize, usize, f64)> {
self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage())
}
pub fn leak_count(&self) -> u64 {
self.leak_counter().load(Ordering::Relaxed)
}
+531
View File
@@ -923,3 +923,534 @@ fn test_m05_deniability_schema_equality_standard_vs_hidden() {
let _ = std::fs::remove_file(&path_std);
let _ = std::fs::remove_file(&path_hidden);
}
#[tokio::test]
async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() {
let path = temp_db_path("c02_rolling_manifest");
let carrier_size_bytes = 10 * 1024 * 1024;
let carrier_name = "test_rolling.dat";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let cfs = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("Initial load of dual-block manifest");
// Datei anlegen -> save_manifest() -> Gen 1 -> schreibt Block 1
let file1_path = DavPath::new("/file_gen1.txt").unwrap();
let open_write = OpenOptions {
create_new: true,
write: true,
..Default::default()
};
let mut file1 = cfs
.open(&file1_path, open_write)
.await
.expect("Create file1");
file1
.write_bytes(Bytes::from_static(b"generation 1 content"))
.await
.unwrap();
file1.flush().await.unwrap();
drop(file1);
drop(cfs);
// Verifiziere: Block 1 hat jetzt Gen 1
let cfs_reloaded = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("Reload higher generation from block 1");
// Zweite Datei anlegen -> save_manifest() -> Gen 2 -> schreibt Block 0
let file2_path = DavPath::new("/file_gen2.txt").unwrap();
let open_write2 = OpenOptions {
create_new: true,
write: true,
..Default::default()
};
let mut file2 = cfs_reloaded
.open(&file2_path, open_write2)
.await
.expect("Create file2");
file2
.write_bytes(Bytes::from_static(b"generation 2 content"))
.await
.unwrap();
file2.flush().await.unwrap();
drop(file2);
drop(cfs_reloaded);
// 2. Simuliere Bitrot auf Block 0 (gezielt mit Rauschen überschreiben)
{
let conn = rusqlite::Connection::open(&path).unwrap();
let mut noise = vec![0u8; 100];
OsRng.fill_bytes(&mut noise);
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
rusqlite::params![noise, carrier_node_id],
)
.unwrap();
}
// Laden MUSS über Block 1 gelingen (Failover-Schutz)
let cfs_recovered_from_b1 = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("Recovery from Block 1 must succeed despite damaged Block 0");
assert!(cfs_recovered_from_b1.metadata(&file1_path).await.is_ok());
drop(cfs_recovered_from_b1);
// 3. Simuliere Beschädigung von Block 1, während Block 0 intakt ist
let cfs_repair = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.unwrap();
let file3_path = DavPath::new("/file_gen3.txt").unwrap();
let mut file3 = cfs_repair
.open(
&file3_path,
OpenOptions {
create_new: true,
write: true,
..Default::default()
},
)
.await
.unwrap();
file3
.write_bytes(Bytes::from_static(b"gen 3"))
.await
.unwrap();
file3.flush().await.unwrap();
drop(file3);
drop(cfs_repair);
// Jetzt Block 1 beschädigen
{
let conn = rusqlite::Connection::open(&path).unwrap();
let mut noise = vec![0u8; 100];
OsRng.fill_bytes(&mut noise);
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 1",
rusqlite::params![noise, carrier_node_id],
)
.unwrap();
}
// Laden MUSS über Block 0 gelingen
let cfs_recovered_from_b0 = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("Recovery from Block 0 must succeed despite damaged Block 1");
drop(cfs_recovered_from_b0);
// 4. Wenn BEIDE Blöcke zerstört sind: Totalverlust mit klarer Fehlermeldung
{
let conn = rusqlite::Connection::open(&path).unwrap();
let mut noise = vec![0u8; 100];
OsRng.fill_bytes(&mut noise);
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
rusqlite::params![noise, carrier_node_id],
)
.unwrap();
}
let both_damaged = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
);
let err_msg = match both_damaged {
Ok(_) => panic!("Both blocks damaged must return Error"),
Err(e) => e.to_string(),
};
assert!(
err_msg.contains("Kritischer Datenverlust (C-02)"),
"Error message must clearly identify C-02 failure: {}",
err_msg
);
drop(db);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_c03_manifest_dirty_decoupling_and_unmount_flush() {
let path = temp_db_path("c03_dirty");
let carrier_size_bytes = 5 * 1024 * 1024; // 5 MB
let carrier_name = "carrier_c03.bin";
let pass_decoy = "DecoyPass2026!";
let pass_hidden = "HiddenPass2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let cfs = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("CarrierFs load");
// 1. Unmittelbar nach Mounten: nicht dirty, 0 Saves
assert_eq!(cfs.manifest_save_count(), 0);
assert!(!cfs.is_manifest_dirty());
// 2. Metadaten-Operationen: 3 Ordner anlegen
let dir1 = DavPath::new("/dir1").unwrap();
let dir2 = DavPath::new("/dir2").unwrap();
let dir3 = DavPath::new("/dir3").unwrap();
cfs.create_dir(&dir1).await.expect("create_dir dir1");
cfs.create_dir(&dir2).await.expect("create_dir dir2");
cfs.create_dir(&dir3).await.expect("create_dir dir3");
// C-03 Verifikation: Manifest ist dirty, aber save_count ist IMMER NOCH 0!
assert!(
cfs.is_manifest_dirty(),
"Manifest must be dirty after create_dir"
);
assert_eq!(
cfs.manifest_save_count(),
0,
"No synchronous save_manifest on create_dir (C-03 decoupling)"
);
// Datei im Verzeichnis erstellen
let file_path = DavPath::new("/dir1/note.txt").unwrap();
let open_write = OpenOptions {
create_new: true,
write: true,
..Default::default()
};
let mut file = cfs.open(&file_path, open_write).await.expect("open create");
assert_eq!(
cfs.manifest_save_count(),
0,
"No synchronous save_manifest on file create"
);
// 3. Expliziter Sync über sync_manifest()
cfs.sync_manifest().expect("sync_manifest");
assert!(
!cfs.is_manifest_dirty(),
"Manifest must be clean after sync"
);
assert_eq!(
cfs.manifest_save_count(),
1,
"Save count incremented to 1 after sync"
);
// Nochmal sync: da nicht dirty, kein unnötiger Save
cfs.sync_manifest().expect("sync_manifest 2");
assert_eq!(
cfs.manifest_save_count(),
1,
"Save count remains 1 when not dirty"
);
// 4. In Datei schreiben und flush() ausführen -> führt zu Manifest-Flush
file.write_bytes(Bytes::from_static(b"carrier dirty test payload"))
.await
.unwrap();
file.flush().await.expect("file flush");
assert_eq!(
cfs.manifest_save_count(),
2,
"File flush must flush manifest"
);
assert!(!cfs.is_manifest_dirty());
drop(file);
// 5. Weiteren Ordner anlegen (mark_dirty), dann cfs droppen
let dir4 = DavPath::new("/dir4").unwrap();
cfs.create_dir(&dir4).await.expect("create_dir dir4");
assert!(cfs.is_manifest_dirty());
assert_eq!(cfs.manifest_save_count(), 2);
// Drop von CarrierFs (Drop-Garantie bei Unmount)
drop(cfs);
// 6. Neu laden und prüfen, dass dir4 persistiert wurde
let cfs_reloaded = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("CarrierFs reload");
assert!(
cfs_reloaded
.read_dir(&dir4, ReadDirMeta::None)
.await
.is_ok(),
"Directory created before drop must exist"
);
drop(cfs_reloaded);
drop(db);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_c04_manifest_capacity_limit_and_warning() {
let path = temp_db_path("c04_capacity");
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
let carrier_name = "carrier_c04.bin";
let pass_decoy = "DecoyPass2026!";
let pass_hidden = "HiddenPass2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
let cfs = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("CarrierFs load");
// 1. Initialer Zustand: Manifest minimal (< 5 KB, << 80%)
let (len, cap, pct) = cfs.manifest_usage();
assert_eq!(cap, sanctum::carrier::CARRIER_MANIFEST_CAPACITY);
assert!(len < 5_000);
assert!(pct < 1.0);
// 2. Fülle Manifest gezielt auf > 80% (> 838.809 Bytes)
// Erstelle Verzeichnisse mit langen Pfadnamen, bis die Größe über 840 KB liegt
for i in 0..1000 {
let dir_path = DavPath::new(&format!("/d_{:04}_{}", i, "a".repeat(1000))).unwrap();
cfs.create_dir(&dir_path).await.expect("create_dir");
let (current_len, _, _) = cfs.manifest_usage();
if current_len >= sanctum::carrier::CARRIER_MANIFEST_WARN_THRESHOLD {
break;
}
}
let (len80, _, pct80) = cfs.manifest_usage();
assert!(
len80 >= sanctum::carrier::CARRIER_MANIFEST_WARN_THRESHOLD,
"Manifest size must exceed 80% threshold, got: {}",
len80
);
assert!(
pct80 >= 80.0,
"Manifest pct must be >= 80.0%, got: {}",
pct80
);
// Speichern (sync_manifest) muss mit Warnung gelingen
cfs.sync_manifest()
.expect("sync_manifest at > 80% must succeed");
drop(cfs);
// Neu laden: muss erfolgreich laden und beim Laden die 80%-Warnung auslösen
let cfs_loaded = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("CarrierFs load at > 80% must succeed");
let (reloaded_len, _, reloaded_pct) = cfs_loaded.manifest_usage();
assert!(reloaded_pct >= 80.0);
assert_eq!(reloaded_len, len80);
// 3. Grenze überschreiten: weiter füllen bis > 1.048.512 Bytes
for i in 1000..1500 {
let dir_path = DavPath::new(&format!("/overflow_{:04}_{}", i, "x".repeat(1000))).unwrap();
cfs_loaded
.create_dir(&dir_path)
.await
.expect("create_dir overflow");
let (cur_len, _, _) = cfs_loaded.manifest_usage();
if cur_len > sanctum::carrier::CARRIER_MANIFEST_CAPACITY {
break;
}
}
// sync_manifest() MUSS jetzt mit Fehler abbrechen
let err = cfs_loaded.sync_manifest().unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("Carrier-Manifest überschreitet die maximale Blockgröße"),
"Error message must specify carrier capacity limit: {}",
err_str
);
assert!(
err_str.contains("7.000 Dateien/Verzeichnisse"),
"Error message must recommend file limit: {}",
err_str
);
drop(cfs_loaded);
drop(db);
let _ = std::fs::remove_file(&path);
}