From 80fd58a85eae7f81d1a876bdf26e2ae80936374b Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 19 Sep 2025 15:30:28 -0700 Subject: [PATCH] Update task statuses and enhance volume detection logic - Changed status of "Epic: Command-Line Interface" from "To Do" to "In Progress". - Updated status of "Semantic Tagging Architecture" from "To Do" to "Done". - Modified status of "Location Watcher Service" from "Done" to "In Progress". - Set status of "Stale File Detection Algorithm" from "In Progress" to "To Do". - Changed status of "Epic: Temporal-Semantic Search" from "To Do" to "In Progress". - Refactored volume detection logic to improve filesystem-aware operations, including enhancements for APFS, Btrfs, NTFS, and ReFS. - Introduced new platform-specific volume detection methods for Linux and macOS, streamlining the overall detection process. - Added comprehensive tests for volume detection and filesystem-aware copy strategy selection. --- .tasks/CLI-000-command-line-interface.md | 2 +- .../CORE-006-semantic-tagging-architecture.md | 2 +- .tasks/INDEX-001-location-watcher-service.md | 2 +- ...NDEX-002-stale-file-detection-algorithm.md | 2 +- .tasks/SEARCH-000-temporal-semantic-search.md | 2 +- core/src/ops/files/copy/routing.rs | 129 ++-- core/src/volume/detection.rs | 171 +++++ core/src/volume/fs/apfs.rs | 630 ++++++++++++++++++ core/src/volume/fs/btrfs.rs | 338 ++++++++++ core/src/volume/fs/generic.rs | 58 ++ core/src/volume/fs/mod.rs | 80 +++ core/src/volume/fs/ntfs.rs | 385 +++++++++++ core/src/volume/fs/refs.rs | 327 +++++++++ core/src/volume/fs/zfs.rs | 406 +++++++++++ core/src/volume/manager.rs | 49 +- core/src/volume/mod.rs | 22 +- core/src/volume/os_detection.rs | 581 ---------------- core/src/volume/platform/linux.rs | 270 ++++++++ core/src/volume/platform/macos.rs | 215 ++++++ core/src/volume/platform/mod.rs | 10 + core/src/volume/platform/windows.rs | 251 +++++++ core/src/volume/types.rs | 153 ++++- core/src/volume/utils.rs | 120 ++++ core/tests/volume_detection_test.rs | 477 +++++++++++++ 24 files changed, 3994 insertions(+), 688 deletions(-) create mode 100644 core/src/volume/detection.rs create mode 100644 core/src/volume/fs/apfs.rs create mode 100644 core/src/volume/fs/btrfs.rs create mode 100644 core/src/volume/fs/generic.rs create mode 100644 core/src/volume/fs/mod.rs create mode 100644 core/src/volume/fs/ntfs.rs create mode 100644 core/src/volume/fs/refs.rs create mode 100644 core/src/volume/fs/zfs.rs delete mode 100644 core/src/volume/os_detection.rs create mode 100644 core/src/volume/platform/linux.rs create mode 100644 core/src/volume/platform/macos.rs create mode 100644 core/src/volume/platform/mod.rs create mode 100644 core/src/volume/platform/windows.rs create mode 100644 core/src/volume/utils.rs create mode 100644 core/tests/volume_detection_test.rs diff --git a/.tasks/CLI-000-command-line-interface.md b/.tasks/CLI-000-command-line-interface.md index 5c4074942..a705672f2 100644 --- a/.tasks/CLI-000-command-line-interface.md +++ b/.tasks/CLI-000-command-line-interface.md @@ -1,7 +1,7 @@ --- id: CLI-000 title: "Epic: Command-Line Interface" -status: To Do +status: In Progress assignee: unassigned priority: High tags: [epic, cli] diff --git a/.tasks/CORE-006-semantic-tagging-architecture.md b/.tasks/CORE-006-semantic-tagging-architecture.md index 9a8580911..b36a087dd 100644 --- a/.tasks/CORE-006-semantic-tagging-architecture.md +++ b/.tasks/CORE-006-semantic-tagging-architecture.md @@ -1,7 +1,7 @@ --- id: CORE-006 title: Semantic Tagging Architecture -status: To Do +status: Done assignee: unassigned parent: CORE-000 priority: Medium diff --git a/.tasks/INDEX-001-location-watcher-service.md b/.tasks/INDEX-001-location-watcher-service.md index 42d4e62ab..f9b3faee5 100644 --- a/.tasks/INDEX-001-location-watcher-service.md +++ b/.tasks/INDEX-001-location-watcher-service.md @@ -1,7 +1,7 @@ --- id: INDEX-001 title: Location Watcher Service -status: Done +status: In Progress assignee: james parent: INDEX-000 priority: High diff --git a/.tasks/INDEX-002-stale-file-detection-algorithm.md b/.tasks/INDEX-002-stale-file-detection-algorithm.md index da35a71c6..e935fe34a 100644 --- a/.tasks/INDEX-002-stale-file-detection-algorithm.md +++ b/.tasks/INDEX-002-stale-file-detection-algorithm.md @@ -1,7 +1,7 @@ --- id: INDEX-002 title: Stale File Detection Algorithm -status: In Progress +status: To Do assignee: unassigned parent: INDEX-000 priority: High diff --git a/.tasks/SEARCH-000-temporal-semantic-search.md b/.tasks/SEARCH-000-temporal-semantic-search.md index 5413a963c..9bb543c2d 100644 --- a/.tasks/SEARCH-000-temporal-semantic-search.md +++ b/.tasks/SEARCH-000-temporal-semantic-search.md @@ -1,7 +1,7 @@ --- id: SEARCH-000 title: "Epic: Temporal-Semantic Search" -status: To Do +status: In Progress assignee: unassigned priority: High tags: [epic, search, ai, fts] diff --git a/core/src/ops/files/copy/routing.rs b/core/src/ops/files/copy/routing.rs index 26c3ce520..a6768d6be 100644 --- a/core/src/ops/files/copy/routing.rs +++ b/core/src/ops/files/copy/routing.rs @@ -53,25 +53,32 @@ impl CopyStrategyRouter { } }; - // Check if paths are on the same volume - let same_volume = if let Some(vm) = volume_manager { - vm.same_volume(source_path, dest_path).await + // Check if paths are on the same physical storage (filesystem-aware) + let same_storage = if let Some(vm) = volume_manager { + vm.same_physical_storage(source_path, dest_path).await } else { - // Fallback: if no volume manager, assume same-device local paths are same-volume - Self::paths_likely_same_volume(source_path, dest_path) + // Fallback: if no volume manager, assume same-device local paths are different storage + // This is safer than assuming same storage + false }; - if same_volume { - // Same volume + if same_storage { + // Same physical storage - use filesystem-specific strategy if is_move { - // Use atomic move for same-volume moves + // Use atomic move for same-storage moves return Box::new(LocalMoveStrategy); } else { - // Same-volume copy - use fast copy strategy (std::fs::copy handles optimizations) + // Same-storage copy - get filesystem-specific strategy + if let Some(vm) = volume_manager { + if let Some(volume) = vm.volume_for_path(source_path).await { + return crate::volume::fs::get_copy_strategy(&volume.file_system); + } + } + // Fallback to fast copy strategy return Box::new(FastCopyStrategy); } } else { - // Cross-volume operation - use streaming copy + // Cross-storage operation - use streaming copy return Box::new(LocalStreamCopyStrategy); } @@ -81,53 +88,6 @@ impl CopyStrategyRouter { } } - /// Heuristic to determine if two local paths are likely on the same volume - /// Used as fallback when VolumeManager is unavailable or incomplete - fn paths_likely_same_volume(path1: &std::path::Path, path2: &std::path::Path) -> bool { - // On macOS, paths under the same root are typically same volume - #[cfg(target_os = "macos")] - { - // Both under /Users, /Applications, /System, etc. are likely same volume - let common_roots = ["/Users", "/Applications", "/System", "/Library", "/private"]; - for root in &common_roots { - if path1.starts_with(root) && path2.starts_with(root) { - return true; - } - } - // Both directly under / (like /tmp, /var) are likely same volume - if path1.parent() == Some(std::path::Path::new("/")) - && path2.parent() == Some(std::path::Path::new("/")) - { - return true; - } - } - - // On Linux, similar heuristics - #[cfg(target_os = "linux")] - { - let common_roots = ["/home", "/usr", "/var", "/opt", "/tmp"]; - for root in &common_roots { - if path1.starts_with(root) && path2.starts_with(root) { - return true; - } - } - } - - // On Windows, same drive letter - #[cfg(target_os = "windows")] - { - if let (Some(s1), Some(s2)) = (path1.to_str(), path2.to_str()) { - if s1.len() >= 2 && s2.len() >= 2 { - return s1.chars().nth(0) == s2.chars().nth(0) - && s1.chars().nth(1) == Some(':') - && s2.chars().nth(1) == Some(':'); - } - } - } - - false - } - /// Provides a human-readable description of the selected strategy pub async fn describe_strategy( source: &SdPath, @@ -176,25 +136,44 @@ impl CopyStrategyRouter { } }; - // Check if paths are on the same volume (same logic as select_strategy) - let same_volume = if let Some(vm) = volume_manager { - vm.same_volume(source_path, dest_path).await + // Check if paths are on the same physical storage (same logic as select_strategy) + let same_storage = if let Some(vm) = volume_manager { + vm.same_physical_storage(source_path, dest_path).await } else { - Self::paths_likely_same_volume(source_path, dest_path) + false // Conservative fallback }; - if same_volume { + if same_storage { if is_move { - "Atomic move".to_string() + "Atomic move (same storage)".to_string() } else { - // Same-volume copy - use fast copy - "Fast copy".to_string() + // Same-storage copy - describe based on filesystem + if let Some(vm) = volume_manager { + if let Some(volume) = vm.volume_for_path(source_path).await { + return match volume.file_system { + crate::volume::types::FileSystem::APFS => { + "Fast copy (APFS clone)".to_string() + } + crate::volume::types::FileSystem::Btrfs => { + "Fast copy (Btrfs reflink)".to_string() + } + crate::volume::types::FileSystem::ZFS => { + "Fast copy (ZFS clone)".to_string() + } + crate::volume::types::FileSystem::ReFS => { + "Fast copy (ReFS block clone)".to_string() + } + _ => "Fast copy (same storage)".to_string(), + }; + } + } + "Fast copy (same storage)".to_string() } } else { if is_move { - "Cross-volume move".to_string() + "Streaming move (cross-storage)".to_string() } else { - "Cross-volume streaming copy".to_string() + "Streaming copy (cross-storage)".to_string() } } } @@ -260,14 +239,14 @@ impl CopyStrategyRouter { } }; - // Check if paths are on the same volume (same logic as select_strategy) - let same_volume = if let Some(vm) = volume_manager { - vm.same_volume(source_path, dest_path).await + // Check if paths are on the same physical storage (same logic as select_strategy) + let same_storage = if let Some(vm) = volume_manager { + vm.same_physical_storage(source_path, dest_path).await } else { - Self::paths_likely_same_volume(source_path, dest_path) + false // Conservative fallback }; - if same_volume { + if same_storage { if is_move { PerformanceEstimate { speed_category: SpeedCategory::Instant, @@ -276,16 +255,16 @@ impl CopyStrategyRouter { is_atomic: true, } } else { - // Same-volume copy - use fast copy + // Same-storage copy - use fast copy (APFS clone, etc.) PerformanceEstimate { - speed_category: SpeedCategory::FastLocal, + speed_category: SpeedCategory::Instant, // APFS clones are instant supports_resume: false, requires_network: false, is_atomic: true, } } } else { - // Cross-volume on same device + // Cross-storage on same device PerformanceEstimate { speed_category: SpeedCategory::LocalDisk, supports_resume: true, diff --git a/core/src/volume/detection.rs b/core/src/volume/detection.rs new file mode 100644 index 000000000..c7dd6f44e --- /dev/null +++ b/core/src/volume/detection.rs @@ -0,0 +1,171 @@ +//! Platform-specific volume detection orchestrator + +use crate::volume::{ + error::{VolumeError, VolumeResult}, + fs, + types::{Volume, VolumeDetectionConfig}, +}; +use std::collections::HashMap; +use tokio::task; +use tracing::{debug, instrument, warn}; +use uuid::Uuid; + +/// Detect all volumes on the system using platform-specific methods +#[instrument(skip(config))] +pub async fn detect_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + debug!("Starting volume detection for device {}", device_id); + + let mut volumes = Vec::new(); + + // Platform-specific detection + #[cfg(target_os = "macos")] + { + volumes.extend(detect_macos_volumes(device_id, config).await?); + } + + #[cfg(target_os = "linux")] + { + volumes.extend(detect_linux_volumes(device_id, config).await?); + } + + #[cfg(target_os = "windows")] + { + volumes.extend(detect_windows_volumes(device_id, config).await?); + } + + // Enhance volumes with filesystem-specific capabilities + enhance_volumes_with_fs_capabilities(&mut volumes).await?; + + debug!( + "Detected {} volumes for device {}", + volumes.len(), + device_id + ); + Ok(volumes) +} + +/// Enhance detected volumes with filesystem-specific capabilities +async fn enhance_volumes_with_fs_capabilities(volumes: &mut Vec) -> VolumeResult<()> { + for volume in volumes.iter_mut() { + match &volume.file_system { + crate::volume::types::FileSystem::APFS => { + // APFS volumes already have container info from detection + // But we could add additional APFS-specific metadata here + } + #[cfg(target_os = "linux")] + crate::volume::types::FileSystem::Btrfs => { + // Add btrfs subvolume and reflink capability detection + fs::btrfs::enhance_volume_from_mount(volume).await?; + } + #[cfg(target_os = "linux")] + crate::volume::types::FileSystem::ZFS => { + // Add ZFS pool and clone capability detection + fs::zfs::enhance_volume_from_mount(volume).await?; + } + #[cfg(target_os = "windows")] + crate::volume::types::FileSystem::ReFS => { + // Add ReFS block cloning capability detection + fs::refs::enhance_volume_from_windows(volume).await?; + } + #[cfg(target_os = "windows")] + crate::volume::types::FileSystem::NTFS => { + // Add NTFS feature detection + fs::ntfs::enhance_volume_from_windows(volume).await?; + } + _ => { + // No special handling for other filesystems + } + } + } + Ok(()) +} + +#[cfg(target_os = "macos")] +async fn detect_macos_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + use crate::volume::fs::apfs; + use std::process::Command; + + debug!("Starting macOS volume detection"); + let mut volumes = Vec::new(); + + // Detect APFS containers using filesystem-specific module + let containers = apfs::detect_containers().await?; + debug!("Detected {} APFS containers", containers.len()); + + // Convert APFS containers to volumes + for container in containers { + volumes.extend(apfs::containers_to_volumes(container, device_id, config)?); + } + + // Detect non-APFS volumes using traditional methods + volumes.extend(detect_generic_volumes_macos(device_id, config).await?); + + Ok(volumes) +} + +#[cfg(target_os = "linux")] +async fn detect_linux_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + use crate::volume::platform::linux; + + debug!("Starting Linux volume detection"); + let mut volumes = linux::detect_volumes(device_id, config).await?; + + // Enhance with filesystem-specific capabilities + for volume in &mut volumes { + match &volume.file_system { + crate::volume::types::FileSystem::Btrfs => { + fs::btrfs::enhance_volume_from_mount(volume).await?; + } + crate::volume::types::FileSystem::ZFS => { + fs::zfs::enhance_volume_from_mount(volume).await?; + } + _ => {} + } + } + + Ok(volumes) +} + +#[cfg(target_os = "windows")] +async fn detect_windows_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + use crate::volume::platform::windows; + + debug!("Starting Windows volume detection"); + let mut volumes = windows::detect_volumes(device_id, config).await?; + + // Enhance with filesystem-specific capabilities + for volume in &mut volumes { + match &volume.file_system { + crate::volume::types::FileSystem::ReFS => { + fs::refs::enhance_volume_from_windows(volume).await?; + } + crate::volume::types::FileSystem::NTFS => { + fs::ntfs::enhance_volume_from_windows(volume).await?; + } + _ => {} + } + } + + Ok(volumes) +} + +#[cfg(target_os = "macos")] +async fn detect_generic_volumes_macos( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + use crate::volume::platform::macos; + macos::detect_non_apfs_volumes(device_id, config).await +} diff --git a/core/src/volume/fs/apfs.rs b/core/src/volume/fs/apfs.rs new file mode 100644 index 000000000..438f6c900 --- /dev/null +++ b/core/src/volume/fs/apfs.rs @@ -0,0 +1,630 @@ +//! APFS filesystem-specific detection and optimization +//! +//! This module handles APFS container detection and provides APFS-specific +//! 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::volume::{ + error::{VolumeError, VolumeResult}, + types::{ + ApfsContainer, ApfsVolumeInfo, ApfsVolumeRole, DiskType, FileSystem, PathMapping, Volume, + VolumeDetectionConfig, + }, +}; +use std::path::PathBuf; +use std::process::Command; +use tokio::task; +use tracing::{debug, warn}; +use uuid::Uuid; + +/// Parse APFS container structure using diskutil +pub async fn detect_containers() -> VolumeResult> { + debug!("Starting APFS container detection"); + + task::spawn_blocking(|| { + // Use diskutil apfs list to get container information + let output = Command::new("diskutil") + .args(["apfs", "list"]) + .output() + .map_err(|e| { + VolumeError::platform(format!("Failed to run diskutil apfs list: {}", e)) + })?; + + if !output.status.success() { + return Err(VolumeError::platform(format!( + "diskutil apfs list failed with status: {}", + output.status + ))); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_apfs_list_output(&output_text) + }) + .await + .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? +} + +/// Parse the output of `diskutil apfs list` +fn parse_apfs_list_output(output: &str) -> VolumeResult> { + let mut containers = Vec::new(); + let mut current_container: Option = None; + let mut current_volumes = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + + // Container header: "+-- Container disk3 55E8C6B4-C7AC-48F5-B67A-A4B765DE3F41" + if line.starts_with("+-- Container ") { + // Save previous container if exists + if let Some(mut container) = current_container.take() { + container.volumes = current_volumes.clone(); + containers.push(container); + current_volumes.clear(); + } + + // Parse new container + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + let container_id = parts[2].to_string(); // e.g., "disk3" + let uuid = parts[3].to_string(); + + current_container = Some(ApfsContainer { + container_id, + uuid, + physical_store: String::new(), // Will be filled later + total_capacity: 0, // Will be filled later + capacity_in_use: 0, // Will be filled later + capacity_free: 0, // Will be filled later + volumes: Vec::new(), + }); + } + } + // Container capacity info: "Size (Capacity Ceiling): 994662584320 B (994.7 GB)" + else if line.contains("Size (Capacity Ceiling):") { + if let Some(container) = &mut current_container { + if let Some(bytes_str) = extract_bytes_from_line(line) { + container.total_capacity = bytes_str; + } + } + } + // Capacity in use: "Capacity In Use By Volumes: 853884600320 B (853.9 GB)" + else if line.contains("Capacity In Use By Volumes:") { + if let Some(container) = &mut current_container { + if let Some(bytes_str) = extract_bytes_from_line(line) { + container.capacity_in_use = bytes_str; + } + } + } + // Capacity free: "Capacity Not Allocated: 140777984000 B (140.8 GB)" + else if line.contains("Capacity Not Allocated:") { + if let Some(container) = &mut current_container { + if let Some(bytes_str) = extract_bytes_from_line(line) { + container.capacity_free = bytes_str; + } + } + } + // Physical store: "+-< Physical Store disk0s2 138391DF-DFE1-4AF1-ADAD-65B5A50334FA" + else if line.contains("Physical Store ") { + if let Some(container) = &mut current_container { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + container.physical_store = parts[3].to_string(); // e.g., "disk0s2" + } + } + } + // Volume header: "+-> Volume disk3s5 589962A3-6036-4CAA-BE8E-0E90B5921035" + else if line.starts_with("+-> Volume ") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + let disk_id = parts[2].to_string(); // e.g., "disk3s5" + let uuid = parts[3].to_string(); + + let volume_info = ApfsVolumeInfo { + disk_id, + uuid, + role: ApfsVolumeRole::Other("Unknown".to_string()), + name: String::new(), + mount_point: None, + capacity_consumed: 0, + sealed: false, + filevault: false, + }; + current_volumes.push(volume_info); + } + } + // Volume role: "| APFS Volume Disk (Role): disk3s5 (Data)" + else if line.contains("APFS Volume Disk (Role):") && !current_volumes.is_empty() { + let last_volume = current_volumes.last_mut().unwrap(); + if let Some(role_str) = extract_role_from_line(line) { + last_volume.role = parse_apfs_role(&role_str); + } + } + // Volume name: "| Name: Data (Case-insensitive)" + else if line.contains("Name:") && !current_volumes.is_empty() { + let last_volume = current_volumes.last_mut().unwrap(); + if let Some(name) = extract_name_from_line(line) { + last_volume.name = name; + } + } + // Mount point: "| Mount Point: /System/Volumes/Data" + else if line.contains("Mount Point:") + && !line.contains("Snapshot Mount Point:") + && !current_volumes.is_empty() + { + let last_volume = current_volumes.last_mut().unwrap(); + debug!("Found mount point line: '{}'", line); + if let Some(mount_point) = extract_mount_point_from_line(line) { + debug!("Extracted mount point: '{}'", mount_point); + last_volume.mount_point = Some(PathBuf::from(mount_point)); + } else { + debug!("Failed to extract mount point from line: '{}'", line); + } + } + // Capacity consumed: "| Capacity Consumed: 821093748736 B (821.1 GB)" + else if line.contains("Capacity Consumed:") && !current_volumes.is_empty() { + let last_volume = current_volumes.last_mut().unwrap(); + if let Some(bytes_str) = extract_bytes_from_line(line) { + last_volume.capacity_consumed = bytes_str; + } + } + // Sealed status: "| Sealed: Yes" + else if line.contains("Sealed:") + && !line.contains("Snapshot Sealed:") + && !current_volumes.is_empty() + { + let last_volume = current_volumes.last_mut().unwrap(); + last_volume.sealed = line.contains("Yes"); + } + // FileVault status: "| FileVault: Yes (Unlocked)" + else if line.contains("FileVault:") && !current_volumes.is_empty() { + let last_volume = current_volumes.last_mut().unwrap(); + last_volume.filevault = line.contains("Yes"); + } + } + + // Save the last container + if let Some(mut container) = current_container { + container.volumes = current_volumes; + containers.push(container); + } + + debug!("Detected {} APFS containers", containers.len()); + Ok(containers) +} + +/// Extract byte value from a line like "Size: 994662584320 B (994.7 GB)" +fn extract_bytes_from_line(line: &str) -> Option { + // Look for pattern "NUMBER B" + let parts: Vec<&str> = line.split_whitespace().collect(); + for i in 0..parts.len().saturating_sub(1) { + if parts[i + 1] == "B" { + if let Ok(bytes) = parts[i].parse::() { + return Some(bytes); + } + } + } + None +} + +/// Extract role from a line like "APFS Volume Disk (Role): disk3s5 (Data)" +fn extract_role_from_line(line: &str) -> Option { + // Look for text in parentheses at the end + if let Some(start) = line.rfind('(') { + if let Some(end) = line.rfind(')') { + if start < end { + return Some(line[start + 1..end].to_string()); + } + } + } + None +} + +/// Extract name from a line like "| Name: Data (Case-insensitive)" +fn extract_name_from_line(line: &str) -> Option { + // Find "Name:" in the line and extract everything after it + let name_pos = line.find("Name:")?; + let name_part = line[name_pos + "Name:".len()..].trim(); + if let Some(paren_pos) = name_part.find('(') { + Some(name_part[..paren_pos].trim().to_string()) + } else { + Some(name_part.to_string()) + } +} + +/// Extract mount point from a line like "| Mount Point: /System/Volumes/Data" +fn extract_mount_point_from_line(line: &str) -> Option { + // Find "Mount Point:" in the line and extract everything after it + let mount_point_pos = line.find("Mount Point:")?; + let mount_part = line[mount_point_pos + "Mount Point:".len()..].trim(); + if mount_part == "Not Mounted" { + None + } else { + Some(mount_part.to_string()) + } +} + +/// Parse APFS volume role from string +fn parse_apfs_role(role_str: &str) -> ApfsVolumeRole { + match role_str.to_lowercase().as_str() { + "system" => ApfsVolumeRole::System, + "data" => ApfsVolumeRole::Data, + "preboot" => ApfsVolumeRole::Preboot, + "recovery" => ApfsVolumeRole::Recovery, + "vm" => ApfsVolumeRole::VM, + other => ApfsVolumeRole::Other(other.to_string()), + } +} + +/// Convert APFS containers to Volume objects +pub fn containers_to_volumes( + container: ApfsContainer, + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + let mut volumes = Vec::new(); + + for volume_info in &container.volumes { + // Only process mounted volumes + if let Some(mount_point) = &volume_info.mount_point { + // Skip system volumes unless configured to include them + if !config.include_system + && matches!( + volume_info.role, + ApfsVolumeRole::System | ApfsVolumeRole::Preboot | ApfsVolumeRole::Recovery + ) { + debug!( + "Skipping system volume: {} ({})", + volume_info.name, volume_info.role + ); + continue; + } + + // Generate path mappings for Data volumes + let path_mappings = if matches!(volume_info.role, ApfsVolumeRole::Data) { + generate_macos_path_mappings() + } else { + Vec::new() + }; + + // Create volume fingerprint using container info for better uniqueness + let fingerprint = crate::volume::types::VolumeFingerprint::new( + &format!("{}:{}", container.container_id, volume_info.disk_id), + container.total_capacity, + "APFS", + ); + + // Determine mount and volume types + let mount_type = determine_mount_type(&volume_info.role, mount_point); + let volume_type = classify_volume_type(&volume_info.role, mount_point); + + // Get space information (would need platform-specific implementation) + let (total_bytes, available_bytes) = get_volume_space_info(mount_point)?; + + // Create volume with APFS container information + let volume = Volume::new_with_apfs_container( + device_id, + volume_info.name.clone(), + mount_type, + volume_type, + mount_point.clone(), + vec![], // Additional mount points handled via path mappings + DiskType::Unknown, // Would need platform-specific detection + FileSystem::APFS, + total_bytes, + available_bytes, + volume_info.sealed, + Some(volume_info.disk_id.clone()), + fingerprint, + container.clone(), + volume_info.disk_id.clone(), + path_mappings, + ); + + volumes.push(volume); + debug!( + "Added APFS volume: {} at {}", + volume_info.name, + mount_point.display() + ); + } + } + + Ok(volumes) +} + +/// Determine mount type based on APFS volume role and mount point +fn determine_mount_type( + role: &ApfsVolumeRole, + mount_point: &PathBuf, +) -> crate::volume::types::MountType { + use crate::volume::types::MountType; + + match role { + ApfsVolumeRole::System + | ApfsVolumeRole::Preboot + | ApfsVolumeRole::Recovery + | ApfsVolumeRole::VM => MountType::System, + ApfsVolumeRole::Data => MountType::System, // Data volume is still system-level + ApfsVolumeRole::Other(_) => { + // For other volumes, check mount point + if mount_point.starts_with("/Volumes/") { + MountType::External + } else { + MountType::System + } + } + } +} + +/// Classify APFS volume type based on role and mount point +fn classify_volume_type( + role: &ApfsVolumeRole, + mount_point: &PathBuf, +) -> crate::volume::types::VolumeType { + use crate::volume::types::VolumeType; + + match role { + ApfsVolumeRole::System => VolumeType::System, + ApfsVolumeRole::Data => VolumeType::UserData, + ApfsVolumeRole::Preboot | ApfsVolumeRole::Recovery => VolumeType::System, + ApfsVolumeRole::VM => VolumeType::System, + ApfsVolumeRole::Other(_) => { + if mount_point.starts_with("/Volumes/") { + VolumeType::External + } else { + VolumeType::Secondary + } + } + } +} + +/// Get volume space information (platform-specific implementation needed) +fn get_volume_space_info(mount_point: &PathBuf) -> VolumeResult<(u64, u64)> { + // This would need platform-specific implementation + // For now, return zeros as placeholder + #[cfg(target_os = "macos")] + { + use std::process::Command; + + let output = Command::new("df") + .args(["-k", mount_point.to_str().unwrap_or("/")]) + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; + + if !output.status.success() { + return Ok((0, 0)); + } + + let df_stdout = String::from_utf8_lossy(&output.stdout); + for line in df_stdout.lines().skip(1) { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 4 { + let total_kb = fields[1].parse::().unwrap_or(0); + let available_kb = fields[3].parse::().unwrap_or(0); + return Ok((total_kb * 1024, available_kb * 1024)); + } + } + } + + Ok((0, 0)) +} + +/// APFS filesystem handler +pub struct ApfsHandler; + +impl ApfsHandler { + pub fn new() -> Self { + Self + } + + /// Check if two paths are on the same APFS container + pub async fn same_physical_storage( + &self, + path1: &std::path::Path, + path2: &std::path::Path, + ) -> bool { + // Resolve paths to actual storage locations (handle firmlinks) + let resolved_path1 = self.resolve_apfs_path(path1).await; + let resolved_path2 = self.resolve_apfs_path(path2).await; + + // Get APFS container information for both paths + if let (Ok(container1), Ok(container2)) = ( + self.get_container_for_path(&resolved_path1).await, + self.get_container_for_path(&resolved_path2).await, + ) { + // Same container = same physical storage + return container1.container_id == container2.container_id; + } + + false + } + + /// Resolve APFS path through firmlinks + async fn resolve_apfs_path(&self, path: &std::path::Path) -> PathBuf { + let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let path_str = canonical_path.to_string_lossy(); + + // Handle common firmlinks to /System/Volumes/Data + if path_str.starts_with("/Users/") { + return PathBuf::from(format!("/System/Volumes/Data{}", path_str)); + } + if path_str.starts_with("/Applications/") + && !path_str.starts_with("/Applications/Utilities/") + { + return PathBuf::from(format!("/System/Volumes/Data{}", path_str)); + } + if path_str.starts_with("/Library/") && !path_str.starts_with("/Library/Apple/") { + return PathBuf::from(format!("/System/Volumes/Data{}", path_str)); + } + if path_str.starts_with("/tmp/") { + return PathBuf::from(format!("/System/Volumes/Data{}", path_str)); + } + if path_str.starts_with("/var/") && !path_str.starts_with("/var/db/") { + return PathBuf::from(format!("/System/Volumes/Data{}", path_str)); + } + + canonical_path + } + + /// Get APFS container information for a path + async fn get_container_for_path(&self, path: &std::path::Path) -> VolumeResult { + // Get all containers and find the one containing this path + let containers = detect_containers().await?; + + for container in containers { + for volume in &container.volumes { + if let Some(mount_point) = &volume.mount_point { + if path.starts_with(mount_point) { + return Ok(container); + } + } + } + } + + Err(VolumeError::platform( + "Path not found in any APFS container".to_string(), + )) + } +} + +#[async_trait::async_trait] +impl super::FilesystemHandler for ApfsHandler { + async fn enhance_volume(&self, volume: &mut Volume) -> VolumeResult<()> { + // APFS volumes should already have container info from detection + // Could add additional APFS-specific metadata here if needed + Ok(()) + } + + async fn same_physical_storage( + &self, + path1: &std::path::Path, + path2: &std::path::Path, + ) -> bool { + self.same_physical_storage(path1, path2).await + } + + fn get_copy_strategy(&self) -> Box { + // Use fast copy strategy for APFS (leverages copy-on-write) + Box::new(crate::ops::files::copy::strategy::FastCopyStrategy) + } + + fn contains_path(&self, volume: &crate::volume::types::Volume, path: &std::path::Path) -> bool { + // Check primary mount point + if path.starts_with(&volume.mount_point) { + return true; + } + + // Check additional mount points + if volume.mount_points.iter().any(|mp| path.starts_with(mp)) { + return true; + } + + // APFS-specific: Check path mappings (firmlinks) + for mapping in &volume.path_mappings { + if path.starts_with(&mapping.virtual_path) { + // Convert virtual path to actual path and check if it's on this volume + if let Ok(relative_path) = path.strip_prefix(&mapping.virtual_path) { + let actual_path = mapping.actual_path.join(relative_path); + if actual_path.starts_with(&volume.mount_point) { + return true; + } + } + } + } + + false + } +} + +/// Generate macOS path mappings for firmlinks +pub fn generate_macos_path_mappings() -> Vec { + vec![ + PathMapping { + virtual_path: PathBuf::from("/Users"), + actual_path: PathBuf::from("/System/Volumes/Data/Users"), + }, + PathMapping { + virtual_path: PathBuf::from("/Applications"), + actual_path: PathBuf::from("/System/Volumes/Data/Applications"), + }, + PathMapping { + virtual_path: PathBuf::from("/Library"), + actual_path: PathBuf::from("/System/Volumes/Data/Library"), + }, + PathMapping { + virtual_path: PathBuf::from("/tmp"), + actual_path: PathBuf::from("/System/Volumes/Data/tmp"), + }, + PathMapping { + virtual_path: PathBuf::from("/var"), + actual_path: PathBuf::from("/System/Volumes/Data/var"), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_bytes_from_line() { + assert_eq!( + extract_bytes_from_line("Size (Capacity Ceiling): 994662584320 B (994.7 GB)"), + Some(994662584320) + ); + assert_eq!( + extract_bytes_from_line("Capacity Consumed: 821093748736 B (821.1 GB)"), + Some(821093748736) + ); + assert_eq!(extract_bytes_from_line("No bytes here"), None); + } + + #[test] + fn test_extract_role_from_line() { + assert_eq!( + extract_role_from_line("APFS Volume Disk (Role): disk3s5 (Data)"), + Some("Data".to_string()) + ); + assert_eq!( + extract_role_from_line("APFS Volume Disk (Role): disk3s1 (System)"), + Some("System".to_string()) + ); + } + + #[test] + fn test_extract_name_from_line() { + assert_eq!( + extract_name_from_line("Name: Data (Case-insensitive)"), + Some("Data".to_string()) + ); + assert_eq!( + extract_name_from_line("Name: Macintosh HD (Case-insensitive)"), + Some("Macintosh HD".to_string()) + ); + } + + #[test] + fn test_extract_mount_point_from_line() { + assert_eq!( + extract_mount_point_from_line("Mount Point: /System/Volumes/Data"), + Some("/System/Volumes/Data".to_string()) + ); + assert_eq!( + extract_mount_point_from_line("Mount Point: Not Mounted"), + None + ); + } + + #[test] + fn test_parse_apfs_role() { + assert_eq!(parse_apfs_role("System"), ApfsVolumeRole::System); + assert_eq!(parse_apfs_role("Data"), ApfsVolumeRole::Data); + assert_eq!(parse_apfs_role("Preboot"), ApfsVolumeRole::Preboot); + assert_eq!(parse_apfs_role("Recovery"), ApfsVolumeRole::Recovery); + assert_eq!(parse_apfs_role("VM"), ApfsVolumeRole::VM); + assert_eq!( + parse_apfs_role("Custom"), + ApfsVolumeRole::Other("custom".to_string()) + ); + } +} diff --git a/core/src/volume/fs/btrfs.rs b/core/src/volume/fs/btrfs.rs new file mode 100644 index 000000000..c98ded142 --- /dev/null +++ b/core/src/volume/fs/btrfs.rs @@ -0,0 +1,338 @@ +//! Btrfs filesystem-specific detection and optimization +//! +//! This module handles Btrfs subvolume detection and provides Btrfs-specific +//! optimizations like reflink copy-on-write operations. + +use crate::volume::{error::VolumeResult, types::Volume}; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tokio::task; +use tracing::{debug, warn}; + +/// Btrfs filesystem handler +pub struct BtrfsHandler; + +impl BtrfsHandler { + pub fn new() -> Self { + Self + } + + /// Check if two paths are on the same Btrfs filesystem and support reflinks + pub async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + // Check if both paths are on Btrfs filesystems + if let (Ok(fs1), Ok(fs2)) = ( + self.get_filesystem_info(path1).await, + self.get_filesystem_info(path2).await, + ) { + // Same filesystem UUID = same physical storage + return fs1.uuid == fs2.uuid && fs1.supports_reflinks && fs2.supports_reflinks; + } + + false + } + + /// Get Btrfs filesystem information for a path + async fn get_filesystem_info(&self, path: &Path) -> VolumeResult { + let path = path.to_path_buf(); + + task::spawn_blocking(move || { + // Use btrfs filesystem show to get UUID and device info + let output = Command::new("btrfs") + .args(["filesystem", "show", path.to_str().unwrap_or("/")]) + .output() + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!( + "Failed to run btrfs: {}", + e + )) + })?; + + if !output.status.success() { + return Err(crate::volume::error::VolumeError::platform( + "btrfs command failed".to_string(), + )); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_btrfs_filesystem_info(&output_text) + }) + .await + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)) + })? + } + + /// Check if a path supports reflinks + async fn supports_reflinks(&self, path: &Path) -> bool { + // Try to get filesystem features + let path = path.to_path_buf(); + + let result = task::spawn_blocking(move || { + let output = Command::new("btrfs") + .args(["filesystem", "features", path.to_str().unwrap_or("/")]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let output_text = String::from_utf8_lossy(&output.stdout); + output_text.contains("reflink") + } + _ => true, // Assume reflinks are supported on Btrfs by default + } + }) + .await; + + result.unwrap_or(true) + } + + /// Get subvolume information for enhanced volume detection + pub async fn get_subvolume_info(&self, path: &Path) -> VolumeResult> { + let path = path.to_path_buf(); + + task::spawn_blocking(move || { + let output = Command::new("btrfs") + .args(["subvolume", "show", path.to_str().unwrap_or("/")]) + .output() + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!( + "Failed to run btrfs subvolume show: {}", + e + )) + })?; + + if !output.status.success() { + return Ok(None); // Not a subvolume or btrfs command failed + } + + let output_text = String::from_utf8_lossy(&output.stdout); + Ok(Some(parse_subvolume_info(&output_text)?)) + }) + .await + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)) + })? + } +} + +#[async_trait] +impl super::FilesystemHandler for BtrfsHandler { + async fn enhance_volume(&self, volume: &mut Volume) -> VolumeResult<()> { + // Add Btrfs-specific information like subvolume details + if let Some(mount_point) = volume.mount_point.to_str() { + if let Ok(Some(subvol_info)) = self.get_subvolume_info(Path::new(mount_point)).await { + debug!( + "Enhanced Btrfs volume with subvolume info: {:?}", + subvol_info + ); + // Could store subvolume info in volume metadata if needed + } + } + Ok(()) + } + + async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + self.same_physical_storage(path1, path2).await + } + + fn get_copy_strategy(&self) -> Box { + // Use reflink copy strategy for Btrfs (copy-on-write) + Box::new(crate::ops::files::copy::strategy::FastCopyStrategy) + } + + fn contains_path(&self, volume: &Volume, path: &std::path::Path) -> bool { + // Check primary mount point + if path.starts_with(&volume.mount_point) { + return true; + } + + // Check additional mount points + if volume.mount_points.iter().any(|mp| path.starts_with(mp)) { + return true; + } + + // TODO: Btrfs-specific logic for subvolumes and bind mounts + // Btrfs can have subvolumes mounted at different locations within the same filesystem + // This would require checking if paths are within the same Btrfs filesystem UUID + // even if they have different mount points + + false + } +} + +/// Btrfs filesystem information +#[derive(Debug, Clone)] +struct BtrfsInfo { + uuid: String, + label: Option, + devices: Vec, + supports_reflinks: bool, +} + +/// Btrfs subvolume information +#[derive(Debug, Clone)] +pub struct SubvolumeInfo { + pub name: String, + pub uuid: String, + pub parent_uuid: Option, + pub creation_time: Option, + pub subvolume_id: u64, + pub generation: u64, +} + +/// Parse btrfs filesystem show output +fn parse_btrfs_filesystem_info(output: &str) -> VolumeResult { + let mut uuid = String::new(); + let mut label = None; + let mut devices = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + + // Parse UUID: "uuid: 12345678-1234-1234-1234-123456789abc" + if line.starts_with("uuid:") { + if let Some(uuid_str) = line.split_whitespace().nth(1) { + uuid = uuid_str.to_string(); + } + } + // Parse label: "Label: 'MyVolume' uuid: ..." + else if line.starts_with("Label:") { + if let Some(label_part) = line.split("uuid:").next() { + if let Some(label_str) = label_part.strip_prefix("Label:").map(|s| s.trim()) { + if label_str != "none" && !label_str.is_empty() { + label = Some(label_str.trim_matches('\'').to_string()); + } + } + } + } + // Parse devices: " devid 1 size 931.51GiB used 123.45GiB path /dev/sda1" + else if line.contains("devid") && line.contains("path") { + if let Some(path_part) = line.split("path").nth(1) { + devices.push(path_part.trim().to_string()); + } + } + } + + if uuid.is_empty() { + return Err(crate::volume::error::VolumeError::platform( + "Could not parse Btrfs UUID".to_string(), + )); + } + + Ok(BtrfsInfo { + uuid, + label, + devices, + supports_reflinks: true, // Btrfs supports reflinks by default + }) +} + +/// Parse btrfs subvolume show output +fn parse_subvolume_info(output: &str) -> VolumeResult { + let mut name = String::new(); + let mut uuid = String::new(); + let mut parent_uuid = None; + let mut creation_time = None; + let mut subvolume_id = 0; + let mut generation = 0; + + for line in output.lines() { + let line = line.trim(); + + if line.starts_with("Name:") { + name = line.strip_prefix("Name:").unwrap_or("").trim().to_string(); + } else if line.starts_with("UUID:") { + uuid = line.strip_prefix("UUID:").unwrap_or("").trim().to_string(); + } else if line.starts_with("Parent UUID:") { + let parent = line.strip_prefix("Parent UUID:").unwrap_or("").trim(); + if parent != "-" { + parent_uuid = Some(parent.to_string()); + } + } else if line.starts_with("Creation time:") { + creation_time = Some( + line.strip_prefix("Creation time:") + .unwrap_or("") + .trim() + .to_string(), + ); + } else if line.starts_with("Subvolume ID:") { + if let Some(id_str) = line + .strip_prefix("Subvolume ID:") + .and_then(|s| s.trim().parse().ok()) + { + subvolume_id = id_str; + } + } else if line.starts_with("Generation:") { + if let Some(gen_str) = line + .strip_prefix("Generation:") + .and_then(|s| s.trim().parse().ok()) + { + generation = gen_str; + } + } + } + + Ok(SubvolumeInfo { + name, + uuid, + parent_uuid, + creation_time, + subvolume_id, + generation, + }) +} + +/// Enhance volume with Btrfs-specific information from mount point +pub async fn enhance_volume_from_mount(volume: &mut Volume) -> VolumeResult<()> { + let handler = BtrfsHandler::new(); + handler.enhance_volume(volume).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_btrfs_filesystem_info() { + let output = r#" +Label: 'MyVolume' uuid: 12345678-1234-1234-1234-123456789abc + Total devices 1 FS bytes used 123.45GiB + devid 1 size 931.51GiB used 456.78GiB path /dev/sda1 +"#; + + let info = parse_btrfs_filesystem_info(output).unwrap(); + assert_eq!(info.uuid, "12345678-1234-1234-1234-123456789abc"); + assert_eq!(info.label, Some("MyVolume".to_string())); + assert_eq!(info.devices, vec!["/dev/sda1"]); + assert!(info.supports_reflinks); + } + + #[test] + fn test_parse_subvolume_info() { + let output = r#" +/home/user/subvol + Name: subvol + UUID: 87654321-4321-4321-4321-210987654321 + Parent UUID: 12345678-1234-1234-1234-123456789abc + Received UUID: - + Creation time: 2023-01-01 12:00:00 +0000 + Subvolume ID: 256 + Generation: 123 + Gen at creation: 100 + Parent ID: 5 + Top level ID: 5 + Flags: - + Snapshot(s): +"#; + + let info = parse_subvolume_info(output).unwrap(); + assert_eq!(info.name, "subvol"); + assert_eq!(info.uuid, "87654321-4321-4321-4321-210987654321"); + assert_eq!( + info.parent_uuid, + Some("12345678-1234-1234-1234-123456789abc".to_string()) + ); + assert_eq!(info.subvolume_id, 256); + assert_eq!(info.generation, 123); + } +} diff --git a/core/src/volume/fs/generic.rs b/core/src/volume/fs/generic.rs new file mode 100644 index 000000000..8cffdc909 --- /dev/null +++ b/core/src/volume/fs/generic.rs @@ -0,0 +1,58 @@ +//! Generic filesystem handler for unknown/unsupported filesystems + +use super::FilesystemHandler; +use crate::volume::{error::VolumeResult, types::Volume}; +use async_trait::async_trait; + +/// Generic handler for filesystems without specific optimizations +pub struct GenericFilesystemHandler; + +#[async_trait] +impl FilesystemHandler for GenericFilesystemHandler { + async fn enhance_volume(&self, _volume: &mut Volume) -> VolumeResult<()> { + // No special enhancements for generic filesystems + Ok(()) + } + + async fn same_physical_storage( + &self, + path1: &std::path::Path, + path2: &std::path::Path, + ) -> bool { + // For generic filesystems, we can only check if they resolve to the same device + // This is a conservative approach that may miss some optimizations + if let (Ok(meta1), Ok(meta2)) = (path1.metadata(), path2.metadata()) { + // On Unix systems, compare device IDs + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + return meta1.dev() == meta2.dev(); + } + + // On Windows, this is more complex and would need additional logic + #[cfg(windows)] + { + // For now, be conservative and assume different storage + return false; + } + } + + false + } + + fn get_copy_strategy(&self) -> Box { + // Use streaming copy as the safe default + Box::new(crate::ops::files::copy::strategy::LocalStreamCopyStrategy) + } + + fn contains_path(&self, volume: &Volume, path: &std::path::Path) -> bool { + // Generic implementation: only check mount points + // Check primary mount point + if path.starts_with(&volume.mount_point) { + return true; + } + + // Check additional mount points + volume.mount_points.iter().any(|mp| path.starts_with(mp)) + } +} diff --git a/core/src/volume/fs/mod.rs b/core/src/volume/fs/mod.rs new file mode 100644 index 000000000..b7688e9cf --- /dev/null +++ b/core/src/volume/fs/mod.rs @@ -0,0 +1,80 @@ +//! Filesystem-specific volume detection and optimization + +pub mod apfs; +pub mod generic; + +#[cfg(target_os = "linux")] +pub mod btrfs; + +#[cfg(target_os = "linux")] +pub mod zfs; + +#[cfg(target_os = "windows")] +pub mod refs; + +#[cfg(target_os = "windows")] +pub mod ntfs; + +use crate::volume::{ + error::VolumeResult, + types::{FileSystem, Volume}, +}; +use std::path::Path; + +/// Trait for filesystem-specific volume enhancement +#[async_trait::async_trait] +pub trait FilesystemHandler: Send + Sync { + /// Enhance a volume with filesystem-specific capabilities + async fn enhance_volume(&self, volume: &mut Volume) -> VolumeResult<()>; + + /// Check if two paths are on the same physical storage for this filesystem + async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool; + + /// Get the optimal copy strategy for this filesystem + fn get_copy_strategy(&self) -> Box; + + /// Check if a path is contained within a volume (filesystem-specific logic) + /// This allows each filesystem to implement custom path resolution logic + fn contains_path(&self, volume: &Volume, path: &Path) -> bool; +} + +/// Get the appropriate filesystem handler for a given filesystem type +pub fn get_filesystem_handler(filesystem: &FileSystem) -> Box { + match filesystem { + FileSystem::APFS => Box::new(apfs::ApfsHandler::new()), + + #[cfg(target_os = "linux")] + FileSystem::Btrfs => Box::new(btrfs::BtrfsHandler::new()), + + #[cfg(target_os = "linux")] + FileSystem::ZFS => Box::new(zfs::ZfsHandler::new()), + + #[cfg(target_os = "windows")] + FileSystem::ReFS => Box::new(refs::RefsHandler::new()), + + #[cfg(target_os = "windows")] + FileSystem::NTFS => Box::new(ntfs::NtfsHandler::new()), + + _ => Box::new(generic::GenericFilesystemHandler), + } +} + +/// Check if two paths are on the same physical storage using filesystem-specific logic +pub async fn same_physical_storage(path1: &Path, path2: &Path, filesystem: &FileSystem) -> bool { + let handler = get_filesystem_handler(filesystem); + handler.same_physical_storage(path1, path2).await +} + +/// Get the optimal copy strategy for a filesystem +pub fn get_copy_strategy( + filesystem: &FileSystem, +) -> Box { + let handler = get_filesystem_handler(filesystem); + handler.get_copy_strategy() +} + +/// Check if a path is contained within a volume using filesystem-specific logic +pub fn contains_path(volume: &Volume, path: &Path) -> bool { + let handler = get_filesystem_handler(&volume.file_system); + handler.contains_path(volume, path) +} diff --git a/core/src/volume/fs/ntfs.rs b/core/src/volume/fs/ntfs.rs new file mode 100644 index 000000000..bf1b0b1d7 --- /dev/null +++ b/core/src/volume/fs/ntfs.rs @@ -0,0 +1,385 @@ +//! NTFS filesystem-specific detection and optimization +//! +//! This module handles NTFS volume detection and provides NTFS-specific +//! optimizations like hardlink and junction point handling. + +use crate::volume::{error::VolumeResult, types::Volume}; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use tokio::task; +use tracing::{debug, warn}; + +/// NTFS filesystem handler +pub struct NtfsHandler; + +impl NtfsHandler { + pub fn new() -> Self { + Self + } + + /// Check if two paths are on the same NTFS volume + pub async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + // Check if both paths are on the same NTFS volume + if let (Ok(vol1), Ok(vol2)) = ( + self.get_volume_info(path1).await, + self.get_volume_info(path2).await, + ) { + // Same volume GUID = same physical storage + return vol1.volume_guid == vol2.volume_guid; + } + + false + } + + /// Get NTFS volume information for a path + async fn get_volume_info(&self, path: &Path) -> VolumeResult { + let path = path.to_path_buf(); + + task::spawn_blocking(move || { + // Use PowerShell to get volume information + let script = format!( + r#" + $volume = Get-Volume -FilePath '{}' + $partition = Get-Partition -DriveLetter $volume.DriveLetter + $disk = Get-Disk -Number $partition.DiskNumber + + [PSCustomObject]@{{ + VolumeGuid = $volume.UniqueId + FileSystem = $volume.FileSystem + DriveLetter = $volume.DriveLetter + Label = $volume.FileSystemLabel + Size = $volume.Size + SizeRemaining = $volume.SizeRemaining + DiskNumber = $partition.DiskNumber + PartitionNumber = $partition.PartitionNumber + MediaType = $disk.MediaType + }} | ConvertTo-Json + "#, + path.display() + ); + + let output = std::process::Command::new("powershell") + .args(["-Command", &script]) + .output() + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Failed to run PowerShell: {}", e)))?; + + if !output.status.success() { + return Err(crate::volume::error::VolumeError::platform("PowerShell command failed".to_string())); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_volume_info(&output_text) + }) + .await + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)))? + } + + /// Check if NTFS hardlinks are supported (they always are on NTFS) + pub async fn supports_hardlinks(&self, path: &Path) -> bool { + // NTFS always supports hardlinks + if let Ok(vol_info) = self.get_volume_info(path).await { + return vol_info.file_system == "NTFS"; + } + false + } + + /// Check if NTFS junction points are supported + pub async fn supports_junctions(&self, path: &Path) -> bool { + // NTFS supports junction points (directory symbolic links) + if let Ok(vol_info) = self.get_volume_info(path).await { + return vol_info.file_system == "NTFS"; + } + false + } + + /// Resolve junction points and symbolic links + pub async fn resolve_ntfs_path(&self, path: &Path) -> PathBuf { + let path = path.to_path_buf(); + + let result = task::spawn_blocking(move || { + // Use PowerShell to resolve the path + let script = format!( + r#" + try {{ + $resolvedPath = Resolve-Path -Path '{}' -ErrorAction Stop + Write-Output $resolvedPath.Path + }} catch {{ + Write-Output '{}' + }} + "#, + path.display(), + path.display() + ); + + let output = std::process::Command::new("powershell") + .args(["-Command", &script]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !resolved.is_empty() { + PathBuf::from(resolved) + } else { + path + } + } + _ => path, + } + }) + .await; + + result.unwrap_or(path) + } + + /// Get NTFS file system features + pub async fn get_ntfs_features(&self, path: &Path) -> VolumeResult { + let path = path.to_path_buf(); + + task::spawn_blocking(move || { + // Use fsutil to get NTFS features + let script = format!( + r#" + $driveLetter = Split-Path -Path '{}' -Qualifier + $features = @{{}} + + # Check for compression support + try {{ + $compressionInfo = fsutil behavior query DisableCompression 2>$null + $features.SupportsCompression = $true + }} catch {{ + $features.SupportsCompression = $false + }} + + # Check for encryption support + try {{ + $encryptionInfo = fsutil behavior query DisableEncryption 2>$null + $features.SupportsEncryption = $true + }} catch {{ + $features.SupportsEncryption = $false + }} + + # NTFS always supports these + $features.SupportsHardlinks = $true + $features.SupportsJunctions = $true + $features.SupportsSymlinks = $true + $features.SupportsStreams = $true + + $features | ConvertTo-Json + "#, + path.display() + ); + + let output = std::process::Command::new("powershell") + .args(["-Command", &script]) + .output() + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Failed to run PowerShell: {}", e)))?; + + if !output.status.success() { + // Return default NTFS features + return Ok(NtfsFeatures { + supports_hardlinks: true, + supports_junctions: true, + supports_symlinks: true, + supports_streams: true, + supports_compression: true, + supports_encryption: true, + }); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_ntfs_features(&output_text) + }) + .await + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)))? + } +} + +#[async_trait] +impl super::FilesystemHandler for NtfsHandler { + async fn enhance_volume(&self, volume: &mut Volume) -> VolumeResult<()> { + // Add NTFS-specific information like feature support + if let Some(mount_point) = volume.mount_point.to_str() { + if let Ok(features) = self.get_ntfs_features(Path::new(mount_point)).await { + debug!("Enhanced NTFS volume with features: {:?}", features); + // Could store NTFS features in volume metadata + } + } + Ok(()) + } + + async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + self.same_physical_storage(path1, path2).await + } + + fn get_copy_strategy(&self) -> Box { + // Use streaming copy for NTFS (no built-in CoW like APFS/ReFS) + // Could potentially use hardlinks for same-volume copies + Box::new(crate::ops::files::copy::strategy::LocalStreamCopyStrategy) + } + + fn contains_path(&self, volume: &Volume, path: &std::path::Path) -> bool { + // Check primary mount point + if path.starts_with(&volume.mount_point) { + return true; + } + + // Check additional mount points + if volume.mount_points.iter().any(|mp| path.starts_with(mp)) { + return true; + } + + // TODO: NTFS-specific logic for junction points and mount points + // Windows can have volumes mounted as folders (mount points) within other volumes + // NTFS also supports junction points and symbolic links that may need resolution + + false + } +} + +/// NTFS volume information +#[derive(Debug, Clone)] +pub struct NtfsVolumeInfo { + pub volume_guid: String, + pub file_system: String, + pub drive_letter: Option, + pub label: Option, + pub size_bytes: u64, + pub available_bytes: u64, + pub disk_number: Option, + pub partition_number: Option, + pub media_type: Option, +} + +/// NTFS filesystem features +#[derive(Debug, Clone)] +pub struct NtfsFeatures { + pub supports_hardlinks: bool, + pub supports_junctions: bool, + pub supports_symlinks: bool, + pub supports_streams: bool, + pub supports_compression: bool, + pub supports_encryption: bool, +} + +/// Parse PowerShell volume info JSON output +fn parse_volume_info(json_output: &str) -> VolumeResult { + // Simple JSON parsing - in production, you'd use serde_json + let json_output = json_output.trim(); + + let volume_guid = extract_json_string(json_output, "VolumeGuid").unwrap_or_default(); + let file_system = extract_json_string(json_output, "FileSystem").unwrap_or_default(); + let drive_letter_str = extract_json_string(json_output, "DriveLetter"); + let label = extract_json_string(json_output, "Label"); + let size_bytes = extract_json_number(json_output, "Size").unwrap_or(0); + let available_bytes = extract_json_number(json_output, "SizeRemaining").unwrap_or(0); + let disk_number = extract_json_number(json_output, "DiskNumber").map(|n| n as u32); + let partition_number = extract_json_number(json_output, "PartitionNumber").map(|n| n as u32); + let media_type = extract_json_string(json_output, "MediaType"); + + let drive_letter = drive_letter_str.and_then(|s| s.chars().next()); + + Ok(NtfsVolumeInfo { + volume_guid, + file_system, + drive_letter, + label, + size_bytes, + available_bytes, + disk_number, + partition_number, + media_type, + }) +} + +/// Parse NTFS features JSON output +fn parse_ntfs_features(json_output: &str) -> VolumeResult { + // Simple parsing - in production, use proper JSON parser + let json_output = json_output.trim(); + + let supports_compression = extract_json_bool(json_output, "SupportsCompression").unwrap_or(true); + let supports_encryption = extract_json_bool(json_output, "SupportsEncryption").unwrap_or(true); + + Ok(NtfsFeatures { + supports_hardlinks: true, // NTFS always supports these + supports_junctions: true, + supports_symlinks: true, + supports_streams: true, + supports_compression, + supports_encryption, + }) +} + +/// Extract string value from JSON (simple implementation) +fn extract_json_string(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":", key); + if let Some(start) = json.find(&pattern) { + let start = start + pattern.len(); + if let Some(value_start) = json[start..].find('"') { + let value_start = start + value_start + 1; + if let Some(value_end) = json[value_start..].find('"') { + let value = &json[value_start..value_start + value_end]; + if value != "null" && !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + None +} + +/// Extract number value from JSON (simple implementation) +fn extract_json_number(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":", key); + if let Some(start) = json.find(&pattern) { + let start = start + pattern.len(); + let remaining = json[start..].trim_start(); + if let Some(end) = remaining.find(|c: char| !c.is_ascii_digit()) { + let number_str = &remaining[..end]; + return number_str.parse().ok(); + } + } + None +} + +/// Extract boolean value from JSON (simple implementation) +fn extract_json_bool(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":", key); + if let Some(start) = json.find(&pattern) { + let start = start + pattern.len(); + let remaining = json[start..].trim_start(); + if remaining.starts_with("true") { + return Some(true); + } else if remaining.starts_with("false") { + return Some(false); + } + } + None +} + +/// Enhance volume with NTFS-specific information from Windows +pub async fn enhance_volume_from_windows(volume: &mut Volume) -> VolumeResult<()> { + let handler = NtfsHandler::new(); + handler.enhance_volume(volume).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_json_string() { + let json = r#"{"VolumeGuid": "12345678-1234-1234-1234-123456789abc", "FileSystem": "NTFS"}"#; + assert_eq!(extract_json_string(json, "VolumeGuid"), Some("12345678-1234-1234-1234-123456789abc".to_string())); + assert_eq!(extract_json_string(json, "FileSystem"), Some("NTFS".to_string())); + assert_eq!(extract_json_string(json, "NonExistent"), None); + } + + #[test] + fn test_extract_json_bool() { + let json = r#"{"SupportsCompression": true, "SupportsEncryption": false}"#; + assert_eq!(extract_json_bool(json, "SupportsCompression"), Some(true)); + assert_eq!(extract_json_bool(json, "SupportsEncryption"), Some(false)); + assert_eq!(extract_json_bool(json, "NonExistent"), None); + } +} diff --git a/core/src/volume/fs/refs.rs b/core/src/volume/fs/refs.rs new file mode 100644 index 000000000..cb713c833 --- /dev/null +++ b/core/src/volume/fs/refs.rs @@ -0,0 +1,327 @@ +//! ReFS filesystem-specific detection and optimization +//! +//! This module handles ReFS volume detection and provides ReFS-specific +//! optimizations like block cloning operations. + +use crate::volume::{error::VolumeResult, types::Volume}; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use tokio::task; +use tracing::{debug, warn}; + +/// ReFS filesystem handler +pub struct RefsHandler; + +impl RefsHandler { + pub fn new() -> Self { + Self + } + + /// Check if two paths are on the same ReFS volume and support block cloning + pub async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + // Check if both paths are on the same ReFS volume + if let (Ok(vol1), Ok(vol2)) = ( + self.get_volume_info(path1).await, + self.get_volume_info(path2).await, + ) { + // Same volume GUID = same physical storage + return vol1.volume_guid == vol2.volume_guid && vol1.supports_block_cloning && vol2.supports_block_cloning; + } + + false + } + + /// Get ReFS volume information for a path + async fn get_volume_info(&self, path: &Path) -> VolumeResult { + let path = path.to_path_buf(); + + task::spawn_blocking(move || { + // Use PowerShell to get volume information + let script = format!( + r#" + $volume = Get-Volume -FilePath '{}' + $partition = Get-Partition -DriveLetter $volume.DriveLetter + $disk = Get-Disk -Number $partition.DiskNumber + + [PSCustomObject]@{{ + VolumeGuid = $volume.UniqueId + FileSystem = $volume.FileSystem + DriveLetter = $volume.DriveLetter + Label = $volume.FileSystemLabel + Size = $volume.Size + SizeRemaining = $volume.SizeRemaining + DiskNumber = $partition.DiskNumber + PartitionNumber = $partition.PartitionNumber + MediaType = $disk.MediaType + }} | ConvertTo-Json + "#, + path.display() + ); + + let output = std::process::Command::new("powershell") + .args(["-Command", &script]) + .output() + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Failed to run PowerShell: {}", e)))?; + + if !output.status.success() { + return Err(crate::volume::error::VolumeError::platform("PowerShell command failed".to_string())); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_volume_info(&output_text) + }) + .await + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)))? + } + + /// Check if ReFS block cloning is supported + async fn supports_block_cloning(&self, path: &Path) -> bool { + // ReFS supports block cloning starting from Windows Server 2016 / Windows 10 + // Check if the volume supports the feature + let path = path.to_path_buf(); + + let result = task::spawn_blocking(move || { + let script = format!( + r#" + try {{ + $volume = Get-Volume -FilePath '{}' + # Check if it's ReFS and supports block cloning + if ($volume.FileSystem -eq 'ReFS') {{ + # Try to get ReFS-specific features + $refsVolume = Get-RefsVolume -DriveLetter $volume.DriveLetter -ErrorAction SilentlyContinue + if ($refsVolume) {{ + # ReFS volumes generally support block cloning + Write-Output 'true' + }} else {{ + Write-Output 'false' + }} + }} else {{ + Write-Output 'false' + }} + }} catch {{ + Write-Output 'false' + }} + "#, + path.display() + ); + + let output = std::process::Command::new("powershell") + .args(["-Command", &script]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let output_text = String::from_utf8_lossy(&output.stdout); + output_text.trim() == "true" + } + _ => false, + } + }) + .await; + + result.unwrap_or(false) + } + + /// Get all ReFS volumes on the system + pub async fn get_all_refs_volumes(&self) -> VolumeResult> { + task::spawn_blocking(|| { + let script = r#" + Get-Volume | Where-Object { $_.FileSystem -eq 'ReFS' } | ForEach-Object { + $partition = Get-Partition -DriveLetter $_.DriveLetter -ErrorAction SilentlyContinue + $disk = if ($partition) { Get-Disk -Number $partition.DiskNumber -ErrorAction SilentlyContinue } else { $null } + + [PSCustomObject]@{ + VolumeGuid = $_.UniqueId + FileSystem = $_.FileSystem + DriveLetter = $_.DriveLetter + Label = $_.FileSystemLabel + Size = $_.Size + SizeRemaining = $_.SizeRemaining + DiskNumber = if ($partition) { $partition.DiskNumber } else { $null } + PartitionNumber = if ($partition) { $partition.PartitionNumber } else { $null } + MediaType = if ($disk) { $disk.MediaType } else { $null } + } + } | ConvertTo-Json + "#; + + let output = std::process::Command::new("powershell") + .args(["-Command", script]) + .output() + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Failed to run PowerShell: {}", e)))?; + + if !output.status.success() { + return Ok(Vec::new()); // Return empty if command fails + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_volume_list(&output_text) + }) + .await + .map_err(|e| crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)))? + } +} + +#[async_trait] +impl super::FilesystemHandler for RefsHandler { + async fn enhance_volume(&self, volume: &mut Volume) -> VolumeResult<()> { + // Add ReFS-specific information like block cloning support + if let Some(mount_point) = volume.mount_point.to_str() { + if self.supports_block_cloning(Path::new(mount_point)).await { + debug!("ReFS volume supports block cloning: {}", mount_point); + // Could store this capability in volume metadata + } + } + Ok(()) + } + + async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + self.same_physical_storage(path1, path2).await + } + + fn get_copy_strategy(&self) -> Box { + // Use fast copy strategy for ReFS (leverages block cloning) + Box::new(crate::ops::files::copy::strategy::FastCopyStrategy) + } + + fn contains_path(&self, volume: &Volume, path: &std::path::Path) -> bool { + // Check primary mount point + if path.starts_with(&volume.mount_point) { + return true; + } + + // Check additional mount points + if volume.mount_points.iter().any(|mp| path.starts_with(mp)) { + return true; + } + + // TODO: ReFS-specific logic for junction points and mount points + // Windows can have volumes mounted as folders (mount points) within other volumes + // This would require checking Windows-specific mount point resolution + + false + } +} + +/// ReFS volume information +#[derive(Debug, Clone)] +pub struct RefsVolumeInfo { + pub volume_guid: String, + pub file_system: String, + pub drive_letter: Option, + pub label: Option, + pub size_bytes: u64, + pub available_bytes: u64, + pub disk_number: Option, + pub partition_number: Option, + pub media_type: Option, + pub supports_block_cloning: bool, +} + +/// Parse PowerShell volume info JSON output +fn parse_volume_info(json_output: &str) -> VolumeResult { + // Simple JSON parsing - in production, you'd use serde_json + let json_output = json_output.trim(); + + // Extract values using simple string parsing (replace with proper JSON parsing) + let volume_guid = extract_json_string(json_output, "VolumeGuid").unwrap_or_default(); + let file_system = extract_json_string(json_output, "FileSystem").unwrap_or_default(); + let drive_letter_str = extract_json_string(json_output, "DriveLetter"); + let label = extract_json_string(json_output, "Label"); + let size_bytes = extract_json_number(json_output, "Size").unwrap_or(0); + let available_bytes = extract_json_number(json_output, "SizeRemaining").unwrap_or(0); + let disk_number = extract_json_number(json_output, "DiskNumber").map(|n| n as u32); + let partition_number = extract_json_number(json_output, "PartitionNumber").map(|n| n as u32); + let media_type = extract_json_string(json_output, "MediaType"); + + let drive_letter = drive_letter_str.and_then(|s| s.chars().next()); + let supports_block_cloning = file_system == "ReFS"; // ReFS generally supports block cloning + + Ok(RefsVolumeInfo { + volume_guid, + file_system, + drive_letter, + label, + size_bytes, + available_bytes, + disk_number, + partition_number, + media_type, + supports_block_cloning, + }) +} + +/// Parse PowerShell volume list JSON output +fn parse_volume_list(json_output: &str) -> VolumeResult> { + // Simple parsing - in production, use proper JSON parser + let json_output = json_output.trim(); + + if json_output.is_empty() || json_output == "null" { + return Ok(Vec::new()); + } + + // For now, assume single volume (extend for array parsing) + match parse_volume_info(json_output) { + Ok(volume) => Ok(vec![volume]), + Err(_) => Ok(Vec::new()), + } +} + +/// Extract string value from JSON (simple implementation) +fn extract_json_string(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":", key); + if let Some(start) = json.find(&pattern) { + let start = start + pattern.len(); + if let Some(value_start) = json[start..].find('"') { + let value_start = start + value_start + 1; + if let Some(value_end) = json[value_start..].find('"') { + let value = &json[value_start..value_start + value_end]; + if value != "null" && !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + None +} + +/// Extract number value from JSON (simple implementation) +fn extract_json_number(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":", key); + if let Some(start) = json.find(&pattern) { + let start = start + pattern.len(); + // Skip whitespace + let remaining = json[start..].trim_start(); + if let Some(end) = remaining.find(|c: char| !c.is_ascii_digit()) { + let number_str = &remaining[..end]; + return number_str.parse().ok(); + } + } + None +} + +/// Enhance volume with ReFS-specific information from Windows +pub async fn enhance_volume_from_windows(volume: &mut Volume) -> VolumeResult<()> { + let handler = RefsHandler::new(); + handler.enhance_volume(volume).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_json_string() { + let json = r#"{"VolumeGuid": "12345678-1234-1234-1234-123456789abc", "FileSystem": "ReFS"}"#; + assert_eq!(extract_json_string(json, "VolumeGuid"), Some("12345678-1234-1234-1234-123456789abc".to_string())); + assert_eq!(extract_json_string(json, "FileSystem"), Some("ReFS".to_string())); + assert_eq!(extract_json_string(json, "NonExistent"), None); + } + + #[test] + fn test_extract_json_number() { + let json = r#"{"Size": 1000000000, "SizeRemaining": 500000000}"#; + assert_eq!(extract_json_number(json, "Size"), Some(1000000000)); + assert_eq!(extract_json_number(json, "SizeRemaining"), Some(500000000)); + assert_eq!(extract_json_number(json, "NonExistent"), None); + } +} diff --git a/core/src/volume/fs/zfs.rs b/core/src/volume/fs/zfs.rs new file mode 100644 index 000000000..b1f6f7398 --- /dev/null +++ b/core/src/volume/fs/zfs.rs @@ -0,0 +1,406 @@ +//! ZFS filesystem-specific detection and optimization +//! +//! This module handles ZFS pool and dataset detection and provides ZFS-specific +//! optimizations like clone operations and snapshot-based copies. + +use crate::volume::{error::VolumeResult, types::Volume}; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tokio::task; +use tracing::{debug, warn}; + +/// ZFS filesystem handler +pub struct ZfsHandler; + +impl ZfsHandler { + pub fn new() -> Self { + Self + } + + /// Check if two paths are on the same ZFS pool + pub async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + // Check if both paths are on ZFS datasets in the same pool + if let (Ok(dataset1), Ok(dataset2)) = ( + self.get_dataset_info(path1).await, + self.get_dataset_info(path2).await, + ) { + // Same pool = same physical storage (can use clones) + return dataset1.pool_name == dataset2.pool_name; + } + + false + } + + /// Get ZFS dataset information for a path + async fn get_dataset_info(&self, path: &Path) -> VolumeResult { + let path = path.to_path_buf(); + + task::spawn_blocking(move || { + // Use zfs list to find the dataset containing this path + let output = Command::new("zfs") + .args([ + "list", + "-H", + "-o", + "name,mountpoint,used,available,type", + "-t", + "filesystem", + ]) + .output() + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!( + "Failed to run zfs list: {}", + e + )) + })?; + + if !output.status.success() { + return Err(crate::volume::error::VolumeError::platform( + "zfs list command failed".to_string(), + )); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + find_dataset_for_path(&output_text, &path) + }) + .await + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)) + })? + } + + /// Get ZFS pool information + pub async fn get_pool_info(&self, pool_name: &str) -> VolumeResult { + let pool_name = pool_name.to_string(); + + task::spawn_blocking(move || { + let output = Command::new("zpool") + .args(["status", "-v", &pool_name]) + .output() + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!( + "Failed to run zpool status: {}", + e + )) + })?; + + if !output.status.success() { + return Err(crate::volume::error::VolumeError::platform( + "zpool status command failed".to_string(), + )); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_zpool_status(&output_text) + }) + .await + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)) + })? + } + + /// Check if ZFS clone operations are supported + pub async fn supports_clones(&self, path: &Path) -> bool { + // ZFS always supports clones, but check if the dataset allows it + if let Ok(dataset_info) = self.get_dataset_info(path).await { + // Check if clones property is enabled (usually is by default) + return !dataset_info.readonly; + } + false + } + + /// Get all datasets in a pool + pub async fn get_pool_datasets(&self, pool_name: &str) -> VolumeResult> { + let pool_name = pool_name.to_string(); + + task::spawn_blocking(move || { + let output = Command::new("zfs") + .args([ + "list", + "-H", + "-r", + "-o", + "name,mountpoint,used,available,type", + "-t", + "filesystem", + &pool_name, + ]) + .output() + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!( + "Failed to run zfs list: {}", + e + )) + })?; + + if !output.status.success() { + return Err(crate::volume::error::VolumeError::platform( + "zfs list command failed".to_string(), + )); + } + + let output_text = String::from_utf8_lossy(&output.stdout); + parse_zfs_datasets(&output_text) + }) + .await + .map_err(|e| { + crate::volume::error::VolumeError::platform(format!("Task join error: {}", e)) + })? + } +} + +#[async_trait] +impl super::FilesystemHandler for ZfsHandler { + async fn enhance_volume(&self, volume: &mut Volume) -> VolumeResult<()> { + // Add ZFS-specific information like pool and dataset details + if let Some(mount_point) = volume.mount_point.to_str() { + if let Ok(dataset_info) = self.get_dataset_info(Path::new(mount_point)).await { + debug!("Enhanced ZFS volume with dataset info: {:?}", dataset_info); + // Could store dataset info in volume metadata if needed + } + } + Ok(()) + } + + async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + self.same_physical_storage(path1, path2).await + } + + fn get_copy_strategy(&self) -> Box { + // Use fast copy strategy for ZFS (can leverage clones) + Box::new(crate::ops::files::copy::strategy::FastCopyStrategy) + } + + fn contains_path(&self, volume: &Volume, path: &std::path::Path) -> bool { + // Check primary mount point + if path.starts_with(&volume.mount_point) { + return true; + } + + // Check additional mount points + if volume.mount_points.iter().any(|mp| path.starts_with(mp)) { + return true; + } + + // TODO: ZFS-specific logic for datasets and pools + // ZFS datasets can be mounted at arbitrary locations within the same pool + // This would require checking if paths are within the same ZFS pool + // even if they have different mount points + + false + } +} + +/// ZFS dataset information +#[derive(Debug, Clone)] +pub struct ZfsDatasetInfo { + pub name: String, + pub pool_name: String, + pub mount_point: Option, + pub used_bytes: u64, + pub available_bytes: u64, + pub dataset_type: String, + pub readonly: bool, +} + +/// ZFS pool information +#[derive(Debug, Clone)] +pub struct ZfsPoolInfo { + pub name: String, + pub state: String, + pub status: String, + pub devices: Vec, + pub errors: u64, +} + +/// Find the ZFS dataset that contains a given path +fn find_dataset_for_path( + zfs_list_output: &str, + target_path: &Path, +) -> VolumeResult { + let mut best_match: Option = None; + let mut best_match_len = 0; + + for line in zfs_list_output.lines() { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() >= 5 { + let name = fields[0]; + let mountpoint = fields[1]; + let used = fields[2]; + let available = fields[3]; + let dataset_type = fields[4]; + + if mountpoint != "-" && mountpoint != "legacy" { + let mount_path = Path::new(mountpoint); + if target_path.starts_with(mount_path) && mountpoint.len() > best_match_len { + let pool_name = name.split('/').next().unwrap_or(name).to_string(); + + best_match = Some(ZfsDatasetInfo { + name: name.to_string(), + pool_name, + mount_point: Some(mount_path.to_path_buf()), + used_bytes: parse_zfs_size(used).unwrap_or(0), + available_bytes: parse_zfs_size(available).unwrap_or(0), + dataset_type: dataset_type.to_string(), + readonly: false, // Would need additional property check + }); + best_match_len = mountpoint.len(); + } + } + } + } + + best_match.ok_or_else(|| { + crate::volume::error::VolumeError::platform("Path not found in any ZFS dataset".to_string()) + }) +} + +/// Parse zfs list output to get all datasets +fn parse_zfs_datasets(zfs_list_output: &str) -> VolumeResult> { + let mut datasets = Vec::new(); + + for line in zfs_list_output.lines() { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() >= 5 { + let name = fields[0]; + let mountpoint = fields[1]; + let used = fields[2]; + let available = fields[3]; + let dataset_type = fields[4]; + + let pool_name = name.split('/').next().unwrap_or(name).to_string(); + let mount_point = if mountpoint != "-" && mountpoint != "legacy" { + Some(PathBuf::from(mountpoint)) + } else { + None + }; + + datasets.push(ZfsDatasetInfo { + name: name.to_string(), + pool_name, + mount_point, + used_bytes: parse_zfs_size(used).unwrap_or(0), + available_bytes: parse_zfs_size(available).unwrap_or(0), + dataset_type: dataset_type.to_string(), + readonly: false, // Would need additional property check + }); + } + } + + Ok(datasets) +} + +/// Parse zpool status output +fn parse_zpool_status(status_output: &str) -> VolumeResult { + let mut name = String::new(); + let mut state = String::new(); + let mut status = String::new(); + let mut devices = Vec::new(); + let mut errors = 0; + + let mut in_config = false; + + for line in status_output.lines() { + let line = line.trim(); + + if line.starts_with("pool:") { + name = line.strip_prefix("pool:").unwrap_or("").trim().to_string(); + } else if line.starts_with("state:") { + state = line.strip_prefix("state:").unwrap_or("").trim().to_string(); + } else if line.starts_with("status:") { + status = line + .strip_prefix("status:") + .unwrap_or("") + .trim() + .to_string(); + } else if line.starts_with("config:") { + in_config = true; + } else if in_config && line.starts_with("errors:") { + in_config = false; + if let Some(error_str) = line + .strip_prefix("errors:") + .and_then(|s| s.trim().split_whitespace().next()) + { + errors = error_str.parse().unwrap_or(0); + } + } else if in_config && (line.starts_with("/dev/") || line.contains("disk")) { + // Extract device names from config section + if let Some(device) = line.split_whitespace().next() { + if device.starts_with("/dev/") { + devices.push(device.to_string()); + } + } + } + } + + Ok(ZfsPoolInfo { + name, + state, + status, + devices, + errors, + }) +} + +/// Parse ZFS size strings like "123K", "456M", "789G" +fn parse_zfs_size(size_str: &str) -> Option { + if size_str == "-" || size_str.is_empty() { + return Some(0); + } + + let size_str = size_str.trim(); + let (number_part, unit) = if let Some(pos) = size_str.find(char::is_alphabetic) { + (&size_str[..pos], &size_str[pos..]) + } else { + (size_str, "") + }; + + let number: f64 = number_part.parse().ok()?; + + let multiplier = match unit.to_uppercase().as_str() { + "" | "B" => 1, + "K" => 1024, + "M" => 1024 * 1024, + "G" => 1024 * 1024 * 1024, + "T" => 1024u64.pow(4), + "P" => 1024u64.pow(5), + _ => 1, + }; + + Some((number * multiplier as f64) as u64) +} + +/// Enhance volume with ZFS-specific information from mount point +pub async fn enhance_volume_from_mount(volume: &mut Volume) -> VolumeResult<()> { + let handler = ZfsHandler::new(); + handler.enhance_volume(volume).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_zfs_size() { + assert_eq!(parse_zfs_size("1024"), Some(1024)); + assert_eq!(parse_zfs_size("1K"), Some(1024)); + assert_eq!(parse_zfs_size("1M"), Some(1024 * 1024)); + assert_eq!(parse_zfs_size("1G"), Some(1024 * 1024 * 1024)); + assert_eq!( + parse_zfs_size("1.5G"), + Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64) + ); + assert_eq!(parse_zfs_size("-"), Some(0)); + } + + #[test] + fn test_find_dataset_for_path() { + let zfs_output = "tank\t/tank\t100M\t900M\tfilesystem\ntank/home\t/home\t50M\t450M\tfilesystem\ntank/var\t/var\t25M\t225M\tfilesystem"; + + let dataset = find_dataset_for_path(zfs_output, Path::new("/home/user/file.txt")).unwrap(); + assert_eq!(dataset.name, "tank/home"); + assert_eq!(dataset.pool_name, "tank"); + assert_eq!(dataset.mount_point, Some(PathBuf::from("/home"))); + } +} diff --git a/core/src/volume/manager.rs b/core/src/volume/manager.rs index b1dff5230..b8058f565 100644 --- a/core/src/volume/manager.rs +++ b/core/src/volume/manager.rs @@ -4,12 +4,13 @@ use crate::infra::db::entities; use crate::infra::event::{Event, EventBus}; use crate::library::LibraryManager; use crate::volume::{ + detection, error::{VolumeError, VolumeResult}, - os_detection, types::{ SpacedriveVolumeId, TrackedVolume, Volume, VolumeDetectionConfig, VolumeFingerprint, VolumeInfo, }, + VolumeExt, }; use crate::Core; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; @@ -303,7 +304,7 @@ impl VolumeManager { debug!("Refreshing volumes for device {}", device_id); // Detect current volumes - let detected_volumes = os_detection::detect_volumes(device_id, config).await?; + let detected_volumes = detection::detect_volumes(device_id, config).await?; let mut current_volumes = volumes.write().await; let mut cache = path_cache.write().await; @@ -509,6 +510,50 @@ impl VolumeManager { } } + /// Check if two paths are on the same physical storage (filesystem-aware) + /// This is the enhanced version that uses filesystem-specific handlers + pub async fn same_physical_storage(&self, path1: &Path, path2: &Path) -> bool { + // 1. Get volumes for both paths + let vol1 = self.volume_for_path(path1).await; + let vol2 = self.volume_for_path(path2).await; + + match (&vol1, &vol2) { + (Some(v1), Some(v2)) => { + // 2. Check if same volume first (fast path) + if v1.fingerprint == v2.fingerprint { + debug!("Paths are on the same volume: {}", v1.fingerprint); + return true; + } + + // 3. Use filesystem-specific logic for cross-volume checks + if v1.file_system == v2.file_system { + debug!( + "Using filesystem-specific handler for {} to check paths: {} vs {}", + v1.file_system, + path1.display(), + path2.display() + ); + return crate::volume::fs::same_physical_storage(path1, path2, &v1.file_system) + .await; + } + + debug!( + "Different filesystems: {} vs {}", + v1.file_system, v2.file_system + ); + false + } + _ => { + debug!( + "Could not find volumes for paths: {:?}, {:?}", + vol1.is_some(), + vol2.is_some() + ); + false + } + } + } + /// Find volumes with available space pub async fn volumes_with_space(&self, required_bytes: u64) -> Vec { self.volumes diff --git a/core/src/volume/mod.rs b/core/src/volume/mod.rs index 5464bad8c..ffba310c2 100644 --- a/core/src/volume/mod.rs +++ b/core/src/volume/mod.rs @@ -5,21 +5,24 @@ //! file operation routing. pub mod classification; +pub mod detection; pub mod error; +pub mod fs; pub mod manager; -pub mod os_detection; +pub mod platform; pub mod speed; pub mod types; +pub mod utils; pub use error::VolumeError; pub use manager::VolumeManager; pub use types::{ - DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig, VolumeEvent, VolumeFingerprint, - VolumeInfo, + ApfsContainer, ApfsVolumeInfo, ApfsVolumeRole, DiskType, FileSystem, MountType, PathMapping, + Volume, VolumeDetectionConfig, VolumeEvent, VolumeFingerprint, VolumeInfo, }; -// Re-export platform-specific detection -pub use os_detection::detect_volumes; +// Re-export detection functions +pub use detection::detect_volumes; /// Extension trait for Volume operations pub trait VolumeExt { @@ -43,13 +46,8 @@ impl VolumeExt for Volume { } fn contains_path(&self, path: &std::path::Path) -> bool { - // Check primary mount point - if path.starts_with(&self.mount_point) { - return true; - } - - // Check additional mount points (for APFS volumes) - self.mount_points.iter().any(|mp| path.starts_with(mp)) + // Use filesystem-specific logic for path resolution + self.contains_path(&path.to_path_buf()) } } diff --git a/core/src/volume/os_detection.rs b/core/src/volume/os_detection.rs deleted file mode 100644 index adaab9d61..000000000 --- a/core/src/volume/os_detection.rs +++ /dev/null @@ -1,581 +0,0 @@ -//! Platform-specific volume detection - -use crate::volume::{ - classification::{get_classifier, VolumeDetectionInfo}, - error::{VolumeError, VolumeResult}, - types::{ - DiskType, FileSystem, MountType, SpacedriveVolumeId, Volume, VolumeDetectionConfig, - VolumeFingerprint, - }, -}; -use std::collections::HashMap; -use std::path::PathBuf; -use tokio::{fs, process::Command, task}; -use tracing::{debug, instrument, warn}; -use uuid::Uuid; - -/// Filename for Spacedrive volume identifier files -const SPACEDRIVE_VOLUME_ID_FILE: &str = ".spacedrive-volume-id"; - -/// Classify a volume using the platform-specific classifier -fn classify_volume( - mount_point: &PathBuf, - file_system: &FileSystem, - name: &str, -) -> crate::volume::types::VolumeType { - let classifier = get_classifier(); - let detection_info = VolumeDetectionInfo { - mount_point: mount_point.clone(), - file_system: file_system.clone(), - total_bytes_capacity: 0, // We don't have this info yet in some contexts - is_removable: None, // Would need additional detection - is_network_drive: None, // Would need additional detection - device_model: None, // Would need additional detection - }; - - classifier.classify(&detection_info) -} - -/// Detect all volumes on the system -#[instrument(skip(config))] -pub async fn detect_volumes( - device_id: uuid::Uuid, - config: &VolumeDetectionConfig, -) -> VolumeResult> { - debug!("Starting volume detection for device {}", device_id); - - #[cfg(target_os = "macos")] - let volumes = macos::detect_volumes(device_id, config).await?; - - #[cfg(target_os = "linux")] - let volumes = linux::detect_volumes(device_id, config).await?; - - #[cfg(target_os = "windows")] - let volumes = windows::detect_volumes(device_id, config).await?; - - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - let volumes = Vec::new(); - - debug!( - "Detected {} volumes for device {}", - volumes.len(), - device_id - ); - Ok(volumes) -} - -#[cfg(target_os = "macos")] -mod macos { - use super::*; - use std::process::Command; - - pub async fn detect_volumes( - device_id: uuid::Uuid, - config: &VolumeDetectionConfig, - ) -> VolumeResult> { - // Clone config for move into task - let config = config.clone(); - - // Run in blocking task since Command is sync - task::spawn_blocking(move || { - let mut volumes = Vec::new(); - - // Use diskutil to get volume information - let output = Command::new("diskutil") - .args(["list", "-plist"]) - .output() - .map_err(|e| VolumeError::platform(format!("Failed to run diskutil: {}", e)))?; - - if !output.status.success() { - return Err(VolumeError::platform(format!( - "diskutil failed with status: {}", - output.status - ))); - } - - // For now, use a simpler approach with df command to get mounted volumes - let df_output = Command::new("df") - .args(["-H"]) - .output() - .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; - - if !df_output.status.success() { - return Err(VolumeError::platform( - "Failed to get volume information".to_string(), - )); - } - - let df_stdout = String::from_utf8_lossy(&df_output.stdout); - for line in df_stdout.lines().skip(1) { - // Skip header - let fields: Vec<&str> = line.split_whitespace().collect(); - if fields.len() >= 9 { - let filesystem = fields[0]; - let total_str = fields[1]; - let used_str = fields[2]; - let available_str = fields[3]; - // Skip percentage field (fields[4]) - // Skip inode fields (fields[5], fields[6], fields[7]) - // Mount point might have spaces, so join remaining fields - let mount_point = fields[8..].join(" "); - - // Skip certain filesystems - if should_skip_filesystem(filesystem) { - debug!("Skipping {} filesystem: {}", filesystem, mount_point); - continue; - } - - // Parse sizes (in bytes) - let total_bytes = parse_size_string(total_str).unwrap_or(0); - let available_bytes = parse_size_string(available_str).unwrap_or(0); - - let mount_path = PathBuf::from(&mount_point); - let name = extract_volume_name(&mount_path); - - let mount_type = if mount_point.starts_with("/Volumes/") { - MountType::External - } else if mount_point.starts_with("/System/") { - MountType::System - } else if filesystem.contains("://") { - MountType::Network - } else { - MountType::System - }; - - let disk_type = detect_disk_type(&mount_path)?; - let file_system = detect_filesystem(&mount_path)?; - - let volume = Volume::new( - device_id, - name.clone(), - mount_type, - classify_volume(&mount_path, &file_system, &name), - mount_path, - vec![], // Additional mount points would need diskutil parsing - disk_type, - file_system.clone(), - total_bytes, - available_bytes, - false, // Read-only detection would need additional checks - Some(filesystem.to_string()), // Use filesystem as hardware ID - VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()), // Content-based fingerprint using intrinsic properties - ); - volumes.push(volume); - } - } - Ok(volumes) - }) - .await - .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? - } - - // Helper function to check if filesystem should be skipped - fn should_skip_filesystem(filesystem: &str) -> bool { - matches!( - filesystem, - "devfs" | "tmpfs" | "proc" | "sysfs" | "fdescfs" | "kernfs" - ) - } - - // Helper function to extract volume name from mount path - fn extract_volume_name(mount_path: &PathBuf) -> String { - if let Some(name) = mount_path.file_name() { - name.to_string_lossy().to_string() - } else if mount_path.to_string_lossy() == "/" { - "Macintosh HD".to_string() - } else { - mount_path.to_string_lossy().to_string() - } - } - - fn parse_df_line( - line: &str, - device_id: uuid::Uuid, - config: &VolumeDetectionConfig, - ) -> VolumeResult> { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 9 { - return Ok(None); - } - - let filesystem = parts[0]; - - // Handle special case where autofs filesystem has name and target split across columns - if filesystem == "map" && parts.len() > 1 && parts[1].contains("auto") { - debug!("Skipping autofs filesystem: map {}", parts[1]); - return Ok(None); - } - - let size_str = parts[1]; - let used_str = parts[2]; - let available_str = parts[3]; - let mount_point = parts[8]; - - // Skip autofs and other special filesystems - if filesystem.starts_with("map") || filesystem.contains("auto_") { - debug!("Skipping autofs filesystem: {}", filesystem); - return Ok(None); - } - - // Skip system filesystems unless requested - if !config.include_system && is_system_filesystem(filesystem) { - return Ok(None); - } - - // Skip virtual filesystems unless requested - if !config.include_virtual && is_virtual_filesystem(filesystem) { - return Ok(None); - } - - let mount_path = PathBuf::from(mount_point); - - // Parse sizes (df output like "931Gi", "465Gi", etc.) - let total_bytes = parse_size_string(size_str)?; - let available_bytes = parse_size_string(available_str)?; - - let name = if mount_point == "/" { - "Macintosh HD".to_string() - } else { - mount_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }; - - let mount_type = if mount_point == "/" { - MountType::System - } else if mount_point.starts_with("/Volumes/") { - MountType::External - } else if filesystem.starts_with("//") { - MountType::Network - } else { - MountType::System - }; - - let disk_type = detect_disk_type(&mount_path)?; - let file_system = detect_filesystem(&mount_path)?; - - let volume = Volume::new( - device_id, - name.clone(), - mount_type, - classify_volume(&mount_path, &file_system, &name), - mount_path.clone(), - vec![], // Additional mount points would need diskutil parsing - disk_type, - file_system.clone(), - total_bytes, - available_bytes, - false, // Read-only detection would need additional checks - Some(filesystem.to_string()), // Use filesystem as hardware ID - VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()), // Content-based fingerprint using intrinsic properties - ); - - Ok(Some(volume)) - } - - fn detect_disk_type(mount_point: &PathBuf) -> VolumeResult { - // Try to detect SSD vs HDD using system_profiler or diskutil - let output = Command::new("diskutil") - .args(["info", mount_point.to_str().unwrap_or("/")]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let info = String::from_utf8_lossy(&output.stdout); - if info.contains("Solid State") { - Ok(DiskType::SSD) - } else if info.contains("Rotational") { - Ok(DiskType::HDD) - } else { - Ok(DiskType::Unknown) - } - } - _ => Ok(DiskType::Unknown), - } - } - - fn detect_filesystem(mount_point: &PathBuf) -> VolumeResult { - let output = Command::new("diskutil") - .args(["info", mount_point.to_str().unwrap_or("/")]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let info = String::from_utf8_lossy(&output.stdout); - if info.contains("APFS") { - Ok(FileSystem::APFS) - } else if info.contains("HFS+") { - Ok(FileSystem::Other("HFS+".to_string())) - } else if info.contains("ExFAT") { - Ok(FileSystem::ExFAT) - } else if info.contains("FAT32") { - Ok(FileSystem::FAT32) - } else { - Ok(FileSystem::Other("Unknown".to_string())) - } - } - _ => Ok(FileSystem::Other("Unknown".to_string())), - } - } -} - -#[cfg(target_os = "linux")] -mod linux { - use super::*; - use std::process::Command; - - pub async fn detect_volumes( - device_id: uuid::Uuid, - config: &VolumeDetectionConfig, - ) -> VolumeResult> { - task::spawn_blocking(move || { - let mut volumes = Vec::new(); - - // Use df to get mounted filesystems - let output = Command::new("df") - .args(["-h", "-T"]) // -T shows filesystem type - .output() - .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; - - if !output.status.success() { - return Err(VolumeError::platform("df command failed")); - } - - let df_text = String::from_utf8_lossy(&output.stdout); - - for line in df_text.lines().skip(1) { - // Skip header - if let Some(volume) = parse_df_line(line, device_id, &config)? { - volumes.push(volume); - } - } - - Ok(volumes) - }) - .await - .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? - } - - fn parse_df_line( - line: &str, - device_id: uuid::Uuid, - config: &VolumeDetectionConfig, - ) -> VolumeResult> { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 7 { - return Ok(None); - } - - let filesystem_device = parts[0]; - let filesystem_type = parts[1]; - let size_str = parts[2]; - let used_str = parts[3]; - let available_str = parts[4]; - let mount_point = parts[6]; - - // Skip system filesystems unless requested - if !config.include_system && is_system_filesystem(filesystem_device) { - return Ok(None); - } - - // Skip virtual filesystems unless requested - if !config.include_virtual && is_virtual_filesystem(filesystem_type) { - return Ok(None); - } - - let mount_path = PathBuf::from(mount_point); - - let total_bytes = parse_size_string(size_str)?; - let available_bytes = parse_size_string(available_str)?; - - let name = if mount_point == "/" { - "Root".to_string() - } else { - mount_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }; - - let mount_type = determine_mount_type(mount_point, filesystem_device); - let disk_type = detect_disk_type_linux(filesystem_device)?; - let file_system = FileSystem::from_string(filesystem_type); - - let volume = Volume::new( - device_id, - name, - mount_type, - mount_path, - vec![], - disk_type, - file_system, - total_bytes, - available_bytes, - false, // Would need additional check for read-only - Some(filesystem_device.to_string()), - ); - - Ok(Some(volume)) - } - - fn detect_disk_type_linux(device: &str) -> VolumeResult { - // Try to detect using /sys/block/*/queue/rotational - if let Some(device_name) = device.strip_prefix("/dev/") { - let base_device = device_name.trim_end_matches(char::is_numeric); - let rotational_path = format!("/sys/block/{}/queue/rotational", base_device); - - if let Ok(contents) = std::fs::read_to_string(rotational_path) { - return match contents.trim() { - "0" => Ok(DiskType::SSD), - "1" => Ok(DiskType::HDD), - _ => Ok(DiskType::Unknown), - }; - } - } - - Ok(DiskType::Unknown) - } - - fn determine_mount_type(mount_point: &str, device: &str) -> MountType { - if mount_point == "/" || mount_point.starts_with("/boot") { - MountType::System - } else if device.starts_with("//") || device.contains("nfs") { - MountType::Network - } else if mount_point.starts_with("/media/") || mount_point.starts_with("/mnt/") { - MountType::External - } else { - MountType::System - } - } -} - -#[cfg(target_os = "windows")] -mod windows { - use super::*; - use std::process::Command; - - pub async fn detect_volumes( - device_id: uuid::Uuid, - _config: &VolumeDetectionConfig, - ) -> VolumeResult> { - task::spawn_blocking(|| { - // Use PowerShell to get volume information - let output = Command::new("powershell") - .args([ - "-Command", - "Get-Volume | Select-Object DriveLetter,FileSystemLabel,Size,SizeRemaining,FileSystem | ConvertTo-Json" - ]) - .output() - .map_err(|e| VolumeError::platform(format!("Failed to run PowerShell: {}", e)))?; - - if !output.status.success() { - return Err(VolumeError::platform("PowerShell command failed")); - } - - // For now, return empty until we implement full Windows support - warn!("Windows volume detection not fully implemented yet"); - Ok(Vec::new()) - }) - .await - .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? - } -} - -#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -mod unsupported { - use super::*; - - pub async fn detect_volumes( - device_id: uuid::Uuid, - _config: &VolumeDetectionConfig, - ) -> VolumeResult> { - warn!("Volume detection not supported on this platform"); - Ok(Vec::new()) - } -} - -// Common utility functions -fn is_system_filesystem(filesystem: &str) -> bool { - matches!( - filesystem, - "/" | "/dev" | "/proc" | "/sys" | "/tmp" | "/var/tmp" - ) -} - -fn is_virtual_filesystem(filesystem: &str) -> bool { - let fs_lower = filesystem.to_lowercase(); - matches!( - fs_lower.as_str(), - "devfs" | "sysfs" | "proc" | "tmpfs" | "ramfs" | "devtmpfs" | "overlay" | "fuse" - ) || fs_lower.starts_with("map ") - || fs_lower.contains("auto_") -} - -fn parse_size_string(size_str: &str) -> VolumeResult { - if size_str == "-" { - return Ok(0); - } - - // Skip invalid size strings that don't look like numbers - if size_str.is_empty() || size_str.chars().all(char::is_alphabetic) { - return Ok(0); - } - - let size_str = size_str.replace(",", ""); // Remove commas - let (number_part, unit) = if let Some(pos) = size_str.find(char::is_alphabetic) { - (&size_str[..pos], &size_str[pos..]) - } else { - (size_str.as_str(), "") - }; - - let number: f64 = number_part - .parse() - .map_err(|_| VolumeError::InvalidData(format!("Invalid size: {}", size_str)))?; - - let multiplier = match unit.to_uppercase().as_str() { - "" | "B" => 1, - "K" | "KB" | "KI" => 1024, - "M" | "MB" | "MI" => 1024 * 1024, - "G" | "GB" | "GI" => 1024 * 1024 * 1024, - "T" | "TB" | "TI" => 1024u64.pow(4), - "P" | "PB" | "PI" => 1024u64.pow(5), - _ => { - warn!("Unknown size unit: {}", unit); - 1 - } - }; - - Ok((number * multiplier as f64) as u64) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_size_string() { - assert_eq!(parse_size_string("1024").unwrap(), 1024); - assert_eq!(parse_size_string("1K").unwrap(), 1024); - assert_eq!(parse_size_string("1M").unwrap(), 1024 * 1024); - assert_eq!(parse_size_string("1G").unwrap(), 1024 * 1024 * 1024); - assert_eq!( - parse_size_string("1.5G").unwrap(), - (1.5 * 1024.0 * 1024.0 * 1024.0) as u64 - ); - assert_eq!(parse_size_string("-").unwrap(), 0); - } - - #[test] - fn test_filesystem_detection() { - assert!(is_virtual_filesystem("tmpfs")); - assert!(is_virtual_filesystem("proc")); - assert!(!is_virtual_filesystem("ext4")); - - assert!(is_system_filesystem("/")); - assert!(is_system_filesystem("/proc")); - assert!(!is_system_filesystem("/home")); - } -} diff --git a/core/src/volume/platform/linux.rs b/core/src/volume/platform/linux.rs new file mode 100644 index 000000000..b699c1d13 --- /dev/null +++ b/core/src/volume/platform/linux.rs @@ -0,0 +1,270 @@ +//! Linux-specific volume detection helpers + +use crate::volume::{ + classification::{get_classifier, VolumeDetectionInfo}, + error::{VolumeError, VolumeResult}, + types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig, VolumeFingerprint}, + utils, +}; +use std::path::PathBuf; +use std::process::Command; +use tokio::task; +use tracing::debug; +use uuid::Uuid; + +/// Mount information from /proc/mounts or df output +#[derive(Debug, Clone)] +pub struct MountInfo { + pub device: String, + pub mount_point: String, + pub filesystem_type: String, + pub total_bytes: u64, + pub available_bytes: u64, +} + +/// Detect Linux volumes using df command +pub async fn detect_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + let config = config.clone(); // Clone to move into async block + task::spawn_blocking(move || { + let mut volumes = Vec::new(); + + // Use df to get mounted filesystems + let output = Command::new("df") + .args(["-h", "-T"]) // -T shows filesystem type + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; + + if !output.status.success() { + return Err(VolumeError::platform("df command failed")); + } + + let df_text = String::from_utf8_lossy(&output.stdout); + + for line in df_text.lines().skip(1) { + // Skip header + if let Some(volume) = parse_df_line(line, device_id, &config)? { + volumes.push(volume); + } + } + + Ok(volumes) + }) + .await + .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? +} + +/// Parse a single df output line into a Volume +fn parse_df_line( + line: &str, + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 7 { + return Ok(None); + } + + let filesystem_device = parts[0]; + let filesystem_type = parts[1]; + let size_str = parts[2]; + let _used_str = parts[3]; + let available_str = parts[4]; + let mount_point = parts[6]; + + // Skip system filesystems unless requested + if !config.include_system && utils::is_system_filesystem(filesystem_device) { + return Ok(None); + } + + // Skip virtual filesystems unless requested + if !config.include_virtual && utils::is_virtual_filesystem(filesystem_type) { + return Ok(None); + } + + let mount_path = PathBuf::from(mount_point); + + let total_bytes = utils::parse_size_string(size_str)?; + let available_bytes = utils::parse_size_string(available_str)?; + + let name = if mount_point == "/" { + "Root".to_string() + } else { + mount_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }; + + let mount_type = determine_mount_type(mount_point, filesystem_device); + let disk_type = detect_disk_type_linux(filesystem_device)?; + let file_system = utils::parse_filesystem_type(filesystem_type); + + let volume = Volume::new( + device_id, + name.clone(), + mount_type, + classify_volume(&mount_path, &file_system, &name), + mount_path, + vec![], + disk_type, + file_system.clone(), + total_bytes, + available_bytes, + false, // Would need additional check for read-only + Some(filesystem_device.to_string()), + VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()), + ); + + Ok(Some(volume)) +} + +/// Classify a volume using the platform-specific classifier +fn classify_volume( + mount_point: &PathBuf, + file_system: &FileSystem, + name: &str, +) -> crate::volume::types::VolumeType { + let classifier = get_classifier(); + let detection_info = VolumeDetectionInfo { + mount_point: mount_point.clone(), + file_system: file_system.clone(), + total_bytes_capacity: 0, // We don't have this info yet in some contexts + is_removable: None, // Would need additional detection + is_network_drive: None, // Would need additional detection + device_model: None, // Would need additional detection + }; + + classifier.classify(&detection_info) +} + +/// Detect disk type (SSD vs HDD) using Linux /sys filesystem +fn detect_disk_type_linux(device: &str) -> VolumeResult { + // Try to detect using /sys/block/*/queue/rotational + if let Some(device_name) = device.strip_prefix("/dev/") { + let base_device = device_name.trim_end_matches(char::is_numeric); + let rotational_path = format!("/sys/block/{}/queue/rotational", base_device); + + if let Ok(contents) = std::fs::read_to_string(rotational_path) { + return match contents.trim() { + "0" => Ok(DiskType::SSD), + "1" => Ok(DiskType::HDD), + _ => Ok(DiskType::Unknown), + }; + } + } + + Ok(DiskType::Unknown) +} + +/// Determine mount type based on mount point and device +fn determine_mount_type(mount_point: &str, device: &str) -> MountType { + if mount_point == "/" || mount_point.starts_with("/boot") { + MountType::System + } else if device.starts_with("//") || device.contains("nfs") { + MountType::Network + } else if mount_point.starts_with("/media/") || mount_point.starts_with("/mnt/") { + MountType::External + } else { + MountType::System + } +} + +/// Parse /proc/mounts for detailed mount information +pub async fn parse_proc_mounts() -> VolumeResult> { + task::spawn_blocking(|| { + let contents = std::fs::read_to_string("/proc/mounts") + .map_err(|e| VolumeError::platform(format!("Failed to read /proc/mounts: {}", e)))?; + + let mut mounts = Vec::new(); + + for line in contents.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + let device = parts[0].to_string(); + let mount_point = parts[1].to_string(); + let filesystem_type = parts[2].to_string(); + + // Skip virtual filesystems + if utils::is_virtual_filesystem(&filesystem_type) { + continue; + } + + // Get size information using statvfs + let (total_bytes, available_bytes) = get_filesystem_space(&mount_point)?; + + mounts.push(MountInfo { + device, + mount_point, + filesystem_type, + total_bytes, + available_bytes, + }); + } + } + + Ok(mounts) + }) + .await + .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? +} + +/// Get filesystem space using statvfs +fn get_filesystem_space(mount_point: &str) -> VolumeResult<(u64, u64)> { + use std::ffi::CString; + use std::mem; + + let path = CString::new(mount_point) + .map_err(|e| VolumeError::platform(format!("Invalid path: {}", e)))?; + + unsafe { + let mut statvfs: libc::statvfs = mem::zeroed(); + if libc::statvfs(path.as_ptr(), &mut statvfs) == 0 { + let total_bytes = statvfs.f_blocks * statvfs.f_frsize; + let available_bytes = statvfs.f_bavail * statvfs.f_frsize; + Ok((total_bytes, available_bytes)) + } else { + Ok((0, 0)) + } + } +} + +/// Create a Volume from MountInfo +pub fn create_volume_from_mount(mount: MountInfo, device_id: Uuid) -> VolumeResult { + let mount_path = PathBuf::from(&mount.mount_point); + let file_system = utils::parse_filesystem_type(&mount.filesystem_type); + + let name = if mount.mount_point == "/" { + "Root".to_string() + } else { + mount_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }; + + let mount_type = determine_mount_type(&mount.mount_point, &mount.device); + let disk_type = detect_disk_type_linux(&mount.device)?; + + let volume = Volume::new( + device_id, + name.clone(), + mount_type, + classify_volume(&mount_path, &file_system, &name), + mount_path, + vec![], + disk_type, + file_system.clone(), + mount.total_bytes, + mount.available_bytes, + false, // Would need additional check for read-only + Some(mount.device), + VolumeFingerprint::new(&name, mount.total_bytes, &file_system.to_string()), + ); + + Ok(volume) +} diff --git a/core/src/volume/platform/macos.rs b/core/src/volume/platform/macos.rs new file mode 100644 index 000000000..64337eac7 --- /dev/null +++ b/core/src/volume/platform/macos.rs @@ -0,0 +1,215 @@ +//! macOS-specific volume detection helpers + +use crate::volume::{ + classification::{get_classifier, VolumeDetectionInfo}, + error::{VolumeError, VolumeResult}, + types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig, VolumeFingerprint}, + utils, +}; +use std::path::PathBuf; +use std::process::Command; +use tokio::task; +use tracing::debug; +use uuid::Uuid; + +/// Detect non-APFS volumes using traditional df method +pub async fn detect_non_apfs_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + let config = config.clone(); // Clone to move into async block + task::spawn_blocking(move || { + let mut volumes = Vec::new(); + + // Use df to get mounted filesystems + let df_output = Command::new("df") + .args(["-H"]) + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; + + if !df_output.status.success() { + return Ok(volumes); // Return empty if df fails + } + + let df_stdout = String::from_utf8_lossy(&df_output.stdout); + for line in df_stdout.lines().skip(1) { + // Skip header + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 9 { + let filesystem = fields[0]; + let mount_point = fields[8..].join(" "); + + // Skip APFS filesystems (already handled by APFS detection) + if filesystem.starts_with("/dev/disk") && mount_point.starts_with("/") { + continue; // Skip APFS volumes + } + + // Skip certain filesystems + if should_skip_filesystem(filesystem) { + debug!("Skipping {} filesystem: {}", filesystem, mount_point); + continue; + } + + // Skip system filesystems unless requested + if !config.include_system && utils::is_system_filesystem(filesystem) { + continue; + } + + // Skip virtual filesystems unless requested + if !config.include_virtual && utils::is_virtual_filesystem(filesystem) { + continue; + } + + // Parse sizes (in bytes) + let total_bytes = utils::parse_size_string(fields[1]).unwrap_or(0); + let available_bytes = utils::parse_size_string(fields[3]).unwrap_or(0); + + let mount_path = PathBuf::from(&mount_point); + let name = extract_volume_name(&mount_path); + + let mount_type = if mount_point.starts_with("/Volumes/") { + MountType::External + } else if filesystem.contains("://") { + MountType::Network + } else { + MountType::System + }; + + let disk_type = detect_disk_type(&mount_path).unwrap_or(DiskType::Unknown); + let file_system = detect_filesystem(&mount_path) + .unwrap_or(FileSystem::Other("Unknown".to_string())); + + let volume = Volume::new( + device_id, + name.clone(), + mount_type, + classify_volume(&mount_path, &file_system, &name), + mount_path, + vec![], + disk_type, + file_system.clone(), + total_bytes, + available_bytes, + false, + Some(filesystem.to_string()), + VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()), + ); + volumes.push(volume); + } + } + Ok(volumes) + }) + .await + .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? +} + +/// Classify a volume using the platform-specific classifier +fn classify_volume( + mount_point: &PathBuf, + file_system: &FileSystem, + name: &str, +) -> crate::volume::types::VolumeType { + let classifier = get_classifier(); + let detection_info = VolumeDetectionInfo { + mount_point: mount_point.clone(), + file_system: file_system.clone(), + total_bytes_capacity: 0, // We don't have this info yet in some contexts + is_removable: None, // Would need additional detection + is_network_drive: None, // Would need additional detection + device_model: None, // Would need additional detection + }; + + classifier.classify(&detection_info) +} + +/// Check if filesystem should be skipped +fn should_skip_filesystem(filesystem: &str) -> bool { + matches!( + filesystem, + "devfs" | "tmpfs" | "proc" | "sysfs" | "fdescfs" | "kernfs" + ) +} + +/// Extract volume name from mount path +fn extract_volume_name(mount_path: &PathBuf) -> String { + if let Some(name) = mount_path.file_name() { + name.to_string_lossy().to_string() + } else if mount_path.to_string_lossy() == "/" { + "Macintosh HD".to_string() + } else { + mount_path.to_string_lossy().to_string() + } +} + +/// Detect disk type (SSD vs HDD) using diskutil +fn detect_disk_type(mount_point: &PathBuf) -> VolumeResult { + // Try to detect SSD vs HDD using diskutil + let output = Command::new("diskutil") + .args(["info", mount_point.to_str().unwrap_or("/")]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let info = String::from_utf8_lossy(&output.stdout); + if info.contains("Solid State") { + Ok(DiskType::SSD) + } else if info.contains("Rotational") { + Ok(DiskType::HDD) + } else { + Ok(DiskType::Unknown) + } + } + _ => Ok(DiskType::Unknown), + } +} + +/// Detect filesystem type using diskutil +fn detect_filesystem(mount_point: &PathBuf) -> VolumeResult { + let output = Command::new("diskutil") + .args(["info", mount_point.to_str().unwrap_or("/")]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let info = String::from_utf8_lossy(&output.stdout); + if info.contains("APFS") { + Ok(FileSystem::APFS) + } else if info.contains("HFS+") { + Ok(FileSystem::Other("HFS+".to_string())) + } else if info.contains("ExFAT") { + Ok(FileSystem::ExFAT) + } else if info.contains("FAT32") { + Ok(FileSystem::FAT32) + } else { + Ok(FileSystem::Other("Unknown".to_string())) + } + } + _ => Ok(FileSystem::Other("Unknown".to_string())), + } +} + +/// Get volume space information using df +pub fn get_volume_space_info(mount_point: &PathBuf) -> VolumeResult<(u64, u64)> { + let output = Command::new("df") + .args(["-k", mount_point.to_str().unwrap_or("/")]) + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run df: {}", e)))?; + + if !output.status.success() { + return Ok((0, 0)); // Return zeros if df fails + } + + let df_stdout = String::from_utf8_lossy(&output.stdout); + for line in df_stdout.lines().skip(1) { + // Skip header + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 4 { + // df -k returns sizes in 1K blocks + let total_kb = fields[1].parse::().unwrap_or(0); + let available_kb = fields[3].parse::().unwrap_or(0); + return Ok((total_kb * 1024, available_kb * 1024)); + } + } + + Ok((0, 0)) +} diff --git a/core/src/volume/platform/mod.rs b/core/src/volume/platform/mod.rs new file mode 100644 index 000000000..6cde9f829 --- /dev/null +++ b/core/src/volume/platform/mod.rs @@ -0,0 +1,10 @@ +//! Platform-specific volume detection helpers + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "linux")] +pub mod linux; + +#[cfg(target_os = "windows")] +pub mod windows; diff --git a/core/src/volume/platform/windows.rs b/core/src/volume/platform/windows.rs new file mode 100644 index 000000000..20567cfbf --- /dev/null +++ b/core/src/volume/platform/windows.rs @@ -0,0 +1,251 @@ +//! Windows-specific volume detection helpers + +use crate::volume::{ + classification::{get_classifier, VolumeDetectionInfo}, + error::{VolumeError, VolumeResult}, + types::{DiskType, FileSystem, MountType, Volume, VolumeDetectionConfig, VolumeFingerprint}, + utils, +}; +use std::path::PathBuf; +use std::process::Command; +use tokio::task; +use tracing::warn; +use uuid::Uuid; + +/// Windows volume information from PowerShell/WMI +#[derive(Debug, Clone)] +pub struct WindowsVolumeInfo { + pub drive_letter: Option, + pub label: Option, + pub size: u64, + pub size_remaining: u64, + pub filesystem: String, + pub volume_guid: Option, +} + +/// Detect Windows volumes using PowerShell +pub async fn detect_volumes( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + let config = config.clone(); // Clone to move into async block + task::spawn_blocking(move || { + // Use PowerShell to get volume information + let output = Command::new("powershell") + .args([ + "-Command", + "Get-Volume | Select-Object DriveLetter,FileSystemLabel,Size,SizeRemaining,FileSystem | ConvertTo-Json" + ]) + .output() + .map_err(|e| VolumeError::platform(format!("Failed to run PowerShell: {}", e)))?; + + if !output.status.success() { + warn!("PowerShell Get-Volume command failed, trying fallback method"); + return detect_volumes_fallback(device_id, &config); + } + + let json_output = String::from_utf8_lossy(&output.stdout); + parse_powershell_volumes(&json_output, device_id, &config) + }) + .await + .map_err(|e| VolumeError::platform(format!("Task join error: {}", e)))? +} + +/// Parse PowerShell JSON output into volumes +fn parse_powershell_volumes( + json_output: &str, + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + // For now, return empty until we implement full JSON parsing + // This would require adding serde_json dependency + warn!("PowerShell JSON parsing not fully implemented yet"); + Ok(Vec::new()) +} + +/// Fallback method using wmic or fsutil +fn detect_volumes_fallback( + device_id: Uuid, + config: &VolumeDetectionConfig, +) -> VolumeResult> { + let mut volumes = Vec::new(); + + // Try using wmic as fallback + let output = Command::new("wmic") + .args([ + "logicaldisk", + "get", + "size,freespace,caption,filesystem,volumename", + "/format:csv", + ]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let csv_output = String::from_utf8_lossy(&output.stdout); + volumes.extend(parse_wmic_output(&csv_output, device_id, config)?); + } + _ => { + warn!("Both PowerShell and wmic methods failed for Windows volume detection"); + } + } + + Ok(volumes) +} + +/// Parse wmic CSV output +fn parse_wmic_output( + csv_output: &str, + device_id: Uuid, + _config: &VolumeDetectionConfig, +) -> VolumeResult> { + let mut volumes = Vec::new(); + + for line in csv_output.lines().skip(1) { + // Skip header + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() >= 6 { + let caption = fields[1].trim(); + let filesystem = fields[2].trim(); + let freespace_str = fields[3].trim(); + let size_str = fields[5].trim(); + let volume_name = fields[6].trim(); + + // Skip if essential fields are empty + if caption.is_empty() || size_str.is_empty() { + continue; + } + + let total_bytes = size_str.parse::().unwrap_or(0); + let available_bytes = freespace_str.parse::().unwrap_or(0); + + if total_bytes == 0 { + continue; + } + + let mount_path = PathBuf::from(caption); + let name = if volume_name.is_empty() { + format!("Local Disk ({})", caption) + } else { + volume_name.to_string() + }; + + let file_system = utils::parse_filesystem_type(filesystem); + let mount_type = determine_mount_type_windows(caption); + let disk_type = DiskType::Unknown; // Would need additional WMI queries + + let volume = Volume::new( + device_id, + name.clone(), + mount_type, + classify_volume(&mount_path, &file_system, &name), + mount_path, + vec![], + disk_type, + file_system.clone(), + total_bytes, + available_bytes, + false, // Would need additional check for read-only + Some(caption.to_string()), + VolumeFingerprint::new(&name, total_bytes, &file_system.to_string()), + ); + + volumes.push(volume); + } + } + + Ok(volumes) +} + +/// Classify a volume using the platform-specific classifier +fn classify_volume( + mount_point: &PathBuf, + file_system: &FileSystem, + name: &str, +) -> crate::volume::types::VolumeType { + let classifier = get_classifier(); + let detection_info = VolumeDetectionInfo { + mount_point: mount_point.clone(), + file_system: file_system.clone(), + total_bytes_capacity: 0, // We don't have this info yet in some contexts + is_removable: None, // Would need additional detection + is_network_drive: None, // Would need additional detection + device_model: None, // Would need additional detection + }; + + classifier.classify(&detection_info) +} + +/// Determine mount type for Windows drives +fn determine_mount_type_windows(drive_letter: &str) -> MountType { + match drive_letter.to_uppercase().as_str() { + "C:\\" | "D:\\" => MountType::System, // Common system drives + _ => MountType::External, // Assume external for others + } +} + +/// 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 + // or Windows API calls + Ok(Vec::new()) +} + +/// Create volume from Windows info (stub for now) +pub fn create_volume_from_windows_info( + info: WindowsVolumeInfo, + device_id: Uuid, +) -> VolumeResult { + let mount_path = if let Some(drive_letter) = &info.drive_letter { + PathBuf::from(format!("{}:\\", drive_letter)) + } else { + PathBuf::from("C:\\") // Default fallback + }; + + let name = info.label.unwrap_or_else(|| { + if let Some(drive) = &info.drive_letter { + format!("Local Disk ({}:)", drive) + } else { + "Unknown Drive".to_string() + } + }); + + let file_system = utils::parse_filesystem_type(&info.filesystem); + let mount_type = if let Some(drive) = &info.drive_letter { + determine_mount_type_windows(&format!("{}:\\", drive)) + } else { + MountType::System + }; + + let volume = Volume::new( + device_id, + name.clone(), + mount_type, + classify_volume(&mount_path, &file_system, &name), + mount_path, + vec![], + DiskType::Unknown, // Would need additional detection + file_system.clone(), + info.size, + info.size_remaining, + false, // Would need additional check for read-only + info.volume_guid, + VolumeFingerprint::new(&name, info.size, &file_system.to_string()), + ); + + Ok(volume) +} + +/// Check if volume should be included based on config +pub fn should_include_volume(volume: &Volume, config: &VolumeDetectionConfig) -> bool { + // Apply filtering based on config + if !config.include_system && matches!(volume.mount_type, MountType::System) { + return false; + } + + if !config.include_virtual && volume.total_bytes_capacity == 0 { + return false; + } + + true +} diff --git a/core/src/volume/types.rs b/core/src/volume/types.rs index f4b26fbbe..b66321266 100644 --- a/core/src/volume/types.rs +++ b/core/src/volume/types.rs @@ -5,6 +5,85 @@ use std::fmt; use std::path::PathBuf; use uuid::Uuid; +/// Represents an APFS container (physical storage with multiple volumes) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApfsContainer { + /// Container identifier (e.g., "disk3") + pub container_id: String, + /// Container UUID + pub uuid: String, + /// Physical store device (e.g., "disk0s2") + pub physical_store: String, + /// Total container capacity in bytes + pub total_capacity: u64, + /// Capacity in use by all volumes in bytes + pub capacity_in_use: u64, + /// Capacity not allocated in bytes + pub capacity_free: u64, + /// All volumes in this container + pub volumes: Vec, +} + +/// APFS volume information within a container +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApfsVolumeInfo { + /// Volume disk identifier (e.g., "disk3s5") + pub disk_id: String, + /// Volume UUID + pub uuid: String, + /// Volume role (System, Data, Preboot, etc.) + pub role: ApfsVolumeRole, + /// Volume name + pub name: String, + /// Mount point (if mounted) + pub mount_point: Option, + /// Capacity consumed by this volume in bytes + pub capacity_consumed: u64, + /// Whether the volume is sealed + pub sealed: bool, + /// Whether FileVault is enabled + pub filevault: bool, +} + +/// APFS volume roles in the container +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ApfsVolumeRole { + /// System volume (read-only system files) + System, + /// Data volume (user data and applications) + Data, + /// Preboot volume (boot support files) + Preboot, + /// Recovery volume (recovery environment) + Recovery, + /// VM volume (virtual memory) + VM, + /// Other role or no specific role + Other(String), +} + +impl fmt::Display for ApfsVolumeRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ApfsVolumeRole::System => write!(f, "System"), + ApfsVolumeRole::Data => write!(f, "Data"), + ApfsVolumeRole::Preboot => write!(f, "Preboot"), + ApfsVolumeRole::Recovery => write!(f, "Recovery"), + ApfsVolumeRole::VM => write!(f, "VM"), + ApfsVolumeRole::Other(role) => write!(f, "{}", role), + } + } +} + +/// Path mapping for resolving virtual paths to actual storage locations +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PathMapping { + /// Virtual path (e.g., "/Users") + pub virtual_path: PathBuf, + /// Actual storage path (e.g., "/System/Volumes/Data/Users") + pub actual_path: PathBuf, +} + /// Spacedrive volume identifier file content /// This file is created in the root of writable volumes for persistent identification #[derive(Debug, Clone, Serialize, Deserialize)] @@ -241,6 +320,13 @@ pub struct Volume { /// Current error status if any pub error_status: Option, + /// APFS container information (macOS only) + pub apfs_container: Option, + /// Container-relative volume ID for same-container detection + pub container_volume_id: Option, + /// Path resolution mappings (for firmlinks/symlinks) + pub path_mappings: Vec, + // Storage information /// Total storage capacity in bytes pub total_bytes_capacity: u64, @@ -353,6 +439,9 @@ impl TrackedVolume { hardware_id: self.device_model.clone(), is_mounted: false, // Offline volumes are not mounted error_status: None, + apfs_container: None, // Not available for offline volumes + container_volume_id: None, // Not available for offline volumes + path_mappings: Vec::new(), // Not available for offline volumes read_speed_mbps: self.read_speed_mbps.map(|s| s as u64), write_speed_mbps: self.write_speed_mbps.map(|s| s as u64), last_updated: self.last_seen_at, @@ -395,6 +484,55 @@ impl Volume { read_only, hardware_id, error_status: None, + apfs_container: None, + container_volume_id: None, + path_mappings: Vec::new(), + read_speed_mbps: None, + write_speed_mbps: None, + auto_track_eligible: volume_type.auto_track_by_default(), + is_user_visible: volume_type.show_by_default(), + last_updated: chrono::Utc::now(), + } + } + + /// Create a new APFS Volume instance with container information + pub fn new_with_apfs_container( + device_id: uuid::Uuid, + name: String, + mount_type: MountType, + volume_type: VolumeType, + mount_point: PathBuf, + additional_mount_points: Vec, + disk_type: DiskType, + file_system: FileSystem, + total_bytes_capacity: u64, + total_bytes_available: u64, + read_only: bool, + hardware_id: Option, + fingerprint: VolumeFingerprint, + apfs_container: ApfsContainer, + container_volume_id: String, + path_mappings: Vec, + ) -> Self { + Self { + fingerprint, + device_id, + name, + mount_type, + volume_type, + mount_point, + mount_points: additional_mount_points, + is_mounted: true, + disk_type, + file_system, + total_bytes_capacity, + total_bytes_available, + read_only, + hardware_id, + error_status: None, + apfs_container: Some(apfs_container), + container_volume_id: Some(container_volume_id), + path_mappings, read_speed_mbps: None, write_speed_mbps: None, auto_track_eligible: volume_type.auto_track_by_default(), @@ -441,19 +579,8 @@ impl Volume { /// Check if a path is contained within this volume pub fn contains_path(&self, path: &PathBuf) -> bool { - // Check primary mount point - if path.starts_with(&self.mount_point) { - return true; - } - - // Check additional mount points - for mount_point in &self.mount_points { - if path.starts_with(mount_point) { - return true; - } - } - - false + // Use filesystem-specific logic for path resolution + crate::volume::fs::contains_path(self, path) } } diff --git a/core/src/volume/utils.rs b/core/src/volume/utils.rs new file mode 100644 index 000000000..9dd4ecaba --- /dev/null +++ b/core/src/volume/utils.rs @@ -0,0 +1,120 @@ +//! Shared utilities for volume detection across platforms + +use crate::volume::{ + error::{VolumeError, VolumeResult}, + types::FileSystem, +}; +use tracing::warn; + +/// Parse size strings from df output (e.g., "1.5G", "931Gi", "1024K") +pub fn parse_size_string(size_str: &str) -> VolumeResult { + if size_str == "-" { + return Ok(0); + } + + // Skip invalid size strings that don't look like numbers + if size_str.is_empty() || size_str.chars().all(char::is_alphabetic) { + return Ok(0); + } + + let size_str = size_str.replace(",", ""); // Remove commas + let (number_part, unit) = if let Some(pos) = size_str.find(char::is_alphabetic) { + (&size_str[..pos], &size_str[pos..]) + } else { + (size_str.as_str(), "") + }; + + let number: f64 = number_part + .parse() + .map_err(|_| VolumeError::InvalidData(format!("Invalid size: {}", size_str)))?; + + let multiplier = match unit.to_uppercase().as_str() { + "" | "B" => 1, + "K" | "KB" | "KI" => 1024, + "M" | "MB" | "MI" => 1024 * 1024, + "G" | "GB" | "GI" => 1024 * 1024 * 1024, + "T" | "TB" | "TI" => 1024u64.pow(4), + "P" | "PB" | "PI" => 1024u64.pow(5), + _ => { + warn!("Unknown size unit: {}", unit); + 1 + } + }; + + Ok((number * multiplier as f64) as u64) +} + +/// Check if a filesystem should be considered a system filesystem +pub fn is_system_filesystem(filesystem: &str) -> bool { + matches!( + filesystem, + "/" | "/dev" | "/proc" | "/sys" | "/tmp" | "/var/tmp" + ) +} + +/// Check if a filesystem is virtual (not backed by physical storage) +pub fn is_virtual_filesystem(filesystem: &str) -> bool { + let fs_lower = filesystem.to_lowercase(); + matches!( + fs_lower.as_str(), + "devfs" | "sysfs" | "proc" | "tmpfs" | "ramfs" | "devtmpfs" | "overlay" | "fuse" + ) || fs_lower.starts_with("map ") + || fs_lower.contains("auto_") +} + +/// Parse filesystem type from string to FileSystem enum +pub fn parse_filesystem_type(fs_type: &str) -> FileSystem { + match fs_type.to_lowercase().as_str() { + "apfs" => FileSystem::APFS, + "btrfs" => FileSystem::Btrfs, + "zfs" => FileSystem::ZFS, + "refs" => FileSystem::ReFS, + "ntfs" => FileSystem::NTFS, + "ext2" | "ext3" | "ext4" => FileSystem::EXT4, + "xfs" => FileSystem::Other("XFS".to_string()), + "fat32" | "vfat" => FileSystem::FAT32, + "exfat" => FileSystem::ExFAT, + "hfs+" | "hfsplus" => FileSystem::Other("HFS+".to_string()), + _ => FileSystem::Other(fs_type.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_size_string() { + assert_eq!(parse_size_string("1024").unwrap(), 1024); + assert_eq!(parse_size_string("1K").unwrap(), 1024); + assert_eq!(parse_size_string("1M").unwrap(), 1024 * 1024); + assert_eq!(parse_size_string("1G").unwrap(), 1024 * 1024 * 1024); + assert_eq!( + parse_size_string("1.5G").unwrap(), + (1.5 * 1024.0 * 1024.0 * 1024.0) as u64 + ); + assert_eq!(parse_size_string("-").unwrap(), 0); + } + + #[test] + fn test_filesystem_detection() { + assert!(is_virtual_filesystem("tmpfs")); + assert!(is_virtual_filesystem("proc")); + assert!(!is_virtual_filesystem("ext4")); + + assert!(is_system_filesystem("/")); + assert!(is_system_filesystem("/proc")); + assert!(!is_system_filesystem("/home")); + } + + #[test] + fn test_parse_filesystem_type() { + assert!(matches!(parse_filesystem_type("apfs"), FileSystem::APFS)); + assert!(matches!(parse_filesystem_type("btrfs"), FileSystem::Btrfs)); + assert!(matches!(parse_filesystem_type("ext4"), FileSystem::EXT4)); + assert!(matches!( + parse_filesystem_type("unknown"), + FileSystem::Other(_) + )); + } +} diff --git a/core/tests/volume_detection_test.rs b/core/tests/volume_detection_test.rs new file mode 100644 index 000000000..713170c1f --- /dev/null +++ b/core/tests/volume_detection_test.rs @@ -0,0 +1,477 @@ +//! Integration tests for volume detection and filesystem-aware copy strategy selection +//! +//! These tests verify that the volume detection system correctly identifies volumes, +//! resolves paths to their storage locations, and selects optimal copy strategies. + +use sd_core::{ + domain::addressing::SdPath, + infra::event::EventBus, + ops::files::copy::{input::CopyMethod, routing::CopyStrategyRouter}, + volume::{ + types::{VolumeDetectionConfig, VolumeType}, + VolumeManager, + }, +}; +use std::{path::PathBuf, sync::Arc}; +use tokio::fs; +use uuid::Uuid; + +/// Test volume detection on macOS +#[cfg(target_os = "macos")] +#[tokio::test] +async fn test_macos_volume_detection() { + // Initialize volume manager + let device_id = Uuid::new_v4(); + let config = VolumeDetectionConfig::default(); + let events = Arc::new(EventBus::default()); + let volume_manager = Arc::new(VolumeManager::new(device_id, config, events)); + + // Initialize and detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Get all detected volumes + let volumes = volume_manager.get_all_volumes().await; + println!("Detected {} volumes:", volumes.len()); + + for volume in &volumes { + println!( + " - {} ({}) at {} [{}] - {}", + volume.name, + volume.file_system, + volume.mount_point.display(), + volume.volume_type.display_name(), + if volume.apfs_container.is_some() { + "APFS Container" + } else { + "Standalone" + } + ); + + // Print APFS container info if available + if let Some(container) = &volume.apfs_container { + println!( + " Container: {} ({} volumes)", + container.container_id, + container.volumes.len() + ); + } + + // Print path mappings if available + if !volume.path_mappings.is_empty() { + println!(" Path mappings:"); + for mapping in &volume.path_mappings { + println!( + " {} -> {}", + mapping.virtual_path.display(), + mapping.actual_path.display() + ); + } + } + } + + // Verify we have at least one volume + assert!(!volumes.is_empty(), "No volumes detected"); + + // On macOS, we should have APFS volumes + let apfs_volumes: Vec<_> = volumes + .iter() + .filter(|v| matches!(v.file_system, sd_core::volume::types::FileSystem::APFS)) + .collect(); + + assert!( + !apfs_volumes.is_empty(), + "No APFS volumes detected on macOS" + ); + println!("✅ Found {} APFS volumes", apfs_volumes.len()); + + // Check for Data volume with path mappings + let data_volumes: Vec<_> = apfs_volumes + .iter() + .filter(|v| matches!(v.volume_type, VolumeType::UserData) && !v.path_mappings.is_empty()) + .collect(); + + if !data_volumes.is_empty() { + println!( + "✅ Found {} APFS Data volumes with path mappings", + data_volumes.len() + ); + } +} + +/// Test path resolution for common macOS paths +#[cfg(target_os = "macos")] +#[tokio::test] +async fn test_macos_path_resolution() { + // Initialize volume manager + let device_id = Uuid::new_v4(); + let config = VolumeDetectionConfig::default(); + let events = Arc::new(EventBus::default()); + let volume_manager = Arc::new(VolumeManager::new(device_id, config, events)); + + // Initialize and detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Test common macOS paths + let test_paths = vec![ + "/Users", + "/Applications", + "/Library", + "/tmp", + "/var", + "/System/Volumes/Data/Users", + "/System/Volumes/Data/Applications", + ]; + + println!("Testing path resolution:"); + for path_str in test_paths { + let path = PathBuf::from(path_str); + if path.exists() { + if let Some(volume) = volume_manager.volume_for_path(&path).await { + println!( + " {} -> Volume: {} ({})", + path_str, volume.name, volume.file_system + ); + } else { + println!(" {} -> No volume found", path_str); + } + } else { + println!(" {} -> Path does not exist", path_str); + } + } +} + +/// Test same physical storage detection for common paths +#[cfg(target_os = "macos")] +#[tokio::test] +async fn test_same_physical_storage_detection() { + // Initialize volume manager + let device_id = Uuid::new_v4(); + let config = VolumeDetectionConfig::default(); + let events = Arc::new(EventBus::default()); + let volume_manager = Arc::new(VolumeManager::new(device_id, config, events)); + + // Initialize and detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Test same-storage detection for common macOS paths + let test_cases = vec![ + ( + "/Users", + "/Applications", + true, + "Both should be on Data volume", + ), + ("/Users", "/tmp", true, "Both should be on Data volume"), + ( + "/Users", + "/System/Volumes/Data/Users", + true, + "Virtual vs actual path", + ), + ( + "/Applications", + "/System/Volumes/Data/Applications", + true, + "Virtual vs actual path", + ), + ("/Users", "/Volumes", false, "Different storage locations"), + ]; + + println!("Testing same physical storage detection:"); + for (path1_str, path2_str, expected, description) in test_cases { + let path1 = PathBuf::from(path1_str); + let path2 = PathBuf::from(path2_str); + + // Only test if both paths exist + if path1.exists() && path2.exists() { + let same_storage = volume_manager.same_physical_storage(&path1, &path2).await; + let status = if same_storage == expected { + "✅" + } else { + "❌" + }; + + println!( + " {} {} <-> {} = {} (expected: {}) - {}", + status, path1_str, path2_str, same_storage, expected, description + ); + + // For critical paths, assert the result + if path1_str == "/Users" && path2_str == "/Applications" { + assert_eq!( + same_storage, expected, + "Users and Applications should be detected as same storage on macOS APFS" + ); + } + } else { + println!( + " ⏭️ {} <-> {} - Skipped (paths don't exist)", + path1_str, path2_str + ); + } + } +} + +/// Test copy strategy selection for same-storage vs cross-storage operations +#[cfg(target_os = "macos")] +#[tokio::test] +async fn test_copy_strategy_selection() { + // Initialize volume manager + let device_id = Uuid::new_v4(); + let config = VolumeDetectionConfig::default(); + let events = Arc::new(EventBus::default()); + let volume_manager = Arc::new(VolumeManager::new(device_id, config, events)); + + // Initialize and detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Create test paths (using existing directories) + let test_cases = vec![ + ( + "/Users", + "/Applications", + "Same APFS container - should use FastCopyStrategy", + ), + ( + "/Users", + "/tmp", + "Same APFS container - should use FastCopyStrategy", + ), + ]; + + println!("Testing copy strategy selection:"); + for (source_str, dest_str, expected_behavior) in test_cases { + let source_path = PathBuf::from(source_str); + let dest_path = PathBuf::from(dest_str); + + // Only test if both paths exist + if source_path.exists() && dest_path.exists() { + // Create SdPath instances (using current device ID) + let source_sdpath = SdPath::new(device_id, source_path.clone()); + let dest_sdpath = SdPath::new(device_id, dest_path.clone()); + + // Test strategy selection + let strategy = CopyStrategyRouter::select_strategy( + &source_sdpath, + &dest_sdpath, + false, // is_move = false + &CopyMethod::Auto, + Some(&*volume_manager), + ) + .await; + + // Get strategy description + let description = CopyStrategyRouter::describe_strategy( + &source_sdpath, + &dest_sdpath, + false, + &CopyMethod::Auto, + Some(&*volume_manager), + ) + .await; + + println!( + " {} -> {} = {} ({})", + source_str, dest_str, description, expected_behavior + ); + + // For same-storage operations, we should get a fast copy strategy + if source_str == "/Users" && dest_str == "/Applications" { + assert!( + description.contains("Fast copy") || description.contains("APFS clone"), + "Same-storage copy should use fast copy strategy, got: {}", + description + ); + } + } else { + println!( + " ⏭️ {} -> {} - Skipped (paths don't exist)", + source_str, dest_str + ); + } + } +} + +/// Test APFS container detection specifically +#[cfg(target_os = "macos")] +#[tokio::test] +async fn test_apfs_container_detection() { + use sd_core::volume::fs::apfs; + + println!("Testing APFS container detection:"); + + // Detect APFS containers directly + let containers = apfs::detect_containers() + .await + .expect("Failed to detect APFS containers"); + + println!("Detected {} APFS containers:", containers.len()); + for container in &containers { + println!( + " Container: {} ({})", + container.container_id, container.uuid + ); + println!(" Physical Store: {}", container.physical_store); + println!( + " Total Capacity: {} GB", + container.total_capacity / (1024 * 1024 * 1024) + ); + println!(" Volumes: {}", container.volumes.len()); + + for volume in &container.volumes { + println!( + " - {} ({}) at {:?} [{}]", + volume.name, volume.disk_id, volume.mount_point, volume.role + ); + } + } + + // Verify we have at least one container + assert!(!containers.is_empty(), "No APFS containers detected"); + + // Check for Data volume + let has_data_volume = containers.iter().any(|container| { + container + .volumes + .iter() + .any(|volume| matches!(volume.role, sd_core::volume::types::ApfsVolumeRole::Data)) + }); + + assert!(has_data_volume, "No APFS Data volume found"); + println!("✅ Found APFS Data volume"); +} + +/// Test filesystem handler selection +#[tokio::test] +async fn test_filesystem_handler_selection() { + use sd_core::volume::{fs, types::FileSystem}; + + println!("Testing filesystem handler selection:"); + + let filesystems = vec![ + FileSystem::APFS, + FileSystem::NTFS, + FileSystem::ExFAT, + FileSystem::FAT32, + FileSystem::Other("Unknown".to_string()), + ]; + + for fs_type in filesystems { + let handler = fs::get_filesystem_handler(&fs_type); + let strategy = handler.get_copy_strategy(); + + println!( + " {} -> Strategy type: {}", + fs_type, + std::any::type_name_of_val(&*strategy) + .split("::") + .last() + .unwrap_or("Unknown") + ); + } +} + +/// Integration test that simulates the full copy workflow +#[cfg(target_os = "macos")] +#[tokio::test] +async fn test_full_copy_workflow_simulation() { + // Initialize volume manager + let device_id = Uuid::new_v4(); + let config = VolumeDetectionConfig::default(); + let events = Arc::new(EventBus::default()); + let volume_manager = Arc::new(VolumeManager::new(device_id, config, events)); + + // Initialize and detect volumes + volume_manager + .initialize() + .await + .expect("Failed to initialize volume manager"); + + // Simulate common copy scenarios + let scenarios = vec![ + ("/Users/Shared", "/Applications", "Desktop to Applications"), + ("/tmp", "/Users/Shared", "Temp to Desktop"), + ]; + + println!("Testing full copy workflow simulation:"); + for (source_str, dest_str, scenario_name) in scenarios { + let source_path = PathBuf::from(source_str); + let dest_path = PathBuf::from(dest_str); + + // Only test if both paths exist + if source_path.exists() && dest_path.exists() { + println!("\n📋 Scenario: {}", scenario_name); + + // Step 1: Check if paths are on same physical storage + let same_storage = volume_manager + .same_physical_storage(&source_path, &dest_path) + .await; + println!(" Same physical storage: {}", same_storage); + + // Step 2: Get volumes for both paths + let source_volume = volume_manager.volume_for_path(&source_path).await; + let dest_volume = volume_manager.volume_for_path(&dest_path).await; + + match (&source_volume, &dest_volume) { + (Some(src_vol), Some(dst_vol)) => { + println!( + " Source volume: {} ({})", + src_vol.name, src_vol.file_system + ); + println!(" Dest volume: {} ({})", dst_vol.name, dst_vol.file_system); + + // Step 3: Select copy strategy + let source_sdpath = SdPath::new(device_id, source_path.clone()); + let dest_sdpath = SdPath::new(device_id, dest_path.clone()); + + let description = CopyStrategyRouter::describe_strategy( + &source_sdpath, + &dest_sdpath, + false, + &CopyMethod::Auto, + Some(&*volume_manager), + ) + .await; + + println!(" Selected strategy: {}", description); + + // Step 4: Verify expected behavior + if same_storage + && matches!( + src_vol.file_system, + sd_core::volume::types::FileSystem::APFS + ) { + assert!( + description.contains("Fast copy") || description.contains("APFS clone"), + "Same-storage APFS copy should use fast strategy, got: {}", + description + ); + println!( + " ✅ Correctly selected fast copy for same-storage APFS operation" + ); + } + } + _ => { + println!(" ❌ Could not find volumes for one or both paths"); + } + } + } else { + println!( + " ⏭️ Scenario '{}' skipped (paths don't exist)", + scenario_name + ); + } + } +}