Initial commit: Sanctum encrypted single-file container for Windows

This commit is contained in:
2026-09-07 15:40:58 +02:00
commit 9115596763
11 changed files with 4020 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
use aes_gcm::{
aead::{AeadInPlace, KeyInit},
Aes256Gcm, Nonce, Tag,
};
use anyhow::{bail, Result};
use argon2::{Algorithm, Argon2, Params, Version};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
pub const FORMAT_VERSION: u32 = 1;
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
pub const DEFAULT_TIME_COST: u32 = 3;
pub const DEFAULT_PARALLELISM: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct KdfParams {
pub memory_cost: u32,
pub time_cost: u32,
pub parallelism: u32,
}
impl Default for KdfParams {
fn default() -> Self {
Self {
memory_cost: DEFAULT_MEMORY_COST_KIB,
time_cost: DEFAULT_TIME_COST,
parallelism: DEFAULT_PARALLELISM,
}
}
}
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
pub fn derive_kek(
password: &str,
salt: &[u8],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(
params.memory_cost,
params.time_cost,
params.parallelism,
Some(32),
)
.map_err(|e| anyhow::anyhow!("Ungültige Argon2-Parameter: {e}"))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut kek = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(password.as_bytes(), salt, &mut *kek)
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
Ok(kek)
}
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
let mut dek = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(&mut *dek);
dek
}
/// Generiert ein kryptografisch sicheres 16-Byte KDF-Salt.
pub fn generate_salt() -> [u8; 16] {
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
salt
}
/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut buffer = dek.to_vec();
let tag = cipher
.encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer)
.map_err(|e| anyhow::anyhow!("DEK-Wrapping fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice());
Ok((buffer, nonce_bytes, tag_bytes))
}
/// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_dek(
kek: &[u8; 32],
wrapped_dek: &[u8],
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
) -> Result<Zeroizing<[u8; 32]>> {
if wrapped_dek.len() != 32 {
bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len());
}
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_dek.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&buffer);
Ok(dek)
}
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
#[inline]
pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
let mut aad = [0u8; 16];
aad[..8].copy_from_slice(&node_id.to_le_bytes());
aad[8..].copy_from_slice(&(chunk_index as u64).to_le_bytes());
aad
}
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
pub fn encrypt_chunk(
dek: &[u8; 32],
node_id: i64,
chunk_index: u32,
plaintext: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let mut buffer = plaintext.to_vec();
let tag = cipher
.encrypt_in_place_detached(nonce, &aad, &mut buffer)
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice());
Ok((buffer, nonce_bytes, tag_bytes))
}
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
pub fn decrypt_chunk(
dek: &[u8; 32],
node_id: i64,
chunk_index: u32,
ciphertext: &[u8],
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let mut buffer = ciphertext.to_vec();
cipher
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
Ok(buffer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kdf_and_dek_wrapping() {
let password = "SuperSecretMasterPassword123!";
let salt = generate_salt();
let params = KdfParams {
memory_cost: 1024, // Schneller für Unit-Test
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &params).unwrap();
let dek = generate_dek();
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
assert_eq!(wrapped.len(), 32);
// Erfolgreiche Entschlüsselung
let unwrapped = unwrap_dek(&kek, &wrapped, &nonce, &tag).unwrap();
assert_eq!(*dek, *unwrapped);
// Falscher KEK schlägt fehl
let wrong_kek = derive_kek("WrongPassword!", &salt, &params).unwrap();
assert!(unwrap_dek(&wrong_kek, &wrapped, &nonce, &tag).is_err());
// Manipulierter Tag schlägt fehl
let mut tampered_tag = tag;
tampered_tag[0] ^= 0xFF;
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
}
#[test]
fn test_chunk_encryption_and_swap_protection() {
let dek = generate_dek();
let plaintext = b"Hello, Sanctum Encrypted Storage World!";
let node_id = 42i64;
let chunk_index = 0u32;
let (ciphertext, nonce, tag) = encrypt_chunk(&dek, node_id, chunk_index, plaintext).unwrap();
// Reguläre Entschlüsselung
let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag).unwrap();
assert_eq!(decrypted, plaintext);
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
let swap_node_err = decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag);
assert!(swap_node_err.is_err());
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
let swap_idx_err = decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag);
assert!(swap_idx_err.is_err());
// Manipulation des Ciphertexts
let mut tampered_ct = ciphertext.clone();
tampered_ct[0] ^= 0x01;
assert!(decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag).is_err());
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod crypto;
pub mod mount;
pub mod storage;
pub mod vfs;
+168
View File
@@ -0,0 +1,168 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use tracing::info;
use tracing_subscriber::EnvFilter;
use sanctum::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams};
use sanctum::mount::{format_drive, mount_container, unmount_drive};
use sanctum::storage::Database;
#[derive(Parser)]
#[command(name = "sanctum")]
#[command(author = "Sanctum Systems & Security Engineering")]
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialisiert einen neuen verschlüsselten Sanctum-Container (.sanctum)
Init {
/// Pfad zur zu erstellenden .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
},
/// Bindet einen Sanctum-Container als Windows-Netzlaufwerk via WebDAV ein
Mount {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:')
#[arg(short, long)]
drive: String,
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
#[arg(long)]
port: Option<u16>,
},
/// Trennt ein eingebundenes Netzlaufwerk manuell
Unmount {
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:')
#[arg(short, long)]
drive: String,
},
}
fn parse_drive_letter(s: &str) -> Result<char> {
let trimmed = s.trim();
let ch = trimmed
.chars()
.next()
.context("Laufwerksangabe darf nicht leer sein")?;
if !ch.is_ascii_alphabetic() {
bail!("Ungültiger Laufwerksbuchstabe: '{}'", trimmed);
}
Ok(ch.to_ascii_uppercase())
}
fn handle_init(container_path: &Path) -> Result<()> {
if container_path.exists() {
bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
container_path.display()
);
}
println!("============================================================");
println!(" Sanctum Container Initialisierung");
println!(" Zieldatei: {}", container_path.display());
println!("============================================================");
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
if password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
if password != confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
}
info!("Generiere KDF-Salt und leite KEK via Argon2id ab ...");
let salt = generate_salt();
let kdf_params = KdfParams::default();
let kek = derive_kek(&password, &salt, &kdf_params)
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
info!("Erzeuge kryptografisch sicheren DEK via CSPRNG (OsRng) ...");
let dek = generate_dek();
info!("Verschlüssele DEK mit KEK via AES-256-GCM ...");
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?;
info!("Initialisiere SQLite-Containerstruktur mit WAL-Modus ...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.context("Fehler bei der Schema-Initialisierung")?;
db.checkpoint()
.context("Fehler beim finalen WAL-Checkpoint")?;
println!();
println!("✔ Sanctum-Container erfolgreich initialisiert!");
println!(" Container: {}", container_path.display());
println!(" Format: Version 1 (Magic: SANCTUM\\0)");
println!(" KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" Cipher: AES-256-GCM mit AEAD-Swap-Protection (1-MB Chunks)");
println!();
println!("Zum Einbinden ausführen:");
println!(
" sanctum mount --path \"{}\" --drive S",
container_path.display()
);
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
// Tracing / Logging initialisieren (Standard-Filter: info)
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("sanctum=info,dav_server=warn,hyper=warn")),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Init { path } => {
handle_init(&path)?;
}
Commands::Mount { path, drive, port } => {
let drive_char = parse_drive_letter(&drive)?;
let password = rpassword::prompt_password(format!(
"Master-Passwort für Container '{}' eingeben: ",
path.display()
))
.context("Fehler beim Einlesen des Passworts")?;
mount_container(&path, drive_char, port, &password).await?;
}
Commands::Unmount { drive } => {
let drive_char = parse_drive_letter(&drive)?;
unmount_drive(drive_char)?;
println!("✔ Laufwerk {} erfolgreich getrennt.", format_drive(drive_char));
}
}
Ok(())
}
+228
View File
@@ -0,0 +1,228 @@
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use dav_server::{fakels::FakeLs, DavHandler};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
use crate::crypto::{derive_kek, unwrap_dek};
use crate::storage::Database;
use crate::vfs::SanctumFs;
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
pub fn format_drive(drive_letter: char) -> String {
format!("{}:", drive_letter.to_ascii_uppercase())
}
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y`.
pub fn unmount_drive(drive_letter: char) -> Result<()> {
let drive_str = format_drive(drive_letter);
info!("Trennen des Netzlaufwerks {} ...", drive_str);
let output = Command::new("net")
.args(["use", &drive_str, "/delete", "/y"])
.output()
.context("Fehler beim Ausführen des Befehls 'net use'")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}",
drive_str,
stdout,
stderr
);
}
info!("Laufwerk {} erfolgreich getrennt.", drive_str);
Ok(())
}
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/ /persistent:no` ein.
fn run_net_use_mount(drive_str: &str, port: u16) -> Result<()> {
let url = format!("http://127.0.0.1:{}/", port);
info!("Verbinde Netzlaufwerk {} mit {} ...", drive_str, url);
let output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"])
.output()
.context("Fehler beim Ausführen des Befehls 'net use'")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}",
drive_str,
stdout,
stderr
);
}
info!("Laufwerk {} erfolgreich eingebunden!", drive_str);
Ok(())
}
/// Startet den WebDAV-Server für den Sanctum-Container und bindet ihn als Netzlaufwerk ein.
pub async fn mount_container(
container_path: &Path,
drive_letter: char,
requested_port: Option<u16>,
password: &str,
) -> Result<()> {
let drive_str = format_drive(drive_letter);
if !container_path.exists() {
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
info!(
"Öffne Container '{}' ...",
container_path.display()
);
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
info!("Lese Header und verifiziere Magic Bytes ...");
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
info!("Leite KEK via Argon2id ab ...");
let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params)
.context("Schlüsselableitung fehlgeschlagen")?;
info!("Entschlüssele DEK via AES-256-GCM ...");
let dek = unwrap_dek(
&kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.context("Ungültiges Master-Passwort oder Container beschädigt")?;
// WebDAV Filesystem und Handler konfigurieren
let fs = SanctumFs::new(db.clone(), dek);
let dav_server = DavHandler::builder()
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
.build_handler();
// TCP-Port ermitteln und binden
let port_to_try = requested_port.unwrap_or(8443);
let listener = match TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port_to_try))).await {
Ok(l) => l,
Err(_) if requested_port.is_none() => {
info!(
"Standard-Port {} belegt, wähle dynamischen freien Port ...",
port_to_try
);
TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.context("Konnte keinen lokalen TCP-Port binden")?
}
Err(e) => {
bail!("Konnte Port 127.0.0.1:{} nicht binden: {}", port_to_try, e);
}
};
let bound_addr = listener.local_addr()?;
let bound_port = bound_addr.port();
info!("WebDAV-Server lauscht auf http://{}", bound_addr);
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
// Hyper HTTP Server Loop im Hintergrund starten
let server_dav = dav_server.clone();
let server_handle = tokio::spawn(async move {
loop {
tokio::select! {
res = listener.accept() => {
let (stream, _) = match res {
Ok(val) => val,
Err(e) => {
warn!("Verbindungsfehler im TCP-Listener: {e}");
continue;
}
};
let io = TokioIo::new(stream);
let handler = server_dav.clone();
tokio::spawn(async move {
let service = service_fn(move |req| {
let h = handler.clone();
async move {
Ok::<_, Infallible>(h.handle(req).await)
}
});
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
// Client-Disconnects im Explorer sind normal
debug!("HTTP-Verbindungsende: {:?}", err);
}
});
}
_ = shutdown_rx.changed() => {
info!("WebDAV-Server-Task empfängt Shutdown-Signal.");
break;
}
}
}
});
// Netzlaufwerk einbinden
if let Err(e) = run_net_use_mount(&drive_str, bound_port) {
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
return Err(e);
}
println!();
println!("============================================================");
println!(" Sanctum Container erfolgreich gemountet!");
println!(" Pfad: {}", container_path.display());
println!(" Laufwerk: {}", drive_str);
println!(" WebDAV URL: http://127.0.0.1:{}/", bound_port);
println!(" Drücke [Ctrl+C] zum sauberen Trennen und Schließen.");
println!("============================================================");
println!();
// Warten auf Strg+C
tokio::signal::ctrl_c()
.await
.context("Fehler beim Registrieren des Ctrl+C Signalhandlers")?;
println!();
info!("Beendigungssignal (Ctrl+C) erhalten.");
info!("Trennen des Windows-Netzlaufwerks {} ...", drive_str);
// Automatisches Unmount
if let Err(e) = unmount_drive(drive_letter) {
warn!("Warnung beim automatischen Unmount: {e}");
}
// HTTP Server beenden
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
// SQLite WAL Checkpoint erzwingen
info!("Führe SQLite WAL-Checkpoint aus (PRAGMA wal_checkpoint(TRUNCATE)) ...");
if let Err(e) = db.checkpoint() {
error!("Fehler beim WAL-Checkpoint: {e}");
}
info!("Sanctum Container wurde sicher und vollständig geschlossen.");
Ok(())
}
+534
View File
@@ -0,0 +1,534 @@
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{KdfParams, FORMAT_VERSION, MAGIC_BYTES};
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct NodeRecord {
pub id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub is_dir: bool,
pub size: u64,
pub created_at: u64,
pub modified_at: u64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ChunkRecord {
pub node_id: i64,
pub chunk_index: u32,
pub nonce: [u8; 12],
pub tag: [u8; 16],
pub ciphertext: Vec<u8>,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ContainerMeta {
pub version: u32,
pub kdf_salt: [u8; 16],
pub kdf_params: KdfParams,
pub wrapped_dek: Vec<u8>,
pub header_nonce: [u8; 12],
pub header_tag: [u8; 16],
}
#[derive(Clone)]
pub struct Database {
conn: Arc<Mutex<Connection>>,
}
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl Database {
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let conn = Connection::open(path)?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
};
db.init_pragmas()?;
Ok(db)
}
/// Öffnet eine In-Memory-Datenbank (vorwiegend für Tests).
#[cfg(test)]
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
};
db.init_pragmas()?;
Ok(db)
}
/// Setzt die vorgeschriebenen SQLite3-Pragmas: WAL, NORMAL synchronous, 8192 Page-Size.
pub fn init_pragmas(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"PRAGMA page_size = 8192;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;",
)?;
Ok(())
}
/// Initialisiert das Datenbankschema für einen neuen Container.
pub fn init_schema(
&self,
salt: &[u8; 16],
kdf_params: &KdfParams,
wrapped_dek: &[u8],
header_nonce: &[u8; 12],
header_tag: &[u8; 16],
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS meta (
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(COALESCE(parent_id, 0), name);
CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index),
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);",
)?;
// Metadaten einfügen
let params_json = serde_json::to_string(kdf_params)?;
conn.execute(
"INSERT INTO meta (magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
salt.as_slice(),
params_json,
wrapped_dek,
header_nonce.as_slice(),
header_tag.as_slice(),
],
)?;
// Root-Verzeichnis '/' mit id = 1 anlegen
let now = current_timestamp();
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
Ok(())
}
/// Liest die Metadaten des Containers aus der `meta`-Tabelle aus und verifiziert die Magic Bytes.
pub fn read_meta(&self) -> Result<ContainerMeta> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag FROM meta LIMIT 1"
)?;
let meta = stmt.query_row([], |row| {
let magic: Vec<u8> = row.get(0)?;
let version: u32 = row.get(1)?;
let kdf_salt: Vec<u8> = row.get(2)?;
let kdf_params_str: String = row.get(3)?;
let wrapped_dek: Vec<u8> = row.get(4)?;
let header_nonce: Vec<u8> = row.get(5)?;
let header_tag: Vec<u8> = row.get(6)?;
Ok((
magic,
version,
kdf_salt,
kdf_params_str,
wrapped_dek,
header_nonce,
header_tag,
))
})?;
if meta.0.as_slice() != MAGIC_BYTES.as_slice() {
bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein");
}
if meta.1 != FORMAT_VERSION {
bail!("Nicht unterstützte Sanctum-Formatversion: {}", meta.1);
}
if meta.2.len() != 16 {
bail!("Ungültige Salt-Länge im Header");
}
let mut salt = [0u8; 16];
salt.copy_from_slice(&meta.2);
let kdf_params: KdfParams = serde_json::from_str(&meta.3)
.context("KDF-Parameter im Header konnten nicht deserialisiert werden")?;
if meta.5.len() != 12 {
bail!("Ungültige Header-Nonce-Länge");
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&meta.5);
if meta.6.len() != 16 {
bail!("Ungültige Header-Tag-Länge");
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&meta.6);
Ok(ContainerMeta {
version: meta.1,
kdf_salt: salt,
kdf_params,
wrapped_dek: meta.4,
header_nonce,
header_tag,
})
}
/// Löst einen hierarchischen Pfad (z. B. "/ordner/datei.txt") in den entsprechenden NodeRecord auf.
pub fn resolve_path(&self, raw_path: &str) -> Result<Option<NodeRecord>> {
let normalized = raw_path.trim_matches('/');
if normalized.is_empty() {
return self.get_node_by_id(1);
}
let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
let conn = self.conn.lock().unwrap();
let mut current_id = 1i64;
let mut last_record = None;
for (idx, segment) in segments.iter().enumerate() {
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes
WHERE parent_id = ?1 AND name = ?2",
)?;
let record: Option<NodeRecord> = stmt
.query_row(params![current_id, segment], |row| {
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name: row.get(2)?,
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()?;
match record {
Some(rec) => {
if idx + 1 < segments.len() && !rec.is_dir {
// Zwischenelement ist kein Verzeichnis
return Ok(None);
}
current_id = rec.id;
last_record = Some(rec);
}
None => return Ok(None),
}
}
Ok(last_record)
}
pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes WHERE id = ?1",
)?;
let record = stmt
.query_row(params![id], |row| {
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name: row.get(2)?,
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)
}
/// Listet alle direkten Kinder eines Verzeichnisknotens auf.
pub fn list_children(&self, parent_id: i64) -> Result<Vec<NodeRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes
WHERE parent_id = ?1
ORDER BY is_dir DESC, name ASC",
)?;
let rows = stmt.query_map(params![parent_id], |row| {
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name: row.get(2)?,
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,
})
})?;
let mut entries = Vec::new();
for r in rows {
entries.push(r?);
}
Ok(entries)
}
/// Erstellt einen neuen Datei- oder Ordnerknoten.
pub fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord> {
let now = current_timestamp();
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (?1, ?2, ?3, 0, ?4, ?5)",
params![parent_id, name, if is_dir { 1 } else { 0 }, now, now],
)?;
let new_id = conn.last_insert_rowid();
// Aktualisiere das Änderungsdatum des Elternordners
let _ = conn.execute(
"UPDATE nodes SET modified_at = ?1 WHERE id = ?2",
params![now, parent_id],
);
Ok(NodeRecord {
id: new_id,
parent_id: Some(parent_id),
name: name.to_string(),
is_dir,
size: 0,
created_at: now,
modified_at: now,
})
}
/// Aktualisiert Dateigröße und Modifikationszeitstempel eines Knotens.
pub fn update_node_size_and_time(&self, id: i64, size: u64, modified_at: u64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
params![size as i64, modified_at as i64, id],
)?;
Ok(())
}
/// Löscht einen Knoten und alle assoziierten Chunks atomar.
pub fn delete_node(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
// Foreign Key Cascade löscht Chunks und Unterordner, wir stellen es explizit sicher:
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
Ok(())
}
/// Benennt einen Knoten um und/oder verschiebt ihn in ein anderes Verzeichnis.
pub fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<()> {
let now = current_timestamp();
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE nodes SET parent_id = ?1, name = ?2, modified_at = ?3 WHERE id = ?4",
params![new_parent_id, new_name, now, id],
)?;
Ok(())
}
/// Liest einen verschlüsselten Chunk aus der Datenbank.
pub fn read_chunk(&self, node_id: i64, chunk_index: u32) -> Result<Option<ChunkRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT nonce, tag, ciphertext FROM chunks WHERE node_id = ?1 AND chunk_index = ?2",
)?;
let record = stmt
.query_row(params![node_id, chunk_index], |row| {
let nonce_vec: Vec<u8> = row.get(0)?;
let tag_vec: Vec<u8> = row.get(1)?;
let ciphertext: Vec<u8> = row.get(2)?;
let mut nonce = [0u8; 12];
let mut tag = [0u8; 16];
if nonce_vec.len() == 12 {
nonce.copy_from_slice(&nonce_vec);
}
if tag_vec.len() == 16 {
tag.copy_from_slice(&tag_vec);
}
Ok(ChunkRecord {
node_id,
chunk_index,
nonce,
tag,
ciphertext,
})
})
.optional()?;
Ok(record)
}
/// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank.
pub fn write_chunk(
&self,
node_id: i64,
chunk_index: u32,
nonce: &[u8; 12],
tag: &[u8; 16],
ciphertext: &[u8],
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
nonce = excluded.nonce,
tag = excluded.tag,
ciphertext = excluded.ciphertext",
params![
node_id,
chunk_index,
nonce.as_slice(),
tag.as_slice(),
ciphertext,
],
)?;
Ok(())
}
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei).
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM chunks WHERE node_id = ?1 AND chunk_index > ?2",
params![node_id, max_chunk_index],
)?;
Ok(())
}
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
pub fn checkpoint(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_schema_and_path_resolution() {
let db = Database::open_in_memory().unwrap();
let salt = [1u8; 16];
let kdf_params = KdfParams::default();
let wrapped_dek = vec![2u8; 32];
let nonce = [3u8; 12];
let tag = [4u8; 16];
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
// Meta abrufen
let meta = db.read_meta().unwrap();
assert_eq!(meta.version, FORMAT_VERSION);
assert_eq!(meta.kdf_salt, salt);
assert_eq!(meta.wrapped_dek, wrapped_dek);
// Root prüfen
let root = db.resolve_path("/").unwrap().expect("Root node must exist");
assert_eq!(root.id, 1);
assert!(root.is_dir);
// Ordner und Datei erstellen
let docs = db.create_node(root.id, "documents", true).unwrap();
assert_eq!(docs.name, "documents");
assert!(docs.is_dir);
let file = db.create_node(docs.id, "notes.txt", false).unwrap();
assert_eq!(file.name, "notes.txt");
assert!(!file.is_dir);
// Pfadauflösung testen
let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve");
assert_eq!(resolved_file.id, file.id);
let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve");
assert_eq!(resolved_docs.id, docs.id);
// Chunks schreiben & lesen
let test_cipher = b"ENCRYPTED_DATA_BLOCK";
let c_nonce = [7u8; 12];
let c_tag = [8u8; 16];
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher).unwrap();
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist");
assert_eq!(chunk.ciphertext, test_cipher);
// Truncate
db.truncate_chunks_after(file.id, 0).unwrap();
let chunk_after = db.read_chunk(file.id, 0).unwrap();
assert!(chunk_after.is_some());
// Löschen
db.delete_node(file.id).unwrap();
let deleted_res = db.resolve_path("/documents/notes.txt").unwrap();
assert!(deleted_res.is_none());
assert!(db.read_chunk(file.id, 0).unwrap().is_none());
}
}
+709
View File
@@ -0,0 +1,709 @@
use std::fmt::Debug;
use std::io::SeekFrom;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use bytes::{Buf, Bytes, BytesMut};
use dav_server::{
davpath::DavPath,
fs::{
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsResult, FsStream,
OpenOptions, ReadDirMeta,
},
};
use futures_util::stream;
use tracing::{debug, error, warn};
use zeroize::Zeroizing;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord};
// ---------------------------------------------------------------------------
// Metadaten
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct SanctumMetaData {
pub is_dir: bool,
pub size: u64,
pub modified_at: SystemTime,
pub created_at: SystemTime,
}
impl DavMetaData for SanctumMetaData {
fn len(&self) -> u64 {
self.size
}
fn modified(&self) -> FsResult<SystemTime> {
Ok(self.modified_at)
}
fn is_dir(&self) -> bool {
self.is_dir
}
fn created(&self) -> FsResult<SystemTime> {
Ok(self.created_at)
}
fn is_file(&self) -> bool {
!self.is_dir
}
}
// ---------------------------------------------------------------------------
// Verzeichniseintrag
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct SanctumDirEntry {
pub name: String,
pub meta: SanctumMetaData,
}
impl DavDirEntry for SanctumDirEntry {
fn name(&self) -> Vec<u8> {
self.name.as_bytes().to_vec()
}
fn metadata(&self) -> FsFuture<'_, Box<dyn DavMetaData>> {
let meta = self.meta.clone();
Box::pin(async move { Ok(Box::new(meta) as Box<dyn DavMetaData>) })
}
}
// ---------------------------------------------------------------------------
// Datei-Handle mit Streaming & Chunk-Pufferung
// ---------------------------------------------------------------------------
pub struct SanctumFile {
node_id: i64,
file_size: u64,
cursor: u64,
db: Database,
dek: Arc<Zeroizing<[u8; 32]>>,
meta: SanctumMetaData,
// (chunk_index, decrypted_payload, is_dirty)
cached_chunk: Option<(u32, Vec<u8>, bool)>,
}
impl Debug for SanctumFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SanctumFile")
.field("node_id", &self.node_id)
.field("file_size", &self.file_size)
.field("cursor", &self.cursor)
.finish()
}
}
impl SanctumFile {
pub fn new(
node: NodeRecord,
db: Database,
dek: Arc<Zeroizing<[u8; 32]>>,
) -> Self {
let meta = SanctumMetaData {
is_dir: node.is_dir,
size: node.size,
created_at: UNIX_EPOCH + Duration::from_secs(node.created_at),
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
};
Self {
node_id: node.id,
file_size: node.size,
cursor: 0,
db,
dek,
meta,
cached_chunk: None,
}
}
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück.
fn flush_cached_chunk(&mut self) -> Result<(), FsError> {
if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data).map_err(|e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure
})?;
self.db
.write_chunk(self.node_id, idx, &nonce, &tag, &ciphertext)
.map_err(|e| {
error!("DB-Fehler beim Schreiben des Chunks: {e}");
FsError::GeneralFailure
})?;
if let Some((_, _, ref mut dirty)) = self.cached_chunk {
*dirty = false;
}
}
Ok(())
}
/// Stellt sicher, dass der angeforderte Chunk im Cache geladen und entschlüsselt ist.
fn ensure_chunk_loaded(&mut self, chunk_index: u32) -> Result<&mut Vec<u8>, FsError> {
let is_current = match &self.cached_chunk {
Some((idx, _, _)) => *idx == chunk_index,
None => false,
};
if !is_current {
self.flush_cached_chunk()?;
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
FsError::GeneralFailure
})? {
Some(record) => decrypt_chunk(
&self.dek,
self.node_id,
chunk_index,
&record.ciphertext,
&record.nonce,
&record.tag,
)
.map_err(|e| {
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
FsError::GeneralFailure
})?,
None => Vec::new(),
};
self.cached_chunk = Some((chunk_index, payload, false));
}
match &mut self.cached_chunk {
Some((_, ref mut data, _)) => Ok(data),
None => unreachable!(),
}
}
}
impl Drop for SanctumFile {
fn drop(&mut self) {
if let Err(e) = self.flush_cached_chunk() {
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = self.db.update_node_size_and_time(self.node_id, self.file_size, now);
}
}
impl DavFile for SanctumFile {
fn metadata(&mut self) -> FsFuture<'_, Box<dyn DavMetaData>> {
self.meta.size = self.file_size;
let meta = self.meta.clone();
Box::pin(async move { Ok(Box::new(meta) as Box<dyn DavMetaData>) })
}
fn read_bytes(&mut self, mut count: usize) -> FsFuture<'_, Bytes> {
Box::pin(async move {
if self.cursor >= self.file_size || count == 0 {
return Ok(Bytes::new());
}
let remaining_file = (self.file_size - self.cursor) as usize;
if count > remaining_file {
count = remaining_file;
}
let mut result = BytesMut::with_capacity(count);
while count > 0 && self.cursor < self.file_size {
let chunk_idx = (self.cursor / CHUNK_SIZE as u64) as u32;
let offset_in_chunk = (self.cursor % CHUNK_SIZE as u64) as usize;
let bytes_in_chunk_left = CHUNK_SIZE - offset_in_chunk;
let to_read = count
.min(bytes_in_chunk_left)
.min((self.file_size - self.cursor) as usize);
let chunk_data = self.ensure_chunk_loaded(chunk_idx)?;
if offset_in_chunk >= chunk_data.len() {
break;
}
let available = (chunk_data.len() - offset_in_chunk).min(to_read);
result.extend_from_slice(&chunk_data[offset_in_chunk..offset_in_chunk + available]);
self.cursor += available as u64;
count -= available;
if available < to_read {
break;
}
}
Ok(result.freeze())
})
}
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
Box::pin(async move {
let mut src = &buf[..];
while !src.is_empty() {
let chunk_idx = (self.cursor / CHUNK_SIZE as u64) as u32;
let offset_in_chunk = (self.cursor % CHUNK_SIZE as u64) as usize;
let space_in_chunk = CHUNK_SIZE - offset_in_chunk;
let to_write = src.len().min(space_in_chunk);
let chunk_data = self.ensure_chunk_loaded(chunk_idx)?;
if chunk_data.len() < offset_in_chunk {
chunk_data.resize(offset_in_chunk, 0);
}
if chunk_data.len() < offset_in_chunk + to_write {
chunk_data.resize(offset_in_chunk + to_write, 0);
}
chunk_data[offset_in_chunk..offset_in_chunk + to_write]
.copy_from_slice(&src[..to_write]);
if let Some((_, _, ref mut dirty)) = self.cached_chunk {
*dirty = true;
}
self.cursor += to_write as u64;
if self.cursor > self.file_size {
self.file_size = self.cursor;
}
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen
if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) {
self.flush_cached_chunk()?;
}
src = &src[to_write..];
}
Ok(())
})
}
fn write_buf(&mut self, mut buf: Box<dyn Buf + Send>) -> FsFuture<'_, ()> {
let bytes = buf.copy_to_bytes(buf.remaining());
self.write_bytes(bytes)
}
fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> {
Box::pin(async move {
let new_cursor = match pos {
SeekFrom::Start(offset) => offset as i64,
SeekFrom::End(offset) => self.file_size as i64 + offset,
SeekFrom::Current(offset) => self.cursor as i64 + offset,
};
if new_cursor < 0 {
return Err(FsError::GeneralFailure);
}
self.cursor = new_cursor as u64;
Ok(self.cursor)
})
}
fn flush(&mut self) -> FsFuture<'_, ()> {
Box::pin(async move {
self.flush_cached_chunk()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.db
.update_node_size_and_time(self.node_id, self.file_size, now)
.map_err(|e| {
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
FsError::GeneralFailure
})?;
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
Ok(())
})
}
}
// ---------------------------------------------------------------------------
// DavFileSystem Implementierung
// ---------------------------------------------------------------------------
#[derive(Clone)]
pub struct SanctumFs {
db: Database,
dek: Arc<Zeroizing<[u8; 32]>>,
}
impl SanctumFs {
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>) -> Self {
Self {
db,
dek: Arc::new(dek),
}
}
fn path_to_str(path: &DavPath) -> String {
String::from_utf8_lossy(path.as_bytes()).to_string()
}
fn split_parent_and_name<'a>(&self, path: &'a str) -> (&'a str, &'a str) {
let trimmed = path.trim_matches('/');
match trimmed.rfind('/') {
Some(pos) => (&trimmed[..pos], &trimmed[pos + 1..]),
None => ("", trimmed),
}
}
}
impl DavFileSystem for SanctumFs {
fn open<'a>(
&'a self,
path: &'a DavPath,
options: OpenOptions,
) -> FsFuture<'a, Box<dyn DavFile>> {
Box::pin(async move {
let path_str = Self::path_to_str(path);
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
let existing_node = self
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?;
let node = match existing_node {
Some(n) => {
if n.is_dir && (options.write || options.append) {
return Err(FsError::Forbidden);
}
if options.create_new {
return Err(FsError::Exists);
}
if options.truncate {
self.db
.truncate_chunks_after(n.id, 0)
.map_err(|_| FsError::GeneralFailure)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.db
.update_node_size_and_time(n.id, 0, now)
.map_err(|_| FsError::GeneralFailure)?;
NodeRecord {
size: 0,
modified_at: now,
..n
}
} else {
n
}
}
None => {
if options.create || options.create_new {
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
let parent = self
.db
.resolve_path(parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
if !parent.is_dir {
return Err(FsError::Forbidden);
}
self.db
.create_node(parent.id, file_name, false)
.map_err(|e| {
error!("Fehler beim Erstellen der Datei: {e}");
FsError::GeneralFailure
})?
} else {
return Err(FsError::NotFound);
}
}
};
let file = SanctumFile::new(node, self.db.clone(), self.dek.clone());
Ok(Box::new(file) as Box<dyn DavFile>)
})
}
fn read_dir<'a>(
&'a self,
path: &'a DavPath,
_meta: ReadDirMeta,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
if !node.is_dir {
return Err(FsError::Forbidden);
}
let children = self
.db
.list_children(node.id)
.map_err(|_| FsError::GeneralFailure)?;
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
.into_iter()
.map(|child| {
Ok(Box::new(SanctumDirEntry {
name: child.name,
meta: SanctumMetaData {
is_dir: child.is_dir,
size: child.size,
created_at: UNIX_EPOCH + Duration::from_secs(child.created_at),
modified_at: UNIX_EPOCH + Duration::from_secs(child.modified_at),
},
}) as Box<dyn DavDirEntry>)
})
.collect();
Ok(Box::pin(stream::iter(entries)) as FsStream<Box<dyn DavDirEntry>>)
})
}
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
let meta = SanctumMetaData {
is_dir: node.is_dir,
size: node.size,
created_at: UNIX_EPOCH + Duration::from_secs(node.created_at),
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
};
Ok(Box::new(meta) as Box<dyn DavMetaData>)
})
}
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move {
let path_str = Self::path_to_str(path);
if self
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.is_some()
{
return Err(FsError::Exists);
}
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
let parent = self
.db
.resolve_path(parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
if !parent.is_dir {
return Err(FsError::Forbidden);
}
self.db
.create_node(parent.id, dir_name, true)
.map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
}
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
if !node.is_dir {
return Err(FsError::Forbidden);
}
if node.id == 1 {
// Root-Verzeichnis darf nicht gelöscht werden
return Err(FsError::Forbidden);
}
self.db
.delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
}
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.db
.resolve_path(&path_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
if node.is_dir {
return Err(FsError::Forbidden);
}
self.db
.delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
}
fn rename<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
) -> FsFuture<'a, ()> {
Box::pin(async move {
let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to);
let node = self
.db
.resolve_path(&from_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
let to_parent = self
.db
.resolve_path(to_parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
if !to_parent.is_dir {
return Err(FsError::Forbidden);
}
// Falls Zieldatei bereits existiert und Datei ist: überschreiben / löschen
if let Some(dest) = self
.db
.resolve_path(&to_str)
.map_err(|_| FsError::GeneralFailure)?
{
if dest.is_dir {
return Err(FsError::Forbidden);
}
self.db
.delete_node(dest.id)
.map_err(|_| FsError::GeneralFailure)?;
}
self.db
.rename_node(node.id, to_parent.id, to_name)
.map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
}
fn copy<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
) -> FsFuture<'a, ()> {
Box::pin(async move {
let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to);
let node = self
.db
.resolve_path(&from_str)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
if node.is_dir {
return Err(FsError::NotImplemented);
}
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
let to_parent = self
.db
.resolve_path(to_parent_path)
.map_err(|_| FsError::GeneralFailure)?
.ok_or(FsError::NotFound)?;
let dest_node = self
.db
.create_node(to_parent.id, to_name, false)
.map_err(|_| FsError::GeneralFailure)?;
// Kopiere alle Chunks und re-verschlüssele mit neuer node_id (wegen AAD-Bindung!)
let total_chunks = if node.size == 0 {
0
} else {
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
};
for idx in 0..total_chunks {
if let Some(record) = self
.db
.read_chunk(node.id, idx)
.map_err(|_| FsError::GeneralFailure)?
{
let plaintext = decrypt_chunk(
&self.dek,
node.id,
idx,
&record.ciphertext,
&record.nonce,
&record.tag,
)
.map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) =
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext)
.map_err(|_| FsError::GeneralFailure)?;
self.db
.write_chunk(dest_node.id, idx, &new_nonce, &new_tag, &new_ct)
.map_err(|_| FsError::GeneralFailure)?;
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.db
.update_node_size_and_time(dest_node.id, node.size, now)
.map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
}
fn get_quota(&self) -> FsFuture<'_, (u64, Option<u64>)> {
Box::pin(async move {
// Virtueller Speicherplatz für Explorer: 1 TB
let total_capacity: u64 = 1024 * 1024 * 1024 * 1024;
Ok((0, Some(total_capacity)))
})
}
}