Files
sanctum/src/vfs.rs
T

1107 lines
36 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 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::carrier::CarrierFs;
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord};
/// Prüft, ob ein Dateiname zu den typischen Windows Explorer Metadaten-, Cache-
/// oder Thumbnail-Dateien gehört (z. B. Thumbs.db, desktop.ini), die standardmäßig
/// im Container blockiert und verborgen werden (Anti-Leak Shield).
pub fn is_leak_file(filename: &str) -> bool {
let lower = filename.trim().to_ascii_lowercase();
match lower.as_str() {
"thumbs.db"
| "ehthumbs.db"
| "ehthumbs_vista.db"
| "desktop.ini"
| "folder.jpg"
| "albumartsmall.jpg"
| "autorun.inf"
| ".ds_store" => true,
_ => {
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
{
true
} else {
false
}
}
}
}
// ---------------------------------------------------------------------------
// 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)>,
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<Zeroizing<[u8; 32]>>,
format_version: u32,
last_activity: Arc<AtomicU64>,
) -> 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,
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.
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, self.format_version).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,
self.format_version,
)
.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> {
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() {
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<'_, ()> {
self.touch();
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> {
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()?;
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]>>,
#[allow(dead_code)]
carrier_dek: Option<Arc<Zeroizing<[u8; 32]>>>,
carrier_node_id: Option<i64>,
carrier_fs: Option<CarrierFs>,
format_version: u32,
anti_leak: bool,
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_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 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let dek_arc = Arc::new(dek);
let carrier_dek_arc = carrier_dek.map(Arc::new);
let carrier_fs = if vault_id == 1 {
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
match CarrierFs::load(
db.clone(),
c_nid,
c_dek.clone(),
dek_arc.clone(),
format_version,
anti_leak,
) {
Ok(cfs) => Some(cfs),
Err(e) => {
warn!("CarrierFs konnte nicht initialisiert werden: {e}");
None
}
}
} else {
None
}
} else {
None
};
Self {
db,
dek: dek_arc,
carrier_dek: carrier_dek_arc,
carrier_node_id,
carrier_fs,
format_version,
anti_leak,
vault_id,
last_activity: Arc::new(AtomicU64::new(now)),
}
}
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 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> {
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> {
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> {
self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
}
}
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.anti_leak && is_leak_file(file_name) {
if options.create
|| options.create_new
|| options.write
|| options.append
|| options.truncate
{
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(),
);
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 {
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 children = self.list_children(node.id)?;
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = children
.into_iter()
.filter(|child| !self.anti_leak || !is_leak_file(&child.name))
.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);
if path_str != "/" && !path_str.is_empty() {
self.touch();
}
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.anti_leak && is_leak_file(dir_name) {
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);
}
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.anti_leak && is_leak_file(to_name) {
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);
}
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, ()> {
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)?;
if node.is_dir {
return Err(FsError::NotImplemented);
}
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) {
return Err(FsError::Forbidden);
}
let to_parent = self
.resolve_path(to_parent_path)?
.ok_or(FsError::NotFound)?;
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,
)
.map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) =
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
.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)))
})
}
}
#[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() {
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"));
// 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"));
}
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: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek("testpwd", &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);
}
}