diff --git a/Cargo.lock b/Cargo.lock index f2e9777e4330..a608af2094d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8945,6 +8945,7 @@ dependencies = [ "rmp", "rmp-serde", "rubato", + "sd-crypto", "sd-ffmpeg", "sd-fs-watcher", "sd-images", diff --git a/core/Cargo.toml b/core/Cargo.toml index a31a14832a67..5acce272859d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -106,6 +106,7 @@ inventory = "0.3" # Automatic job registration job-derive = { path = "../crates/job-derive" } # Job derive macros rmp = "0.8" # MessagePack core types rmp-serde = "1.3" # MessagePack serialization for job state +sd-crypto = { path = "../crates/crypto" } # Cryptographic operations including secure erase sd-task-system = { path = "../crates/task-system" } # Vector database for memory files (optional for now) diff --git a/core/src/domain/volume.rs b/core/src/domain/volume.rs index 5eb0c22f9862..9831672ca01f 100644 --- a/core/src/domain/volume.rs +++ b/core/src/domain/volume.rs @@ -2,6 +2,12 @@ //! //! This represents volumes in Spacedrive, combining runtime detection capabilities //! with database tracking and user preferences. Supports local, network, and cloud volumes. +//! +//! # Encryption Detection +//! +//! Volumes may have encryption status detected from the underlying filesystem. +//! This information is used to optimize secure deletion strategies - encrypted volumes +//! don't require multi-pass overwrites since the data is already ciphertext. use crate::domain::resource::Identifiable; use chrono::{DateTime, Utc}; @@ -85,6 +91,87 @@ impl fmt::Display for VolumeFingerprint { } } +/// Type of full-disk encryption detected on a volume +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +pub enum EncryptionType { + /// macOS FileVault (APFS encrypted) + FileVault, + /// Windows BitLocker + BitLocker, + /// Linux Unified Key Setup + LUKS, + /// Linux eCryptfs (stacked filesystem encryption) + ECryptfs, + /// VeraCrypt (cross-platform) + VeraCrypt, + /// Hardware-based encryption (SED, Opal) + Hardware, + /// Other or unknown encryption type + Other, +} + +impl fmt::Display for EncryptionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EncryptionType::FileVault => write!(f, "FileVault"), + EncryptionType::BitLocker => write!(f, "BitLocker"), + EncryptionType::LUKS => write!(f, "LUKS"), + EncryptionType::ECryptfs => write!(f, "eCryptfs"), + EncryptionType::VeraCrypt => write!(f, "VeraCrypt"), + EncryptionType::Hardware => write!(f, "Hardware"), + EncryptionType::Other => write!(f, "Other"), + } + } +} + +/// Encryption information for a volume +/// +/// Stores detected encryption status and type. Used to optimize secure deletion +/// strategies - encrypted volumes don't require multi-pass overwrites since data +/// is already ciphertext on disk. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] +pub struct VolumeEncryption { + /// Whether encryption is enabled on this volume + pub enabled: bool, + + /// Type of encryption detected + pub encryption_type: EncryptionType, + + /// Whether the volume is currently unlocked (accessible) + /// For FileVault/BitLocker, this means the key is loaded + pub is_unlocked: bool, +} + +impl VolumeEncryption { + /// Create a new VolumeEncryption instance + pub fn new(encryption_type: EncryptionType, is_unlocked: bool) -> Self { + Self { + enabled: true, + encryption_type, + is_unlocked, + } + } + + /// Create an unencrypted volume marker + pub fn none() -> Option { + None + } + + /// Check if this encryption provides at-rest protection + /// Used to determine if multi-pass secure delete is necessary + pub fn provides_at_rest_protection(&self) -> bool { + self.enabled + && matches!( + self.encryption_type, + EncryptionType::FileVault + | EncryptionType::BitLocker + | EncryptionType::LUKS + | EncryptionType::VeraCrypt + | EncryptionType::Hardware + ) + } +} + /// Represents an APFS container (physical storage with multiple volumes) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] pub struct ApfsContainer { @@ -277,6 +364,9 @@ pub struct Volume { /// Disk type (SSD, HDD, etc.) pub disk_type: DiskType, + /// Encryption status (FileVault, BitLocker, LUKS, etc.) + pub encryption: Option, + /// Filesystem type pub file_system: FileSystem, @@ -597,6 +687,7 @@ impl Volume { volume_type: VolumeType::Unknown, mount_type: MountType::System, disk_type: DiskType::Unknown, + encryption: None, file_system: FileSystem::Other("Unknown".to_string()), total_capacity: 0, available_space: 0, @@ -695,6 +786,48 @@ impl Volume { ) } + /// Check if this volume has full-disk encryption enabled + pub fn is_encrypted(&self) -> bool { + self.encryption.as_ref().is_some_and(|e| e.enabled) + } + + /// Get the encryption type if the volume is encrypted + pub fn encryption_type(&self) -> Option { + self.encryption.as_ref().map(|e| e.encryption_type) + } + + /// Check if multi-pass secure delete is necessary for this volume + /// + /// Returns false if the volume has at-rest encryption (data is already + /// ciphertext on disk) or is an SSD (where TRIM is more appropriate). + pub fn needs_multi_pass_secure_delete(&self) -> bool { + // Encrypted volumes don't need multi-pass overwrite + if self.encryption.as_ref().is_some_and(|e| e.provides_at_rest_protection()) { + return false; + } + // SSDs should use TRIM instead of overwriting + if matches!(self.disk_type, DiskType::SSD) { + return false; + } + // HDDs and unknown types benefit from multi-pass overwrite + true + } + + /// Get the recommended number of secure delete passes for this volume + /// + /// Modern guidance (NIST SP 800-88): 1 pass sufficient for most drives. + /// Encrypted volumes: 1 pass (data is ciphertext anyway). + /// HDDs without encryption: 3 passes for extra assurance. + pub fn recommended_secure_delete_passes(&self) -> usize { + if self.is_encrypted() { + 1 + } else if matches!(self.disk_type, DiskType::SSD) { + 1 // TRIM is more effective than overwriting for SSDs + } else { + 3 // HDDs benefit from multi-pass for magnetic remnants + } + } + /// Get capacity utilization percentage pub fn utilization_percentage(&self) -> f64 { if self.total_capacity == 0 { @@ -860,6 +993,7 @@ impl TrackedVolume { }, mount_type: MountType::External, disk_type: DiskType::Unknown, + encryption: None, // Encryption status is only known at runtime file_system: FileSystem::from_string( &self .file_system diff --git a/core/src/ops/files/delete/job.rs b/core/src/ops/files/delete/job.rs index c07a678ac498..253d9508b97c 100644 --- a/core/src/ops/files/delete/job.rs +++ b/core/src/ops/files/delete/job.rs @@ -17,8 +17,76 @@ pub enum DeleteMode { Trash, /// Permanent deletion (cannot be undone) Permanent, - /// Secure deletion (overwrite data) - Secure, + /// Secure deletion with configurable options. + /// Uses encryption-aware deletion strategy based on volume encryption status. + Secure(SecureDeleteOptions), +} + +/// Options for secure file deletion +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecureDeleteOptions { + /// Number of overwrite passes. If None, auto-determine based on volume encryption. + /// - Encrypted volumes (FileVault, BitLocker, LUKS): 1 pass (NIST SP 800-88 guidance) + /// - Unencrypted SSDs: 1 pass (TRIM is more effective than overwriting) + /// - Unencrypted HDDs: 3 passes (DOD standard for magnetic media) + pub passes: Option, + + /// Whether to use TRIM/hole punching for SSDs instead of overwrite. + /// This is more effective on SSDs due to wear-leveling. + /// Default: true (auto-detect SSD and use TRIM if available) + pub use_trim: bool, + + /// Force overwrite even on encrypted volumes. + /// By default, encrypted volumes skip multi-pass overwrite since data is ciphertext. + /// Set to true for maximum paranoia. + pub force_overwrite: bool, + + /// Truncate file to zero length after erasure (recommended). + /// This ensures the file metadata is also cleaned up. + pub truncate_after: bool, +} + +impl Default for SecureDeleteOptions { + fn default() -> Self { + Self { + passes: None, // Auto-determine based on volume + use_trim: true, // Use TRIM on SSDs when available + force_overwrite: false, // Trust encryption + truncate_after: true, // Clean up file metadata + } + } +} + +impl SecureDeleteOptions { + /// Create options for a quick secure delete (1 pass, trust encryption) + pub fn quick() -> Self { + Self { + passes: Some(1), + use_trim: true, + force_overwrite: false, + truncate_after: true, + } + } + + /// Create options for thorough secure delete (3 passes, force overwrite) + pub fn thorough() -> Self { + Self { + passes: Some(3), + use_trim: false, + force_overwrite: true, + truncate_after: true, + } + } + + /// Create options for paranoid secure delete (7 passes DOD 5220.22-M) + pub fn paranoid() -> Self { + Self { + passes: Some(7), + use_trim: false, + force_overwrite: true, + truncate_after: true, + } + } } /// Options for file delete operations @@ -86,16 +154,21 @@ impl JobHandler for DeleteJob { async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { ctx.log(format!( "Starting {} deletion of {} files", - match self.mode { - DeleteMode::Trash => "trash", - DeleteMode::Permanent => "permanent", - DeleteMode::Secure => "secure", + match &self.mode { + DeleteMode::Trash => "trash".to_string(), + DeleteMode::Permanent => "permanent".to_string(), + DeleteMode::Secure(opts) => format!( + "secure (passes: {}, trim: {}, force: {})", + opts.passes.map_or("auto".to_string(), |p| p.to_string()), + opts.use_trim, + opts.force_overwrite + ), }, self.targets.paths.len() )); // Safety check for permanent deletion - if matches!(self.mode, DeleteMode::Permanent | DeleteMode::Secure) + if matches!(self.mode, DeleteMode::Permanent | DeleteMode::Secure(_)) && !self.confirm_permanent { return Err(JobError::execution( @@ -180,9 +253,18 @@ impl DeleteJob { job } - /// Create a secure delete operation (requires confirmation) + /// Create a secure delete operation with default options (requires confirmation) pub fn secure(targets: SdPathBatch, confirmed: bool) -> Self { - let mut job = Self::new(targets, DeleteMode::Secure); + Self::secure_with_options(targets, confirmed, SecureDeleteOptions::default()) + } + + /// Create a secure delete operation with custom options (requires confirmation) + pub fn secure_with_options( + targets: SdPathBatch, + confirmed: bool, + options: SecureDeleteOptions, + ) -> Self { + let mut job = Self::new(targets, DeleteMode::Secure(options)); job.confirm_permanent = confirmed; job } diff --git a/core/src/ops/files/delete/mod.rs b/core/src/ops/files/delete/mod.rs index 7d68cd8e12ce..a40acb918938 100644 --- a/core/src/ops/files/delete/mod.rs +++ b/core/src/ops/files/delete/mod.rs @@ -6,6 +6,7 @@ pub mod job; pub mod output; pub mod routing; pub mod strategy; +pub mod trim; pub use action::FileDeleteAction; pub use input::FileDeleteInput; diff --git a/core/src/ops/files/delete/strategy.rs b/core/src/ops/files/delete/strategy.rs index fb4d2cea9093..aacff17473bd 100644 --- a/core/src/ops/files/delete/strategy.rs +++ b/core/src/ops/files/delete/strategy.rs @@ -11,9 +11,11 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; +use tracing::debug; use uuid::Uuid; -use super::job::DeleteMode; +use super::job::{DeleteMode, SecureDeleteOptions}; +use super::trim; /// Result of a delete operation for a single path #[derive(Debug, Clone, Serialize, Deserialize)] @@ -48,6 +50,7 @@ impl DeleteStrategy for LocalDeleteStrategy { mode: DeleteMode, ) -> Result> { let mut results = Vec::new(); + let volume_manager = ctx.volume_manager(); for path in paths { let result = match path { @@ -59,10 +62,20 @@ impl DeleteStrategy for LocalDeleteStrategy { let size = self.get_path_size(local_path).await.unwrap_or(0); - let deletion_result = match mode { + let deletion_result = match &mode { DeleteMode::Trash => self.move_to_trash(local_path).await, DeleteMode::Permanent => self.permanent_delete(local_path).await, - DeleteMode::Secure => self.secure_delete(local_path).await, + DeleteMode::Secure(opts) => { + // Use encryption-aware options for secure delete + let optimal_opts = self + .determine_optimal_options( + local_path, + opts, + volume_manager.as_deref(), + ) + .await; + self.secure_delete(local_path, &optimal_opts).await + } }; DeleteResult { @@ -328,53 +341,160 @@ impl LocalDeleteStrategy { Ok(()) } - /// Securely delete file by overwriting with random data - pub async fn secure_delete(&self, path: &Path) -> Result<(), std::io::Error> { + /// Securely delete file by overwriting with random data. + /// Uses the crypto crate's erase function with configurable options. + pub async fn secure_delete( + &self, + path: &Path, + options: &SecureDeleteOptions, + ) -> Result<(), std::io::Error> { let metadata = fs::metadata(path).await?; if metadata.is_file() { - self.secure_overwrite_file(path, metadata.len()).await?; + self.secure_overwrite_file(path, metadata.len(), options) + .await?; fs::remove_file(path).await?; } else if metadata.is_dir() { - self.secure_delete_directory(path).await?; + self.secure_delete_directory(path, options).await?; fs::remove_dir_all(path).await?; } Ok(()) } - /// Securely overwrite a file with random data - async fn secure_overwrite_file(&self, path: &Path, size: u64) -> Result<(), std::io::Error> { - use rand::RngCore; - use tokio::io::{AsyncSeekExt, AsyncWriteExt}; + /// Determine optimal SecureDeleteOptions for a path based on volume encryption status. + /// This is the encryption-aware deletion strategy that auto-tunes passes. + /// + /// Strategy: + /// - Encrypted volumes (FileVault, BitLocker, LUKS): 1 pass (data is ciphertext anyway) + /// - Unencrypted SSDs: 1 pass + TRIM (TRIM is more effective than overwriting) + /// - Unencrypted HDDs: 3 passes (DOD standard for magnetic media) + /// - Unknown: 3 passes (conservative default) + pub async fn determine_optimal_options( + &self, + path: &Path, + base_options: &SecureDeleteOptions, + volume_manager: Option<&crate::volume::VolumeManager>, + ) -> SecureDeleteOptions { + let mut options = base_options.clone(); + + // If passes are explicitly set and force_overwrite is true, use as-is + if options.passes.is_some() && options.force_overwrite { + return options; + } + + // Try to get volume info for this path + if let Some(vm) = volume_manager { + if let Some(volume) = vm.volume_for_path(path).await { + // Check encryption status + let is_encrypted = volume.is_encrypted(); + let is_ssd = matches!( + volume.disk_type, + crate::volume::types::DiskType::SSD + | crate::volume::types::DiskType::NVMe + | crate::volume::types::DiskType::Flash + ); + + // Auto-determine passes if not explicitly set + if options.passes.is_none() { + options.passes = Some(volume.recommended_secure_delete_passes()); + debug!( + "Auto-determined {} passes for path {} (encrypted: {}, SSD: {})", + options.passes.unwrap(), + path.display(), + is_encrypted, + is_ssd + ); + } + + // Enable TRIM for SSDs if not explicitly disabled + if is_ssd && !options.force_overwrite { + options.use_trim = true; + } + + // On encrypted volumes, skip overwrite unless force_overwrite is set + if is_encrypted && !options.force_overwrite && options.passes.is_none() { + options.passes = Some(1); + debug!( + "Encrypted volume detected, using single pass for {}", + path.display() + ); + } + } + } + + // Default to conservative 3 passes if we couldn't determine + if options.passes.is_none() { + options.passes = Some(3); + debug!( + "Could not determine volume info, using default 3 passes for {}", + path.display() + ); + } + + options + } + + /// Securely overwrite a file with random data using crypto crate's erase function. + /// Respects SecureDeleteOptions for number of passes, TRIM support, and truncation behavior. + /// + /// Strategy: + /// 1. If use_trim is enabled and TRIM is supported, try TRIM first (more effective on SSDs) + /// 2. If TRIM fails or is disabled, fall back to multi-pass overwrite + /// 3. Optionally truncate file to clean up metadata + async fn secure_overwrite_file( + &self, + path: &Path, + size: u64, + options: &SecureDeleteOptions, + ) -> Result<(), std::io::Error> { + use tokio::io::AsyncWriteExt; + + // Determine number of passes (default to 1 if not specified, will be auto-determined by caller) + let passes = options.passes.unwrap_or(1) as usize; + + // Skip overwrite if passes is 0 (useful for encrypted volumes where overwrite is unnecessary) + if passes == 0 { + return Ok(()); + } + + // Try TRIM first if enabled (more effective on SSDs) + if options.use_trim { + let trim_result = trim::trim_file(path).await; + if trim_result.success { + debug!( + "TRIM succeeded for file: {} ({} bytes)", + path.display(), + trim_result.bytes_trimmed + ); + // TRIM succeeded, but we still do at least one overwrite pass for extra security + // unless we're on an encrypted volume (handled by passes=0 above) + } else { + debug!( + "TRIM not available or failed: {:?}, falling back to overwrite", + trim_result.error + ); + } + } let mut file = fs::OpenOptions::new() + .read(true) .write(true) .truncate(false) .open(path) .await?; - // Overwrite with random data (3 passes) - for _ in 0..3 { - file.seek(std::io::SeekFrom::Start(0)).await?; - - let mut remaining = size; - - while remaining > 0 { - let chunk_size = std::cmp::min(remaining, 64 * 1024) as usize; + // Use crypto crate's erase function for cryptographically secure overwriting + sd_crypto::erase(&mut file, size as usize, passes) + .await + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; - let buffer = { - let mut rng = rand::thread_rng(); - let mut buf = vec![0u8; chunk_size]; - rng.fill_bytes(&mut buf); - buf - }; + // Sync to ensure data is written to disk + file.sync_all().await?; - file.write_all(&buffer).await?; - remaining -= chunk_size as u64; - } - - file.flush().await?; + // Optionally truncate file to zero length after erasure + if options.truncate_after { + file.set_len(0).await?; file.sync_all().await?; } @@ -382,7 +502,11 @@ impl LocalDeleteStrategy { } /// Secure delete directory using iterative approach - async fn secure_delete_directory(&self, path: &Path) -> Result<(), std::io::Error> { + async fn secure_delete_directory( + &self, + path: &Path, + options: &SecureDeleteOptions, + ) -> Result<(), std::io::Error> { let mut stack = vec![path.to_path_buf()]; while let Some(current_path) = stack.pop() { @@ -393,7 +517,7 @@ impl LocalDeleteStrategy { if entry_path.is_file() { let metadata = fs::metadata(&entry_path).await?; - self.secure_overwrite_file(&entry_path, metadata.len()) + self.secure_overwrite_file(&entry_path, metadata.len(), options) .await?; fs::remove_file(&entry_path).await?; } else if entry_path.is_dir() { diff --git a/core/src/ops/files/delete/trim.rs b/core/src/ops/files/delete/trim.rs new file mode 100644 index 000000000000..037948bade8c --- /dev/null +++ b/core/src/ops/files/delete/trim.rs @@ -0,0 +1,455 @@ +//! Platform-specific TRIM and hole punching for secure deletion +//! +//! TRIM operations notify SSDs that data blocks are no longer in use, +//! allowing the drive's controller to garbage collect them. This is more +//! effective than overwriting on SSDs due to wear-leveling. +//! +//! Hole punching (FALLOC_FL_PUNCH_HOLE) deallocates storage space while +//! preserving the file, making the data unrecoverable on supporting filesystems. + +use std::path::Path; +use tracing::{debug, warn}; + +/// Result of a TRIM/hole punch operation +#[derive(Debug, Clone)] +pub struct TrimResult { + pub success: bool, + pub bytes_trimmed: u64, + pub error: Option, +} + +impl TrimResult { + fn success(bytes: u64) -> Self { + Self { + success: true, + bytes_trimmed: bytes, + error: None, + } + } + + fn error(msg: impl Into) -> Self { + Self { + success: false, + bytes_trimmed: 0, + error: Some(msg.into()), + } + } + + fn unsupported(reason: &str) -> Self { + Self { + success: false, + bytes_trimmed: 0, + error: Some(format!("TRIM not supported: {}", reason)), + } + } +} + +/// Attempt to TRIM/hole punch a file to securely deallocate its storage. +/// Falls back gracefully if not supported on the platform or filesystem. +pub async fn trim_file(path: &Path) -> TrimResult { + #[cfg(target_os = "macos")] + { + trim_file_macos(path).await + } + + #[cfg(target_os = "linux")] + { + trim_file_linux(path).await + } + + #[cfg(target_os = "windows")] + { + trim_file_windows(path).await + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + TrimResult::unsupported("platform not supported") + } +} + +/// Check if TRIM is likely supported for a given path. +/// Returns true if the underlying storage supports TRIM operations. +pub async fn is_trim_supported(path: &Path) -> bool { + #[cfg(target_os = "macos")] + { + is_trim_supported_macos(path).await + } + + #[cfg(target_os = "linux")] + { + is_trim_supported_linux(path).await + } + + #[cfg(target_os = "windows")] + { + is_trim_supported_windows(path).await + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + false + } +} + +// ============================================================================= +// macOS Implementation +// ============================================================================= + +#[cfg(target_os = "macos")] +async fn trim_file_macos(path: &Path) -> TrimResult { + use std::os::unix::io::AsRawFd; + use tokio::fs; + + let file = match fs::OpenOptions::new().write(true).open(path).await { + Ok(f) => f, + Err(e) => return TrimResult::error(format!("Failed to open file: {}", e)), + }; + + let metadata = match fs::metadata(path).await { + Ok(m) => m, + Err(e) => return TrimResult::error(format!("Failed to get metadata: {}", e)), + }; + + let size = metadata.len(); + let std_file = file.into_std().await; + let fd = std_file.as_raw_fd(); + + // Use F_PUNCHHOLE to deallocate file blocks (macOS 10.12+) + // This punches a hole in the file, deallocating the underlying storage + let result = tokio::task::spawn_blocking(move || { + // fpunchhole_t structure for F_PUNCHHOLE + #[repr(C)] + struct FPunchHole { + fp_flags: libc::c_uint, + reserved: libc::c_uint, + fp_offset: libc::off_t, + fp_length: libc::off_t, + } + + let punch_hole = FPunchHole { + fp_flags: 0, + reserved: 0, + fp_offset: 0, + fp_length: size as libc::off_t, + }; + + // F_PUNCHHOLE = 99 on macOS + const F_PUNCHHOLE: libc::c_int = 99; + + let ret = unsafe { + libc::fcntl( + fd, + F_PUNCHHOLE, + &punch_hole as *const FPunchHole as *const libc::c_void, + ) + }; + + if ret == 0 { + debug!("Successfully punched hole in file: {} bytes", size); + TrimResult::success(size) + } else { + let errno = std::io::Error::last_os_error(); + warn!("F_PUNCHHOLE failed: {}", errno); + TrimResult::error(format!("F_PUNCHHOLE failed: {}", errno)) + } + }) + .await; + + match result { + Ok(r) => r, + Err(e) => TrimResult::error(format!("Task join error: {}", e)), + } +} + +#[cfg(target_os = "macos")] +async fn is_trim_supported_macos(path: &Path) -> bool { + use std::process::Command; + + // Check if file is on an APFS or HFS+ volume with TRIM support + // Most SSDs on macOS support TRIM natively since macOS 10.10.4 + let output = tokio::task::spawn_blocking(move || { + Command::new("diskutil") + .args(["info", "-plist", "/"]) + .output() + }) + .await; + + match output { + Ok(Ok(output)) if output.status.success() => { + let output_str = String::from_utf8_lossy(&output.stdout); + // APFS and modern HFS+ volumes on SSDs support TRIM + output_str.contains("APFS") || output_str.contains("SolidState") + } + _ => { + // Default to true on macOS since most Macs have SSDs + true + } + } +} + +// ============================================================================= +// Linux Implementation +// ============================================================================= + +#[cfg(target_os = "linux")] +async fn trim_file_linux(path: &Path) -> TrimResult { + use std::os::unix::io::AsRawFd; + use tokio::fs; + + let file = match fs::OpenOptions::new().write(true).open(path).await { + Ok(f) => f, + Err(e) => return TrimResult::error(format!("Failed to open file: {}", e)), + }; + + let metadata = match fs::metadata(path).await { + Ok(m) => m, + Err(e) => return TrimResult::error(format!("Failed to get metadata: {}", e)), + }; + + let size = metadata.len(); + let std_file = file.into_std().await; + let fd = std_file.as_raw_fd(); + + // Use fallocate with FALLOC_FL_PUNCH_HOLE to deallocate file blocks + let result = tokio::task::spawn_blocking(move || { + // FALLOC_FL_PUNCH_HOLE = 0x02, FALLOC_FL_KEEP_SIZE = 0x01 + const FALLOC_FL_PUNCH_HOLE: libc::c_int = 0x02; + const FALLOC_FL_KEEP_SIZE: libc::c_int = 0x01; + + let ret = unsafe { + libc::fallocate( + fd, + FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, + 0, + size as libc::off_t, + ) + }; + + if ret == 0 { + debug!("Successfully punched hole in file: {} bytes", size); + TrimResult::success(size) + } else { + let errno = std::io::Error::last_os_error(); + warn!("fallocate PUNCH_HOLE failed: {}", errno); + TrimResult::error(format!("fallocate PUNCH_HOLE failed: {}", errno)) + } + }) + .await; + + match result { + Ok(r) => r, + Err(e) => TrimResult::error(format!("Task join error: {}", e)), + } +} + +#[cfg(target_os = "linux")] +async fn is_trim_supported_linux(path: &Path) -> bool { + use std::process::Command; + + // Check if the filesystem supports hole punching via /proc/mounts or similar + let path_str = path.to_string_lossy().to_string(); + + let result = tokio::task::spawn_blocking(move || { + // Try to determine if TRIM is supported by checking the mount options + // and filesystem type + let output = Command::new("findmnt") + .args(["-n", "-o", "FSTYPE,OPTIONS", "-T", &path_str]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let output_str = String::from_utf8_lossy(&output.stdout); + // ext4, xfs, btrfs support hole punching + output_str.contains("ext4") + || output_str.contains("xfs") + || output_str.contains("btrfs") + || output_str.contains("f2fs") + } + _ => { + // Fallback: assume modern filesystems support it + true + } + } + }) + .await; + + result.unwrap_or(false) +} + +// ============================================================================= +// Windows Implementation +// ============================================================================= + +#[cfg(target_os = "windows")] +async fn trim_file_windows(path: &Path) -> TrimResult { + use std::os::windows::io::AsRawHandle; + use tokio::fs; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, DeviceIoControl, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, + }; + use windows_sys::Win32::System::Ioctl::FSCTL_FILE_LEVEL_TRIM; + + let metadata = match fs::metadata(path).await { + Ok(m) => m, + Err(e) => return TrimResult::error(format!("Failed to get metadata: {}", e)), + }; + + let size = metadata.len(); + let path_clone = path.to_path_buf(); + + let result = tokio::task::spawn_blocking(move || { + // FILE_LEVEL_TRIM_RANGE structure + #[repr(C)] + struct FileLevelTrimRange { + offset: u64, + length: u64, + } + + // FILE_LEVEL_TRIM_OUTPUT structure + #[repr(C)] + struct FileLevelTrimOutput { + num_ranges_processed: u32, + } + + // Convert path to wide string + use std::os::windows::ffi::OsStrExt; + let wide_path: Vec = path_clone + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let handle = unsafe { + CreateFileW( + wide_path.as_ptr(), + 0x40000000, // GENERIC_WRITE + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut() as HANDLE, + ) + }; + + if handle == -1isize as HANDLE { + let err = std::io::Error::last_os_error(); + return TrimResult::error(format!("Failed to open file: {}", err)); + } + + let range = FileLevelTrimRange { + offset: 0, + length: size, + }; + + let mut output = FileLevelTrimOutput { + num_ranges_processed: 0, + }; + let mut bytes_returned: u32 = 0; + + let success = unsafe { + DeviceIoControl( + handle, + FSCTL_FILE_LEVEL_TRIM, + &range as *const _ as *const std::ffi::c_void, + std::mem::size_of::() as u32, + &mut output as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + + unsafe { + CloseHandle(handle); + } + + if success != 0 { + debug!("Successfully trimmed file: {} bytes", size); + TrimResult::success(size) + } else { + let err = std::io::Error::last_os_error(); + warn!("FSCTL_FILE_LEVEL_TRIM failed: {}", err); + TrimResult::error(format!("FSCTL_FILE_LEVEL_TRIM failed: {}", err)) + } + }) + .await; + + match result { + Ok(r) => r, + Err(e) => TrimResult::error(format!("Task join error: {}", e)), + } +} + +#[cfg(target_os = "windows")] +async fn is_trim_supported_windows(path: &Path) -> bool { + use std::process::Command; + + let path_str = path.to_string_lossy().to_string(); + + // Extract drive letter from path + let drive = if path_str.len() >= 2 && path_str.chars().nth(1) == Some(':') { + path_str[..2].to_string() + } else { + return false; + }; + + let result = tokio::task::spawn_blocking(move || { + // Use PowerShell to check if the drive is an SSD with TRIM support + let output = Command::new("powershell") + .args([ + "-Command", + &format!( + "$disk = Get-PhysicalDisk | Where-Object {{ $_.DeviceId -eq (Get-Partition -DriveLetter '{}').DiskNumber }}; $disk.MediaType", + drive.chars().next().unwrap_or('C') + ), + ]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let output_str = String::from_utf8_lossy(&output.stdout); + output_str.trim() == "SSD" + } + _ => { + // Default to false on Windows + false + } + } + }) + .await; + + result.unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[tokio::test] + async fn test_trim_result_constructors() { + let success = TrimResult::success(1024); + assert!(success.success); + assert_eq!(success.bytes_trimmed, 1024); + assert!(success.error.is_none()); + + let error = TrimResult::error("test error"); + assert!(!error.success); + assert_eq!(error.bytes_trimmed, 0); + assert!(error.error.is_some()); + + let unsupported = TrimResult::unsupported("test reason"); + assert!(!unsupported.success); + assert!(unsupported.error.unwrap().contains("TRIM not supported")); + } + + #[tokio::test] + async fn test_is_trim_supported() { + // This test just verifies the function doesn't panic + let temp_file = NamedTempFile::new().unwrap(); + let _ = is_trim_supported(temp_file.path()).await; + } +} diff --git a/core/src/ops/volumes/add_cloud/action.rs b/core/src/ops/volumes/add_cloud/action.rs index 7b591b77118d..87322b988bd4 100644 --- a/core/src/ops/volumes/add_cloud/action.rs +++ b/core/src/ops/volumes/add_cloud/action.rs @@ -428,6 +428,7 @@ impl LibraryAction for VolumeAddCloudAction { volume_type: crate::volume::types::VolumeType::Network, mount_type: crate::volume::types::MountType::Network, disk_type: crate::volume::types::DiskType::Unknown, + encryption: None, // Cloud volumes don't have local encryption detection file_system: crate::volume::types::FileSystem::Other(format!( "{:?}", self.input.service diff --git a/core/src/ops/volumes/encryption/mod.rs b/core/src/ops/volumes/encryption/mod.rs new file mode 100644 index 000000000000..a297925fe93d --- /dev/null +++ b/core/src/ops/volumes/encryption/mod.rs @@ -0,0 +1,10 @@ +//! Volume encryption query module +//! +//! Provides queries for checking encryption status of volumes and paths. +//! Used by the frontend to determine optimal secure delete strategies. + +pub mod output; +pub mod query; + +pub use output::{PathEncryptionInfo, VolumeEncryptionOutput}; +pub use query::{VolumeEncryptionQuery, VolumeEncryptionQueryInput}; diff --git a/core/src/ops/volumes/encryption/output.rs b/core/src/ops/volumes/encryption/output.rs new file mode 100644 index 000000000000..1f256e89833d --- /dev/null +++ b/core/src/ops/volumes/encryption/output.rs @@ -0,0 +1,43 @@ +//! Volume encryption query output + +use serde::{Deserialize, Serialize}; +use specta::Type; +use uuid::Uuid; + +/// Encryption information for a specific path +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct PathEncryptionInfo { + /// The path that was queried + pub path: String, + + /// Whether the volume is encrypted + pub is_encrypted: bool, + + /// Type of encryption (FileVault, BitLocker, LUKS, etc.) if encrypted + pub encryption_type: Option, + + /// Whether the encrypted volume is currently unlocked + pub is_unlocked: Option, + + /// Recommended number of secure delete passes based on encryption and disk type + pub recommended_passes: u32, + + /// Whether TRIM should be used (for SSDs) + pub use_trim: bool, + + /// The volume fingerprint this path belongs to + pub volume_fingerprint: Option, + + /// The volume ID this path belongs to + pub volume_id: Option, + + /// Human-readable reason for the recommendation + pub recommendation_reason: String, +} + +/// Output for volume encryption query +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VolumeEncryptionOutput { + /// Encryption info for each queried path + pub paths: Vec, +} diff --git a/core/src/ops/volumes/encryption/query.rs b/core/src/ops/volumes/encryption/query.rs new file mode 100644 index 000000000000..eed6ba98c6ca --- /dev/null +++ b/core/src/ops/volumes/encryption/query.rs @@ -0,0 +1,110 @@ +//! Volume encryption query +//! +//! Query encryption status for one or more paths. Used by the frontend to +//! determine optimal secure delete strategies before initiating deletion. + +use super::output::{PathEncryptionInfo, VolumeEncryptionOutput}; +use crate::{ + context::CoreContext, + domain::volume::DiskType, + infra::query::{CoreQuery, QueryResult}, +}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::{path::Path, sync::Arc}; + +/// Input for volume encryption query +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VolumeEncryptionQueryInput { + /// Paths to check encryption status for + pub paths: Vec, +} + +/// Query for checking encryption status of volumes containing specific paths +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VolumeEncryptionQuery { + paths: Vec, +} + +impl CoreQuery for VolumeEncryptionQuery { + type Input = VolumeEncryptionQueryInput; + type Output = VolumeEncryptionOutput; + + fn from_input(input: Self::Input) -> QueryResult { + Ok(Self { paths: input.paths }) + } + + async fn execute( + self, + context: Arc, + _session: crate::infra::api::SessionContext, + ) -> QueryResult { + let volume_manager = &context.volume_manager; + let mut path_infos = Vec::with_capacity(self.paths.len()); + + for path_str in &self.paths { + let path = Path::new(path_str); + + // Try to find the volume containing this path + let volume = volume_manager.volume_for_path(path).await; + + let info = match volume { + Some(vol) => { + let is_encrypted = vol.is_encrypted(); + let encryption_type = vol.encryption_type().map(|e| e.to_string()); + let is_unlocked = vol.encryption.as_ref().map(|e| e.is_unlocked); + let recommended_passes = vol.recommended_secure_delete_passes() as u32; + + // Determine if TRIM should be used + let is_ssd = matches!(vol.disk_type, DiskType::SSD); + + let recommendation_reason = if is_encrypted { + format!( + "Volume is encrypted with {}. Single pass sufficient as data is ciphertext.", + encryption_type.as_deref().unwrap_or("unknown encryption") + ) + } else if is_ssd { + "Unencrypted SSD. Single pass with TRIM recommended for wear leveling." + .to_string() + } else { + "Unencrypted HDD. Multiple passes recommended for magnetic remnants." + .to_string() + }; + + PathEncryptionInfo { + path: path_str.clone(), + is_encrypted, + encryption_type, + is_unlocked, + recommended_passes, + use_trim: is_ssd, + volume_fingerprint: Some(vol.fingerprint.0.clone()), + volume_id: Some(vol.id), + recommendation_reason, + } + } + None => { + // Volume not found - use conservative defaults + PathEncryptionInfo { + path: path_str.clone(), + is_encrypted: false, + encryption_type: None, + is_unlocked: None, + recommended_passes: 3, + use_trim: false, + volume_fingerprint: None, + volume_id: None, + recommendation_reason: + "Volume not detected. Using conservative 3-pass default.".to_string(), + } + } + }; + + path_infos.push(info); + } + + Ok(VolumeEncryptionOutput { paths: path_infos }) + } +} + +crate::register_core_query!(VolumeEncryptionQuery, "volumes.encryption"); diff --git a/core/src/ops/volumes/list/mod.rs b/core/src/ops/volumes/list/mod.rs index e18f837320f3..068672a0ecad 100644 --- a/core/src/ops/volumes/list/mod.rs +++ b/core/src/ops/volumes/list/mod.rs @@ -3,5 +3,5 @@ pub mod output; pub mod query; -pub use output::VolumeListOutput; +pub use output::{VolumeEncryptionInfo, VolumeListOutput}; pub use query::{VolumeFilter, VolumeListQuery, VolumeListQueryInput}; diff --git a/core/src/ops/volumes/list/output.rs b/core/src/ops/volumes/list/output.rs index b3bd9170bcc0..7951c40186d4 100644 --- a/core/src/ops/volumes/list/output.rs +++ b/core/src/ops/volumes/list/output.rs @@ -5,6 +5,17 @@ use serde::{Deserialize, Serialize}; use specta::Type; use uuid::Uuid; +/// Encryption information for a volume (frontend-friendly subset of VolumeEncryption) +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VolumeEncryptionInfo { + /// Whether encryption is enabled on this volume + pub enabled: bool, + /// Type of encryption (FileVault, BitLocker, LUKS, etc.) + pub encryption_type: String, + /// Whether the volume is currently unlocked + pub is_unlocked: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct VolumeItem { pub id: Uuid, @@ -26,6 +37,9 @@ pub struct VolumeItem { pub file_system: Option, /// Disk type (SSD, HDD, etc.) pub disk_type: Option, + /// Encryption status (FileVault, BitLocker, LUKS, etc.) + /// Only available for currently-mounted volumes on the local device + pub encryption: Option, /// Read speed in MB/s pub read_speed_mbps: Option, /// Write speed in MB/s diff --git a/core/src/ops/volumes/list/query.rs b/core/src/ops/volumes/list/query.rs index 86b2339fa7b6..63d83a67404a 100644 --- a/core/src/ops/volumes/list/query.rs +++ b/core/src/ops/volumes/list/query.rs @@ -1,8 +1,9 @@ //! Volume list query -use super::output::VolumeListOutput; +use super::output::{VolumeEncryptionInfo, VolumeListOutput}; use crate::{ context::CoreContext, + domain::volume::VolumeEncryption, infra::{ db::entities, query::{LibraryQuery, QueryError, QueryResult}, @@ -15,6 +16,15 @@ use specta::Type; use std::{collections::HashMap, sync::Arc}; use uuid::Uuid; +/// Convert domain VolumeEncryption to API-friendly VolumeEncryptionInfo +fn encryption_to_info(encryption: &VolumeEncryption) -> VolumeEncryptionInfo { + VolumeEncryptionInfo { + enabled: encryption.enabled, + encryption_type: encryption.encryption_type.to_string(), + is_unlocked: encryption.is_unlocked, + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub enum VolumeFilter { /// Only return tracked volumes @@ -190,6 +200,14 @@ impl LibraryQuery for VolumeListQuery { let volume_manager = &context.volume_manager; let mut volume_items = Vec::new(); + // Get all runtime volumes for encryption lookup + let runtime_volumes = volume_manager.get_all_volumes().await; + let runtime_by_fingerprint: HashMap = + runtime_volumes + .iter() + .map(|v| (v.fingerprint.0.clone(), v)) + .collect(); + match self.filter { VolumeFilter::TrackedOnly | VolumeFilter::All => { // For TrackedOnly and All, return volumes from database (all devices) @@ -207,6 +225,12 @@ impl LibraryQuery for VolumeListQuery { .cloned() .unwrap_or_else(|| "unknown".to_string()); + // Get encryption info from runtime volume if available + let encryption = runtime_by_fingerprint + .get(&tracked_vol.fingerprint) + .and_then(|v| v.encryption.as_ref()) + .map(encryption_to_info); + volume_items.push(super::output::VolumeItem { id: tracked_vol.uuid, name: tracked_vol @@ -226,6 +250,7 @@ impl LibraryQuery for VolumeListQuery { unique_bytes, file_system: tracked_vol.file_system.clone(), disk_type, + encryption, read_speed_mbps: tracked_vol.read_speed_mbps.map(|s| s as u32), write_speed_mbps: tracked_vol.write_speed_mbps.map(|s| s as u32), device_id: tracked_vol.device_id, @@ -235,8 +260,7 @@ impl LibraryQuery for VolumeListQuery { // For All filter, also add untracked volumes from volume_manager if matches!(self.filter, VolumeFilter::All) { - let all_volumes = volume_manager.get_all_volumes().await; - for vol in all_volumes { + for vol in &runtime_volumes { // Only show user-visible volumes if !tracked_map.contains_key(&vol.fingerprint.0) && vol.is_user_visible { let device_slug = device_slug_map @@ -244,6 +268,8 @@ impl LibraryQuery for VolumeListQuery { .cloned() .unwrap_or_else(|| "unknown".to_string()); + let encryption = vol.encryption.as_ref().map(encryption_to_info); + volume_items.push(super::output::VolumeItem { id: vol.id, name: vol.display_name.clone().unwrap_or_else(|| vol.name.clone()), @@ -257,6 +283,7 @@ impl LibraryQuery for VolumeListQuery { unique_bytes: None, file_system: Some(vol.file_system.to_string()), disk_type: Some(format!("{:?}", vol.disk_type)), + encryption, read_speed_mbps: vol.read_speed_mbps.map(|s| s as u32), write_speed_mbps: vol.write_speed_mbps.map(|s| s as u32), device_id: vol.device_id, @@ -267,17 +294,16 @@ impl LibraryQuery for VolumeListQuery { } } VolumeFilter::UntrackedOnly => { - // Get all detected volumes from volume manager (current device only) - let all_volumes = volume_manager.get_all_volumes().await; - // Only return volumes that are NOT tracked and are user-visible - for vol in all_volumes { + for vol in &runtime_volumes { if !tracked_map.contains_key(&vol.fingerprint.0) && vol.is_user_visible { let device_slug = device_slug_map .get(&vol.device_id) .cloned() .unwrap_or_else(|| "unknown".to_string()); + let encryption = vol.encryption.as_ref().map(encryption_to_info); + volume_items.push(super::output::VolumeItem { id: vol.id, name: vol.display_name.clone().unwrap_or_else(|| vol.name.clone()), @@ -291,6 +317,7 @@ impl LibraryQuery for VolumeListQuery { unique_bytes: None, file_system: Some(vol.file_system.to_string()), disk_type: Some(format!("{:?}", vol.disk_type)), + encryption, read_speed_mbps: vol.read_speed_mbps.map(|s| s as u32), write_speed_mbps: vol.write_speed_mbps.map(|s| s as u32), device_id: vol.device_id, diff --git a/core/src/ops/volumes/mod.rs b/core/src/ops/volumes/mod.rs index 262c31997863..5915669c6cc5 100644 --- a/core/src/ops/volumes/mod.rs +++ b/core/src/ops/volumes/mod.rs @@ -6,8 +6,10 @@ //! - Adding/removing cloud volumes //! - Listing volumes //! - Ephemeral indexing entire volumes +//! - Querying encryption status for secure delete optimization pub mod add_cloud; +pub mod encryption; pub mod index; pub mod list; pub mod refresh; @@ -17,8 +19,13 @@ pub mod track; pub mod untrack; pub use add_cloud::{action::VolumeAddCloudAction, VolumeAddCloudOutput}; +pub use encryption::{ + PathEncryptionInfo, VolumeEncryptionOutput, VolumeEncryptionQuery, VolumeEncryptionQueryInput, +}; pub use index::{IndexVolumeAction, IndexVolumeInput, IndexVolumeOutput}; -pub use list::{VolumeFilter, VolumeListOutput, VolumeListQuery, VolumeListQueryInput}; +pub use list::{ + VolumeEncryptionInfo, VolumeFilter, VolumeListOutput, VolumeListQuery, VolumeListQueryInput, +}; pub use refresh::{action::VolumeRefreshAction, VolumeRefreshOutput}; pub use remove_cloud::{action::VolumeRemoveCloudAction, VolumeRemoveCloudOutput}; pub use speed_test::{action::VolumeSpeedTestAction, VolumeSpeedTestOutput}; diff --git a/core/src/volume/fs/apfs.rs b/core/src/volume/fs/apfs.rs index 9393a7014fc6..77c08b4dd2b1 100644 --- a/core/src/volume/fs/apfs.rs +++ b/core/src/volume/fs/apfs.rs @@ -4,6 +4,7 @@ //! optimizations like copy-on-write cloning. While primarily used on macOS, //! this module is designed to work on any platform that supports APFS. +use crate::domain::volume::{EncryptionType, VolumeEncryption}; use crate::volume::{ error::{VolumeError, VolumeResult}, types::{ @@ -392,6 +393,13 @@ pub fn containers_to_volumes( volume_info.name.clone() }; + // Detect FileVault encryption status + let encryption = if volume_info.filevault { + Some(VolumeEncryption::new(EncryptionType::FileVault, true)) + } else { + None + }; + let volume = Volume { // Use fingerprint to generate stable UUID id: uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, fingerprint.0.as_bytes()), @@ -405,6 +413,7 @@ pub fn containers_to_volumes( volume_type, mount_type, disk_type: DiskType::Unknown, + encryption, file_system: FileSystem::APFS, total_capacity: total_bytes, available_space: available_bytes, diff --git a/core/src/volume/manager.rs b/core/src/volume/manager.rs index 553d4fb64c15..593c965e19a0 100644 --- a/core/src/volume/manager.rs +++ b/core/src/volume/manager.rs @@ -349,6 +349,7 @@ impl VolumeManager { volume_type: crate::volume::types::VolumeType::Network, mount_type: crate::volume::types::MountType::Network, disk_type: crate::volume::types::DiskType::Unknown, + encryption: None, // Cloud volumes don't have local encryption detection file_system: crate::volume::types::FileSystem::Other(format!( "{:?}", credential.service @@ -1798,6 +1799,73 @@ impl VolumeManager { .collect() } + /// Check if a volume is encrypted. + /// Returns the encryption information if the volume is encrypted. + pub async fn is_volume_encrypted( + &self, + fingerprint: &VolumeFingerprint, + ) -> Option { + let volumes = self.volumes.read().await; + volumes.get(fingerprint).and_then(|v| v.encryption.clone()) + } + + /// Get all encrypted volumes. + /// Returns volumes that have full-disk encryption enabled (FileVault, BitLocker, LUKS, etc.) + pub async fn get_encrypted_volumes(&self) -> Vec { + self.volumes + .read() + .await + .values() + .filter(|v| v.is_encrypted()) + .cloned() + .collect() + } + + /// Check if a path is on an encrypted volume. + /// Useful for determining secure delete strategy. + pub async fn is_path_on_encrypted_volume(&self, path: &Path) -> bool { + if let Some(volume) = self.volume_for_path(path).await { + volume.is_encrypted() + } else { + false + } + } + + /// Get encryption information for a path. + /// Returns encryption details if the path is on an encrypted volume. + pub async fn get_encryption_for_path( + &self, + path: &Path, + ) -> Option { + self.volume_for_path(path).await.and_then(|v| v.encryption) + } + + /// Determine the recommended number of secure delete passes for a path. + /// Takes into account both encryption status and disk type. + /// - Encrypted SSDs: 1 pass (data already encrypted, TRIM handles blocks) + /// - Encrypted HDDs: 1 pass (data already encrypted) + /// - Unencrypted SSDs: 1 pass (TRIM is more effective than overwriting) + /// - Unencrypted HDDs: 3 passes (DOD standard for magnetic media) + pub async fn recommended_secure_delete_passes(&self, path: &Path) -> u32 { + if let Some(volume) = self.volume_for_path(path).await { + volume.recommended_secure_delete_passes() + } else { + // Default to conservative 3 passes if volume unknown + 3 + } + } + + /// Check if multi-pass secure delete is needed for a path. + /// Returns false for encrypted volumes or SSDs, true for unencrypted HDDs. + pub async fn needs_multi_pass_secure_delete(&self, path: &Path) -> bool { + if let Some(volume) = self.volume_for_path(path).await { + volume.needs_multi_pass_secure_delete() + } else { + // Default to conservative behavior if volume unknown + true + } + } + /// Create or read Spacedrive identifier file for a volume /// Returns the UUID from the identifier file if successfully created/read async fn manage_spacedrive_identifier(&self, volume: &Volume) -> Option { diff --git a/core/src/volume/platform/linux.rs b/core/src/volume/platform/linux.rs index fa15a31ce174..59742c22d120 100644 --- a/core/src/volume/platform/linux.rs +++ b/core/src/volume/platform/linux.rs @@ -1,15 +1,17 @@ //! Linux-specific volume detection helpers +use crate::domain::volume::{EncryptionType, VolumeEncryption}; use crate::volume::{ classification::{get_classifier, VolumeDetectionInfo}, error::{VolumeError, VolumeResult}, types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig, VolumeFingerprint}, utils, }; +use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; use tokio::task; -use tracing::debug; +use tracing::{debug, warn}; use uuid::Uuid; /// Mount information from /proc/mounts or df output @@ -105,11 +107,15 @@ fn parse_df_line( let volume_type = classify_volume(&mount_path, &file_system, &name); let fingerprint = VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()); + // Detect LUKS/eCryptfs encryption status + let encryption = detect_luks_encryption(filesystem_device); + let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); volume.mount_type = mount_type; volume.volume_type = volume_type; volume.disk_type = disk_type; + volume.encryption = encryption; volume.file_system = file_system; volume.total_capacity = total_bytes; volume.available_space = available_bytes; @@ -170,6 +176,76 @@ fn determine_mount_type(mount_point: &str, device: &str) -> MountType { } } +/// Detect LUKS encryption status for a device. +/// Checks if the device is a dm-crypt/LUKS encrypted volume by examining /sys/block/*/dm/uuid +/// and querying lsblk for crypto information. +fn detect_luks_encryption(device: &str) -> Option { + // Extract device name (e.g., "dm-0" from "/dev/mapper/luks-xxx" or "sda1" from "/dev/sda1") + let device_name = device.strip_prefix("/dev/").unwrap_or(device); + + // Check if this is a device-mapper device (common for LUKS) + if device.starts_with("/dev/mapper/") || device.starts_with("/dev/dm-") { + // Try to get the dm device name for /dev/mapper paths + let dm_name = if device.starts_with("/dev/mapper/") { + // Resolve the mapper name to dm-X + if let Ok(resolved) = std::fs::read_link(device) { + resolved + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + } else { + None + } + } else { + Some(device_name.to_string()) + }; + + if let Some(dm_device) = dm_name { + // Check /sys/block/dm-X/dm/uuid for CRYPT-LUKS prefix + let uuid_path = format!("/sys/block/{}/dm/uuid", dm_device); + if let Ok(uuid) = std::fs::read_to_string(&uuid_path) { + let uuid = uuid.trim(); + if uuid.starts_with("CRYPT-LUKS") { + debug!("Detected LUKS encryption for device {} (uuid: {})", device, uuid); + return Some(VolumeEncryption::new(EncryptionType::LUKS, true)); + } + } + } + } + + // Alternative: use lsblk to detect crypto devices + // lsblk -o NAME,FSTYPE,TYPE -J shows TYPE=crypt for encrypted devices + if let Ok(output) = Command::new("lsblk") + .args(["-o", "NAME,TYPE", "-J", device]) + .output() + { + if output.status.success() { + let lsblk_output = String::from_utf8_lossy(&output.stdout); + // Check if any device in the hierarchy has type "crypt" + if lsblk_output.contains("\"type\":\"crypt\"") || lsblk_output.contains("\"type\": \"crypt\"") { + debug!("Detected LUKS encryption via lsblk for device {}", device); + return Some(VolumeEncryption::new(EncryptionType::LUKS, true)); + } + } + } + + // Check for eCryptfs (stacked filesystem encryption) + if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") { + for line in mounts.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 && parts[2] == "ecryptfs" { + // Check if this mount point matches our device's mount + if parts[0] == device { + debug!("Detected eCryptfs encryption for device {}", device); + return Some(VolumeEncryption::new(EncryptionType::ECryptfs, true)); + } + } + } + } + + None +} + /// Parse /proc/mounts for detailed mount information pub async fn parse_proc_mounts() -> VolumeResult> { task::spawn_blocking(|| { @@ -249,11 +325,15 @@ pub fn create_volume_from_mount(mount: MountInfo, device_id: Uuid) -> VolumeResu let volume_type = classify_volume(&mount_path, &file_system, &name); let fingerprint = VolumeFingerprint::new(&name, mount.total_bytes, &file_system.to_string()); + // Detect LUKS/eCryptfs encryption status + let encryption = detect_luks_encryption(&mount.device); + let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); volume.mount_type = mount_type; volume.volume_type = volume_type; volume.disk_type = disk_type; + volume.encryption = encryption; volume.file_system = file_system; volume.total_capacity = mount.total_bytes; volume.available_space = mount.available_bytes; diff --git a/core/src/volume/platform/macos.rs b/core/src/volume/platform/macos.rs index 8c566f57bb02..e7f8cdca3ce5 100644 --- a/core/src/volume/platform/macos.rs +++ b/core/src/volume/platform/macos.rs @@ -117,6 +117,7 @@ pub async fn detect_non_apfs_volumes( volume_type, mount_type, disk_type, + encryption: None, // Non-APFS macOS volumes don't have encryption detection yet file_system, total_capacity: total_bytes, available_space: available_bytes, diff --git a/core/src/volume/platform/windows.rs b/core/src/volume/platform/windows.rs index b94aff4fbb86..c5a346327ba4 100644 --- a/core/src/volume/platform/windows.rs +++ b/core/src/volume/platform/windows.rs @@ -1,5 +1,6 @@ //! Windows-specific volume detection helpers +use crate::domain::volume::{EncryptionType, VolumeEncryption}; use crate::volume::{ classification::{get_classifier, VolumeDetectionInfo}, error::{VolumeError, VolumeResult}, @@ -9,7 +10,7 @@ use crate::volume::{ use std::path::PathBuf; use std::process::Command; use tokio::task; -use tracing::warn; +use tracing::{debug, warn}; use uuid::Uuid; /// Windows volume information from PowerShell/WMI @@ -137,11 +138,15 @@ fn parse_wmic_output( let volume_type = classify_volume(&mount_path, &file_system, &name); let fingerprint = VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()); + // Detect BitLocker encryption status + let encryption = detect_bitlocker_encryption(caption); + let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); volume.mount_type = mount_type; volume.volume_type = volume_type; volume.disk_type = disk_type; + volume.encryption = encryption; volume.file_system = file_system; volume.total_capacity = total_bytes; volume.available_space = available_bytes; @@ -182,6 +187,91 @@ fn determine_mount_type_windows(drive_letter: &str) -> MountType { } } +/// Detect BitLocker encryption status for a Windows drive. +/// Uses PowerShell's Get-BitLockerVolume cmdlet or manage-bde command. +fn detect_bitlocker_encryption(drive_letter: &str) -> Option { + // Normalize drive letter format (e.g., "C:" or "C:\") + let drive = drive_letter.trim_end_matches('\\'); + + // Try Get-BitLockerVolume first (requires admin, but provides detailed info) + let powershell_result = Command::new("powershell") + .args([ + "-Command", + &format!( + "$vol = Get-BitLockerVolume -MountPoint '{}' -ErrorAction SilentlyContinue; \ + if ($vol) {{ $vol.ProtectionStatus.ToString() }} else {{ 'NotFound' }}", + drive + ), + ]) + .output(); + + if let Ok(output) = powershell_result { + if output.status.success() { + let status = String::from_utf8_lossy(&output.stdout).trim().to_string(); + match status.as_str() { + "On" => { + debug!("Detected BitLocker encryption (enabled) for drive {}", drive); + return Some(VolumeEncryption::new(EncryptionType::BitLocker, true)); + } + "Off" => { + // BitLocker is configured but protection is suspended/off + debug!("BitLocker present but protection off for drive {}", drive); + return Some(VolumeEncryption { + enabled: true, + encryption_type: EncryptionType::BitLocker, + is_unlocked: true, + }); + } + "Unknown" => { + // Encryption in progress or unknown state + debug!("BitLocker in unknown state for drive {}", drive); + return Some(VolumeEncryption::new(EncryptionType::BitLocker, true)); + } + _ => {} // "NotFound" or other - try fallback + } + } + } + + // Fallback: Use manage-bde (available on all Windows versions with BitLocker) + let manage_bde_result = Command::new("manage-bde") + .args(["-status", drive]) + .output(); + + if let Ok(output) = manage_bde_result { + let output_str = String::from_utf8_lossy(&output.stdout); + + // Check for encryption status indicators + if output_str.contains("Protection Status:") { + if output_str.contains("Protection On") { + debug!("Detected BitLocker encryption via manage-bde for drive {}", drive); + return Some(VolumeEncryption::new(EncryptionType::BitLocker, true)); + } else if output_str.contains("Protection Off") { + // BitLocker present but suspended + debug!("BitLocker suspended via manage-bde for drive {}", drive); + return Some(VolumeEncryption { + enabled: true, + encryption_type: EncryptionType::BitLocker, + is_unlocked: true, + }); + } + } + + // Check if drive is fully encrypted + if output_str.contains("Percentage Encrypted:") { + if output_str.contains("100%") || output_str.contains("100.0%") { + debug!("Detected fully encrypted BitLocker drive {}", drive); + return Some(VolumeEncryption::new(EncryptionType::BitLocker, true)); + } + } + } + + // Check for hardware encryption (some SSDs with OPAL) + // This would require WMI queries for MSFT_PhysicalDisk EncryptionType + // For now, we don't detect hardware encryption without explicit queries + + None +} + /// Get Windows volume info using PowerShell (stub for now) pub async fn get_windows_volume_info() -> VolumeResult> { // This would be implemented with proper PowerShell parsing @@ -217,11 +307,19 @@ pub fn create_volume_from_windows_info( let volume_type = classify_volume(&mount_path, &file_system, &name); let fingerprint = VolumeFingerprint::new(&name, info.size, &file_system.to_string()); + // Detect BitLocker encryption status + let encryption = if let Some(drive) = &info.drive_letter { + detect_bitlocker_encryption(&format!("{}:", drive)) + } else { + None + }; + let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path); volume.mount_type = mount_type; volume.volume_type = volume_type; volume.disk_type = DiskType::Unknown; + volume.encryption = encryption; volume.file_system = file_system; volume.total_capacity = info.size; volume.available_space = info.size_remaining; diff --git a/core/src/volume/speed.rs b/core/src/volume/speed.rs index 01d3042894c7..95417a8a21c1 100644 --- a/core/src/volume/speed.rs +++ b/core/src/volume/speed.rs @@ -351,6 +351,7 @@ mod tests { volume_type: VolumeType::External, mount_type: MountType::External, disk_type: DiskType::Unknown, + encryption: None, file_system: FileSystem::Other("test".to_string()), total_capacity: 1000000000, available_space: 500000000, diff --git a/core/src/volume/types.rs b/core/src/volume/types.rs index 6a07d5fe0cb4..9e03672f8082 100644 --- a/core/src/volume/types.rs +++ b/core/src/volume/types.rs @@ -5,7 +5,7 @@ // Re-export all volume types from domain pub use crate::domain::volume::{ - ApfsContainer, ApfsVolumeInfo, ApfsVolumeRole, DiskType, FileSystem, MountType, PathMapping, - SpacedriveVolumeId, TrackedVolume, Volume, VolumeDetectionConfig, VolumeEvent, - VolumeFingerprint, VolumeInfo, VolumeType, + ApfsContainer, ApfsVolumeInfo, ApfsVolumeRole, DiskType, EncryptionType, FileSystem, MountType, + PathMapping, SpacedriveVolumeId, TrackedVolume, Volume, VolumeDetectionConfig, VolumeEncryption, + VolumeEvent, VolumeFingerprint, VolumeInfo, VolumeType, };