Initial commit: Sanctum encrypted single-file container for Windows
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
*.sanctum
|
||||||
|
*.log
|
||||||
Generated
+1858
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "sanctum"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Sanctum Engineering Team"]
|
||||||
|
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
rpassword = "7.3"
|
||||||
|
argon2 = { version = "0.5", features = ["password-hash"] }
|
||||||
|
aes-gcm = { version = "0.10", features = ["zeroize"] }
|
||||||
|
rand = "0.8"
|
||||||
|
zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] }
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
dav-server = { version = "0.11", default-features = false }
|
||||||
|
hyper = { version = "1.4", features = ["server", "http1"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["tokio", "server-auto"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
bytes = "1.7"
|
||||||
|
futures-util = "0.3"
|
||||||
|
thiserror = "2.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
dyn-clone = "1.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Sanctum 🛡️
|
||||||
|
|
||||||
|
Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendung in Rust, die einen verschlüsselten Ein-Datei-Container (`.sanctum`) unter Windows (10/11) im reinen Userland verwaltet.
|
||||||
|
|
||||||
|
- **Keine Administratorrechte erforderlich**
|
||||||
|
- **Keine Kernel-Treiber** (weder WinFsp noch Dokan)
|
||||||
|
- **Transparenter Windows Explorer-Zugriff** über einen integrierten lokalen WebDAV-Server (`127.0.0.1`) via Windows-Bordmittel (`net use`)
|
||||||
|
- **Statisches Single-Binary** (`sanctum.exe`, ~4.6 MB) ohne externe DLL-Abhängigkeiten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Kryptografie & Sicherheitsarchitektur
|
||||||
|
|
||||||
|
- **Key Derivation (Argon2id)**:
|
||||||
|
Aus dem Master-Passwort wird mittels `Argon2id` ($M=64\,\text{MB}, T=3, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet.
|
||||||
|
- **Data Encryption Key (DEK)**:
|
||||||
|
Ein zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt.
|
||||||
|
- **RAM-Schutz**:
|
||||||
|
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im Arbeitsspeicher sicher zu nullen.
|
||||||
|
- **Chunk-Verschlüsselung (AES-256-GCM)**:
|
||||||
|
Dateien werden in Blöcken von 1 MB verschlüsselt.
|
||||||
|
- **Schutz vor Swap-Angriffen**:
|
||||||
|
Als Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) fest eingebunden. Dadurch wird verhindert, dass Chunks zwischen Dateien oder innerhalb einer Datei vertauscht werden können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Storage Engine (SQLite3 im VFS-Container)
|
||||||
|
|
||||||
|
Der gesamte Container besteht aus exakt **einer** Datei auf der Host-Festplatte (`.sanctum`), die dynamisch bis über 100 GB wachsen kann.
|
||||||
|
|
||||||
|
- **Pragmas**:
|
||||||
|
- `PRAGMA journal_mode = WAL;`
|
||||||
|
- `PRAGMA synchronous = NORMAL;`
|
||||||
|
- `PRAGMA page_size = 8192;`
|
||||||
|
- `PRAGMA foreign_keys = ON;`
|
||||||
|
- **Tabellen**:
|
||||||
|
- `meta`: Container-Header mit Magic Bytes (`SANCTUM\0`), Version 1, Salt, KDF-Parametern und Wrapped DEK.
|
||||||
|
- `nodes`: Verzeichnis- und Dateiknoten mit Hierarchiebaum und Zeitstempeln.
|
||||||
|
- `chunks`: Verschlüsselte Nutzdatenblöcke mit Nonce und Authentifizierungstag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Kompilieren
|
||||||
|
|
||||||
|
Voraussetzungen: Rust (>= 1.85) mit GCC/MinGW-Toolchain für SQLite:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Das fertige Binary befindet sich unter `target/release/sanctum.exe`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Verwendung
|
||||||
|
|
||||||
|
### 1. Neuen Container anlegen
|
||||||
|
```powershell
|
||||||
|
sanctum.exe init --path "C:\Users\username\Documents\safe.sanctum"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Container als Windows-Laufwerk einbinden
|
||||||
|
```powershell
|
||||||
|
sanctum.exe mount --path "C:\Users\username\Documents\safe.sanctum" --drive S
|
||||||
|
```
|
||||||
|
Das Netzlaufwerk `S:` steht sofort im Windows Explorer zur Verfügung.
|
||||||
|
Zum Beenden und sicheren Trennen einfach `Ctrl+C` im Terminal drücken.
|
||||||
|
|
||||||
|
### 3. Laufwerk manuell trennen
|
||||||
|
```powershell
|
||||||
|
sanctum.exe unmount --drive S
|
||||||
|
```
|
||||||
+248
@@ -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, ¶ms).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, ¶ms).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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod crypto;
|
||||||
|
pub mod mount;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod vfs;
|
||||||
+168
@@ -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
@@ -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
@@ -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
@@ -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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
use std::io::SeekFrom;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use dav_server::{
|
||||||
|
davpath::DavPath,
|
||||||
|
fs::{DavFileSystem, OpenOptions, ReadDirMeta},
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use sanctum::{
|
||||||
|
crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams, CHUNK_SIZE,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
},
|
||||||
|
storage::Database,
|
||||||
|
vfs::SanctumFs,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sanctum_full_container_lifecycle() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_{}.sanctum", std::process::id()));
|
||||||
|
|
||||||
|
// Aufräumen, falls alte Testdatei existiert
|
||||||
|
if container_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = "CorrectMasterPassword2026!";
|
||||||
|
let wrong_password = "WrongMasterPassword!";
|
||||||
|
|
||||||
|
// 1. Initialisierung
|
||||||
|
let salt = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: 1024, // Schnell für Tests
|
||||||
|
time_cost: 1,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek = derive_kek(password, &salt, &kdf_params).expect("KEK derivation");
|
||||||
|
let dek = generate_dek();
|
||||||
|
let (wrapped_dek, header_nonce, header_tag) =
|
||||||
|
wrap_dek(&kek, &dek).expect("DEK wrapping");
|
||||||
|
|
||||||
|
let db = Database::open(&container_path).expect("Open database");
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
||||||
|
.expect("Init schema");
|
||||||
|
db.checkpoint().expect("Checkpoint");
|
||||||
|
|
||||||
|
// 2. Header & Magic Bytes Prüfung
|
||||||
|
let meta = db.read_meta().expect("Read meta");
|
||||||
|
assert_eq!(meta.version, FORMAT_VERSION);
|
||||||
|
assert_eq!(meta.kdf_salt, salt);
|
||||||
|
assert_eq!(meta.wrapped_dek, wrapped_dek);
|
||||||
|
|
||||||
|
// Falsches Passwort schlägt fehl
|
||||||
|
let wrong_kek = derive_kek(wrong_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
|
||||||
|
assert!(unwrap_dek(&wrong_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag).is_err());
|
||||||
|
|
||||||
|
// Richtiges Passwort entschlüsselt DEK
|
||||||
|
let unwrapped_dek =
|
||||||
|
unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag).unwrap();
|
||||||
|
assert_eq!(*dek, *unwrapped_dek);
|
||||||
|
|
||||||
|
// 3. VFS Filesystem-Operationen (WebDAV Trait)
|
||||||
|
let fs = SanctumFs::new(db.clone(), unwrapped_dek);
|
||||||
|
|
||||||
|
// Ordner erstellen
|
||||||
|
let docs_path = DavPath::new("/documents").unwrap();
|
||||||
|
fs.create_dir(&docs_path).await.expect("Create /documents");
|
||||||
|
|
||||||
|
let docs_meta = fs.metadata(&docs_path).await.expect("Metadata /documents");
|
||||||
|
assert!(docs_meta.is_dir());
|
||||||
|
|
||||||
|
// Multi-MB Datei über mehrere Chunks hinweg schreiben (2.5 MB = 3 Chunks à 1 MB)
|
||||||
|
let file_path = DavPath::new("/documents/large_payload.bin").unwrap();
|
||||||
|
let mut opts_write = OpenOptions::default();
|
||||||
|
opts_write.write = true;
|
||||||
|
opts_write.create_new = true;
|
||||||
|
|
||||||
|
let mut file = fs.open(&file_path, opts_write).await.expect("Open for write");
|
||||||
|
|
||||||
|
let payload_size = (2.5 * CHUNK_SIZE as f64) as usize;
|
||||||
|
let mut sample_data = Vec::with_capacity(payload_size);
|
||||||
|
for i in 0..payload_size {
|
||||||
|
sample_data.push((i % 251) as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
file.write_bytes(Bytes::copy_from_slice(&sample_data))
|
||||||
|
.await
|
||||||
|
.expect("Write 2.5 MB");
|
||||||
|
file.flush().await.expect("Flush file");
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// Metadaten verifizieren
|
||||||
|
let file_meta = fs.metadata(&file_path).await.expect("Metadata file");
|
||||||
|
assert_eq!(file_meta.len(), payload_size as u64);
|
||||||
|
assert!(!file_meta.is_dir());
|
||||||
|
|
||||||
|
// Datei lesen & Seek über Chunk-Grenzen testen
|
||||||
|
let mut opts_read = OpenOptions::default();
|
||||||
|
opts_read.read = true;
|
||||||
|
let mut read_file = fs.open(&file_path, opts_read).await.expect("Open for read");
|
||||||
|
|
||||||
|
// Seek mitten in den 2. Chunk (1 MB + 500 Bytes)
|
||||||
|
let seek_offset = (CHUNK_SIZE + 500) as u64;
|
||||||
|
let new_pos = read_file
|
||||||
|
.seek(SeekFrom::Start(seek_offset))
|
||||||
|
.await
|
||||||
|
.expect("Seek");
|
||||||
|
assert_eq!(new_pos, seek_offset);
|
||||||
|
|
||||||
|
// 2000 Bytes lesen (überschreitet evtl. Chunk-Grenze oder bleibt im Chunk)
|
||||||
|
let read_len = 2000;
|
||||||
|
let read_chunk_bytes = read_file.read_bytes(read_len).await.expect("Read bytes");
|
||||||
|
assert_eq!(read_chunk_bytes.len(), read_len);
|
||||||
|
assert_eq!(
|
||||||
|
&read_chunk_bytes[..],
|
||||||
|
&sample_data[seek_offset as usize..seek_offset as usize + read_len]
|
||||||
|
);
|
||||||
|
drop(read_file);
|
||||||
|
|
||||||
|
// Datei umbenennen
|
||||||
|
let renamed_path = DavPath::new("/documents/renamed_payload.bin").unwrap();
|
||||||
|
fs.rename(&file_path, &renamed_path).await.expect("Rename file");
|
||||||
|
|
||||||
|
assert!(fs.metadata(&file_path).await.is_err());
|
||||||
|
let renamed_meta = fs.metadata(&renamed_path).await.expect("Metadata renamed");
|
||||||
|
assert_eq!(renamed_meta.len(), payload_size as u64);
|
||||||
|
|
||||||
|
// Datei kopieren
|
||||||
|
let copy_path = DavPath::new("/documents/copy_payload.bin").unwrap();
|
||||||
|
fs.copy(&renamed_path, ©_path).await.expect("Copy file");
|
||||||
|
let copy_meta = fs.metadata(©_path).await.expect("Metadata copy");
|
||||||
|
assert_eq!(copy_meta.len(), payload_size as u64);
|
||||||
|
|
||||||
|
// Verzeichnis auflisten
|
||||||
|
let stream = fs.read_dir(&docs_path, ReadDirMeta::None).await.expect("Read dir");
|
||||||
|
let entries: Vec<_> = stream.collect().await;
|
||||||
|
assert_eq!(entries.len(), 2); // renamed_payload.bin & copy_payload.bin
|
||||||
|
|
||||||
|
// Dateien und Verzeichnis löschen
|
||||||
|
fs.remove_file(&renamed_path).await.expect("Remove renamed");
|
||||||
|
fs.remove_file(©_path).await.expect("Remove copy");
|
||||||
|
fs.remove_dir(&docs_path).await.expect("Remove dir");
|
||||||
|
|
||||||
|
// Prüfen, dass Root leer ist
|
||||||
|
let root_path = DavPath::new("/").unwrap();
|
||||||
|
let root_stream = fs.read_dir(&root_path, ReadDirMeta::None).await.expect("Read root");
|
||||||
|
let root_entries: Vec<_> = root_stream.collect().await;
|
||||||
|
assert_eq!(root_entries.len(), 0);
|
||||||
|
|
||||||
|
// Finaler DB Checkpoint
|
||||||
|
db.checkpoint().expect("Final checkpoint");
|
||||||
|
drop(fs);
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
// Verifizieren, dass die Containerdatei existiert und aufgeräumt werden kann
|
||||||
|
assert!(container_path.exists());
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user