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.
This commit is contained in:
Jamie Pine
2025-09-19 15:30:28 -07:00
parent cb11170eec
commit 80fd58a85e
24 changed files with 3994 additions and 688 deletions

View File

@@ -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]

View File

@@ -1,7 +1,7 @@
---
id: CORE-006
title: Semantic Tagging Architecture
status: To Do
status: Done
assignee: unassigned
parent: CORE-000
priority: Medium

View File

@@ -1,7 +1,7 @@
---
id: INDEX-001
title: Location Watcher Service
status: Done
status: In Progress
assignee: james
parent: INDEX-000
priority: High

View File

@@ -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

View File

@@ -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]

View File

@@ -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,

View File

@@ -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<Vec<Volume>> {
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<Volume>) -> 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<Vec<Volume>> {
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<Vec<Volume>> {
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<Vec<Volume>> {
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<Vec<Volume>> {
use crate::volume::platform::macos;
macos::detect_non_apfs_volumes(device_id, config).await
}

630
core/src/volume/fs/apfs.rs Normal file
View File

@@ -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<Vec<ApfsContainer>> {
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<Vec<ApfsContainer>> {
let mut containers = Vec::new();
let mut current_container: Option<ApfsContainer> = 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<u64> {
// 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::<u64>() {
return Some(bytes);
}
}
}
None
}
/// Extract role from a line like "APFS Volume Disk (Role): disk3s5 (Data)"
fn extract_role_from_line(line: &str) -> Option<String> {
// 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<String> {
// 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<String> {
// 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<Vec<Volume>> {
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::<u64>().unwrap_or(0);
let available_kb = fields[3].parse::<u64>().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<ApfsContainer> {
// 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<dyn crate::ops::files::copy::strategy::CopyStrategy> {
// 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<PathMapping> {
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())
);
}
}

338
core/src/volume/fs/btrfs.rs Normal file
View File

@@ -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<BtrfsInfo> {
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<Option<SubvolumeInfo>> {
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<dyn crate::ops::files::copy::strategy::CopyStrategy> {
// 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<String>,
devices: Vec<String>,
supports_reflinks: bool,
}
/// Btrfs subvolume information
#[derive(Debug, Clone)]
pub struct SubvolumeInfo {
pub name: String,
pub uuid: String,
pub parent_uuid: Option<String>,
pub creation_time: Option<String>,
pub subvolume_id: u64,
pub generation: u64,
}
/// Parse btrfs filesystem show output
fn parse_btrfs_filesystem_info(output: &str) -> VolumeResult<BtrfsInfo> {
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<SubvolumeInfo> {
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);
}
}

View File

@@ -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<dyn crate::ops::files::copy::strategy::CopyStrategy> {
// 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))
}
}

80
core/src/volume/fs/mod.rs Normal file
View File

@@ -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<dyn crate::ops::files::copy::strategy::CopyStrategy>;
/// 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<dyn FilesystemHandler> {
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<dyn crate::ops::files::copy::strategy::CopyStrategy> {
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)
}

385
core/src/volume/fs/ntfs.rs Normal file
View File

@@ -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<NtfsVolumeInfo> {
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<NtfsFeatures> {
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<dyn crate::ops::files::copy::strategy::CopyStrategy> {
// 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<char>,
pub label: Option<String>,
pub size_bytes: u64,
pub available_bytes: u64,
pub disk_number: Option<u32>,
pub partition_number: Option<u32>,
pub media_type: Option<String>,
}
/// 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<NtfsVolumeInfo> {
// 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<NtfsFeatures> {
// 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<String> {
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<u64> {
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<bool> {
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);
}
}

327
core/src/volume/fs/refs.rs Normal file
View File

@@ -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<RefsVolumeInfo> {
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<Vec<RefsVolumeInfo>> {
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<dyn crate::ops::files::copy::strategy::CopyStrategy> {
// 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<char>,
pub label: Option<String>,
pub size_bytes: u64,
pub available_bytes: u64,
pub disk_number: Option<u32>,
pub partition_number: Option<u32>,
pub media_type: Option<String>,
pub supports_block_cloning: bool,
}
/// Parse PowerShell volume info JSON output
fn parse_volume_info(json_output: &str) -> VolumeResult<RefsVolumeInfo> {
// 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<Vec<RefsVolumeInfo>> {
// 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<String> {
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<u64> {
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);
}
}

406
core/src/volume/fs/zfs.rs Normal file
View File

@@ -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<ZfsDatasetInfo> {
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<ZfsPoolInfo> {
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<Vec<ZfsDatasetInfo>> {
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<dyn crate::ops::files::copy::strategy::CopyStrategy> {
// 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<PathBuf>,
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<String>,
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<ZfsDatasetInfo> {
let mut best_match: Option<ZfsDatasetInfo> = 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<Vec<ZfsDatasetInfo>> {
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<ZfsPoolInfo> {
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<u64> {
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")));
}
}

View File

@@ -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<Volume> {
self.volumes

View File

@@ -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())
}
}

View File

@@ -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<Vec<Volume>> {
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<Vec<Volume>> {
// 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<Option<Volume>> {
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<DiskType> {
// 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<FileSystem> {
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<Vec<Volume>> {
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<Option<Volume>> {
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<DiskType> {
// 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<Vec<Volume>> {
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<Vec<Volume>> {
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<u64> {
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"));
}
}

View File

@@ -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<Vec<Volume>> {
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<Option<Volume>> {
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<DiskType> {
// 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<Vec<MountInfo>> {
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<Volume> {
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)
}

View File

@@ -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<Vec<Volume>> {
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<DiskType> {
// 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<FileSystem> {
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::<u64>().unwrap_or(0);
let available_kb = fields[3].parse::<u64>().unwrap_or(0);
return Ok((total_kb * 1024, available_kb * 1024));
}
}
Ok((0, 0))
}

View File

@@ -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;

View File

@@ -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<String>,
pub label: Option<String>,
pub size: u64,
pub size_remaining: u64,
pub filesystem: String,
pub volume_guid: Option<String>,
}
/// Detect Windows volumes using PowerShell
pub async fn detect_volumes(
device_id: Uuid,
config: &VolumeDetectionConfig,
) -> VolumeResult<Vec<Volume>> {
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<Vec<Volume>> {
// 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<Vec<Volume>> {
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<Vec<Volume>> {
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::<u64>().unwrap_or(0);
let available_bytes = freespace_str.parse::<u64>().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<Vec<WindowsVolumeInfo>> {
// 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<Volume> {
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
}

View File

@@ -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<ApfsVolumeInfo>,
}
/// 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<PathBuf>,
/// 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<String>,
/// APFS container information (macOS only)
pub apfs_container: Option<ApfsContainer>,
/// Container-relative volume ID for same-container detection
pub container_volume_id: Option<String>,
/// Path resolution mappings (for firmlinks/symlinks)
pub path_mappings: Vec<PathMapping>,
// 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<PathBuf>,
disk_type: DiskType,
file_system: FileSystem,
total_bytes_capacity: u64,
total_bytes_available: u64,
read_only: bool,
hardware_id: Option<String>,
fingerprint: VolumeFingerprint,
apfs_container: ApfsContainer,
container_volume_id: String,
path_mappings: Vec<PathMapping>,
) -> 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)
}
}

120
core/src/volume/utils.rs Normal file
View File

@@ -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<u64> {
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(_)
));
}
}

View File

@@ -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
);
}
}
}