1457 lines
44 KiB
Rust
1457 lines
44 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use bytes::Bytes;
|
|
use dav_server::davpath::DavPath;
|
|
use dav_server::fs::{DavFileSystem, FsError, OpenOptions, ReadDirMeta};
|
|
use rand::rngs::OsRng;
|
|
use rand::RngCore;
|
|
|
|
use sanctum::crypto::{
|
|
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
|
|
CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
|
};
|
|
use sanctum::storage::Database;
|
|
use sanctum::verify::verify_container;
|
|
use sanctum::vfs::SanctumFs;
|
|
|
|
fn temp_db_path(prefix: &str) -> PathBuf {
|
|
let mut path = std::env::temp_dir();
|
|
let id: u64 = OsRng.next_u64();
|
|
path.push(format!("sanctum_test_{}_{}.sanctum", prefix, id));
|
|
path
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
|
|
let path = temp_db_path("carrier_accounting");
|
|
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB (10 Blöcke à 1 MB)
|
|
let carrier_name = "system_backup.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");
|
|
|
|
let created_cid = 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();
|
|
assert_eq!(created_cid, Some(carrier_node_id));
|
|
|
|
// 1. Authentifizierung beider Passwörter
|
|
let meta = db.read_meta().unwrap();
|
|
let auth_decoy = meta.authenticate(pass_decoy).expect("Auth decoy");
|
|
assert_eq!(auth_decoy.slot_id(), 0);
|
|
assert_eq!(**auth_decoy.dek(), *dek_0);
|
|
assert_eq!(auth_decoy.carrier_node_id(), Some(carrier_node_id));
|
|
|
|
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
|
assert_eq!(auth_hidden.slot_id(), 1);
|
|
assert_eq!(**auth_hidden.dek(), *dek_1);
|
|
assert_eq!(*auth_hidden.carrier_dek().unwrap(), *dek_0);
|
|
assert_eq!(auth_hidden.carrier_node_id(), Some(carrier_node_id));
|
|
|
|
// 2. Decoy Mount: Schutz der Alibi-Datei (system_backup.dat)
|
|
let decoy_fs = SanctumFs::with_carrier(
|
|
db.clone(),
|
|
auth_decoy.dek().clone(),
|
|
auth_decoy.carrier_dek(),
|
|
auth_decoy.carrier_node_id(),
|
|
auth_decoy.version(),
|
|
true,
|
|
0,
|
|
);
|
|
|
|
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
|
|
|
|
// Metadaten der Alibi-Datei im Decoy prüfen
|
|
let carrier_meta = decoy_fs
|
|
.metadata(&carrier_path)
|
|
.await
|
|
.expect("Carrier meta");
|
|
assert_eq!(carrier_meta.len(), carrier_size_bytes);
|
|
assert!(!carrier_meta.is_dir());
|
|
|
|
// Alibi-Datei darf im Decoy-Mount NICHT zum Schreiben geöffnet werden
|
|
let write_opts = OpenOptions {
|
|
write: true,
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
matches!(
|
|
decoy_fs.open(&carrier_path, write_opts).await,
|
|
Err(FsError::Forbidden)
|
|
),
|
|
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
|
|
);
|
|
|
|
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
|
|
assert!(
|
|
matches!(
|
|
decoy_fs.remove_file(&carrier_path).await,
|
|
Err(FsError::Forbidden)
|
|
),
|
|
"Alibi-Datei darf nicht gelöscht werden"
|
|
);
|
|
|
|
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
|
|
let new_name = DavPath::new("/renamed.iso").unwrap();
|
|
assert!(
|
|
matches!(
|
|
decoy_fs.rename(&carrier_path, &new_name).await,
|
|
Err(FsError::Forbidden)
|
|
),
|
|
"Alibi-Datei darf nicht umbenannt werden"
|
|
);
|
|
|
|
// Alibi-Datei KANN im Decoy-Mount gelesen werden
|
|
let read_opts = OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
};
|
|
let mut file_reader = decoy_fs
|
|
.open(&carrier_path, read_opts)
|
|
.await
|
|
.expect("Open read");
|
|
let first_mb = file_reader
|
|
.read_bytes(CHUNK_SIZE)
|
|
.await
|
|
.expect("Read first chunk");
|
|
assert_eq!(first_mb.len(), CHUNK_SIZE);
|
|
|
|
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
|
|
let hidden_fs = SanctumFs::with_carrier(
|
|
db.clone(),
|
|
auth_hidden.dek().clone(),
|
|
auth_hidden.carrier_dek(),
|
|
auth_hidden.carrier_node_id(),
|
|
auth_hidden.version(),
|
|
true,
|
|
1,
|
|
);
|
|
|
|
// Wurzelverzeichnis des Hidden Vault auflisten (anfangs leer)
|
|
let root_path = DavPath::new("/").unwrap();
|
|
let mut stream = hidden_fs
|
|
.read_dir(&root_path, ReadDirMeta::None)
|
|
.await
|
|
.expect("Read dir root");
|
|
use futures_util::StreamExt;
|
|
let mut entries = Vec::new();
|
|
while let Some(item) = stream.next().await {
|
|
entries.push(item.unwrap().name());
|
|
}
|
|
assert!(
|
|
entries.is_empty(),
|
|
"Hidden Vault Wurzelverzeichnis muss anfangs leer sein"
|
|
);
|
|
|
|
// Ordner erstellen
|
|
let secret_dir = DavPath::new("/Classified").unwrap();
|
|
hidden_fs
|
|
.create_dir(&secret_dir)
|
|
.await
|
|
.expect("Create Classified dir");
|
|
|
|
// Datei im Ordner anlegen und schreiben
|
|
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
|
|
let create_opts = OpenOptions {
|
|
create: true,
|
|
write: true,
|
|
..Default::default()
|
|
};
|
|
let mut secret_file = hidden_fs
|
|
.open(&secret_file_path, create_opts)
|
|
.await
|
|
.expect("Create secret file");
|
|
|
|
let secret_content = b"TopSecretCredentials_2026_SanctumCoreSecureVault";
|
|
secret_file
|
|
.write_bytes(Bytes::from_static(secret_content))
|
|
.await
|
|
.expect("Write secret content");
|
|
secret_file.flush().await.expect("Flush secret file");
|
|
drop(secret_file);
|
|
|
|
// Datei lesen und verifizieren
|
|
let read_opts = OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
};
|
|
let mut read_handle = hidden_fs
|
|
.open(&secret_file_path, read_opts)
|
|
.await
|
|
.expect("Open secret file for read");
|
|
let read_data = read_handle
|
|
.read_bytes(1024)
|
|
.await
|
|
.expect("Read secret bytes");
|
|
assert_eq!(&read_data[..], secret_content);
|
|
drop(read_handle);
|
|
|
|
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
|
|
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
|
|
let mut big_file = hidden_fs
|
|
.open(
|
|
&big_file_path,
|
|
OpenOptions {
|
|
create: true,
|
|
write: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Create big file");
|
|
|
|
let payload_size = 2 * 1024 * 1024 + 12345; // 2 MB + 12.345 Bytes
|
|
let mut payload = vec![0u8; payload_size];
|
|
OsRng.fill_bytes(&mut payload);
|
|
|
|
big_file
|
|
.write_bytes(Bytes::copy_from_slice(&payload))
|
|
.await
|
|
.expect("Write big payload");
|
|
big_file.flush().await.expect("Flush big file");
|
|
drop(big_file);
|
|
|
|
// Datei zurücklesen und Bit-für-Bit verifizieren
|
|
let mut read_big = hidden_fs
|
|
.open(
|
|
&big_file_path,
|
|
OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open big file");
|
|
let read_big_bytes = read_big
|
|
.read_bytes(payload_size + 100)
|
|
.await
|
|
.expect("Read big file bytes");
|
|
assert_eq!(read_big_bytes.len(), payload_size);
|
|
assert_eq!(&read_big_bytes[..], &payload[..]);
|
|
drop(read_big);
|
|
|
|
// Datei umbenennen
|
|
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
|
|
hidden_fs
|
|
.rename(&big_file_path, &renamed_path)
|
|
.await
|
|
.expect("Rename file");
|
|
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
|
|
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
|
|
|
|
// Datei löschen (Blöcke werden geshreddert und freigegeben)
|
|
hidden_fs
|
|
.remove_file(&renamed_path)
|
|
.await
|
|
.expect("Remove file");
|
|
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
|
|
|
|
// Checkpoint SQLite
|
|
db.checkpoint().unwrap();
|
|
|
|
// 4. CHUNKS-ACCOUNTING-ANGRIFF & INTEGRITÄTSPRÜFUNG
|
|
// Ein Angreifer besitzt nur das Decoy-Passwort (dek_0).
|
|
// Er führt eine 100%-ige kryptografische AEAD-Prüfung aller Chunks in der SQLite-Datenbank durch.
|
|
// ALLE Chunks müssen fehlerfrei unter DEK_0 entschlüsseln!
|
|
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
|
|
assert!(
|
|
report.is_healthy(),
|
|
"Container muss für einen Angreifer mit DEK_0 100% gesund und fehlerfrei sein! Fehler: {:?}",
|
|
report.errors
|
|
);
|
|
assert_eq!(
|
|
report.corrupted_chunks, 0,
|
|
"Chunks-Accounting: Es darf exakt 0 korrupte Chunks unter DEK_0 geben!"
|
|
);
|
|
assert_eq!(
|
|
report.orphan_nodes, 0,
|
|
"Es darf keine verwaisten Knoten geben!"
|
|
);
|
|
|
|
// Aufräumen
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_model_a_container_file_size_invariance() {
|
|
let path = temp_db_path("carrier_size_invariance");
|
|
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
|
|
|
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((
|
|
"virtual_disk.vhdx",
|
|
carrier_size_bytes,
|
|
&salt_1,
|
|
&kdf_params,
|
|
&wrapped_1,
|
|
&nonce_1,
|
|
&tag_1,
|
|
&dek_0,
|
|
&dek_1,
|
|
)),
|
|
)
|
|
.expect("Init carrier schema");
|
|
|
|
db.checkpoint().unwrap();
|
|
|
|
// Initiale Dateigröße messen
|
|
let initial_file_size = std::fs::metadata(&path).unwrap().len();
|
|
assert!(
|
|
initial_file_size >= carrier_size_bytes,
|
|
"Containergröße muss mindestens 10 MB betragen"
|
|
);
|
|
|
|
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
|
|
let meta = db.read_meta().unwrap();
|
|
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
|
|
|
let hidden_fs = SanctumFs::with_carrier(
|
|
db.clone(),
|
|
auth_hidden.dek().clone(),
|
|
auth_hidden.carrier_dek(),
|
|
auth_hidden.carrier_node_id(),
|
|
auth_hidden.version(),
|
|
true,
|
|
1,
|
|
);
|
|
|
|
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
|
|
let mut handle = hidden_fs
|
|
.open(
|
|
&test_file,
|
|
OpenOptions {
|
|
create: true,
|
|
write: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open file");
|
|
|
|
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
|
|
OsRng.fill_bytes(&mut random_data);
|
|
handle
|
|
.write_bytes(Bytes::copy_from_slice(&random_data))
|
|
.await
|
|
.expect("Write 4MB");
|
|
handle.flush().await.expect("Flush 4MB");
|
|
drop(handle);
|
|
|
|
db.checkpoint().unwrap();
|
|
|
|
// Dateigröße nach dem Schreiben von 4 MB im Hidden Vault messen
|
|
let size_after_hidden_writes = std::fs::metadata(&path).unwrap().len();
|
|
|
|
// Die Dateigröße auf der Festplatte DARF NICHT WACHSEN!
|
|
// Alle Chunks wurden in vorallokierte Carrier-Blöcke überschrieben.
|
|
assert_eq!(
|
|
initial_file_size, size_after_hidden_writes,
|
|
"Dateigröße auf der Festplatte darf sich beim Schreiben in den Hidden Vault NICHT verändern! Vorher: {}, Nachher: {}",
|
|
initial_file_size, size_after_hidden_writes
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_carrier_file_drop_and_append_mode() {
|
|
let path = temp_db_path("carrier_drop_append");
|
|
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
|
let carrier_name = "test_carrier.iso";
|
|
|
|
let pass_decoy = "DecoyPassword2026!";
|
|
let pass_hidden = "HiddenSecretPassword2026!";
|
|
|
|
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 hidden_fs = SanctumFs::with_carrier(
|
|
db.clone(),
|
|
auth_hidden.dek().clone(),
|
|
auth_hidden.carrier_dek(),
|
|
auth_hidden.carrier_node_id(),
|
|
auth_hidden.version(),
|
|
true,
|
|
1,
|
|
);
|
|
|
|
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
|
|
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
|
|
let mut write_handle = hidden_fs
|
|
.open(
|
|
&test_file,
|
|
OpenOptions {
|
|
create: true,
|
|
write: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open file for write");
|
|
|
|
let initial_data = b"Hello from unflushed write!";
|
|
write_handle
|
|
.write_bytes(Bytes::from_static(initial_data))
|
|
.await
|
|
.expect("Write initial data");
|
|
// WICHTIG: KEIN write_handle.flush()! Nur drop:
|
|
drop(write_handle);
|
|
|
|
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
|
|
let mut read_handle = hidden_fs
|
|
.open(
|
|
&test_file,
|
|
OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open file for read");
|
|
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
|
|
assert_eq!(
|
|
&read_back[..],
|
|
initial_data,
|
|
"Drop muss ungeflushte Datenblöcke und Inode automatisch sichern"
|
|
);
|
|
drop(read_handle);
|
|
|
|
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
|
|
let append_data = b" - Appended data at EOF!";
|
|
let mut append_handle = hidden_fs
|
|
.open(
|
|
&test_file,
|
|
OpenOptions {
|
|
write: true,
|
|
append: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open file for append");
|
|
|
|
append_handle
|
|
.write_bytes(Bytes::from_static(append_data))
|
|
.await
|
|
.expect("Write appended data");
|
|
drop(append_handle); // Drop sichert auch hier
|
|
|
|
// Prüfe den vollständigen Dateiinhalt nach Append
|
|
let mut read_handle_2 = hidden_fs
|
|
.open(
|
|
&test_file,
|
|
OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open file for read after append");
|
|
let full_content = read_handle_2
|
|
.read_bytes(200)
|
|
.await
|
|
.expect("Read full content");
|
|
let mut expected = Vec::new();
|
|
expected.extend_from_slice(initial_data);
|
|
expected.extend_from_slice(append_data);
|
|
assert_eq!(
|
|
&full_content[..],
|
|
&expected[..],
|
|
"O_APPEND muss Daten am Dateiende anhängen"
|
|
);
|
|
drop(read_handle_2);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_v01_carrier_fs_copy_delegation_and_functionality() {
|
|
let path = temp_db_path("v01_copy");
|
|
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
|
let carrier_name = "carrier_for_copy.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 hidden_fs = SanctumFs::with_carrier(
|
|
db.clone(),
|
|
auth_hidden.dek().clone(),
|
|
auth_hidden.carrier_dek(),
|
|
auth_hidden.carrier_node_id(),
|
|
auth_hidden.version(),
|
|
true,
|
|
1,
|
|
);
|
|
|
|
// Ordner anlegen
|
|
let folder = DavPath::new("/work").unwrap();
|
|
hidden_fs.create_dir(&folder).await.expect("Create folder");
|
|
|
|
// Datei anlegen und Inhalt schreiben
|
|
let src_path = DavPath::new("/work/original.txt").unwrap();
|
|
let test_data = b"V01 CarrierFs Copy Functionality Test Payload 2026";
|
|
let mut file = hidden_fs
|
|
.open(
|
|
&src_path,
|
|
OpenOptions {
|
|
create: true,
|
|
write: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Create source file");
|
|
file.write_bytes(Bytes::from_static(test_data))
|
|
.await
|
|
.expect("Write source data");
|
|
file.flush().await.expect("Flush source file");
|
|
drop(file);
|
|
|
|
// V-01: copy von /work/original.txt nach /work/copy.txt aufrufen
|
|
let dest_path = DavPath::new("/work/copy.txt").unwrap();
|
|
hidden_fs
|
|
.copy(&src_path, &dest_path)
|
|
.await
|
|
.expect("V-01: Copy must succeed on CarrierFs");
|
|
|
|
// Metadaten der Kopie prüfen
|
|
let copy_meta = hidden_fs
|
|
.metadata(&dest_path)
|
|
.await
|
|
.expect("Metadata of copied file");
|
|
assert_eq!(copy_meta.len(), test_data.len() as u64);
|
|
assert!(!copy_meta.is_dir());
|
|
|
|
// Inhalt der Kopie lesen und verifizieren
|
|
let mut read_copy = hidden_fs
|
|
.open(
|
|
&dest_path,
|
|
OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open copied file");
|
|
let copy_bytes = read_copy.read_bytes(1024).await.expect("Read copy bytes");
|
|
assert_eq!(©_bytes[..], test_data);
|
|
drop(read_copy);
|
|
|
|
// Quelldatei löschen: Die Kopie muss unabhängig erhalten bleiben!
|
|
hidden_fs
|
|
.remove_file(&src_path)
|
|
.await
|
|
.expect("Delete source file");
|
|
assert!(hidden_fs.metadata(&src_path).await.is_err());
|
|
|
|
let mut read_copy_again = hidden_fs
|
|
.open(
|
|
&dest_path,
|
|
OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open copied file after source delete");
|
|
let copy_bytes_2 = read_copy_again
|
|
.read_bytes(1024)
|
|
.await
|
|
.expect("Read copy bytes after source delete");
|
|
assert_eq!(©_bytes_2[..], test_data);
|
|
drop(read_copy_again);
|
|
|
|
// Overwrite-Copy testen
|
|
let src2_path = DavPath::new("/work/source2.txt").unwrap();
|
|
let test_data_2 = b"New Overwrite Payload Content";
|
|
let mut file2 = hidden_fs
|
|
.open(
|
|
&src2_path,
|
|
OpenOptions {
|
|
create: true,
|
|
write: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Create source2");
|
|
file2
|
|
.write_bytes(Bytes::from_static(test_data_2))
|
|
.await
|
|
.expect("Write source2");
|
|
file2.flush().await.expect("Flush source2");
|
|
drop(file2);
|
|
|
|
hidden_fs
|
|
.copy(&src2_path, &dest_path)
|
|
.await
|
|
.expect("Overwrite copy must succeed");
|
|
|
|
let mut read_overwritten = hidden_fs
|
|
.open(
|
|
&dest_path,
|
|
OpenOptions {
|
|
read: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("Open overwritten copy");
|
|
let overwritten_bytes = read_overwritten
|
|
.read_bytes(1024)
|
|
.await
|
|
.expect("Read overwritten bytes");
|
|
assert_eq!(&overwritten_bytes[..], test_data_2);
|
|
drop(read_overwritten);
|
|
|
|
// Checkpoint & Verify
|
|
db.checkpoint().unwrap();
|
|
let report = verify_container(&path, Some(&dek_0), true).expect("Verify with DEK_0");
|
|
assert!(
|
|
report.is_healthy(),
|
|
"Container muss integer und gesund bleiben: {:?}",
|
|
report.errors
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_m05_deniability_schema_equality_standard_vs_hidden() {
|
|
let path_std = temp_db_path("schema_std");
|
|
let path_hidden = temp_db_path("schema_hidden");
|
|
|
|
let kdf_params = KdfParams {
|
|
memory_cost: MIN_MEMORY_COST_KIB,
|
|
time_cost: MIN_TIME_COST,
|
|
parallelism: 1,
|
|
};
|
|
|
|
let salt_std = generate_salt();
|
|
let kek_std = derive_kek("StandardPass123!", &salt_std, &kdf_params).unwrap();
|
|
let dek_std = generate_dek();
|
|
let (wrapped_std, nonce_std, tag_std) = wrap_slot0_payload(&kek_std, &dek_std, 0).unwrap();
|
|
|
|
let db_std = Database::open(&path_std).expect("Open std db");
|
|
db_std
|
|
.init_schema(&salt_std, &kdf_params, &wrapped_std, &nonce_std, &tag_std)
|
|
.expect("Init std schema");
|
|
db_std.checkpoint().unwrap();
|
|
|
|
let salt_0 = generate_salt();
|
|
let kek_0 = derive_kek("DecoyPass123!", &salt_0, &kdf_params).unwrap();
|
|
let dek_0 = generate_dek();
|
|
|
|
let salt_1 = generate_salt();
|
|
let kek_1 = derive_kek("HiddenPass123!", &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_hidden = Database::open(&path_hidden).expect("Open hidden db");
|
|
db_hidden
|
|
.init_schema_with_carrier(
|
|
&salt_0,
|
|
&kdf_params,
|
|
&wrapped_0,
|
|
&nonce_0,
|
|
&tag_0,
|
|
Some((
|
|
"carrier.dat",
|
|
1024 * 1024,
|
|
&salt_1,
|
|
&kdf_params,
|
|
&wrapped_1,
|
|
&nonce_1,
|
|
&tag_1,
|
|
&dek_0,
|
|
&dek_1,
|
|
)),
|
|
)
|
|
.expect("Init carrier schema");
|
|
db_hidden.checkpoint().unwrap();
|
|
|
|
// Vergleiche Schemas via rusqlite
|
|
let conn_std = rusqlite::Connection::open(&path_std).unwrap();
|
|
let conn_hidden = rusqlite::Connection::open(&path_hidden).unwrap();
|
|
|
|
let get_schema_elements =
|
|
|conn: &rusqlite::Connection| -> Vec<(String, String, Option<String>)> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT type, name, sql FROM sqlite_master
|
|
WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%'
|
|
ORDER BY type, name",
|
|
)
|
|
.unwrap();
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, Option<String>>(2)?,
|
|
))
|
|
})
|
|
.unwrap();
|
|
rows.map(|r| r.unwrap()).collect()
|
|
};
|
|
|
|
let schema_std = get_schema_elements(&conn_std);
|
|
let schema_hidden = get_schema_elements(&conn_hidden);
|
|
|
|
assert_eq!(
|
|
schema_std, schema_hidden,
|
|
"M-05: SQLite-Schema (Tabellen, Indizes, DDL) muss zwischen Standard- und Hidden-Vault identisch sein"
|
|
);
|
|
|
|
// Spalten- und Typinformationen vergleichen
|
|
for table in &["meta", "nodes", "chunks"] {
|
|
let get_table_info =
|
|
|conn: &rusqlite::Connection| -> Vec<(i64, String, String, i64, Option<String>, i64)> {
|
|
let mut stmt = conn
|
|
.prepare(&format!("PRAGMA table_info({});", table))
|
|
.unwrap();
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok((
|
|
row.get::<_, i64>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, String>(2)?,
|
|
row.get::<_, i64>(3)?,
|
|
row.get::<_, Option<String>>(4)?,
|
|
row.get::<_, i64>(5)?,
|
|
))
|
|
})
|
|
.unwrap();
|
|
rows.map(|r| r.unwrap()).collect()
|
|
};
|
|
|
|
let cols_std = get_table_info(&conn_std);
|
|
let cols_hidden = get_table_info(&conn_hidden);
|
|
assert_eq!(
|
|
cols_std, cols_hidden,
|
|
"M-05: Spalten und Typen für Tabelle '{}' müssen identisch sein",
|
|
table
|
|
);
|
|
}
|
|
|
|
// Pragmas vergleichen (page_size, auto_vacuum, secure_delete)
|
|
let get_pragma = |conn: &rusqlite::Connection, pragma: &str| -> i64 {
|
|
conn.query_row(&format!("PRAGMA {};", pragma), [], |r| r.get(0))
|
|
.unwrap()
|
|
};
|
|
|
|
assert_eq!(
|
|
get_pragma(&conn_std, "page_size"),
|
|
get_pragma(&conn_hidden, "page_size")
|
|
);
|
|
assert_eq!(
|
|
get_pragma(&conn_std, "auto_vacuum"),
|
|
get_pragma(&conn_hidden, "auto_vacuum")
|
|
);
|
|
assert_eq!(
|
|
get_pragma(&conn_std, "secure_delete"),
|
|
get_pragma(&conn_hidden, "secure_delete")
|
|
);
|
|
|
|
drop(conn_std);
|
|
drop(conn_hidden);
|
|
drop(db_std);
|
|
drop(db_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);
|
|
}
|