Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
134 changes: 134 additions & 0 deletions core/src/domain/volume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method always returns None, making it a no-op. If the intent is to provide a constructor for the unencrypted case, consider returning Self with enabled: false instead, or removing this method entirely since callers can just use None directly.

}

/// Create an unencrypted volume marker
pub fn none() -> Option<Self> {
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
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ECryptfs excluded from at-rest encryption protection check

Medium Severity

The provides_at_rest_protection method does not include EncryptionType::ECryptfs in its match arms, despite ECryptfs being a valid encryption type that's detected on Linux volumes. Since ECryptfs does provide at-rest encryption for files, volumes using it will incorrectly get 3-pass secure delete instead of the optimized 1-pass deletion, causing unnecessary I/O overhead and wear.

Fix in Cursor Fix in Web

}

/// Represents an APFS container (physical storage with multiple volumes)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
pub struct ApfsContainer {
Expand Down Expand Up @@ -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<VolumeEncryption>,

/// Filesystem type
pub file_system: FileSystem,

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<EncryptionType> {
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 {
Expand Down Expand Up @@ -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
Expand Down
100 changes: 91 additions & 9 deletions core/src/ops/files/delete/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,

/// 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
Expand Down Expand Up @@ -86,16 +154,21 @@ impl JobHandler for DeleteJob {
async fn run(&mut self, ctx: JobContext<'_>) -> JobResult<Self::Output> {
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(
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions core/src/ops/files/delete/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading