From d93b03322e22fe34e4a0e98ad4a6077e8b38bdfa Mon Sep 17 00:00:00 2001 From: harald Date: Wed, 21 Oct 2026 12:58:20 +0200 Subject: [PATCH] feat(mount): add native Windows Cloud Files Engine (cldapi.dll + DefineDosDeviceW) fallback without WebClient --- AGENTS.md | 2 +- CHANGELOG.md | 17 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 3 + SECURITY.md | 2 +- packages/scoop/sanctum.json | 6 +- packages/winget/HaraldPansi.Sanctum.yaml | 8 +- src/cloudfiles.rs | 1767 ++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 13 +- src/mount.rs | 251 ++- src/windows.rs | 347 ++++- 13 files changed, 2371 insertions(+), 50 deletions(-) create mode 100644 src/cloudfiles.rs diff --git a/AGENTS.md b/AGENTS.md index 24b9701..10db836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ Die gesamte Genese dieses Projekts (Audit-Behebung aller 39 Findings, Carrier V2 | [`RELEASE_PROCESS.md`](RELEASE_PROCESS.md) | Multi-Platform CI/CD Release-Architektur, Toolchain-Pins und Minisign-Signierung | | [`QUICKSTART.md`](QUICKSTART.md) | Kompakte Schritt-für-Schritt-Anleitung für Endanwender | | [`INSTALL.md`](INSTALL.md) | Installationsanweisungen (Binaries, Scoop, WinGet, Cargo) | -| [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog Versionshistorie (aktuell: v0.9.5) | +| [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog Versionshistorie (aktuell: v0.9.6) | | [`deny.toml`](deny.toml) | Strikte Konfiguration für `cargo-deny` (Advisories, Bans, Lizenzen, Quellen) | | [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml) | Automatisierte CI/CD Release-Pipeline für Windows & Linux musl | diff --git a/CHANGELOG.md b/CHANGELOG.md index 203f415..77559de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/). +## [0.9.6] - 2026-10-21 + +### Windows Cloud Files Engine (`cldapi.dll`) & Userland-Fallback ohne `WebClient` +Dieses Release macht `sanctum mount` unter Windows unabhängig vom Windows-Dienst `WebClient` (`mrxdav.sys`), sodass Container auch dann als regulärer Laufwerksbuchstabe (z. B. `S:` oder `Z:`) im Windows Explorer eingebunden werden können, wenn `WebClient` durch Gruppenrichtlinien oder die Systemadministration deaktiviert (`Disabled`) wurde – komplett im Userland und ohne Administratorrechte: +- **Native Windows Cloud Files API (`src/cloudfiles.rs`)**: + - Verbindet `SanctumFs` direkt mit der in Windows 10/11 integrierten Cloud-Filter-Engine (`cldapi.dll` / `cldflt.sys`) und weist das Staging-Verzeichnis via `DefineDosDeviceW` (`DDD_NO_BROADCAST_SYSTEM` + `SHChangeNotify`) dem gewünschten oder automatisch gewählten freien Laufwerksbuchstaben (`--drive` bzw. `Z:` abwärts) zu. + - **0-Byte Placeholders**: Beim Einbinden werden mittels `CfCreatePlaceholders` ausschließlich 0-Byte-Platzhalterdateien angelegt; es befindet sich beim Start kein entschlüsselter Dateiinhalt auf dem Host-Datenträger. + - **On-Demand Hydration (`CF_CALLBACK_TYPE_FETCH_DATA`)**: Erst beim Öffnen/Lesen einer Datei entschlüsselt Sanctum die angeforderten 1-MB-Chunks live aus `SanctumFs` (regulärer Tresor sowie versteckter `CarrierFs`-Tresor), überträgt sie via `CfExecute` an den Kernel und nullt den temporären Puffer (`Zeroizing`). + - **Bidirektionale Synchronisation, Auto-Dehydration & CSPRNG-Shredding**: + - Neue, geänderte oder gelöschte Dateien und Ordner auf dem Laufwerk werden unter Einhaltung des **Anti-Leak Shields** und des **Carrier-Schutzes** laufend in den verschlüsselten Container zurückgeschrieben. + - Nicht mehr genutzte hydrierte Dateien werden nach Inaktivität automatisch via `CfDehydratePlaceholder` wieder auf 0 Bytes dehydriert. + - Beim Aushängen (`unmount`, `Ctrl+C`, Sitzungssperre oder Idle-Timeout) werden etwaige auf Disk liegende Dateibytes mit kryptografischem Zufallsrauschen (`OsRng`) überschrieben (geshreddet) und das Staging-Verzeichnis samt Laufwerksbuchstaben restlos entfernt. +- **Dreistufiges Mount-Backend (`--backend auto|cloudfiles|webdav`) & ETW-Service-Trigger**: + - `auto` (Standard): Prüft ohne Administratorrechte den Status des Dienstes `WebClient`. Ist er lediglich gestoppt (`Manual`), weckt Sanctum ihn automatisch per Userland-ETW-Trigger (`22b6d684-fa63-4578-87c9-effcbe6643c7`) auf. Ist `WebClient` administrativ deaktiviert (`Disabled`) oder schlägt `WNetAddConnection2W` fehl, wechselt Sanctum nahtlos auf die `CloudFilesSession` auf demselben Laufwerksbuchstaben. + - `cloudfiles`: Erzwingt das direkte Einbinden über `cldapi.dll` + `DefineDosDeviceW` ohne `WebClient` (z. B. auch zur Umgehung des 4-GB-Limits von `WebClient`). + - `webdav`: Erzwingt das klassische Einbinden über `WNetAddConnection2W`. + ## [0.9.5] - 2026-09-22 ### Transcript-Version & Upgrade-MAC (F-04, F-05) diff --git a/Cargo.lock b/Cargo.lock index 1b46958..92ba6d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "sanctum" -version = "0.9.5" +version = "0.9.6" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index f05d8b2..13a982f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sanctum" -version = "0.9.5" +version = "0.9.6" edition = "2021" authors = ["Harald Pansi ", "Sanctum Engineering Team"] description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" diff --git a/README.md b/README.md index 1d31916..de13b0f 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum" # Bestimmten Laufwerksbuchstaben erzwingen: sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S +# Mount-Backend wählen (auto | cloudfiles | webdav; Standard: auto mit Cloud-Files-Fallback falls WebClient deaktiviert ist): +sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --backend cloudfiles + # Mit Inaktivitäts-Timeout (in Sekunden): sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300 diff --git a/SECURITY.md b/SECURITY.md index e90cf16..e1bee5e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ Sicherheitsupdates und Patches werden jeweils für die neueste Version von Sanct | Version | Status | Sicherheits-Support | |:---:|:---:|:---:| -| **v0.9.x** | **Aktiv (Aktuell: v0.9.5)** | **Vollständig unterstützt** | +| **v0.9.x** | **Aktiv (Aktuell: v0.9.6)** | **Vollständig unterstützt** | | <= v0.8.x | Veraltet | Nicht mehr unterstützt (Upgrade empfohlen) | --- diff --git a/packages/scoop/sanctum.json b/packages/scoop/sanctum.json index 4670c16..77b922e 100644 --- a/packages/scoop/sanctum.json +++ b/packages/scoop/sanctum.json @@ -1,12 +1,12 @@ { - "version": "0.9.5", + "version": "0.9.6", "description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV", "homepage": "https://gitea.pansi.eu/harald/sanctum", "license": "MIT", "architecture": { "64bit": { - "url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.5/sanctum-v0.9.5-windows-x86_64.zip", - "hash": "f47d43e455a73d6ed0affa7af07437b82c7f61188aade9b2a46cbb4ea43968fc", + "url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.6/sanctum-v0.9.6-windows-x86_64.zip", + "hash": "f5236b24d0c8c645364e1c5d3ca9c503518646c879163cedabe160029db9a099", "bin": "sanctum.exe" } }, diff --git a/packages/winget/HaraldPansi.Sanctum.yaml b/packages/winget/HaraldPansi.Sanctum.yaml index 858c9e3..a2fa656 100644 --- a/packages/winget/HaraldPansi.Sanctum.yaml +++ b/packages/winget/HaraldPansi.Sanctum.yaml @@ -1,6 +1,6 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json PackageIdentifier: HaraldPansi.Sanctum -PackageVersion: 0.9.5 +PackageVersion: 0.9.6 PackageName: Sanctum Publisher: Harald Pansi PublisherUrl: https://gitea.pansi.eu/harald @@ -18,7 +18,7 @@ Tags: - webdav - container - plausible-deniability -ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.9.5 +ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.9.6 Installers: - Architecture: x64 InstallerType: zip @@ -26,7 +26,7 @@ Installers: NestedInstallerFiles: - RelativeFilePath: sanctum.exe PortableCommandAlias: sanctum - InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.5/sanctum-v0.9.5-windows-x86_64.zip - InstallerSha256: f47d43e455a73d6ed0affa7af07437b82c7f61188aade9b2a46cbb4ea43968fc + InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.6/sanctum-v0.9.6-windows-x86_64.zip + InstallerSha256: f5236b24d0c8c645364e1c5d3ca9c503518646c879163cedabe160029db9a099 ManifestType: singleton ManifestVersion: 1.6.0 diff --git a/src/cloudfiles.rs b/src/cloudfiles.rs new file mode 100644 index 0000000..acc0aa5 --- /dev/null +++ b/src/cloudfiles.rs @@ -0,0 +1,1767 @@ +//! Native Windows Cloud Files API (`cldapi.dll`) + `DefineDosDeviceW` Userland-Laufwerk. +//! +//! Ermöglicht das Einbinden eines Sanctum-Containers als vollwertigen Laufwerksbuchstaben +//! (z. B. `S:` oder `Z:`) im Windows Explorer **ohne** den Windows-Dienst `WebClient` +//! (`mrxdav.sys`), **ohne** Administratorrechte und **ohne** Kernel-Treiber von Drittanbietern. +//! +//! Architektur & Sicherheitsmerkmale: +//! 1. **0-Byte Placeholders**: Beim Mounten werden im geschützten Staging-Verzeichnis +//! ausschließlich leere Cloud-Platzhalter (`CfCreatePlaceholders`) mit den Metadaten +//! des Containers angelegt. Es befinden sich beim Start 0 Bytes Klartext auf der Festplatte. +//! 2. **On-Demand Hydration (`CF_CALLBACK_TYPE_FETCH_DATA`)**: Erst wenn der Benutzer +//! eine Datei tatsächlich öffnet/liest, fordert der Windows-Kernel (`cldflt.sys`) +//! die benötigten Byte-Bereiche über den Userland-Callback an. Sanctum entschlüsselt +//! die entsprechenden 1-MB-Chunks im RAM, überträgt sie via `CfExecute` und nullt den +//! temporären Puffer sofort (`Zeroizing`). +//! 3. **Bidirektionale Synchronisation**: Neue, geänderte, umbenannte oder gelöschte Dateien +//! und Ordner auf dem Laufwerk werden unter Beachtung des Anti-Leak Shields und des +//! Carrier-Schutzes laufend in den verschlüsselten Container zurückgeschrieben. +//! 4. **Auto-Dehydration & CSPRNG-Shredding**: Geöffnete (hydrierte) Dateien werden nach +//! Inaktivität automatisch wieder auf 0 Bytes dehydriert (`CfDehydratePlaceholder`). +//! Beim Aushängen (`unmount`, `Ctrl+C`, Bildschirmsperre oder Inaktivitäts-Timeout) +//! werden sämtliche Dateien im Staging-Ordner kryptografisch mit `OsRng`-Zufallsdaten +//! überschrieben (geshreddet) und restlos entfernt. + +use std::path::Path; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; + +use anyhow::Result; + +use crate::vfs::SanctumFs; + +#[cfg(windows)] +#[allow(non_camel_case_types, dead_code)] +mod imp { + use super::*; + use std::collections::{HashMap, HashSet}; + use std::ffi::c_void; + use std::fs::{self, OpenOptions as StdOpenOptions}; + use std::io::{Read, SeekFrom, Write}; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::fs::OpenOptionsExt; + use std::path::PathBuf; + use std::sync::atomic::Ordering; + use std::sync::{Mutex, OnceLock}; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + use anyhow::{bail, Context}; + use bytes::Bytes; + use dav_server::davpath::DavPath; + use dav_server::fs::{DavFileSystem, OpenOptions as DavOpenOptions, ReadDirMeta}; + use futures_util::StreamExt; + use rand::RngCore; + use tracing::{debug, warn}; + use zeroize::Zeroizing; + + use crate::crypto::CHUNK_SIZE; + + // ----------------------------------------------------------------------- + // Win32 & Cloud Files API (cldapi.dll) FFI-Definitionen + // ----------------------------------------------------------------------- + + type HRESULT = i32; + type NTSTATUS = i32; + type HANDLE = isize; + type BOOL = i32; + type PCWSTR = *const u16; + type CF_CONNECTION_KEY = i64; + + const INVALID_HANDLE_VALUE: HANDLE = -1; + const STATUS_SUCCESS: NTSTATUS = 0; + const STATUS_UNSUCCESSFUL: NTSTATUS = -1073741823; // 0xC0000001 + + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x0000_0002; + const FILE_ATTRIBUTE_SYSTEM: u32 = 0x0000_0004; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: u32 = 0x0000_2000; + const FILE_ATTRIBUTE_PINNED: u32 = 0x0008_0000; + const FILE_ATTRIBUTE_UNPINNED: u32 = 0x0010_0000; + const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x0040_0000; + + const FILE_READ_ATTRIBUTES: u32 = 0x0080; + const FILE_WRITE_ATTRIBUTES: u32 = 0x0100; + const FILE_WRITE_DATA: u32 = 0x0002; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + const OPEN_EXISTING: u32 = 3; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + const CF_CALLBACK_TYPE_FETCH_DATA: i32 = 0; + const CF_CALLBACK_TYPE_CANCEL_FETCH_DATA: i32 = 2; + const CF_CALLBACK_TYPE_NOTIFY_FILE_CLOSE_COMPLETION: i32 = 6; + const CF_CALLBACK_TYPE_NONE: i32 = -1; + + const CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH: i32 = 4; + const CF_CONNECT_FLAG_BLOCK_SELF_IMPLICIT_HYDRATION: i32 = 8; + + const CF_REGISTER_FLAG_NONE: i32 = 0; + const CF_REGISTER_FLAG_DISABLE_ON_DEMAND_POPULATION_ON_ROOT: i32 = 2; + const CF_REGISTER_FLAG_MARK_IN_SYNC_ON_ROOT: i32 = 4; + + const CF_HYDRATION_POLICY_FULL: u16 = 2; + const CF_HYDRATION_POLICY_MODIFIER_AUTO_DEHYDRATION_ALLOWED: u16 = 4; + const CF_POPULATION_POLICY_FULL: u16 = 2; + const CF_POPULATION_POLICY_MODIFIER_NONE: u16 = 0; + const CF_INSYNC_POLICY_TRACK_FILE_LAST_WRITE_TIME: u32 = 256; + const CF_INSYNC_POLICY_PRESERVE_INSYNC_FOR_SYNC_ENGINE: u32 = 0x8000_0000; + const CF_HARDLINK_POLICY_NONE: i32 = 0; + const CF_PLACEHOLDER_MANAGEMENT_POLICY_DEFAULT: i32 = 0; + + const CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC: i32 = 2; + const CF_PLACEHOLDER_CREATE_FLAG_SUPERSEDE: i32 = 4; + const CF_CREATE_FLAG_NONE: i32 = 0; + + const CF_OPERATION_TYPE_TRANSFER_DATA: i32 = 0; + const CF_OPERATION_TRANSFER_DATA_FLAG_NONE: i32 = 0; + + const CF_CONVERT_FLAG_MARK_IN_SYNC: i32 = 1; + const CF_IN_SYNC_STATE_IN_SYNC: i32 = 1; + const CF_SET_IN_SYNC_FLAG_NONE: i32 = 0; + const CF_DEHYDRATE_FLAG_NONE: i32 = 0; + const CF_PLACEHOLDER_INFO_STANDARD: i32 = 1; + + /// Zeitspanne ohne Zugriff, nach der eine geöffnete (hydrierte) Datei automatisch wieder + /// auf 0 Bytes dehydriert wird. + const AUTO_DEHYDRATE_IDLE_SECS: u64 = 30; + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct GUID { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct FILE_BASIC_INFO { + creation_time: i64, + last_access_time: i64, + last_write_time: i64, + change_time: i64, + file_attributes: u32, + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct CF_FS_METADATA { + basic_info: FILE_BASIC_INFO, + file_size: i64, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_PLACEHOLDER_CREATE_INFO { + relative_file_name: PCWSTR, + fs_metadata: CF_FS_METADATA, + file_identity: *const c_void, + file_identity_length: u32, + flags: i32, + result: HRESULT, + create_usn: i64, + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct CF_HYDRATION_POLICY { + primary: u16, + modifier: u16, + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct CF_POPULATION_POLICY { + primary: u16, + modifier: u16, + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct CF_SYNC_POLICIES { + struct_size: u32, + hydration: CF_HYDRATION_POLICY, + population: CF_POPULATION_POLICY, + in_sync: u32, + hard_link: i32, + placeholder_management: i32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_SYNC_REGISTRATION { + struct_size: u32, + provider_name: PCWSTR, + provider_version: PCWSTR, + sync_root_identity: *const c_void, + sync_root_identity_length: u32, + file_identity: *const c_void, + file_identity_length: u32, + provider_id: GUID, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_CALLBACK_INFO { + struct_size: u32, + connection_key: CF_CONNECTION_KEY, + callback_context: *mut c_void, + volume_guid_name: PCWSTR, + volume_dos_name: PCWSTR, + volume_serial_number: u32, + sync_root_file_id: i64, + sync_root_identity: *const c_void, + sync_root_identity_length: u32, + file_id: i64, + file_size: i64, + file_identity: *const c_void, + file_identity_length: u32, + normalized_path: PCWSTR, + transfer_key: i64, + priority_hint: u8, + correlation_vector: *mut c_void, + process_info: *mut c_void, + request_key: i64, + } + + #[repr(C)] + #[derive(Clone, Copy, Default)] + struct CF_CALLBACK_FETCH_DATA_PARAMS { + flags: i32, + required_file_offset: i64, + required_length: i64, + optional_file_offset: i64, + optional_length: i64, + last_dehydration_time: i64, + last_dehydration_reason: i32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + union CF_CALLBACK_PARAMETERS_UNION { + fetch_data: CF_CALLBACK_FETCH_DATA_PARAMS, + padding: [u64; 8], + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_CALLBACK_PARAMETERS { + param_size: u32, + u: CF_CALLBACK_PARAMETERS_UNION, + } + + type CfCallbackFn = + Option; + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_CALLBACK_REGISTRATION { + callback_type: i32, + callback: CfCallbackFn, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_OPERATION_INFO { + struct_size: u32, + operation_type: i32, + connection_key: CF_CONNECTION_KEY, + transfer_key: i64, + correlation_vector: *const c_void, + sync_status: *const c_void, + request_key: i64, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_OPERATION_TRANSFER_DATA_PARAMS { + flags: i32, + completion_status: NTSTATUS, + buffer: *const c_void, + offset: i64, + length: i64, + } + + #[repr(C)] + #[derive(Clone, Copy)] + union CF_OPERATION_PARAMETERS_UNION { + transfer_data: CF_OPERATION_TRANSFER_DATA_PARAMS, + padding: [u64; 6], + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_OPERATION_PARAMETERS { + param_size: u32, + u: CF_OPERATION_PARAMETERS_UNION, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct CF_PLACEHOLDER_STANDARD_INFO { + on_disk_data_size: i64, + validated_data_size: i64, + modified_data_size: i64, + properties_size: i64, + pin_state: i32, + in_sync_state: i32, + file_id: i64, + sync_root_file_id: i64, + file_identity_length: u32, + file_identity: [u8; 4096], + } + + #[link(name = "cldapi", kind = "raw-dylib")] + extern "system" { + fn CfRegisterSyncRoot( + sync_root_path: PCWSTR, + registration: *const CF_SYNC_REGISTRATION, + policies: *const CF_SYNC_POLICIES, + register_flags: i32, + ) -> HRESULT; + + fn CfUnregisterSyncRoot(sync_root_path: PCWSTR) -> HRESULT; + + fn CfConnectSyncRoot( + sync_root_path: PCWSTR, + callback_table: *const CF_CALLBACK_REGISTRATION, + callback_context: *const c_void, + connect_flags: i32, + connection_key: *mut CF_CONNECTION_KEY, + ) -> HRESULT; + + fn CfDisconnectSyncRoot(connection_key: CF_CONNECTION_KEY) -> HRESULT; + + fn CfCreatePlaceholders( + base_directory_path: PCWSTR, + placeholder_array: *mut CF_PLACEHOLDER_CREATE_INFO, + placeholder_count: u32, + create_flags: i32, + entries_processed: *mut u32, + ) -> HRESULT; + + fn CfExecute( + op_info: *const CF_OPERATION_INFO, + op_params: *mut CF_OPERATION_PARAMETERS, + ) -> HRESULT; + + fn CfConvertToPlaceholder( + file_handle: HANDLE, + file_identity: *const c_void, + file_identity_length: u32, + convert_flags: i32, + convert_usn: *mut i64, + overlapped: *mut c_void, + ) -> HRESULT; + + fn CfSetInSyncState( + file_handle: HANDLE, + in_sync_state: i32, + in_sync_flags: i32, + in_sync_usn: *mut i64, + ) -> HRESULT; + + fn CfDehydratePlaceholder( + file_handle: HANDLE, + starting_offset: i64, + length: i64, + dehydrate_flags: i32, + overlapped: *mut c_void, + ) -> HRESULT; + + fn CfGetPlaceholderInfo( + file_handle: HANDLE, + info_class: i32, + info_buffer: *mut c_void, + info_buffer_length: u32, + returned_length: *mut u32, + ) -> HRESULT; + } + + #[link(name = "kernel32")] + extern "system" { + fn CreateFileW( + lp_file_name: PCWSTR, + dw_desired_access: u32, + dw_share_mode: u32, + lp_security_attributes: *const c_void, + dw_creation_disposition: u32, + dw_flags_and_attributes: u32, + h_template_file: HANDLE, + ) -> HANDLE; + + fn CloseHandle(h_object: HANDLE) -> BOOL; + fn SetFileAttributesW(lp_file_name: PCWSTR, dw_file_attributes: u32) -> BOOL; + fn GetFileAttributesW(lp_file_name: PCWSTR) -> u32; + } + + fn to_wide(s: &str) -> Vec { + std::ffi::OsStr::new(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + fn path_to_wide(p: &Path) -> Vec { + p.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + /// Konvertiert `SystemTime` in das Windows-FILETIME-Format (100-ns Intervalle seit 01.01.1601). + fn system_time_to_filetime(t: SystemTime) -> i64 { + let unix_secs = t + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + ((unix_secs.saturating_add(11_644_473_600)) as i64).saturating_mul(10_000_000) + } + + // ----------------------------------------------------------------------- + // Globaler Callback-Registry-State für aktive Cloud-Files-Verbindungen + // ----------------------------------------------------------------------- + + #[derive(Clone, Debug)] + struct TrackedFileState { + size: u64, + modified_secs: u64, + is_dir: bool, + hydrated_at: Option, + } + + struct ActiveSyncContext { + fs: SanctumFs, + staging_root: PathBuf, + tracked: Mutex>, + } + + fn active_contexts() -> &'static Mutex>> { + static MAP: OnceLock>>> = + OnceLock::new(); + MAP.get_or_init(|| Mutex::new(HashMap::new())) + } + + unsafe fn decode_file_identity(info: *const CF_CALLBACK_INFO) -> Option { + let info_ref = &*info; + if !info_ref.file_identity.is_null() && info_ref.file_identity_length > 0 { + let slice = std::slice::from_raw_parts( + info_ref.file_identity as *const u8, + info_ref.file_identity_length as usize, + ); + if let Ok(s) = std::str::from_utf8(slice) { + return Some(s.to_string()); + } + } + None + } + + unsafe fn transfer_chunk_to_kernel( + info: *const CF_CALLBACK_INFO, + status: NTSTATUS, + buffer: *const u8, + offset: i64, + length: i64, + ) -> HRESULT { + let info_ref = &*info; + let op_info = CF_OPERATION_INFO { + struct_size: std::mem::size_of::() as u32, + operation_type: CF_OPERATION_TYPE_TRANSFER_DATA, + connection_key: info_ref.connection_key, + transfer_key: info_ref.transfer_key, + correlation_vector: info_ref.correlation_vector, + sync_status: std::ptr::null(), + request_key: info_ref.request_key, + }; + + // WICHTIG: cldapi.dll verlangt exakt offsetof(CF_OPERATION_PARAMETERS, u) + sizeof(TransferData) = 8 + 32 = 40 Bytes! + let param_size = (std::mem::offset_of!(CF_OPERATION_PARAMETERS, u) + + std::mem::size_of::()) + as u32; + + let mut op_params = CF_OPERATION_PARAMETERS { + param_size, + u: CF_OPERATION_PARAMETERS_UNION { + transfer_data: CF_OPERATION_TRANSFER_DATA_PARAMS { + flags: CF_OPERATION_TRANSFER_DATA_FLAG_NONE, + completion_status: status, + buffer: buffer as *const c_void, + offset, + length, + }, + }, + }; + + CfExecute(&op_info, &mut op_params) + } + + fn block_on_future(fut: F) -> F::Output { + let mut fut = std::pin::pin!(fut); + let waker = std::task::Waker::noop(); + let mut cx = std::task::Context::from_waker(waker); + loop { + match fut.as_mut().poll(&mut cx) { + std::task::Poll::Ready(val) => return val, + std::task::Poll::Pending => std::thread::yield_now(), + } + } + } + + /// Kernel-Callback (`CF_CALLBACK_TYPE_FETCH_DATA`): Wird von `cldflt.sys` aufgerufen, + /// sobald ein Prozess auf dem gemounteten Laufwerk Daten einer Platzhalterdatei liest. + unsafe extern "system" fn on_fetch_data( + callback_info: *const CF_CALLBACK_INFO, + callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + if callback_info.is_null() || callback_parameters.is_null() { + return; + } + let info_ref = &*callback_info; + let fetch = (*callback_parameters).u.fetch_data; + let req_offset = fetch.required_file_offset; + let req_len = fetch.required_length; + + let ctx = { + let guard = match active_contexts().lock() { + Ok(g) => g, + Err(_) => return, + }; + guard.get(&info_ref.connection_key).cloned() + }; + + let Some(ctx) = ctx else { + let _ = transfer_chunk_to_kernel( + callback_info, + STATUS_UNSUCCESSFUL, + std::ptr::null(), + req_offset, + req_len, + ); + return; + }; + + let Some(vault_rel_path) = decode_file_identity(callback_info) else { + let _ = transfer_chunk_to_kernel( + callback_info, + STATUS_UNSUCCESSFUL, + std::ptr::null(), + req_offset, + req_len, + ); + return; + }; + + ctx.fs.touch(); + + // Markiere Datei als hydriert für spätere Auto-Dehydration + if let Ok(mut tracked) = ctx.tracked.lock() { + if let Some(entry) = tracked.get_mut(&vault_rel_path) { + entry.hydrated_at = Some(Instant::now()); + } + } + + // Lese angeforderten Bereich in 1-MB-Blöcken (4-KB-aligned für cldflt.sys) aus SanctumFs + let fs = ctx.fs.clone(); + let rel_clone = vault_rel_path.clone(); + let read_res: Result>> = block_on_future(async move { + let dav_path = DavPath::new(&rel_clone) + .map_err(|e| anyhow::anyhow!("Ungültiger DavPath '{}': {:?}", rel_clone, e))?; + let mut open_opts = DavOpenOptions::default(); + open_opts.read = true; + let mut file = fs + .open(&dav_path, open_opts) + .await + .map_err(|e| anyhow::anyhow!("Konnte '{}' nicht öffnen: {:?}", rel_clone, e))?; + + file.seek(SeekFrom::Start(req_offset as u64)) + .await + .map_err(|e| anyhow::anyhow!("Seek fehlgeschlagen: {:?}", e))?; + + let mut out = Zeroizing::new(Vec::with_capacity(req_len as usize)); + let mut remaining = req_len as usize; + while remaining > 0 { + let to_read = remaining.min(CHUNK_SIZE); + let chunk_bytes = file + .read_bytes(to_read) + .await + .map_err(|e| anyhow::anyhow!("Read fehlgeschlagen: {:?}", e))?; + if chunk_bytes.is_empty() { + break; + } + out.extend_from_slice(&chunk_bytes); + remaining -= chunk_bytes.len(); + } + // Falls kürzer als angefordert (EOF), mit Nullen auf req_len auffüllen + if out.len() < req_len as usize { + out.resize(req_len as usize, 0); + } + Ok(out) + }); + + match read_res { + Ok(plain_buf) => { + // Übertrage in 1-MB-Schritten (1 MB ist Vielfaches von 4096 Bytes) + let mut sent = 0usize; + let total = plain_buf.len(); + while sent < total { + let step = (total - sent).min(CHUNK_SIZE); + let hr = transfer_chunk_to_kernel( + callback_info, + STATUS_SUCCESS, + plain_buf[sent..sent + step].as_ptr(), + req_offset + sent as i64, + step as i64, + ); + if hr < 0 { + warn!( + "CfExecute TRANSFER_DATA fehlgeschlagen für '{}' (Offset {}, Len {}): HRESULT 0x{:08X}", + vault_rel_path, + req_offset + sent as i64, + step, + hr as u32 + ); + break; + } + sent += step; + } + } + Err(e) => { + warn!( + "CloudFiles Hydration-Fehler für '{}': {}", + vault_rel_path, e + ); + let _ = transfer_chunk_to_kernel( + callback_info, + STATUS_UNSUCCESSFUL, + std::ptr::null(), + req_offset, + req_len, + ); + } + } + } + + unsafe extern "system" fn on_cancel_fetch_data( + _callback_info: *const CF_CALLBACK_INFO, + _callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + } + + unsafe extern "system" fn on_close_completion( + callback_info: *const CF_CALLBACK_INFO, + _callback_parameters: *const CF_CALLBACK_PARAMETERS, + ) { + if callback_info.is_null() { + return; + } + let info_ref = &*callback_info; + let ctx = { + let guard = match active_contexts().lock() { + Ok(g) => g, + Err(_) => return, + }; + guard.get(&info_ref.connection_key).cloned() + }; + if let Some(ctx) = ctx { + ctx.fs.touch(); + if let Some(vault_rel_path) = unsafe { decode_file_identity(callback_info) } { + if let Ok(mut tracked) = ctx.tracked.lock() { + if let Some(entry) = tracked.get_mut(&vault_rel_path) { + if entry.hydrated_at.is_some() { + entry.hydrated_at = Some(Instant::now()); + } + } + } + } + } + } + + // ----------------------------------------------------------------------- + // CloudFilesSession Guard & Synchronisations-Engine + // ----------------------------------------------------------------------- + + pub struct CloudFilesSession { + drive_letter: char, + staging_root: PathBuf, + connection_key: CF_CONNECTION_KEY, + ctx: Arc, + sync_stop_tx: tokio::sync::watch::Sender, + sync_handle: Option>, + cleaned_up: bool, + } + + impl CloudFilesSession { + /// Startet die Cloud-Files-Engine auf dem angegebenen Laufwerksbuchstaben (`drive_letter`). + pub async fn mount(drive_letter: char, fs: SanctumFs) -> Result { + let base_local = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + let mut rnd = [0u8; 6]; + rand::rngs::OsRng.fill_bytes(&mut rnd); + let staging_root = base_local.join("Sanctum").join("mounts").join(format!( + "drive_{}_{}_{}", + drive_letter.to_ascii_uppercase(), + std::process::id(), + hex::encode(rnd) + )); + + fs::create_dir_all(&staging_root).with_context(|| { + format!( + "Konnte Cloud-Files Staging-Verzeichnis '{}' nicht erstellen", + staging_root.display() + ) + })?; + + // Verzeichnis vor Windows Search Indexer verbergen + let wide_root = path_to_wide(&staging_root); + unsafe { + SetFileAttributesW( + wide_root.as_ptr(), + FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, + ); + } + + // 1. Sync-Root bei cldapi.dll registrieren & 2. Callbacks verbinden + let connection_key = { + let provider_name = to_wide("Sanctum Encrypted Vault"); + let provider_version = to_wide(env!("CARGO_PKG_VERSION")); + let sync_identity = b"SANCTUM_CF_SYNC_ROOT_V1"; + + let registration = CF_SYNC_REGISTRATION { + struct_size: std::mem::size_of::() as u32, + provider_name: provider_name.as_ptr(), + provider_version: provider_version.as_ptr(), + sync_root_identity: sync_identity.as_ptr() as *const c_void, + sync_root_identity_length: sync_identity.len() as u32, + file_identity: std::ptr::null(), + file_identity_length: 0, + provider_id: GUID { + data1: 0x5341_4E43, + data2: 0x5455, + data3: 0x4D56, + data4: [ + 0x30, + 0x39, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + drive_letter.to_ascii_uppercase() as u8, + ], + }, + }; + + let policies = CF_SYNC_POLICIES { + struct_size: std::mem::size_of::() as u32, + hydration: CF_HYDRATION_POLICY { + primary: CF_HYDRATION_POLICY_FULL, + modifier: CF_HYDRATION_POLICY_MODIFIER_AUTO_DEHYDRATION_ALLOWED, + }, + population: CF_POPULATION_POLICY { + primary: CF_POPULATION_POLICY_FULL, + modifier: CF_POPULATION_POLICY_MODIFIER_NONE, + }, + in_sync: CF_INSYNC_POLICY_TRACK_FILE_LAST_WRITE_TIME + | CF_INSYNC_POLICY_PRESERVE_INSYNC_FOR_SYNC_ENGINE, + hard_link: CF_HARDLINK_POLICY_NONE, + placeholder_management: CF_PLACEHOLDER_MANAGEMENT_POLICY_DEFAULT, + }; + + let hr_reg = unsafe { + CfRegisterSyncRoot( + wide_root.as_ptr(), + ®istration, + &policies, + CF_REGISTER_FLAG_DISABLE_ON_DEMAND_POPULATION_ON_ROOT + | CF_REGISTER_FLAG_MARK_IN_SYNC_ON_ROOT, + ) + }; + if hr_reg < 0 { + let _ = fs::remove_dir_all(&staging_root); + bail!( + "CfRegisterSyncRoot fehlgeschlagen (HRESULT 0x{:08X}). Ist der Windows-Treiber 'cldflt' aktiv?", + hr_reg as u32 + ); + } + + let callbacks = [ + CF_CALLBACK_REGISTRATION { + callback_type: CF_CALLBACK_TYPE_FETCH_DATA, + callback: Some(on_fetch_data), + }, + CF_CALLBACK_REGISTRATION { + callback_type: CF_CALLBACK_TYPE_CANCEL_FETCH_DATA, + callback: Some(on_cancel_fetch_data), + }, + CF_CALLBACK_REGISTRATION { + callback_type: CF_CALLBACK_TYPE_NOTIFY_FILE_CLOSE_COMPLETION, + callback: Some(on_close_completion), + }, + CF_CALLBACK_REGISTRATION { + callback_type: CF_CALLBACK_TYPE_NONE, + callback: None, + }, + ]; + + let mut conn_key: CF_CONNECTION_KEY = 0; + let hr_conn = unsafe { + CfConnectSyncRoot( + wide_root.as_ptr(), + callbacks.as_ptr(), + std::ptr::null(), + CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, + &mut conn_key, + ) + }; + if hr_conn < 0 { + unsafe { + CfUnregisterSyncRoot(wide_root.as_ptr()); + } + let _ = fs::remove_dir_all(&staging_root); + bail!( + "CfConnectSyncRoot fehlgeschlagen (HRESULT 0x{:08X})", + hr_conn as u32 + ); + } + conn_key + }; + + let ctx = Arc::new(ActiveSyncContext { + fs: fs.clone(), + staging_root: staging_root.clone(), + tracked: Mutex::new(HashMap::new()), + }); + + if let Ok(mut map) = active_contexts().lock() { + map.insert(connection_key, ctx.clone()); + } + + // 3. Initiale Verzeichnisstruktur & 0-Byte-Platzhalter aufbauen + if let Err(e) = populate_initial_placeholders(&ctx, "").await { + if let Ok(mut map) = active_contexts().lock() { + map.remove(&connection_key); + } + unsafe { + CfDisconnectSyncRoot(connection_key); + CfUnregisterSyncRoot(wide_root.as_ptr()); + } + let _ = fs::remove_dir_all(&staging_root); + return Err(e); + } + + // 4. Laufwerksbuchstaben via DefineDosDeviceW im Userland zuweisen + if let Err(e) = crate::windows::mount_dos_device(drive_letter, &staging_root) { + if let Ok(mut map) = active_contexts().lock() { + map.remove(&connection_key); + } + unsafe { + CfDisconnectSyncRoot(connection_key); + CfUnregisterSyncRoot(wide_root.as_ptr()); + } + shred_and_remove_directory(&staging_root); + return Err(e); + } + + // 5. Hintergrund-Synchronisation & Auto-Dehydration starten + let (sync_stop_tx, mut sync_stop_rx) = tokio::sync::watch::channel(false); + let ctx_bg = ctx.clone(); + let sync_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(1200)); + loop { + tokio::select! { + _ = interval.tick() => { + if let Err(e) = sync_staging_changes(&ctx_bg, false).await { + debug!("CloudFiles Hintergrund-Sync Hinweis: {e}"); + } + dehydrate_idle_files(&ctx_bg); + } + _ = sync_stop_rx.changed() => { + break; + } + } + } + }); + + Ok(Self { + drive_letter, + staging_root, + connection_key, + ctx, + sync_stop_tx, + sync_handle: Some(sync_handle), + cleaned_up: false, + }) + } + + /// Führt einen finalen Sync aller geänderten Dateien durch, trennt das Laufwerk + /// und überschreibt alle temporären Dateien im Staging-Ordner kryptografisch (CSPRNG). + pub async fn shutdown(&mut self) -> Result<()> { + if self.cleaned_up { + return Ok(()); + } + self.cleaned_up = true; + + // 1. Hintergrund-Task stoppen + let _ = self.sync_stop_tx.send(true); + if let Some(handle) = self.sync_handle.take() { + let _ = handle.await; + } + + // 2. Finale Synchronisation aller ausstehenden Dateiänderungen in den Tresor + if let Err(e) = sync_staging_changes(&self.ctx, true).await { + warn!( + "Warnung beim finalen Synchronisieren des Cloud-Files-Laufwerks: {}", + e + ); + } + + // 3. Laufwerksbuchstaben entfernen + let _ = crate::windows::unmount_dos_device(self.drive_letter, Some(&self.staging_root)); + + // 4. Cloud-Files-Verbindung trennen und Sync-Root deregistrieren + if let Ok(mut map) = active_contexts().lock() { + map.remove(&self.connection_key); + } + let wide_root = path_to_wide(&self.staging_root); + unsafe { + CfDisconnectSyncRoot(self.connection_key); + CfUnregisterSyncRoot(wide_root.as_ptr()); + } + + // 5. Alle Dateien im Staging-Ordner kryptografisch schreddern (CSPRNG) und Ordner löschen + shred_and_remove_directory(&self.staging_root); + + Ok(()) + } + } + + impl Drop for CloudFilesSession { + fn drop(&mut self) { + if !self.cleaned_up { + self.cleaned_up = true; + let _ = self.sync_stop_tx.send(true); + if let Some(handle) = self.sync_handle.take() { + handle.abort(); + } + let _ = + crate::windows::unmount_dos_device(self.drive_letter, Some(&self.staging_root)); + if let Ok(mut map) = active_contexts().lock() { + map.remove(&self.connection_key); + } + let wide_root = path_to_wide(&self.staging_root); + unsafe { + CfDisconnectSyncRoot(self.connection_key); + CfUnregisterSyncRoot(wide_root.as_ptr()); + } + shred_and_remove_directory(&self.staging_root); + } + } + } + + /// Erstellt rekursiv alle Verzeichnisse und 0-Byte-Platzhalterdateien aus `SanctumFs`. + async fn populate_initial_placeholders( + ctx: &Arc, + rel_dir: &str, + ) -> Result<()> { + let mut queue = vec![rel_dir.to_string()]; + + while let Some(current_dir) = queue.pop() { + let dav_path_str = if current_dir.is_empty() { + "/".to_string() + } else { + format!("/{}", current_dir) + }; + let dav_path = DavPath::new(&dav_path_str) + .map_err(|e| anyhow::anyhow!("Ungültiger Pfad '{}': {:?}", dav_path_str, e))?; + + let mut stream = ctx + .fs + .read_dir(&dav_path, ReadDirMeta::Data) + .await + .map_err(|e| { + anyhow::anyhow!("Konnte Verzeichnis '{}' nicht lesen: {:?}", dav_path_str, e) + })?; + + let target_host_dir = if current_dir.is_empty() { + ctx.staging_root.clone() + } else { + ctx.staging_root.join(current_dir.replace('/', "\\")) + }; + fs::create_dir_all(&target_host_dir)?; + + while let Some(item_res) = stream.next().await { + let entry = match item_res { + Ok(e) => e, + Err(_) => continue, + }; + let name = String::from_utf8_lossy(&entry.name()).to_string(); + if name.is_empty() || name == "." || name == ".." { + continue; + } + let Ok(meta) = entry.metadata().await else { + continue; + }; + + let child_rel = if current_dir.is_empty() { + name.clone() + } else { + format!("{}/{}", current_dir, name) + }; + let vault_identity = format!("/{}", child_rel); + let modified_time = meta.modified().unwrap_or(UNIX_EPOCH); + let created_time = meta.created().unwrap_or(modified_time); + let modified_secs = modified_time + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if meta.is_dir() { + let child_dir_path = target_host_dir.join(&name); + fs::create_dir_all(&child_dir_path)?; + if let Ok(mut tracked) = ctx.tracked.lock() { + tracked.insert( + vault_identity, + TrackedFileState { + size: 0, + modified_secs, + is_dir: true, + hydrated_at: None, + }, + ); + } + queue.push(child_rel); + } else { + let file_size = meta.len(); + create_single_placeholder( + &target_host_dir, + &name, + &vault_identity, + file_size, + created_time, + modified_time, + )?; + if let Ok(mut tracked) = ctx.tracked.lock() { + tracked.insert( + vault_identity, + TrackedFileState { + size: file_size, + modified_secs, + is_dir: false, + hydrated_at: None, + }, + ); + } + } + } + } + + Ok(()) + } + + fn create_single_placeholder( + parent_dir: &Path, + file_name: &str, + vault_identity: &str, + file_size: u64, + created: SystemTime, + modified: SystemTime, + ) -> Result<()> { + let wide_parent = path_to_wide(parent_dir); + let wide_name = to_wide(file_name); + let identity_bytes = vault_identity.as_bytes(); + let ft_created = system_time_to_filetime(created); + let ft_modified = system_time_to_filetime(modified); + + let mut create_info = CF_PLACEHOLDER_CREATE_INFO { + relative_file_name: wide_name.as_ptr(), + fs_metadata: CF_FS_METADATA { + basic_info: FILE_BASIC_INFO { + creation_time: ft_created, + last_access_time: ft_modified, + last_write_time: ft_modified, + change_time: ft_modified, + file_attributes: FILE_ATTRIBUTE_NORMAL | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, + }, + file_size: file_size as i64, + }, + file_identity: identity_bytes.as_ptr() as *const c_void, + file_identity_length: identity_bytes.len() as u32, + flags: CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC | CF_PLACEHOLDER_CREATE_FLAG_SUPERSEDE, + result: 0, + create_usn: 0, + }; + + let mut processed: u32 = 0; + let hr = unsafe { + CfCreatePlaceholders( + wide_parent.as_ptr(), + &mut create_info, + 1, + CF_CREATE_FLAG_NONE, + &mut processed, + ) + }; + if hr < 0 || create_info.result < 0 { + bail!( + "CfCreatePlaceholders für '{}' fehlgeschlagen (hr=0x{:08X}, res=0x{:08X})", + file_name, + hr as u32, + create_info.result as u32 + ); + } + Ok(()) + } + + /// Prüft ohne Auslösen einer Hydration, ob eine Datei ein unveränderter Cloud-Platzhalter ist + /// und ob Daten auf Disk liegen. + fn query_placeholder_status(path: &Path) -> Option<(bool, bool, i64)> { + let wide = path_to_wide(path); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + }; + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return None; + } + + let mut info: CF_PLACEHOLDER_STANDARD_INFO = unsafe { std::mem::zeroed() }; + let mut returned: u32 = 0; + let hr = unsafe { + CfGetPlaceholderInfo( + handle, + CF_PLACEHOLDER_INFO_STANDARD, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + &mut returned, + ) + }; + unsafe { + CloseHandle(handle); + } + + if hr < 0 { + // Kein Cloud-Placeholder (z. B. neu vom Benutzer angelegte Datei) + Some((false, false, 0)) + } else { + let in_sync = + info.in_sync_state == CF_IN_SYNC_STATE_IN_SYNC && info.modified_data_size == 0; + Some((true, in_sync, info.on_disk_data_size)) + } + } + + /// Synchronisiert Änderungen im Staging-Verzeichnis (neue/modifizierte/gelöschte Dateien & Ordner) + /// zurück in `SanctumFs`. + async fn sync_staging_changes(ctx: &Arc, is_final: bool) -> Result<()> { + let mut seen_paths: HashSet = HashSet::new(); + let mut dir_stack = vec![(ctx.staging_root.clone(), String::new())]; + + while let Some((host_dir, rel_prefix)) = dir_stack.pop() { + let Ok(read_dir) = fs::read_dir(&host_dir) else { + continue; + }; + + for entry_res in read_dir { + let Ok(entry) = entry_res else { continue }; + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.is_empty() || file_name == "." || file_name == ".." { + continue; + } + + // Anti-Leak Shield: Blockierte Dateien nicht in den Container übernehmen + if ctx.fs.is_leak(&file_name) { + ctx.fs.leak_counter().fetch_add(1, Ordering::Relaxed); + let _ = fs::remove_file(entry.path()); + continue; + } + + let child_rel = if rel_prefix.is_empty() { + file_name.clone() + } else { + format!("{}/{}", rel_prefix, file_name) + }; + let vault_identity = format!("/{}", child_rel); + let host_path = entry.path(); + + // Wichtig: Metadaten über Reparse-Point-sichere Abfrage lesen, ohne Hydration auszulösen! + let Ok(meta) = fs::symlink_metadata(&host_path) else { + continue; + }; + + seen_paths.insert(vault_identity.clone()); + + if meta.is_dir() { + let already_tracked = ctx + .tracked + .lock() + .ok() + .and_then(|t| t.get(&vault_identity).cloned()) + .is_some(); + + if !already_tracked { + if let Ok(dav_path) = DavPath::new(&vault_identity) { + let _ = ctx.fs.create_dir(&dav_path).await; + ctx.fs.touch(); + if let Ok(mut tracked) = ctx.tracked.lock() { + tracked.insert( + vault_identity.clone(), + TrackedFileState { + size: 0, + modified_secs: 0, + is_dir: true, + hydrated_at: None, + }, + ); + } + } + } + dir_stack.push((host_path, child_rel)); + } else { + let file_size = meta.len(); + let modified_secs = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let tracked_state = ctx + .tracked + .lock() + .ok() + .and_then(|t| t.get(&vault_identity).cloned()); + + let placeholder_status = query_placeholder_status(&host_path); + let (is_placeholder, is_in_sync, _on_disk) = + placeholder_status.unwrap_or((false, false, 0)); + + let needs_upload = match tracked_state { + None => true, + Some(ref st) => { + !is_placeholder + || !is_in_sync + || st.size != file_size + || st.modified_secs != modified_secs + } + }; + + if needs_upload { + if upload_host_file_to_vault(ctx, &host_path, &vault_identity, is_final) + .await + .is_ok() + { + if let Ok(mut tracked) = ctx.tracked.lock() { + tracked.insert( + vault_identity, + TrackedFileState { + size: file_size, + modified_secs, + is_dir: false, + hydrated_at: Some(Instant::now()), + }, + ); + } + } + } + } + } + } + + // Gelöschte Dateien/Ordner erkennen und im Tresor entfernen + let tracked_keys: Vec<(String, bool)> = ctx + .tracked + .lock() + .map(|t| t.iter().map(|(k, v)| (k.clone(), v.is_dir)).collect()) + .unwrap_or_default(); + + // Sortiere tiefste Pfade zuerst, damit Dateien vor ihren Eltern-Ordnern gelöscht werden + let mut missing: Vec<(String, bool)> = tracked_keys + .into_iter() + .filter(|(k, _)| !seen_paths.contains(k)) + .collect(); + missing.sort_by_key(|a| std::cmp::Reverse(a.0.len())); + + for (missing_path, is_dir) in missing { + if let Ok(dav_path) = DavPath::new(&missing_path) { + let res = if is_dir { + ctx.fs.remove_dir(&dav_path).await + } else { + ctx.fs.remove_file(&dav_path).await + }; + if res.is_ok() { + ctx.fs.touch(); + if let Ok(mut tracked) = ctx.tracked.lock() { + tracked.remove(&missing_path); + } + } else if !is_final { + // Falls geschützt (z. B. Carrier-Datei im Decoy-Tresor): Platzhalter wiederherstellen! + if let Ok(meta) = ctx.fs.metadata(&dav_path).await { + let rel = missing_path.trim_start_matches('/'); + let host_target = ctx.staging_root.join(rel.replace('/', "\\")); + if let (Some(parent), Some(name)) = + (host_target.parent(), host_target.file_name()) + { + let _ = create_single_placeholder( + parent, + &name.to_string_lossy(), + &missing_path, + meta.len(), + meta.created().unwrap_or(UNIX_EPOCH), + meta.modified().unwrap_or(UNIX_EPOCH), + ); + } + } + } + } + } + + let _ = ctx.fs.sync_carrier_manifest(); + Ok(()) + } + + async fn upload_host_file_to_vault( + ctx: &Arc, + host_path: &Path, + vault_identity: &str, + is_final: bool, + ) -> Result<()> { + // Öffne Datei zum Lesen; falls sie noch exklusiv von einer App beschrieben wird, beim nächsten Tick erneut versuchen + let mut reader = match StdOpenOptions::new() + .read(true) + .share_mode(if is_final { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + } else { + FILE_SHARE_READ + }) + .open(host_path) + { + Ok(f) => f, + Err(e) => { + bail!("Datei '{}' noch gesperrt: {}", host_path.display(), e); + } + }; + + let dav_path = DavPath::new(vault_identity) + .map_err(|e| anyhow::anyhow!("Ungültiger DavPath '{}': {:?}", vault_identity, e))?; + + let mut open_opts = DavOpenOptions::default(); + open_opts.write = true; + open_opts.create = true; + open_opts.truncate = true; + + let mut dav_file = ctx.fs.open(&dav_path, open_opts).await.map_err(|e| { + anyhow::anyhow!( + "Konnte '{}' im Tresor nicht öffnen: {:?}", + vault_identity, + e + ) + })?; + + let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE]); + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + dav_file + .write_buf(Box::new(Bytes::copy_from_slice(&buf[..n]))) + .await + .map_err(|e| anyhow::anyhow!("Schreibfehler in '{}': {:?}", vault_identity, e))?; + } + dav_file + .flush() + .await + .map_err(|e| anyhow::anyhow!("Flush-Fehler in '{}': {:?}", vault_identity, e))?; + drop(reader); + + ctx.fs.touch(); + + // Markiere die Datei wieder als synchronisierten Cloud-Placeholder + let wide = path_to_wide(host_path); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | FILE_WRITE_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + }; + if handle != INVALID_HANDLE_VALUE && handle != 0 { + let id_bytes = vault_identity.as_bytes(); + let mut usn: i64 = 0; + unsafe { + let _ = CfConvertToPlaceholder( + handle, + id_bytes.as_ptr() as *const c_void, + id_bytes.len() as u32, + CF_CONVERT_FLAG_MARK_IN_SYNC, + &mut usn, + std::ptr::null_mut(), + ); + let _ = CfSetInSyncState( + handle, + CF_IN_SYNC_STATE_IN_SYNC, + CF_SET_IN_SYNC_FLAG_NONE, + &mut usn, + ); + CloseHandle(handle); + } + } + + Ok(()) + } + + /// Dehydriert geöffnete (hydrierte) Platzhalterdateien automatisch nach Inaktivität, + /// sofern sie nicht vom Benutzer als "Immer auf diesem Gerät behalten" (`PINNED`) markiert wurden + /// und aktuell von keinem Prozess offengehalten werden. + fn dehydrate_idle_files(ctx: &Arc) { + let now = Instant::now(); + let candidates: Vec = ctx + .tracked + .lock() + .map(|t| { + t.iter() + .filter_map(|(path, st)| { + if let Some(hydrated_at) = st.hydrated_at { + if now.duration_since(hydrated_at).as_secs() >= AUTO_DEHYDRATE_IDLE_SECS + { + return Some(path.clone()); + } + } + None + }) + .collect() + }) + .unwrap_or_default(); + + for vault_identity in candidates { + let rel = vault_identity.trim_start_matches('/'); + let host_path = ctx.staging_root.join(rel.replace('/', "\\")); + let wide = path_to_wide(&host_path); + + let attrs = unsafe { GetFileAttributesW(wide.as_ptr()) }; + if attrs == u32::MAX || (attrs & FILE_ATTRIBUTE_PINNED) != 0 { + continue; + } + + // Öffne ohne Share-Mode (0), um sicherzustellen, dass kein Programm die Datei noch offen hält! + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | FILE_WRITE_DATA, + 0, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + }; + if handle != INVALID_HANDLE_VALUE && handle != 0 { + let hr = unsafe { + CfDehydratePlaceholder( + handle, + 0, + -1, + CF_DEHYDRATE_FLAG_NONE, + std::ptr::null_mut(), + ) + }; + unsafe { + CloseHandle(handle); + } + if hr >= 0 { + debug!( + "CloudFiles Auto-Dehydrate erfolgreich für '{}'", + vault_identity + ); + if let Ok(mut tracked) = ctx.tracked.lock() { + if let Some(st) = tracked.get_mut(&vault_identity) { + st.hydrated_at = None; + } + } + } + } + } + } + + /// Überschreibt alle im Staging-Verzeichnis verbliebenen Dateiinhalte kryptografisch mit + /// `OsRng`-Zufallsrauschen (CSPRNG-Shredding) und löscht anschließend das Verzeichnis. + pub fn shred_and_remove_directory(root: &Path) { + if !root.exists() { + return; + } + let mut stack = vec![root.to_path_buf()]; + let mut dirs_to_remove = Vec::new(); + + while let Some(dir) = stack.pop() { + dirs_to_remove.push(dir.clone()); + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry_res in entries { + let Ok(entry) = entry_res else { continue }; + let path = entry.path(); + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + if meta.is_dir() { + stack.push(path); + } else { + shred_single_host_file(&path); + } + } + } + + for dir in dirs_to_remove.into_iter().rev() { + let wide = path_to_wide(&dir); + unsafe { + SetFileAttributesW(wide.as_ptr(), FILE_ATTRIBUTE_NORMAL); + } + let _ = fs::remove_dir_all(&dir); + } + } + + fn shred_single_host_file(path: &Path) { + let wide = path_to_wide(path); + unsafe { + SetFileAttributesW(wide.as_ptr(), FILE_ATTRIBUTE_NORMAL); + } + + // Prüfe, ob überhaupt Datenblöcke auf Disk liegen (ohne ein Re-Hydrieren auszulösen!) + let on_disk_bytes = match query_placeholder_status(path) { + Some((true, _, on_disk)) => on_disk.max(0) as u64, + _ => fs::symlink_metadata(path).map(|m| m.len()).unwrap_or(0), + }; + + if on_disk_bytes > 0 { + if let Ok(mut file) = StdOpenOptions::new().write(true).open(path) { + let mut noise = Zeroizing::new(vec![0u8; 64 * 1024]); + let mut remaining = on_disk_bytes; + while remaining > 0 { + let step = (remaining as usize).min(noise.len()); + rand::rngs::OsRng.fill_bytes(&mut noise[..step]); + if file.write_all(&noise[..step]).is_err() { + break; + } + remaining -= step as u64; + } + let _ = file.sync_all(); + let _ = file.set_len(0); + } + } + + let _ = fs::remove_file(path); + } + + pub fn is_cloudfiles_available() -> bool { + let sys_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); + Path::new(&sys_root) + .join("System32") + .join("cldapi.dll") + .exists() + } +} + +#[cfg(windows)] +pub use imp::{is_cloudfiles_available, shred_and_remove_directory, CloudFilesSession}; + +#[cfg(not(windows))] +pub struct CloudFilesSession; + +#[cfg(not(windows))] +impl CloudFilesSession { + pub async fn mount(_drive_letter: char, _fs: SanctumFs) -> Result { + anyhow::bail!("Windows Cloud Files API (cldapi.dll) ist nur unter Windows verfügbar.") + } + + pub async fn shutdown(&mut self) -> Result<()> { + Ok(()) + } +} + +#[cfg(not(windows))] +pub fn is_cloudfiles_available() -> bool { + false +} + +#[cfg(not(windows))] +pub fn shred_and_remove_directory(_root: &Path) {} + +#[allow(dead_code)] +fn _keep_atomic_import(_a: &Arc) {} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use crate::storage::Database; + use bytes::Bytes; + use dav_server::davpath::DavPath; + use dav_server::fs::{DavFileSystem, OpenOptions as DavOpenOptions}; + use std::io::SeekFrom; + use std::time::Duration; + use zeroize::Zeroizing; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cloudfiles_userland_mount_read_write_shred() { + if !is_cloudfiles_available() { + return; + } + + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("cf_test.sanctum"); + let db = Database::open(&db_path).expect("db open"); + db.init_schema( + &[1u8; 16], + &crate::crypto::KdfParams::default(), + &[2u8; 32], + &[3u8; 12], + &[4u8; 16], + ) + .expect("init schema"); + + let dek = Zeroizing::new([0x42u8; 32]); + let fs = SanctumFs::new(db, dek, 3); + + // 1. Lege im verschlüsselten Container eine Textdatei, eine Multi-Chunk-Datei (1.5 MB) und einen Unterordner an + let secret_content = b"Sanctum CloudFiles Userland On-Demand Hydration Test 1234567890!"; + let dav_secret = DavPath::new("/geheim.txt").unwrap(); + let mut opts = DavOpenOptions::default(); + opts.write = true; + opts.create = true; + opts.truncate = true; + let mut f = fs.open(&dav_secret, opts).await.expect("open geheim.txt"); + f.write_bytes(Bytes::from_static(secret_content)) + .await + .expect("write geheim.txt"); + f.flush().await.expect("flush geheim.txt"); + drop(f); + + let large_payload: Vec = (0..(1536 * 1024)).map(|i| (i % 251) as u8).collect(); + let dav_large = DavPath::new("/gross.bin").unwrap(); + let mut opts_l = DavOpenOptions::default(); + opts_l.write = true; + opts_l.create = true; + opts_l.truncate = true; + let mut fl = fs.open(&dav_large, opts_l).await.expect("open gross.bin"); + fl.write_bytes(Bytes::from(large_payload.clone())) + .await + .expect("write gross.bin"); + fl.flush().await.expect("flush gross.bin"); + drop(fl); + + let dav_dir = DavPath::new("/ordner").unwrap(); + fs.create_dir(&dav_dir).await.expect("create_dir /ordner"); + + // 2. Freien Laufwerksbuchstaben suchen und via CloudFilesSession mounten + let drive = crate::windows::find_next_available_drive().expect("free drive"); + let mut session = CloudFilesSession::mount(drive, fs.clone()) + .await + .expect("CloudFilesSession::mount"); + + let drive_root = format!("{}:\\", drive.to_ascii_uppercase()); + let mounted_secret = Path::new(&drive_root).join("geheim.txt"); + let mounted_large = Path::new(&drive_root).join("gross.bin"); + let mounted_dir = Path::new(&drive_root).join("ordner"); + + assert!( + mounted_secret.exists(), + "Platzhalter geheim.txt muss auf dem Laufwerk sichtbar sein" + ); + assert!( + mounted_large.exists(), + "Platzhalter gross.bin muss auf dem Laufwerk sichtbar sein" + ); + assert!( + mounted_dir.is_dir(), + "Unterordner muss auf dem Laufwerk sichtbar sein" + ); + + // 3. On-Demand Hydration testen: Dateien (inkl. > 1 MB Multi-Chunk) über regulären Windows-Pfad lesen + let read_back = + std::fs::read(&mounted_secret).expect("Lesen über gemounteten Laufwerksbuchstaben"); + assert_eq!( + read_back.as_slice(), + secret_content, + "Hydrierter Klartext muss exakt mit dem Container-Inhalt übereinstimmen" + ); + + let read_large = + std::fs::read(&mounted_large).expect("Multi-Chunk Lesen über Laufwerksbuchstaben"); + assert_eq!( + read_large, large_payload, + "Multi-Chunk Datei (1.5 MB) muss bytegenau hydriert werden" + ); + + // 4. Neue Datei schreiben, bestehende Datei überschreiben & eine Datei löschen + let new_file_path = mounted_dir.join("neu.txt"); + let new_payload = b"Direkt ueber Laufwerksbuchstaben ohne WebClient geschrieben!"; + std::fs::write(&new_file_path, new_payload).expect("Schreiben auf Laufwerk"); + + let modified_secret = b"Aktualisierter Geheimtext ueber CloudFiles-Laufwerk!"; + std::fs::write(&mounted_secret, modified_secret).expect("Überschreiben auf Laufwerk"); + + std::fs::remove_file(&mounted_large).expect("Löschen von gross.bin auf Laufwerk"); + + // Anti-Leak Shield testen: Temporäre Office-Lockdatei darf nicht übernommen werden + let leak_path = Path::new(&drive_root).join("~$geheim.docx"); + let _ = std::fs::write(&leak_path, b"leak"); + + // 5. Sauberes Unmount (inkl. Final-Sync & CSPRNG-Shredding) + session.shutdown().await.expect("session shutdown"); + + assert!( + !crate::windows::is_dos_device_mounted(drive), + "DOS-Device muss nach shutdown entfernt sein" + ); + + // 6. Prüfe, ob alle Änderungen im verschlüsselten SanctumFs angekommen sind + let dav_new = DavPath::new("/ordner/neu.txt").unwrap(); + let mut ro = DavOpenOptions::default(); + ro.read = true; + let mut vf = fs + .open(&dav_new, ro) + .await + .expect("neu.txt muss im Tresor existieren"); + vf.seek(SeekFrom::Start(0)).await.unwrap(); + let synced_bytes = vf.read_bytes(1024).await.unwrap(); + assert_eq!(synced_bytes.as_ref(), new_payload); + + let mut ro2 = DavOpenOptions::default(); + ro2.read = true; + let mut vf_sec = fs + .open(&dav_secret, ro2) + .await + .expect("geheim.txt muss existieren"); + vf_sec.seek(SeekFrom::Start(0)).await.unwrap(); + let updated_bytes = vf_sec.read_bytes(1024).await.unwrap(); + assert_eq!(updated_bytes.as_ref(), modified_secret); + + assert!( + fs.metadata(&dav_large).await.is_err(), + "Gelöschte Datei gross.bin muss auch im Container entfernt sein" + ); + + // Prüfe, dass die Anti-Leak-Datei blockiert wurde + let dav_leak = DavPath::new("/~$geheim.docx").unwrap(); + assert!( + fs.metadata(&dav_leak).await.is_err(), + "Anti-Leak-Datei darf nicht in den Container synchronisiert werden" + ); + let _ = Duration::from_secs(1); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1af77a5..deb54fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod carrier; +pub mod cloudfiles; pub mod crypto; pub mod mount; pub mod pathutil; diff --git a/src/main.rs b/src/main.rs index b645501..a2b4597 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,9 @@ use sanctum::crypto::{ mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION, FORMAT_VERSION_V3, }; -use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth}; +use sanctum::mount::{ + format_drive, mount_container_with_backend, unmount_drive, ContainerAuth, MountBackend, +}; use sanctum::recovery::{ detect_recovery_key_slot, export_header_backup, restore_header_backup, restore_slot_from_recovery_key, @@ -121,6 +123,10 @@ enum Commands { /// Autorisiert den Neuaufbau des Metadaten-MAC nach einem Header-Restore (F-02) #[arg(long, default_value_t = false)] rebuild_mac: bool, + + /// Mount-Backend unter Windows: 'auto' (WebClient mit nahtlosem Fallback auf Cloud-Files), 'cloudfiles' (cldapi.dll ohne WebClient) oder 'webdav' + #[arg(long, default_value = "auto", value_name = "MODE")] + backend: String, }, /// Trennt ein eingebundenes Netzlaufwerk manuell @@ -1648,7 +1654,9 @@ async fn run() -> Result<()> { anti_leak_list, stealth, rebuild_mac, + backend, } => { + let mount_backend = MountBackend::from_str_opt(&backend)?; let drive_char = match drive { Some(ref d) => parse_drive_letter(d)?, None => { @@ -1688,7 +1696,7 @@ async fn run() -> Result<()> { let enable_tray = !no_tray && !stealth; let lock_on_screen_lock = !no_screen_lock; let anti_leak = !no_anti_leak; - mount_container( + mount_container_with_backend( &path, drive_char, mount_point.as_deref(), @@ -1702,6 +1710,7 @@ async fn run() -> Result<()> { anti_leak_list.as_deref(), stealth, rebuild_mac, + mount_backend, ) .await?; } diff --git a/src/mount.rs b/src/mount.rs index 8edaa7c..4a21351 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -29,6 +29,32 @@ pub enum ContainerAuth { RecoveryKey(Zeroizing), } +/// Auswahl des Mount-Backends unter Windows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MountBackend { + /// Versucht zuerst WebClient (inkl. Userland-ETW-Wakeup); falls deaktiviert, + /// übernimmt nahtlos die Windows Cloud-Files-Engine (`cldapi.dll` + `DefineDosDeviceW`). + Auto, + /// Nutzt direkt die Windows Cloud-Files-Engine (`cldapi.dll` + `DefineDosDeviceW`) ohne WebClient. + CloudFiles, + /// Nutzt ausschließlich das klassische WebDAV-Netzlaufwerk (`WNetAddConnection2W`). + WebDav, +} + +impl MountBackend { + pub fn from_str_opt(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "auto" => Ok(Self::Auto), + "cloudfiles" | "cfapi" | "cldapi" | "cloud" => Ok(Self::CloudFiles), + "webdav" | "wnet" | "dav" => Ok(Self::WebDav), + other => bail!( + "Unbekanntes Mount-Backend '{}'. Erlaubte Werte: auto, cloudfiles, webdav", + other + ), + } + } +} + /// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:") pub fn format_drive(drive_letter: char) -> String { format!("{}:", drive_letter.to_ascii_uppercase()) @@ -88,6 +114,42 @@ pub async fn mount_container( anti_leak_list: Option<&Path>, stealth: bool, rebuild_mac: bool, +) -> Result<()> { + mount_container_with_backend( + container_path, + drive_letter, + mount_point, + requested_port, + auth, + open_explorer, + enable_tray, + idle_timeout, + lock_on_screen_lock, + anti_leak, + anti_leak_list, + stealth, + rebuild_mac, + MountBackend::Auto, + ) + .await +} + +/// Startet das Sanctum-VFS mit expliziter Auswahl des Mount-Backends (`Auto`, `CloudFiles`, `WebDav`). +pub async fn mount_container_with_backend( + container_path: &Path, + drive_letter: char, + mount_point: Option<&Path>, + requested_port: Option, + auth: ContainerAuth, + open_explorer: bool, + enable_tray: bool, + idle_timeout: Option, + lock_on_screen_lock: bool, + anti_leak: bool, + anti_leak_list: Option<&Path>, + stealth: bool, + rebuild_mac: bool, + backend: MountBackend, ) -> Result<()> { let drive_str = format_drive(drive_letter); let _ = &drive_str; @@ -95,6 +157,7 @@ pub async fn mount_container( let _ = open_explorer; let _ = enable_tray; let _ = lock_on_screen_lock; + let _ = backend; if !container_path.exists() { bail!( @@ -346,15 +409,19 @@ pub async fn mount_container( if !stealth { #[cfg(windows)] - ui::step( - 4, - 4, - "🌐", - &format!( - "Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...", - bound_port, drive_str - ), - ); + { + let step_msg = match backend { + MountBackend::CloudFiles => format!( + "Initialisiere Cloud-Files Userland-Engine (cldapi.dll) & binde Laufwerk {} ein...", + drive_str + ), + _ => format!( + "Starte WebDAV (Port {}) & binde Laufwerk {} ein...", + bound_port, drive_str + ), + }; + ui::step(4, 4, "🌐", &step_msg); + } #[cfg(not(windows))] ui::step( 4, @@ -377,13 +444,80 @@ pub async fn mount_container( shutdown_rx, )); - // Netzlaufwerk bzw. Verzeichnis einbinden - if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) { - let _ = fs_clone.sync_carrier_manifest(); - let _ = shutdown_tx.send(true); - let _ = server_handle.await; - return Err(e); + // Netzlaufwerk bzw. Cloud-Files-Laufwerk einbinden + let mut cf_session: Option = None; + #[cfg(windows)] + { + match backend { + MountBackend::CloudFiles => { + match crate::cloudfiles::CloudFilesSession::mount(drive_letter, fs_clone.clone()) + .await + { + Ok(s) => cf_session = Some(s), + Err(e) => { + let _ = fs_clone.sync_carrier_manifest(); + let _ = shutdown_tx.send(true); + let _ = server_handle.await; + return Err(e); + } + } + } + MountBackend::WebDav => { + if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) { + let _ = fs_clone.sync_carrier_manifest(); + let _ = shutdown_tx.send(true); + let _ = server_handle.await; + return Err(e); + } + } + MountBackend::Auto => { + if let Err(wnet_err) = run_mount_command(drive_letter, bound_port, &session_token) { + if !stealth { + println!( + " {} Windows WebClient nicht verfügbar ({}) — schalte nahtlos auf Userland Cloud-Files-Engine (cldapi.dll) um...", + ui::yellow("[i]"), + wnet_err + .to_string() + .lines() + .next() + .unwrap_or("WebClient deaktiviert") + ); + } + match crate::cloudfiles::CloudFilesSession::mount( + drive_letter, + fs_clone.clone(), + ) + .await + { + Ok(s) => cf_session = Some(s), + Err(cf_err) => { + let _ = fs_clone.sync_carrier_manifest(); + let _ = shutdown_tx.send(true); + let _ = server_handle.await; + bail!( + "Weder WebClient ({}) noch Cloud-Files-Engine ({}) konnten Laufwerk {} einbinden.", + wnet_err, + cf_err, + drive_str + ); + } + } + } + } + } } + #[cfg(not(windows))] + { + if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) { + let _ = fs_clone.sync_carrier_manifest(); + let _ = shutdown_tx.send(true); + let _ = server_handle.await; + return Err(e); + } + } + + let using_cloudfiles = cf_session.is_some(); + let _ = using_cloudfiles; // Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault) #[cfg(windows)] @@ -450,6 +584,24 @@ pub async fn mount_container( } } + // Monitor für externes `sanctum unmount --drive ` bei Cloud-Files DOS-Device + #[cfg(windows)] + let (ext_unmount_tx, mut ext_unmount_rx) = tokio::sync::mpsc::channel::<()>(1); + #[cfg(windows)] + if using_cloudfiles { + let dl = drive_letter; + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + interval.tick().await; + if !crate::windows::is_dos_device_mounted(dl) { + let _ = ext_unmount_tx.send(()).await; + break; + } + } + }); + } + // Windows-Sitzungssperre (Win + L Auto-Lock, nur Windows) #[cfg(windows)] let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1); @@ -479,10 +631,19 @@ pub async fn mount_container( println!(); println!(" • Container: {}", container_path.display()); #[cfg(windows)] - println!( - " • Netzlaufwerk: {} (im Windows Explorer bereit)", - ui::cyan(&drive_str) - ); + { + if using_cloudfiles { + println!( + " • Laufwerk: {} (Cloud-Files Userland-Engine cldapi.dll, ohne WebClient)", + ui::cyan(&drive_str) + ); + } else { + println!( + " • Netzlaufwerk: {} (im Windows Explorer bereit)", + ui::cyan(&drive_str) + ); + } + } #[cfg(not(windows))] { if let Some(mp) = mount_point { @@ -569,7 +730,7 @@ pub async fn mount_container( #[cfg(unix)] let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok(); - // Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen) + // Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen, externes Unmount) #[cfg(windows)] tokio::select! { res = tokio::signal::ctrl_c() => { @@ -603,6 +764,12 @@ pub async fn mount_container( println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]")); } } + Some(()) = ext_unmount_rx.recv() => { + if !stealth { + println!(); + println!(" {} Externer Unmount-Befehl für {} erkannt — schließe Container sicher ab!", ui::yellow("[!]"), drive_str); + } + } } #[cfg(unix)] @@ -662,8 +829,12 @@ pub async fn mount_container( } if stealth { + if let Some(mut cf) = cf_session.take() { + let _ = cf.shutdown().await; + } else { + let _ = unmount_drive(drive_letter); + } let _ = fs_clone.sync_carrier_manifest(); - let _ = unmount_drive(drive_letter); let _ = shutdown_tx.send(true); let _ = server_handle.await; if let Ok(freelist) = db.freelist_count() { @@ -677,23 +848,37 @@ pub async fn mount_container( #[cfg(not(windows))] println!("Sanctum: Container geschlossen und WebDAV-Server beendet."); } else { - let _ = fs_clone.sync_carrier_manifest(); #[cfg(windows)] { - print!( - " {} Trenne Windows-Netzlaufwerk {} ... ", - ui::dim("[-]"), - drive_str - ); - let _ = std::io::Write::flush(&mut std::io::stdout()); - - // Automatisches Unmount - if let Err(e) = unmount_drive(drive_letter) { - println!("{}", ui::yellow(&format!("Warnung ({e})"))); + if let Some(mut cf) = cf_session.take() { + print!( + " {} Synchronisiere & schreddere Cloud-Files-Staging für {} ... ", + ui::dim("[-]"), + drive_str + ); + let _ = std::io::Write::flush(&mut std::io::stdout()); + if let Err(e) = cf.shutdown().await { + println!("{}", ui::yellow(&format!("Warnung ({e})"))); + } else { + println!("{}", ui::green("OK")); + } } else { - println!("{}", ui::green("OK")); + print!( + " {} Trenne Windows-Netzlaufwerk {} ... ", + ui::dim("[-]"), + drive_str + ); + let _ = std::io::Write::flush(&mut std::io::stdout()); + + // Automatisches Unmount + if let Err(e) = unmount_drive(drive_letter) { + println!("{}", ui::yellow(&format!("Warnung ({e})"))); + } else { + println!("{}", ui::green("OK")); + } } } + let _ = fs_clone.sync_carrier_manifest(); #[cfg(not(windows))] { print!(" {} Beende WebDAV-Server ... ", ui::dim("[-]")); diff --git a/src/windows.rs b/src/windows.rs index b55d7ff..56a61a7 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -581,6 +581,11 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 { drive_str.encode_utf16().chain(std::iter::once(0)).collect(); unsafe { WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1); + DefineDosDeviceW( + 0x0000_0002, /* DDD_REMOVE_DEFINITION */ + wide_drive.as_ptr(), + std::ptr::null(), + ); } } } @@ -722,9 +727,338 @@ pub fn unlock_memory(ptr: *const u8, len: usize) -> bool { } } +#[cfg(windows)] +extern "system" { + fn DefineDosDeviceW( + dw_flags: u32, + lp_device_name: *const u16, + lp_target_path: *const u16, + ) -> i32; + fn QueryDosDeviceW(lp_device_name: *const u16, lp_target_path: *mut u16, ucch_max: u32) -> u32; +} + +/// Prüft ohne Administratorrechte über den Windows Service Control Manager, +/// ob der Dienst `WebClient` deaktiviert (`SERVICE_DISABLED = 4`) oder bereits aktiv ist. +/// Rückgabe: `(is_running, is_disabled)` +#[cfg(windows)] +pub fn query_webclient_service_state() -> (bool, bool) { + #[repr(C)] + struct SERVICE_STATUS { + dw_service_type: u32, + dw_current_state: u32, + dw_controls_accepted: u32, + dw_win32_exit_code: u32, + dw_service_specific_exit_code: u32, + dw_check_point: u32, + dw_wait_hint: u32, + } + + #[repr(C)] + struct QUERY_SERVICE_CONFIGW { + dw_service_type: u32, + dw_start_type: u32, + dw_error_control: u32, + lp_binary_path_name: *mut u16, + lp_load_order_group: *mut u16, + dw_tag_id: u32, + lp_dependencies: *mut u16, + lp_service_start_name: *mut u16, + lp_display_name: *mut u16, + } + + #[link(name = "advapi32")] + extern "system" { + fn OpenSCManagerW( + lp_machine_name: *const u16, + lp_database_name: *const u16, + dw_desired_access: u32, + ) -> isize; + fn OpenServiceW( + h_sc_manager: isize, + lp_service_name: *const u16, + dw_desired_access: u32, + ) -> isize; + fn QueryServiceStatus(h_service: isize, lp_service_status: *mut SERVICE_STATUS) -> i32; + fn QueryServiceConfigW( + h_service: isize, + lp_service_config: *mut QUERY_SERVICE_CONFIGW, + cb_buf_size: u32, + pcb_bytes_needed: *mut u32, + ) -> i32; + fn CloseServiceHandle(h_sc_object: isize) -> i32; + } + + const SC_MANAGER_CONNECT: u32 = 0x0001; + const SERVICE_QUERY_CONFIG: u32 = 0x0001; + const SERVICE_QUERY_STATUS: u32 = 0x0004; + const SERVICE_RUNNING: u32 = 4; + const SERVICE_DISABLED: u32 = 4; + + unsafe { + let scm = OpenSCManagerW(std::ptr::null(), std::ptr::null(), SC_MANAGER_CONNECT); + if scm == 0 { + return (false, false); + } + let svc_name: Vec = "WebClient\0".encode_utf16().collect(); + let svc = OpenServiceW( + scm, + svc_name.as_ptr(), + SERVICE_QUERY_STATUS | SERVICE_QUERY_CONFIG, + ); + if svc == 0 { + CloseServiceHandle(scm); + return (false, true); + } + + let mut status: SERVICE_STATUS = std::mem::zeroed(); + let is_running = + QueryServiceStatus(svc, &mut status) != 0 && status.dw_current_state == SERVICE_RUNNING; + + let mut bytes_needed: u32 = 0; + let _ = QueryServiceConfigW(svc, std::ptr::null_mut(), 0, &mut bytes_needed); + let mut is_disabled = false; + if bytes_needed > 0 { + let mut buf = vec![0u8; bytes_needed as usize]; + let cfg_ptr = buf.as_mut_ptr() as *mut QUERY_SERVICE_CONFIGW; + if QueryServiceConfigW(svc, cfg_ptr, bytes_needed, &mut bytes_needed) != 0 { + if (*cfg_ptr).dw_start_type == SERVICE_DISABLED { + is_disabled = true; + } + } + } + + CloseServiceHandle(svc); + CloseServiceHandle(scm); + (is_running, is_disabled) + } +} + +/// Weckt den Windows-Dienst `WebClient` aus dem reinen Userland (ohne Administratorrechte) +/// über seinen offiziellen Microsoft ETW-Service-Trigger (`22b6d684-fa63-4578-87c9-effcbe6643c7`) auf. +#[cfg(windows)] +pub fn trigger_webclient_service_etw() -> bool { + #[repr(C)] + struct GUID { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], + } + + #[repr(C)] + struct EVENT_DESCRIPTOR { + id: u16, + version: u8, + channel: u8, + level: u8, + opcode: u8, + task: u16, + keyword: u64, + } + + #[link(name = "advapi32")] + extern "system" { + fn EventRegister( + provider_id: *const GUID, + enable_callback: *const std::ffi::c_void, + callback_context: *const std::ffi::c_void, + reg_handle: *mut u64, + ) -> u32; + fn EventWrite( + reg_handle: u64, + event_descriptor: *const EVENT_DESCRIPTOR, + user_data_count: u32, + user_data: *const std::ffi::c_void, + ) -> u32; + fn EventUnregister(reg_handle: u64) -> u32; + } + + let (running, disabled) = query_webclient_service_state(); + if running { + return true; + } + if disabled { + return false; + } + + // Offizielle Microsoft ETW-Trigger-GUID für WebClient (MRxDAV): 22b6d684-fa63-4578-87c9-effcbe6643c7 + let webclient_trigger_guid = GUID { + data1: 0x22b6_d684, + data2: 0xfa63, + data3: 0x4578, + data4: [0x87, 0xc9, 0xef, 0xfc, 0xbe, 0x66, 0x43, 0xc7], + }; + + unsafe { + let mut reg_handle: u64 = 0; + if EventRegister( + &webclient_trigger_guid, + std::ptr::null(), + std::ptr::null(), + &mut reg_handle, + ) == 0 + { + let desc = EVENT_DESCRIPTOR { + id: 1, + version: 0, + channel: 0, + level: 0, + opcode: 0, + task: 0, + keyword: 0, + }; + let _ = EventWrite(reg_handle, &desc, 0, std::ptr::null()); + let _ = EventUnregister(reg_handle); + } + } + + // Warte kurz (max. 1.5s), bis der SCM den Dienst auf SERVICE_RUNNING geschaltet hat + for _ in 0..15 { + std::thread::sleep(std::time::Duration::from_millis(100)); + let (now_running, _) = query_webclient_service_state(); + if now_running { + return true; + } + } + + false +} + +/// Bindet ein lokales Verzeichnis als Laufwerksbuchstaben im aktuellen Benutzersitzungs-Namensraum +/// via `DefineDosDeviceW` ein (erfordert keine Administratorrechte und keinen WebClient-Dienst). +#[cfg(windows)] +pub fn mount_dos_device(drive_letter: char, target_dir: &std::path::Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + + let drive_str = format!("{}:", drive_letter.to_ascii_uppercase()); + let wide_drive: Vec = drive_str.encode_utf16().chain(std::iter::once(0)).collect(); + let wide_target: Vec = target_dir + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // Bereinige ggf. verwaiste Zuordnung auf demselben Buchstaben + if is_dos_device_mounted(drive_letter) { + unsafe { + DefineDosDeviceW(0x0000_0002, wide_drive.as_ptr(), std::ptr::null()); + } + } + + let ok = unsafe { DefineDosDeviceW(0, wide_drive.as_ptr(), wide_target.as_ptr()) }; + if ok == 0 { + bail!( + "DefineDosDeviceW konnte Laufwerk {} nicht auf '{}' zuweisen", + drive_str, + target_dir.display() + ); + } + + // Benachrichtige die Windows Shell (Explorer) über das neue Laufwerk (SHCNE_DRIVEADD) + notify_shell_drive_change(drive_letter, true); + Ok(()) +} + +/// Entfernt eine über `DefineDosDeviceW` erstellte Laufwerkszuordnung. +#[cfg(windows)] +pub fn unmount_dos_device(drive_letter: char, target_dir: Option<&std::path::Path>) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + + const DDD_REMOVE_DEFINITION: u32 = 0x0000_0002; + const DDD_EXACT_MATCH_ON_REMOVE: u32 = 0x0000_0004; + + let drive_str = format!("{}:", drive_letter.to_ascii_uppercase()); + let wide_drive: Vec = drive_str.encode_utf16().chain(std::iter::once(0)).collect(); + + let ok = if let Some(dir) = target_dir { + let wide_target: Vec = dir + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + unsafe { + DefineDosDeviceW( + DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE, + wide_drive.as_ptr(), + wide_target.as_ptr(), + ) + } + } else { + unsafe { DefineDosDeviceW(DDD_REMOVE_DEFINITION, wide_drive.as_ptr(), std::ptr::null()) } + }; + + if ok == 0 { + // Fallback ohne exakten Pfad + let _ = unsafe { + DefineDosDeviceW(DDD_REMOVE_DEFINITION, wide_drive.as_ptr(), std::ptr::null()) + }; + } + + notify_shell_drive_change(drive_letter, false); + Ok(()) +} + +/// Prüft, ob ein Laufwerksbuchstabe aktuell als DOS-Device existiert. +#[cfg(windows)] +pub fn is_dos_device_mounted(drive_letter: char) -> bool { + let drive_str = format!("{}:", drive_letter.to_ascii_uppercase()); + let wide_drive: Vec = drive_str.encode_utf16().chain(std::iter::once(0)).collect(); + let mut buf = [0u16; 512]; + let len = unsafe { QueryDosDeviceW(wide_drive.as_ptr(), buf.as_mut_ptr(), buf.len() as u32) }; + len > 0 +} + +#[cfg(not(windows))] +pub fn is_dos_device_mounted(_drive_letter: char) -> bool { + false +} + +#[cfg(windows)] +fn notify_shell_drive_change(drive_letter: char, added: bool) { + extern "system" { + fn SHChangeNotify( + w_event_id: i32, + u_flags: u32, + dw_item1: *const std::ffi::c_void, + dw_item2: *const std::ffi::c_void, + ); + } + const SHCNE_DRIVEADD: i32 = 0x0000_0100; + const SHCNE_DRIVEREMOVED: i32 = 0x0000_0080; + const SHCNF_PATHW: u32 = 0x0005; + + let root_str = format!("{}:\\", drive_letter.to_ascii_uppercase()); + let wide_root: Vec = root_str.encode_utf16().chain(std::iter::once(0)).collect(); + let event = if added { + SHCNE_DRIVEADD + } else { + SHCNE_DRIVEREMOVED + }; + unsafe { + SHChangeNotify( + event, + SHCNF_PATHW, + wide_root.as_ptr() as *const std::ffi::c_void, + std::ptr::null(), + ); + } +} + /// Bindet ein Windows-Netzlaufwerk über WNetAddConnection2W ein (in-process, ohne argv-Token-Leak). #[cfg(windows)] pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> Result<()> { + // Versuche vorab den WebClient-Dienst per Userland-ETW-Trigger aufzuwecken, falls er nur gestoppt (Manual) ist + let (running, disabled) = query_webclient_service_state(); + if disabled { + bail!( + "Der Windows-Dienst 'WebClient' wurde durch die Systemadministration deaktiviert (StartType = Disabled)." + ); + } + if !running { + debug!("WebClient-Dienst ist gestoppt — sende Userland-ETW-Wakeup-Trigger..."); + let _ = trigger_webclient_service_etw(); + } + let drive_str = format!("{}:", drive_letter.to_ascii_uppercase()); let mut local_name: Vec = drive_str.encode_utf16().chain(std::iter::once(0)).collect(); let remote_url = format!("http://127.0.0.1:{}/", port); @@ -771,13 +1105,13 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R } if res != 0 { - let webclient_hint = if res == 67 { - "\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'." + let webclient_hint = if res == 67 || res == 1244 { + "\n\nHinweis: Das Einbinden von WebDAV-Netzlaufwerken erfordert den Windows-Dienst 'WebClient'." } else { "" }; bail!( - "Laufwerk {} konnte nicht eingebunden werden (Win32 Fehlercode {}){}", + "Laufwerk {} konnte nicht über WebClient eingebunden werden (Win32 Fehlercode {}){}", drive_str, res, webclient_hint @@ -787,7 +1121,7 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R Ok(()) } -/// Trennt ein eingebundenes Windows-Netzlaufwerk via WNetCancelConnection2W (in-process). +/// Trennt ein eingebundenes Windows-Netzlaufwerk via WNetCancelConnection2W oder Cloud-Files DOS-Device (in-process). #[cfg(windows)] pub fn unmount_drive_wnet(drive_letter: char) -> Result<()> { let drive_str = format!("{}:", drive_letter.to_ascii_uppercase()); @@ -797,6 +1131,11 @@ pub fn unmount_drive_wnet(drive_letter: char) -> Result<()> { WNetCancelConnection2W(local_name.as_ptr(), 0, 1 /* fForce = TRUE */) }; + // Falls kein WNet-Laufwerk verbunden war (2250 = ERROR_NOT_CONNECTED), prüfe auf DefineDosDeviceW (Cloud Files) + if res == 2250 && is_dos_device_mounted(drive_letter) { + return unmount_dos_device(drive_letter, None); + } + // 0 = NO_ERROR, 2250 = ERROR_NOT_CONNECTED (bereits getrennt) if res != 0 && res != 2250 { bail!(