1915 lines
65 KiB
Rust
1915 lines
65 KiB
Rust
use std::fmt::Debug;
|
|
use std::io::SeekFrom;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use anyhow::{Context, Result};
|
|
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 std::path::Path;
|
|
use tracing::{debug, error, warn};
|
|
use zeroize::{Zeroize, Zeroizing};
|
|
|
|
use crate::carrier::CarrierFs;
|
|
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
|
use crate::storage::{Database, NodeRecord};
|
|
|
|
/// Exakte Namen von Explorer-, OS- und Desktop-Metadaten (V-04).
|
|
pub const LEAK_EXACT_NAMES: &[&str] = &[
|
|
"thumbs.db",
|
|
"ehthumbs.db",
|
|
"ehthumbs_vista.db",
|
|
"desktop.ini",
|
|
"folder.jpg",
|
|
"albumartsmall.jpg",
|
|
"autorun.inf",
|
|
".ds_store",
|
|
".directory",
|
|
".fseventsd",
|
|
".spotlight-v100",
|
|
];
|
|
|
|
/// Präfixe bekannter temporärer Metadaten und Lock-Dateien (V-04).
|
|
pub const LEAK_PREFIXES: &[&str] = &[
|
|
"~$", // MS Office temporäre Lock-Dateien (z. B. ~$Document.docx)
|
|
"._", // macOS AppleDouble Metadaten-Dateien (z. B. ._Document.pdf)
|
|
];
|
|
|
|
/// Suffixe und Dateiendungen temporärer Caches und unvollständiger Downloads (V-04).
|
|
pub const LEAK_SUFFIXES: &[&str] = &[
|
|
".tmp",
|
|
".temp",
|
|
".crdownload", // Google Chrome temporärer Download
|
|
".part", // Mozilla Firefox unvollständiger Download
|
|
".partial",
|
|
"~", // Linux/UNIX Editor-Backups (Vim, Emacs, Gedit)
|
|
];
|
|
|
|
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer-, OS- oder Anwendungs-
|
|
/// Metadaten-, Cache- oder Lock-Dateien gehört, die standardmäßig im Container
|
|
/// blockiert und verborgen werden (Anti-Leak Shield, V-04).
|
|
pub fn is_leak_file(filename: &str) -> bool {
|
|
is_leak_file_with_custom(filename, &[])
|
|
}
|
|
|
|
/// Prüft, ob ein Dateiname gemäß der Standardregeln oder benutzerdefinierten Regeln (V-04)
|
|
/// als Leak-Datei blockiert werden soll.
|
|
pub fn is_leak_file_with_custom(filename: &str, custom_rules: &[String]) -> bool {
|
|
let trimmed = filename.trim();
|
|
if trimmed.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
// 1. NTFS Alternate Data Streams (ADS) wie "file.txt:Zone.Identifier"
|
|
if trimmed.contains(':') {
|
|
return true;
|
|
}
|
|
|
|
let lower = trimmed.to_ascii_lowercase();
|
|
|
|
// 2. Exakte Namen
|
|
for &exact in LEAK_EXACT_NAMES {
|
|
if lower == exact {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 3. Präfixe
|
|
for &prefix in LEAK_PREFIXES {
|
|
if lower.starts_with(prefix) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 4. Suffixe
|
|
for &suffix in LEAK_SUFFIXES {
|
|
if lower.ends_with(suffix) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 5. Spezielle Muster
|
|
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) {
|
|
return true;
|
|
}
|
|
if lower.starts_with(".trash") {
|
|
return true;
|
|
}
|
|
|
|
// 6. Benutzerdefinierte Regeln
|
|
for custom in custom_rules {
|
|
let pat = custom.trim().to_ascii_lowercase();
|
|
if pat.is_empty() || pat.starts_with('#') {
|
|
continue;
|
|
}
|
|
if pat.starts_with('*') && pat.ends_with('*') && pat.len() > 2 {
|
|
let sub = &pat[1..pat.len() - 1];
|
|
if lower.contains(sub) {
|
|
return true;
|
|
}
|
|
} else if pat.starts_with('*') {
|
|
if lower.ends_with(&pat[1..]) {
|
|
return true;
|
|
}
|
|
} else if pat.ends_with('*') {
|
|
if lower.starts_with(&pat[..pat.len() - 1]) {
|
|
return true;
|
|
}
|
|
} else if lower == pat {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Lädt eine benutzerdefinierte Liste von Anti-Leak Filterregeln aus einer Textdatei (V-04).
|
|
pub fn load_anti_leak_list<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
|
|
let p = path.as_ref();
|
|
let content = std::fs::read_to_string(p)
|
|
.with_context(|| format!("Konnte Anti-Leak-Listendatei '{}' nicht lesen", p.display()))?;
|
|
let mut rules = Vec::new();
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if !trimmed.is_empty() && !trimmed.starts_with('#') {
|
|
rules.push(trimmed.to_string());
|
|
}
|
|
}
|
|
Ok(rules)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>) })
|
|
}
|
|
}
|
|
|
|
/// RAII-Guard für im physischen RAM verriegelte Schlüssel (VirtualLock / mlock).
|
|
/// Entriegelt den Speicherbereich via VirtualUnlock / munlock erst beim Drop der letzten verbleibenden Referenz (V-03).
|
|
#[derive(Debug)]
|
|
pub struct MemoryLockGuard(Zeroizing<[u8; 32]>);
|
|
|
|
impl MemoryLockGuard {
|
|
pub fn new(key: Zeroizing<[u8; 32]>) -> Self {
|
|
crate::windows::lock_memory(key.as_ptr(), 32);
|
|
Self(key)
|
|
}
|
|
|
|
pub fn key(&self) -> &Zeroizing<[u8; 32]> {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::ops::Deref for MemoryLockGuard {
|
|
type Target = Zeroizing<[u8; 32]>;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl AsRef<[u8; 32]> for MemoryLockGuard {
|
|
fn as_ref(&self) -> &[u8; 32] {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for MemoryLockGuard {
|
|
fn drop(&mut self) {
|
|
crate::windows::unlock_memory(self.0.as_ptr(), 32);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Datei-Handle mit Streaming & Chunk-Pufferung
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub struct SanctumFile {
|
|
node_id: i64,
|
|
file_size: u64,
|
|
cursor: u64,
|
|
db: Database,
|
|
dek: Arc<MemoryLockGuard>,
|
|
meta: SanctumMetaData,
|
|
// (chunk_index, decrypted_payload, is_dirty)
|
|
cached_chunk: Option<(u32, Vec<u8>, bool)>,
|
|
format_version: u32,
|
|
last_activity: Arc<AtomicU64>,
|
|
}
|
|
|
|
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)
|
|
.field("format_version", &self.format_version)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl SanctumFile {
|
|
pub fn new(
|
|
node: NodeRecord,
|
|
db: Database,
|
|
dek: Arc<MemoryLockGuard>,
|
|
format_version: u32,
|
|
last_activity: Arc<AtomicU64>,
|
|
append: bool,
|
|
) -> 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),
|
|
};
|
|
|
|
let cursor = if append { node.size } else { 0 };
|
|
|
|
Self {
|
|
node_id: node.id,
|
|
file_size: node.size,
|
|
cursor,
|
|
db,
|
|
dek,
|
|
meta,
|
|
cached_chunk: None,
|
|
format_version,
|
|
last_activity,
|
|
}
|
|
}
|
|
|
|
fn touch(&self) {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
self.last_activity.store(now, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
|
|
/// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01).
|
|
/// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03).
|
|
fn flush_cached_chunk_and_size(&mut self) -> Result<(), FsError> {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
|
|
if let Some((idx, ref data, true)) = self.cached_chunk {
|
|
let gen = self
|
|
.db
|
|
.next_chunk_generation(self.node_id, idx)
|
|
.unwrap_or(0);
|
|
let (ciphertext, nonce, tag) =
|
|
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version, gen)
|
|
.map_err(|e| {
|
|
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
|
|
FsError::GeneralFailure
|
|
})?;
|
|
|
|
if let Err(e) = self.db.write_chunk_and_update_size(
|
|
self.node_id,
|
|
idx,
|
|
gen,
|
|
&nonce,
|
|
&tag,
|
|
&ciphertext,
|
|
self.file_size,
|
|
now,
|
|
) {
|
|
error!("DB-Fehler beim atomaren Chunk- und Size-Write #{idx}: {e}");
|
|
// CHAOS-03: Bei I/O- oder Disk-Full-Fehlern den Cache sauber invalidieren,
|
|
// um Folgefehler und Panic-/Warnungsschleifen beim Drop zu unterbinden!
|
|
self.cached_chunk = None;
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
|
|
if let Some((_, _, ref mut dirty)) = self.cached_chunk {
|
|
*dirty = false;
|
|
}
|
|
self.meta.size = self.file_size;
|
|
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
|
} else if self.meta.size != self.file_size {
|
|
// Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat
|
|
if let Err(e) = self
|
|
.db
|
|
.update_node_size_and_time(self.node_id, self.file_size, now)
|
|
{
|
|
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
self.meta.size = self.file_size;
|
|
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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_and_size()?;
|
|
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
|
data.zeroize();
|
|
}
|
|
|
|
let expected_chunks = if self.file_size == 0 {
|
|
0
|
|
} else {
|
|
self.file_size.div_ceil(CHUNK_SIZE as u64) as u32
|
|
};
|
|
|
|
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,
|
|
self.format_version,
|
|
record.generation,
|
|
)
|
|
.map_err(|e| {
|
|
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
|
|
FsError::GeneralFailure
|
|
})?,
|
|
None => {
|
|
if chunk_index < expected_chunks {
|
|
error!(
|
|
"Integritätsfehler: Fehlender Chunk #{} bei Knoten id={} (erwartete Chunks: {})",
|
|
chunk_index, self.node_id, expected_chunks
|
|
);
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
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_and_size() {
|
|
error!(
|
|
"SanctumFile::drop: Fehler beim automatischen Flush von Knoten {}: {:?}",
|
|
self.node_id, e
|
|
);
|
|
}
|
|
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
|
data.zeroize();
|
|
}
|
|
}
|
|
}
|
|
|
|
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> {
|
|
self.touch();
|
|
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() {
|
|
if self.cursor < self.file_size {
|
|
error!(
|
|
"Integritätsfehler: Unerwartetes Chunk-Ende bei Offset {} in Chunk #{} (Dateigröße: {}, Cursor: {})",
|
|
offset_in_chunk, chunk_idx, self.file_size, self.cursor
|
|
);
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
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 {
|
|
if self.cursor < self.file_size {
|
|
error!(
|
|
"Integritätsfehler: Vorzeitiges Chunk-Ende (verfügbar: {}, benötigt: {}, Dateigröße: {}, Cursor: {})",
|
|
available, to_read, self.file_size, self.cursor
|
|
);
|
|
return Err(FsError::GeneralFailure);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(result.freeze())
|
|
})
|
|
}
|
|
|
|
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
|
|
self.touch();
|
|
Box::pin(async move {
|
|
// V-06: Sparse Writes — Lücke zwischen bisherigem Dateiende und Cursor mit Nullen füllen
|
|
if self.cursor > self.file_size {
|
|
let target = self.cursor;
|
|
while self.file_size < target {
|
|
let chunk_idx = (self.file_size / CHUNK_SIZE as u64) as u32;
|
|
let offset_in_chunk = (self.file_size % CHUNK_SIZE as u64) as usize;
|
|
let space_in_chunk = CHUNK_SIZE - offset_in_chunk;
|
|
let to_pad = ((target - self.file_size) as usize).min(space_in_chunk);
|
|
|
|
let chunk_data = self.ensure_chunk_loaded(chunk_idx)?;
|
|
if chunk_data.len() < offset_in_chunk + to_pad {
|
|
chunk_data.resize(offset_in_chunk + to_pad, 0);
|
|
}
|
|
|
|
let mut is_full = false;
|
|
if let Some((_, ref d, ref mut dirty)) = self.cached_chunk {
|
|
*dirty = true;
|
|
is_full = d.len() >= CHUNK_SIZE;
|
|
}
|
|
|
|
self.file_size += to_pad as u64;
|
|
|
|
if is_full {
|
|
self.flush_cached_chunk_and_size()?;
|
|
}
|
|
}
|
|
}
|
|
|
|
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_and_size()?;
|
|
}
|
|
|
|
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> {
|
|
self.touch();
|
|
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<'_, ()> {
|
|
self.touch();
|
|
Box::pin(async move {
|
|
self.flush_cached_chunk_and_size()?;
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DavFileSystem Implementierung
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
pub struct SanctumFs {
|
|
db: Database,
|
|
dek: Arc<MemoryLockGuard>,
|
|
#[allow(dead_code)]
|
|
carrier_dek: Option<Arc<MemoryLockGuard>>,
|
|
carrier_node_id: Option<i64>,
|
|
carrier_fs: Option<CarrierFs>,
|
|
format_version: u32,
|
|
anti_leak: bool,
|
|
custom_leak_rules: Arc<Vec<String>>,
|
|
leak_counter: Arc<AtomicU64>,
|
|
vault_id: u32,
|
|
last_activity: Arc<AtomicU64>,
|
|
}
|
|
|
|
impl SanctumFs {
|
|
pub fn new(db: Database, dek: Zeroizing<[u8; 32]>, format_version: u32) -> Self {
|
|
Self::with_vault(db, dek, format_version, true, 0)
|
|
}
|
|
|
|
pub fn with_options(
|
|
db: Database,
|
|
dek: Zeroizing<[u8; 32]>,
|
|
format_version: u32,
|
|
anti_leak: bool,
|
|
) -> Self {
|
|
Self::with_vault(db, dek, format_version, anti_leak, 0)
|
|
}
|
|
|
|
pub fn with_options_and_leak_rules(
|
|
db: Database,
|
|
dek: Zeroizing<[u8; 32]>,
|
|
format_version: u32,
|
|
anti_leak: bool,
|
|
custom_leak_rules: Vec<String>,
|
|
) -> Self {
|
|
Self::with_carrier_and_leak_rules(
|
|
db,
|
|
dek,
|
|
None,
|
|
None,
|
|
format_version,
|
|
anti_leak,
|
|
custom_leak_rules,
|
|
0,
|
|
)
|
|
}
|
|
|
|
pub fn with_vault(
|
|
db: Database,
|
|
dek: Zeroizing<[u8; 32]>,
|
|
format_version: u32,
|
|
anti_leak: bool,
|
|
vault_id: u32,
|
|
) -> Self {
|
|
Self::with_carrier(db, dek, None, None, format_version, anti_leak, vault_id)
|
|
}
|
|
|
|
pub fn with_carrier(
|
|
db: Database,
|
|
dek: Zeroizing<[u8; 32]>,
|
|
carrier_dek: Option<Zeroizing<[u8; 32]>>,
|
|
carrier_node_id: Option<i64>,
|
|
format_version: u32,
|
|
anti_leak: bool,
|
|
vault_id: u32,
|
|
) -> Self {
|
|
Self::with_carrier_and_leak_rules(
|
|
db,
|
|
dek,
|
|
carrier_dek,
|
|
carrier_node_id,
|
|
format_version,
|
|
anti_leak,
|
|
Vec::new(),
|
|
vault_id,
|
|
)
|
|
}
|
|
|
|
pub fn with_carrier_and_leak_rules(
|
|
db: Database,
|
|
dek: Zeroizing<[u8; 32]>,
|
|
carrier_dek: Option<Zeroizing<[u8; 32]>>,
|
|
carrier_node_id: Option<i64>,
|
|
format_version: u32,
|
|
anti_leak: bool,
|
|
custom_leak_rules: Vec<String>,
|
|
vault_id: u32,
|
|
) -> Self {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
let dek_guard = Arc::new(MemoryLockGuard::new(dek));
|
|
let carrier_dek_guard = carrier_dek.map(|k| Arc::new(MemoryLockGuard::new(k)));
|
|
|
|
let db = db.with_session(vault_id, (*dek_guard.key()).clone());
|
|
|
|
// Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist,
|
|
// um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
|
|
let carrier_node_id = carrier_node_id.or_else(|| {
|
|
if vault_id == 0 {
|
|
db.find_carrier_node_id().ok().flatten()
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let carrier_fs = if vault_id == 1 {
|
|
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_guard, carrier_node_id) {
|
|
match CarrierFs::load_with_leak_rules(
|
|
db.clone(),
|
|
c_nid,
|
|
Arc::new((*c_dek.key()).clone()),
|
|
Arc::new((*dek_guard.key()).clone()),
|
|
format_version,
|
|
anti_leak,
|
|
custom_leak_rules.clone(),
|
|
) {
|
|
Ok(cfs) => Some(cfs),
|
|
Err(e) => {
|
|
warn!("CarrierFs konnte nicht initialisiert werden: {e}");
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Self {
|
|
db,
|
|
dek: dek_guard,
|
|
carrier_dek: carrier_dek_guard,
|
|
carrier_node_id,
|
|
carrier_fs,
|
|
format_version,
|
|
anti_leak,
|
|
custom_leak_rules: Arc::new(custom_leak_rules),
|
|
leak_counter: Arc::new(AtomicU64::new(0)),
|
|
vault_id,
|
|
last_activity: Arc::new(AtomicU64::new(now)),
|
|
}
|
|
}
|
|
|
|
pub fn dek_strong_count(&self) -> usize {
|
|
Arc::strong_count(&self.dek)
|
|
}
|
|
|
|
pub fn vault_id(&self) -> u32 {
|
|
self.vault_id
|
|
}
|
|
|
|
pub fn last_activity(&self) -> Arc<AtomicU64> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
cfs.last_activity()
|
|
} else {
|
|
self.last_activity.clone()
|
|
}
|
|
}
|
|
|
|
pub fn is_anti_leak_enabled(&self) -> bool {
|
|
self.anti_leak
|
|
}
|
|
|
|
pub fn leak_counter(&self) -> Arc<AtomicU64> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
cfs.leak_counter()
|
|
} else {
|
|
self.leak_counter.clone()
|
|
}
|
|
}
|
|
|
|
pub fn sync_carrier_manifest(&self) -> Result<()> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
cfs.sync_manifest()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn carrier_manifest_usage(&self) -> Option<(usize, usize, f64)> {
|
|
self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage())
|
|
}
|
|
|
|
pub fn leak_count(&self) -> u64 {
|
|
self.leak_counter().load(Ordering::Relaxed)
|
|
}
|
|
|
|
pub fn is_leak(&self, name: &str) -> bool {
|
|
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
|
|
}
|
|
|
|
pub fn touch(&self) {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
cfs.touch();
|
|
} else {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
self.last_activity.store(now, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
fn resolve_path(&self, path: &str) -> Result<Option<NodeRecord>, FsError> {
|
|
validate_path_safety(path)?;
|
|
self.db
|
|
.resolve_path_in_vault(path, self.vault_id, &self.dek)
|
|
.map_err(|_| FsError::GeneralFailure)
|
|
}
|
|
|
|
fn list_children(&self, parent_id: i64) -> Result<Vec<NodeRecord>, FsError> {
|
|
self.db
|
|
.list_children_in_vault(parent_id, self.vault_id, &self.dek)
|
|
.map_err(|_| FsError::GeneralFailure)
|
|
}
|
|
|
|
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
|
|
validate_path_safety(name)?;
|
|
crate::pathutil::validate_node_name(name).map_err(|_| FsError::Forbidden)?;
|
|
self.db
|
|
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
|
|
.map_err(|e| {
|
|
error!("Fehler beim Erstellen des Knotens '{}': {e}", name);
|
|
FsError::GeneralFailure
|
|
})
|
|
}
|
|
|
|
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
|
|
validate_path_safety(new_name)?;
|
|
crate::pathutil::validate_node_name(new_name).map_err(|_| FsError::Forbidden)?;
|
|
self.db
|
|
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
|
|
.map_err(|_| FsError::GeneralFailure)
|
|
}
|
|
}
|
|
|
|
/// Validiert, dass ein Pfad oder Dateiname keine Null-Bytes oder unzulässige Steuerzeichen enthält (CHAOS-02).
|
|
pub fn validate_path_safety(path: &str) -> Result<(), FsError> {
|
|
if path.contains('\0') {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
for c in path.chars() {
|
|
if (c as u32) < 0x20 {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl DavFileSystem for SanctumFs {
|
|
fn open<'a>(
|
|
&'a self,
|
|
path: &'a DavPath,
|
|
options: OpenOptions,
|
|
) -> FsFuture<'a, Box<dyn DavFile>> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.open(path, options);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
let path_str = Self::path_to_str(path);
|
|
let (parent_path, file_name) = self.split_parent_and_name(&path_str);
|
|
|
|
// Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten
|
|
if self.is_leak(file_name) {
|
|
if options.create
|
|
|| options.create_new
|
|
|| options.write
|
|
|| options.append
|
|
|| options.truncate
|
|
{
|
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
|
debug!(
|
|
"Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'",
|
|
file_name
|
|
);
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
}
|
|
|
|
self.touch();
|
|
debug!(
|
|
"VFS open aufgerufen: path='{}', options={:?}",
|
|
path_str, options
|
|
);
|
|
|
|
let existing_node = self.resolve_path(&path_str)?;
|
|
|
|
let node = match existing_node {
|
|
Some(n) => {
|
|
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
|
|
if self.carrier_node_id == Some(n.id)
|
|
&& (options.write || options.truncate || options.append)
|
|
{
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
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 = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
|
|
|
|
if !parent.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
self.create_node(parent.id, file_name, false)?
|
|
} else {
|
|
return Err(FsError::NotFound);
|
|
}
|
|
}
|
|
};
|
|
|
|
let file = SanctumFile::new(
|
|
node,
|
|
self.db.clone(),
|
|
self.dek.clone(),
|
|
self.format_version,
|
|
self.last_activity.clone(),
|
|
options.append,
|
|
);
|
|
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>>> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.read_dir(path, meta);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
let path_str = Self::path_to_str(path);
|
|
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
|
|
|
if !node.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let children = self.list_children(node.id)?;
|
|
|
|
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
|
|
.into_iter()
|
|
.filter(|child| {
|
|
if self.is_leak(&child.name) {
|
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
.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>> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.metadata(path);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
let path_str = Self::path_to_str(path);
|
|
let node = self.resolve_path(&path_str)?.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 symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.symlink_metadata(path);
|
|
}
|
|
self.metadata(path)
|
|
}
|
|
|
|
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.create_dir(path);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
self.touch();
|
|
let path_str = Self::path_to_str(path);
|
|
let (parent_path, dir_name) = self.split_parent_and_name(&path_str);
|
|
|
|
if self.is_leak(dir_name) {
|
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
if self.resolve_path(&path_str)?.is_some() {
|
|
return Err(FsError::Exists);
|
|
}
|
|
|
|
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
|
|
|
|
if !parent.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
self.create_node(parent.id, dir_name, true)?;
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, ()> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.remove_dir(path);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
self.touch();
|
|
let path_str = Self::path_to_str(path);
|
|
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
|
|
|
if !node.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
let root_id = Database::get_root_node_id_for_vault(self.vault_id);
|
|
if node.id == root_id {
|
|
// Root-Verzeichnis darf nicht gelöscht werden
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
// Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält!
|
|
if let Some(carrier_id) = self.carrier_node_id {
|
|
if self
|
|
.db
|
|
.is_descendant_of(carrier_id, node.id)
|
|
.map_err(|_| FsError::GeneralFailure)?
|
|
{
|
|
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, ()> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.remove_file(path);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
self.touch();
|
|
let path_str = Self::path_to_str(path);
|
|
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
|
|
|
if node.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
// Schutz der Trägerdatei im Decoy Vault: Löschen verboten!
|
|
if self.carrier_node_id == Some(node.id) {
|
|
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, ()> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.rename(from, to);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
self.touch();
|
|
let from_str = Self::path_to_str(from);
|
|
let to_str = Self::path_to_str(to);
|
|
|
|
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
|
|
|
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
|
|
if self.carrier_node_id == Some(node.id) {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
|
|
|
if self.is_leak(to_name) {
|
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let to_parent = self
|
|
.resolve_path(to_parent_path)?
|
|
.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.resolve_path(&to_str)? {
|
|
if dest.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Rename verboten!
|
|
if self.carrier_node_id == Some(dest.id) {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
self.db
|
|
.delete_node(dest.id)
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
}
|
|
|
|
self.rename_node(node.id, to_parent.id, to_name)?;
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.copy(from, to);
|
|
}
|
|
|
|
Box::pin(async move {
|
|
self.touch();
|
|
let from_str = Self::path_to_str(from);
|
|
let to_str = Self::path_to_str(to);
|
|
|
|
if from_str == to_str {
|
|
return Ok(());
|
|
}
|
|
|
|
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
|
|
|
if node.is_dir {
|
|
return Err(FsError::NotImplemented);
|
|
}
|
|
|
|
// R-02: Schutz der Trägerdatei: Kopieren der Trägerdatei (Quelle) ist strikt verboten!
|
|
if self.carrier_node_id == Some(node.id) {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
|
|
|
|
if self.is_leak(to_name) {
|
|
self.leak_counter.fetch_add(1, Ordering::Relaxed);
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
let to_parent = self
|
|
.resolve_path(to_parent_path)?
|
|
.ok_or(FsError::NotFound)?;
|
|
|
|
if !to_parent.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
|
|
// RFC-4918 (V-07): Falls Zieldatei bereits existiert:
|
|
// - Wenn Verzeichnis: 403 Forbidden
|
|
// - Wenn Datei: bestehende Datei vor dem Kopieren löschen & shredden
|
|
if let Some(dest) = self.resolve_path(&to_str)? {
|
|
if dest.is_dir {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten!
|
|
if self.carrier_node_id == Some(dest.id) {
|
|
return Err(FsError::Forbidden);
|
|
}
|
|
self.db
|
|
.delete_node(dest.id)
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
}
|
|
|
|
let dest_node = self.create_node(to_parent.id, to_name, false)?;
|
|
|
|
// 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,
|
|
self.format_version,
|
|
record.generation,
|
|
)
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
let gen = self
|
|
.db
|
|
.next_chunk_generation(dest_node.id, idx)
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
let (new_ct, new_nonce, new_tag) = encrypt_chunk(
|
|
&self.dek,
|
|
dest_node.id,
|
|
idx,
|
|
&plaintext,
|
|
self.format_version,
|
|
gen,
|
|
)
|
|
.map_err(|_| FsError::GeneralFailure)?;
|
|
|
|
self.db
|
|
.write_chunk(dest_node.id, idx, gen, &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>)> {
|
|
if let Some(ref cfs) = self.carrier_fs {
|
|
return cfs.get_quota();
|
|
}
|
|
|
|
Box::pin(async move {
|
|
self.touch();
|
|
// Z-04: Echte Containergröße (physisch auf Disk) und freier Host-Speicher
|
|
let container_size = self.db.get_container_file_size().unwrap_or(0);
|
|
let used_bytes = if container_size > 0 {
|
|
container_size
|
|
} else {
|
|
self.db.get_total_used_size().unwrap_or(0)
|
|
};
|
|
|
|
let free_host_space = self
|
|
.db
|
|
.container_path()
|
|
.and_then(|p| crate::windows::get_available_disk_space(&p))
|
|
.unwrap_or(1024 * 1024 * 1024 * 1024); // Fallback: 1 TB
|
|
|
|
let total_capacity = used_bytes.saturating_add(free_host_space);
|
|
Ok((used_bytes, Some(total_capacity)))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::crypto::{
|
|
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
|
|
};
|
|
use dav_server::fs::OpenOptions;
|
|
use futures_util::StreamExt;
|
|
|
|
#[test]
|
|
fn test_is_leak_file() {
|
|
// Exakte Namen
|
|
assert!(is_leak_file("Thumbs.db"));
|
|
assert!(is_leak_file("thumbs.db"));
|
|
assert!(is_leak_file("THUMBS.DB"));
|
|
assert!(is_leak_file("ehthumbs.db"));
|
|
assert!(is_leak_file("ehthumbs_vista.db"));
|
|
assert!(is_leak_file("desktop.ini"));
|
|
assert!(is_leak_file("Desktop.ini"));
|
|
assert!(is_leak_file("Folder.jpg"));
|
|
assert!(is_leak_file("albumartsmall.jpg"));
|
|
assert!(is_leak_file("AlbumArt_{12345}_Large.jpg"));
|
|
assert!(is_leak_file("AlbumArt_{12345}_Small.jpg"));
|
|
assert!(is_leak_file("autorun.inf"));
|
|
assert!(is_leak_file(".ds_store"));
|
|
assert!(is_leak_file(".DS_Store"));
|
|
assert!(is_leak_file(".directory"));
|
|
assert!(is_leak_file(".fseventsd"));
|
|
assert!(is_leak_file(".spotlight-v100"));
|
|
|
|
// Präfixe (V-04)
|
|
assert!(is_leak_file("~$MyDocument.docx"));
|
|
assert!(is_leak_file("._Document.pdf"));
|
|
|
|
// Suffixe (V-04)
|
|
assert!(is_leak_file("temp_file.tmp"));
|
|
assert!(is_leak_file("cache.temp"));
|
|
assert!(is_leak_file("video.crdownload"));
|
|
assert!(is_leak_file("archive.tar.gz.part"));
|
|
assert!(is_leak_file("bigfile.partial"));
|
|
assert!(is_leak_file("notes.txt~"));
|
|
|
|
// NTFS Alternate Data Streams (ADS, V-04)
|
|
assert!(is_leak_file("document.pdf:Zone.Identifier"));
|
|
assert!(is_leak_file("file.exe:$DATA"));
|
|
|
|
// Wildcards (.trash*, V-04)
|
|
assert!(is_leak_file(".trash"));
|
|
assert!(is_leak_file(".Trash-1000"));
|
|
assert!(is_leak_file(".trashes"));
|
|
|
|
// Harmlos:
|
|
assert!(!is_leak_file("secret.txt"));
|
|
assert!(!is_leak_file("passwords.kdbx"));
|
|
assert!(!is_leak_file("my_folder.jpg.txt"));
|
|
assert!(!is_leak_file("desktop_notes.ini.bak"));
|
|
assert!(!is_leak_file("temp_report.docx"));
|
|
assert!(!is_leak_file("part1_chapter.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_v04_custom_anti_leak_rules_and_loader() {
|
|
let custom_rules = vec![
|
|
"*.secret_log".to_string(),
|
|
"debug_*".to_string(),
|
|
"*_temp_*".to_string(),
|
|
"custom_exact.bin".to_string(),
|
|
];
|
|
|
|
assert!(is_leak_file_with_custom("audit.secret_log", &custom_rules));
|
|
assert!(is_leak_file_with_custom("debug_dump.txt", &custom_rules));
|
|
assert!(is_leak_file_with_custom(
|
|
"app_temp_cache.dat",
|
|
&custom_rules
|
|
));
|
|
assert!(is_leak_file_with_custom("custom_exact.bin", &custom_rules));
|
|
|
|
// Normale Datei wird nicht blockiert
|
|
assert!(!is_leak_file_with_custom("regular_file.txt", &custom_rules));
|
|
|
|
// Test load_anti_leak_list
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let rule_file = temp_dir.path().join("anti_leak_rules.txt");
|
|
std::fs::write(
|
|
&rule_file,
|
|
"# Kommentarzeile\n*.bak\n\n # Noch ein Kommentar\nprivate_*\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let loaded = load_anti_leak_list(&rule_file).unwrap();
|
|
assert_eq!(loaded.len(), 2);
|
|
assert_eq!(loaded[0], "*.bak");
|
|
assert_eq!(loaded[1], "private_*");
|
|
|
|
assert!(is_leak_file_with_custom("data.bak", &loaded));
|
|
assert!(is_leak_file_with_custom("private_keys.pem", &loaded));
|
|
assert!(!is_leak_file_with_custom("public_data.txt", &loaded));
|
|
}
|
|
|
|
fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) {
|
|
let temp_dir = tempfile_placeholder::TempDir::new();
|
|
let db_path = temp_dir.path().join("test_vfs.sanctum");
|
|
let db = Database::open(&db_path).unwrap();
|
|
|
|
let salt = generate_salt();
|
|
let kdf_params = KdfParams {
|
|
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
|
|
time_cost: crate::crypto::MIN_TIME_COST,
|
|
parallelism: 1,
|
|
};
|
|
let kek = derive_kek("TestMasterPassword123!", &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
|
|
.unwrap();
|
|
|
|
let fs = SanctumFs::with_options(db, dek, FORMAT_VERSION, anti_leak);
|
|
(fs, temp_dir)
|
|
}
|
|
|
|
mod tempfile_placeholder {
|
|
use std::path::{Path, PathBuf};
|
|
pub struct TempDir(PathBuf);
|
|
impl TempDir {
|
|
pub fn new() -> Self {
|
|
let p =
|
|
std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
Self(p)
|
|
}
|
|
pub fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_anti_leak_blocks_creation() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
|
|
let path = DavPath::new("/desktop.ini").unwrap();
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
|
|
// desktop.ini muss blockiert werden mit Forbidden
|
|
let res = fs.open(&path, opts).await;
|
|
assert!(matches!(res, Err(FsError::Forbidden)));
|
|
|
|
// create_dir mit Thumbs.db muss auch blockiert werden
|
|
let dir_path = DavPath::new("/Thumbs.db").unwrap();
|
|
let res_dir = fs.create_dir(&dir_path).await;
|
|
assert!(matches!(res_dir, Err(FsError::Forbidden)));
|
|
|
|
// Normale Datei muss erlaubt sein
|
|
let valid_path = DavPath::new("/notes.txt").unwrap();
|
|
let mut valid_opts = OpenOptions::default();
|
|
valid_opts.write = true;
|
|
valid_opts.create_new = true;
|
|
let res_valid = fs.open(&valid_path, valid_opts).await;
|
|
assert!(res_valid.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_anti_leak_filters_read_dir() {
|
|
let (fs_shielded, _dir) = create_test_fs(true);
|
|
|
|
// Erstelle eine normale Datei
|
|
let normal_path = DavPath::new("/legit.txt").unwrap();
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
let res = fs_shielded.open(&normal_path, opts).await;
|
|
assert!(res.is_ok());
|
|
|
|
// Erzwinge direkt in die DB eine Thumbs.db Datei
|
|
fs_shielded.db.create_node(1, "Thumbs.db", false).unwrap();
|
|
|
|
// read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen
|
|
let root_path = DavPath::new("/").unwrap();
|
|
let mut stream = fs_shielded
|
|
.read_dir(&root_path, ReadDirMeta::None)
|
|
.await
|
|
.unwrap();
|
|
let mut names = Vec::new();
|
|
while let Some(entry) = stream.next().await {
|
|
let entry = entry.unwrap();
|
|
names.push(String::from_utf8_lossy(&entry.name()).to_string());
|
|
}
|
|
|
|
assert!(names.contains(&"legit.txt".to_string()));
|
|
assert!(!names.contains(&"Thumbs.db".to_string()));
|
|
|
|
// Mit unshielded FS (anti_leak = false) muss Thumbs.db sichtbar sein
|
|
let fs_unshielded = SanctumFs::with_options(
|
|
fs_shielded.db.clone(),
|
|
zeroize::Zeroizing::new([0u8; 32]),
|
|
FORMAT_VERSION,
|
|
false,
|
|
);
|
|
let mut stream_unshielded = fs_unshielded
|
|
.read_dir(&root_path, ReadDirMeta::None)
|
|
.await
|
|
.unwrap();
|
|
let mut names_unshielded = Vec::new();
|
|
while let Some(entry) = stream_unshielded.next().await {
|
|
let entry = entry.unwrap();
|
|
names_unshielded.push(String::from_utf8_lossy(&entry.name()).to_string());
|
|
}
|
|
assert!(names_unshielded.contains(&"Thumbs.db".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_vfs_activity_tracking() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
let act_arc = fs.last_activity();
|
|
let initial_time = act_arc.load(Ordering::Relaxed);
|
|
assert!(initial_time > 0);
|
|
|
|
// Manuell zurückdatieren
|
|
act_arc.store(1000, Ordering::Relaxed);
|
|
assert_eq!(act_arc.load(Ordering::Relaxed), 1000);
|
|
|
|
// Nach einem VFS-Zugriff muss die Zeit aktualisiert sein
|
|
let path = DavPath::new("/test_activity.txt").unwrap();
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
let _ = fs.open(&path, opts).await;
|
|
|
|
let new_time = act_arc.load(Ordering::Relaxed);
|
|
assert!(new_time > 1000);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_carrier_protection_in_decoy_vault() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
// Erstelle eine Carrier-Datei "system_backup.dat"
|
|
let carrier_node = fs.db.create_node(1, "system_backup.dat", false).unwrap();
|
|
let carrier_id = carrier_node.id;
|
|
|
|
// Erstelle FS mit bekanntem carrier_node_id
|
|
let protected_fs = SanctumFs::with_carrier(
|
|
fs.db.clone(),
|
|
(*fs.dek).clone(),
|
|
None,
|
|
Some(carrier_id),
|
|
FORMAT_VERSION,
|
|
true,
|
|
0,
|
|
);
|
|
|
|
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
|
|
|
|
// 1. Lesen muss erlaubt sein
|
|
let mut read_opts = OpenOptions::default();
|
|
read_opts.read = true;
|
|
assert!(protected_fs.open(&carrier_path, read_opts).await.is_ok());
|
|
|
|
// 2. Schreiben / Truncate muss verboten sein (FsError::Forbidden)
|
|
let mut write_opts = OpenOptions::default();
|
|
write_opts.write = true;
|
|
assert!(matches!(
|
|
protected_fs.open(&carrier_path, write_opts).await,
|
|
Err(FsError::Forbidden)
|
|
));
|
|
|
|
let mut trunc_opts = OpenOptions::default();
|
|
trunc_opts.truncate = true;
|
|
assert!(matches!(
|
|
protected_fs.open(&carrier_path, trunc_opts).await,
|
|
Err(FsError::Forbidden)
|
|
));
|
|
|
|
// 3. Löschen der Carrier-Datei muss verboten sein
|
|
assert!(matches!(
|
|
protected_fs.remove_file(&carrier_path).await,
|
|
Err(FsError::Forbidden)
|
|
));
|
|
|
|
// 4. Umbenennen der Carrier-Datei muss verboten sein
|
|
let rename_target = DavPath::new("/renamed_backup.dat").unwrap();
|
|
assert!(matches!(
|
|
protected_fs.rename(&carrier_path, &rename_target).await,
|
|
Err(FsError::Forbidden)
|
|
));
|
|
|
|
// 5. Überschreiben der Carrier-Datei durch Rename einer anderen Datei muss verboten sein
|
|
let other_path = DavPath::new("/other.txt").unwrap();
|
|
let mut other_opts = OpenOptions::default();
|
|
other_opts.write = true;
|
|
other_opts.create_new = true;
|
|
protected_fs.open(&other_path, other_opts).await.unwrap();
|
|
|
|
assert!(matches!(
|
|
protected_fs.rename(&other_path, &carrier_path).await,
|
|
Err(FsError::Forbidden)
|
|
));
|
|
|
|
// 6. Überschreiben der Carrier-Datei durch Copy einer anderen Datei muss verboten sein
|
|
assert!(matches!(
|
|
protected_fs.copy(&other_path, &carrier_path).await,
|
|
Err(FsError::Forbidden)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_path_safety_rejects_null_bytes_and_control_chars() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
|
|
// 1. Null-Byte im Pfad
|
|
let null_path = DavPath::new("/bad\0file.txt");
|
|
assert!(
|
|
null_path.is_err()
|
|
|| fs
|
|
.open(&null_path.unwrap(), OpenOptions::default())
|
|
.await
|
|
.is_err()
|
|
);
|
|
assert!(validate_path_safety("/bad\0file.txt").is_err());
|
|
|
|
// 2. Steuerzeichen < 0x20
|
|
assert!(validate_path_safety("/bad\x01file.txt").is_err());
|
|
assert!(validate_path_safety("/bad\rfile.txt").is_err());
|
|
assert!(validate_path_safety("/bad\nfile.txt").is_err());
|
|
assert!(validate_path_safety("/bad\tfile.txt").is_err());
|
|
|
|
// 3. Gültiger Pfad
|
|
assert!(validate_path_safety("/normal_file_123.txt").is_ok());
|
|
assert!(validate_path_safety("/path/to/subfolder/file.pdf").is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_atomic_and_cache_invalidation() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
let path = DavPath::new("/atomic_test.bin").unwrap();
|
|
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
let mut file = fs.open(&path, opts).await.unwrap();
|
|
|
|
// 1. Schreibe 500 Bytes
|
|
let payload = Bytes::from(vec![42u8; 500]);
|
|
file.write_buf(Box::new(std::io::Cursor::new(payload)))
|
|
.await
|
|
.unwrap();
|
|
|
|
// 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren
|
|
file.flush().await.unwrap();
|
|
|
|
let node = fs.resolve_path("/atomic_test.bin").unwrap().unwrap();
|
|
assert_eq!(node.size, 500);
|
|
|
|
// Chunk in DB prüfen
|
|
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
|
|
assert!(!chunk.ciphertext.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_v02_missing_chunk_returns_error_instead_of_silent_truncation() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
let path = DavPath::new("/corrupt_test.bin").unwrap();
|
|
|
|
// 1. Datei mit 3 Chunks anlegen (3 * 1 MB = 3 MB)
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
let mut file = fs.open(&path, opts).await.unwrap();
|
|
|
|
let chunk_data = vec![0xABu8; CHUNK_SIZE];
|
|
for _ in 0..3 {
|
|
file.write_bytes(Bytes::copy_from_slice(&chunk_data))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
file.flush().await.unwrap();
|
|
drop(file);
|
|
|
|
let node = fs.resolve_path("/corrupt_test.bin").unwrap().unwrap();
|
|
assert_eq!(node.size, 3 * CHUNK_SIZE as u64);
|
|
|
|
// 2. Chunk 1 direkt per SQL aus der chunks-Tabelle löschen (stille Datenkorruption)
|
|
{
|
|
let conn = fs.db.conn_for_test();
|
|
let deleted = conn
|
|
.execute(
|
|
"DELETE FROM chunks WHERE node_id = ?1 AND chunk_index = 1",
|
|
[node.id],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(deleted, 1, "Chunk 1 muss gelöscht worden sein");
|
|
}
|
|
|
|
// 3. Datei im VFS zum Lesen öffnen
|
|
let mut read_opts = OpenOptions::default();
|
|
read_opts.read = true;
|
|
let mut read_file = fs.open(&path, read_opts).await.unwrap();
|
|
|
|
// Erstes Megabyte (Chunk 0) lesen -> muss klappen
|
|
let c0 = read_file.read_bytes(CHUNK_SIZE).await.unwrap();
|
|
assert_eq!(c0.len(), CHUNK_SIZE);
|
|
|
|
// Zweites Megabyte (fehlender Chunk 1) lesen -> V-02 MUSS FsError::GeneralFailure zurückgeben!
|
|
let c1_res = read_file.read_bytes(CHUNK_SIZE).await;
|
|
assert!(
|
|
matches!(c1_res, Err(FsError::GeneralFailure)),
|
|
"Lesen eines gelöschten/fehlenden Chunks muss mit GeneralFailure fehlschlagen, nicht stillschweigend gekürzt werden! Erhalten: {:?}",
|
|
c1_res
|
|
);
|
|
|
|
// 4. Test jenseits der Dateigröße bei leeren Dateien: kein Regress
|
|
let empty_path = DavPath::new("/empty.txt").unwrap();
|
|
let mut empty_opts = OpenOptions::default();
|
|
empty_opts.create_new = true;
|
|
empty_opts.write = true;
|
|
let mut empty_file = fs.open(&empty_path, empty_opts).await.unwrap();
|
|
empty_file.flush().await.unwrap();
|
|
drop(empty_file);
|
|
|
|
let mut read_empty_opts = OpenOptions::default();
|
|
read_empty_opts.read = true;
|
|
let mut read_empty = fs.open(&empty_path, read_empty_opts).await.unwrap();
|
|
let bytes = read_empty.read_bytes(100).await.unwrap();
|
|
assert!(bytes.is_empty(), "Leere Datei liefert 0 Bytes ohne Fehler");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_v05_sanctum_file_drop_flushes_dirty_chunk_automatically() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
let path = DavPath::new("/drop_flush_test.bin").unwrap();
|
|
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
let mut file = fs.open(&path, opts).await.unwrap();
|
|
|
|
let test_data = b"Autoflush on drop without explicit flush() call (V-05)";
|
|
file.write_bytes(Bytes::copy_from_slice(test_data))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Absichtlich KEIN file.flush().await aufrufen!
|
|
// Drop des Handles muss Daten & Größe zwingend automatisch persistieren.
|
|
drop(file);
|
|
|
|
// Prüfe, ob Datei in DB existiert und korrekte Größe hat
|
|
let node = fs.resolve_path("/drop_flush_test.bin").unwrap().unwrap();
|
|
assert_eq!(node.size, test_data.len() as u64);
|
|
|
|
// Lese Datei neu ein und verifiziere Inhalt
|
|
let mut read_opts = OpenOptions::default();
|
|
read_opts.read = true;
|
|
let mut read_file = fs.open(&path, read_opts).await.unwrap();
|
|
let read_bytes = read_file.read_bytes(test_data.len()).await.unwrap();
|
|
assert_eq!(&read_bytes[..], test_data);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_v06_sparse_write_zero_fills_gap() {
|
|
use std::io::SeekFrom;
|
|
|
|
let (fs, _dir) = create_test_fs(true);
|
|
let path = DavPath::new("/sparse_test.bin").unwrap();
|
|
|
|
let mut opts = OpenOptions::default();
|
|
opts.write = true;
|
|
opts.create_new = true;
|
|
let mut file = fs.open(&path, opts).await.unwrap();
|
|
|
|
// 1. Schreibe 5 Bytes am Anfang (Chunk 0)
|
|
file.write_bytes(Bytes::copy_from_slice(b"START"))
|
|
.await
|
|
.unwrap();
|
|
|
|
// 2. Springe weit über das Dateiende hinaus in Chunk 2 (2,5 MB)
|
|
let seek_pos = 2 * CHUNK_SIZE as u64 + 500;
|
|
file.seek(SeekFrom::Start(seek_pos)).await.unwrap();
|
|
|
|
// 3. Schreibe 3 Bytes an Position seek_pos
|
|
file.write_bytes(Bytes::copy_from_slice(b"END"))
|
|
.await
|
|
.unwrap();
|
|
file.flush().await.unwrap();
|
|
drop(file);
|
|
|
|
// 4. Verifiziere Dateigröße
|
|
let node = fs.resolve_path("/sparse_test.bin").unwrap().unwrap();
|
|
assert_eq!(node.size, seek_pos + 3);
|
|
|
|
// 5. Lese Datei vollständig ein und prüfe Nullen in der Lücke
|
|
let mut read_opts = OpenOptions::default();
|
|
read_opts.read = true;
|
|
let mut read_file = fs.open(&path, read_opts).await.unwrap();
|
|
|
|
// Anfang lesen
|
|
let start_bytes = read_file.read_bytes(5).await.unwrap();
|
|
assert_eq!(&start_bytes[..], b"START");
|
|
|
|
// Lücke in Chunk 0 und 1 muss mit Nullen gefüllt sein
|
|
let zero_gap = read_file.read_bytes(1000).await.unwrap();
|
|
assert_eq!(zero_gap.len(), 1000);
|
|
assert!(zero_gap.iter().all(|&b| b == 0));
|
|
|
|
// Springe zum Ende und lies die geschriebenen Daten
|
|
read_file.seek(SeekFrom::Start(seek_pos)).await.unwrap();
|
|
let end_bytes = read_file.read_bytes(3).await.unwrap();
|
|
assert_eq!(&end_bytes[..], b"END");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_v07_copy_overwrite_existing_file_semantics() {
|
|
let (fs, _dir) = create_test_fs(true);
|
|
let src_path = DavPath::new("/source.txt").unwrap();
|
|
let dest_path = DavPath::new("/dest.txt").unwrap();
|
|
|
|
// 1. Erstelle Quelldatei
|
|
let mut opts1 = OpenOptions::default();
|
|
opts1.write = true;
|
|
opts1.create_new = true;
|
|
let mut src_file = fs.open(&src_path, opts1).await.unwrap();
|
|
let src_data = b"Freshly copied source data (RFC-4918)";
|
|
src_file
|
|
.write_bytes(Bytes::copy_from_slice(src_data))
|
|
.await
|
|
.unwrap();
|
|
src_file.flush().await.unwrap();
|
|
drop(src_file);
|
|
|
|
// 2. Erstelle Zieldatei mit abweichendem Inhalt und Größe
|
|
let mut opts2 = OpenOptions::default();
|
|
opts2.write = true;
|
|
opts2.create_new = true;
|
|
let mut dest_file = fs.open(&dest_path, opts2).await.unwrap();
|
|
let old_dest_data = b"Old obsolete destination data that MUST be overwritten";
|
|
dest_file
|
|
.write_bytes(Bytes::copy_from_slice(old_dest_data))
|
|
.await
|
|
.unwrap();
|
|
dest_file.flush().await.unwrap();
|
|
drop(dest_file);
|
|
|
|
// 3. Kopiere Quelle auf existierendes Ziel (RFC-4918 Copy Overwrite)
|
|
fs.copy(&src_path, &dest_path)
|
|
.await
|
|
.expect("Copy with overwrite must succeed per RFC-4918");
|
|
|
|
// 4. Verifiziere Zielinhalt
|
|
let mut read_opts = OpenOptions::default();
|
|
read_opts.read = true;
|
|
let mut read_dest = fs.open(&dest_path, read_opts).await.unwrap();
|
|
let read_bytes = read_dest.read_bytes(src_data.len() + 100).await.unwrap();
|
|
assert_eq!(&read_bytes[..], src_data);
|
|
|
|
let dest_node = fs.resolve_path("/dest.txt").unwrap().unwrap();
|
|
assert_eq!(dest_node.size, src_data.len() as u64);
|
|
|
|
// 5. Test: Kopieren auf existierendes Verzeichnis muss mit Forbidden fehlschlagen
|
|
let dir_path = DavPath::new("/sub_dir").unwrap();
|
|
fs.create_dir(&dir_path).await.unwrap();
|
|
let copy_to_dir_res = fs.copy(&src_path, &dir_path).await;
|
|
assert!(matches!(copy_to_dir_res, Err(FsError::Forbidden)));
|
|
}
|
|
}
|