mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 13:55:40 -04:00
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:
@@ -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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: CORE-006
|
||||
title: Semantic Tagging Architecture
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: unassigned
|
||||
parent: CORE-000
|
||||
priority: Medium
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: INDEX-001
|
||||
title: Location Watcher Service
|
||||
status: Done
|
||||
status: In Progress
|
||||
assignee: james
|
||||
parent: INDEX-000
|
||||
priority: High
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
171
core/src/volume/detection.rs
Normal file
171
core/src/volume/detection.rs
Normal 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
630
core/src/volume/fs/apfs.rs
Normal 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
338
core/src/volume/fs/btrfs.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
58
core/src/volume/fs/generic.rs
Normal file
58
core/src/volume/fs/generic.rs
Normal 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
80
core/src/volume/fs/mod.rs
Normal 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
385
core/src/volume/fs/ntfs.rs
Normal 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
327
core/src/volume/fs/refs.rs
Normal 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
406
core/src/volume/fs/zfs.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
270
core/src/volume/platform/linux.rs
Normal file
270
core/src/volume/platform/linux.rs
Normal 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)
|
||||
}
|
||||
215
core/src/volume/platform/macos.rs
Normal file
215
core/src/volume/platform/macos.rs
Normal 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))
|
||||
}
|
||||
10
core/src/volume/platform/mod.rs
Normal file
10
core/src/volume/platform/mod.rs
Normal 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;
|
||||
251
core/src/volume/platform/windows.rs
Normal file
251
core/src/volume/platform/windows.rs
Normal 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
|
||||
}
|
||||
@@ -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
120
core/src/volume/utils.rs
Normal 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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
477
core/tests/volume_detection_test.rs
Normal file
477
core/tests/volume_detection_test.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user