diff --git a/src/error.rs b/src/error.rs index 43d77fca..a105dc96 100644 --- a/src/error.rs +++ b/src/error.rs @@ -102,7 +102,9 @@ impl MsdErrorCode { Self::MsdDownloadIncomplete => "The remote image download was incomplete.", Self::MsdDriveNotInitialized => "The virtual drive is not initialized.", Self::MsdDriveConnected => "The virtual drive is connected to the controlled computer.", - Self::MsdDriveFilesystemUnsupported => "The virtual drive filesystem is unsupported.", + Self::MsdDriveFilesystemUnsupported => { + "Web file management does not support this virtual drive format." + } Self::MsdDriveSizeInvalid => "The virtual drive size is invalid.", Self::MsdStorageSpaceUnavailable => { "Available virtual media storage space could not be determined." @@ -183,7 +185,7 @@ impl MsdErrorCode { "Verify the remote server and network connection, then retry." } Self::MsdDriveFilesystemUnsupported => { - "Reinitialize the virtual drive with a supported filesystem, then retry." + "Mount the drive on the controlled computer, or use a supported format for web file management." } Self::MsdStorageSpaceUnavailable => { "Verify that virtual media storage is available, then retry." @@ -386,7 +388,7 @@ mod tests { ( MsdDriveFilesystemUnsupported, "MSD_DRIVE_FILESYSTEM_UNSUPPORTED", - "The virtual drive filesystem is unsupported.", + "Web file management does not support this virtual drive format.", ), ( MsdDriveSizeInvalid, diff --git a/src/msd/controller.rs b/src/msd/controller.rs index 027ca973..c9b249f1 100644 --- a/src/msd/controller.rs +++ b/src/msd/controller.rs @@ -8,9 +8,10 @@ use tracing::{debug, info, warn}; use super::image::ImageManager; use super::monitor::MsdHealthMonitor; use super::types::{ - DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia, - MountedMediaKind, MsdState, + DiskMode, DownloadProgress, DownloadStatus, DriveFileAccess, DriveInfo, ImageInfo, + MountedMedia, MountedMediaKind, MsdState, }; +use super::ventoy_drive::VentoyDrive; use crate::error::{AppError, MsdErrorCode, Result}; use crate::otg::{MsdFunction, MsdLunConfig, OtgService}; @@ -83,14 +84,9 @@ impl MsdController { state.available = true; if self.drive_path.exists() { - if let Ok(metadata) = std::fs::metadata(&self.drive_path) { - let drive_info = DriveInfo { - size: metadata.len(), - used: 0, - free: metadata.len(), - initialized: true, - path: self.drive_path.clone(), - }; + if let Ok(drive_info) = + VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown) + { state.drive_info = Some(drive_info.clone()); debug!( "Found existing virtual drive: {}", @@ -199,28 +195,6 @@ impl MsdController { self.assert_available(&state).await?; - if !self.drive_path.exists() { - self.monitor - .report_error("Virtual drive not initialized", "drive_not_found") - .await; - return Err(MsdErrorCode::MsdDriveNotInitialized.into()); - } - - let drive_info = state.drive_info.clone().or_else(|| { - std::fs::metadata(&self.drive_path) - .ok() - .map(|metadata| DriveInfo { - size: metadata.len(), - used: 0, - free: metadata.len(), - initialized: true, - path: self.drive_path.clone(), - }) - }); - if state.drive_info.is_none() { - state.drive_info = drive_info.clone(); - } - if state .mounted_media .iter() @@ -229,8 +203,22 @@ impl MsdController { return Err(MsdErrorCode::MsdMediaAlreadyMounted.into()); } - let drive_info = - drive_info.ok_or_else(|| AppError::from(MsdErrorCode::MsdDriveNotInitialized))?; + let drive_info = match self.drive_mount_info() { + Ok(info) => info, + Err(error) => { + if matches!( + &error, + AppError::Msd(msd) if msd.code() == MsdErrorCode::MsdDriveNotInitialized + ) { + self.monitor + .report_error("Virtual drive not initialized", "drive_not_found") + .await; + } + return Err(error); + } + }; + state.drive_info = Some(drive_info.clone()); + let lun = Self::lowest_free_lun(&state) .ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull))?; @@ -240,6 +228,8 @@ impl MsdController { return Err(e); } state.mounted_media.push(media); + state.drive_info = + Some(drive_info.with_file_access(DriveFileAccess::BlockedWhileConnected)); info!( "Mounted virtual drive on LUN {}: {}", @@ -254,6 +244,15 @@ impl MsdController { Ok(()) } + fn drive_mount_info(&self) -> Result { + VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown) + } + + pub async fn set_drive_info(&self, drive_info: Option) { + self.state.write().await.drive_info = drive_info; + self.mark_device_info_dirty().await; + } + async fn assert_available(&self, state: &MsdState) -> Result<()> { if !state.available { self.monitor @@ -293,6 +292,16 @@ impl MsdController { } fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) { + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) + { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } state.disk_mode = disk_mode; state.mounted_media.clear(); } @@ -397,6 +406,12 @@ impl MsdController { self.disconnect_lun(media.lun).await?; state.mounted_media.remove(index); + if media.kind == MountedMediaKind::Drive { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } info!("Unmounted media"); drop(state); @@ -490,6 +505,16 @@ impl MsdController { disconnected.push(media.clone()); } + if state + .mounted_media + .iter() + .any(|media| media.kind == MountedMediaKind::Drive) + { + state.drive_info = state + .drive_info + .take() + .map(|info| info.with_file_access(DriveFileAccess::Unknown)); + } state.mounted_media.clear(); info!("Disconnected all mounted media"); @@ -744,6 +769,29 @@ mod tests { assert!(state.mounted_media.is_empty()); } + #[tokio::test] + async fn drive_mount_metadata_ignores_cached_drive_info() { + let temp_dir = TempDir::new().unwrap(); + let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path()); + std::fs::create_dir_all(&controller.ventoy_dir).unwrap(); + std::fs::write(&controller.drive_path, vec![0u8; 128]).unwrap(); + controller.state.write().await.drive_info = Some(DriveInfo::from_raw( + controller.drive_path.clone(), + 64, + DriveFileAccess::Available, + )); + + std::fs::write(&controller.drive_path, vec![0u8; 256]).unwrap(); + let info = controller.drive_mount_info().unwrap(); + + assert_eq!(info.size, 256); + assert_eq!(info.used, None); + assert_eq!(info.file_access, DriveFileAccess::Unknown); + let media = MountedMedia::drive(0, &info); + let config = MsdController::media_config(&media); + assert_eq!(config.file, controller.drive_path); + } + #[test] fn single_disk_mode_only_exposes_lun_zero() { let mut state = MsdState::default(); @@ -842,13 +890,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let drive_path = temp_dir.path().join("ventoy.img"); std::fs::write(&drive_path, b"drive").unwrap(); - let drive = DriveInfo { - size: 5, - used: 0, - free: 5, - initialized: true, - path: drive_path, - }; + let drive = DriveInfo::from_raw(drive_path, 5, DriveFileAccess::Unknown); let mut state = MsdState::default(); MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi); state.mounted_media.push(MountedMedia::drive(0, &drive)); @@ -900,13 +942,11 @@ mod tests { let image_path = temp_dir.path().join("test.img"); std::fs::write(&image_path, b"img").unwrap(); let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3); - let drive = DriveInfo { - size: 5, - used: 0, - free: 5, - initialized: true, - path: temp_dir.path().join("ventoy.img"), - }; + let drive = DriveInfo::from_raw( + temp_dir.path().join("ventoy.img"), + 5, + DriveFileAccess::Unknown, + ); let mut state = MsdState::default(); state .mounted_media diff --git a/src/msd/mod.rs b/src/msd/mod.rs index 35fbf71a..be1bfa44 100644 --- a/src/msd/mod.rs +++ b/src/msd/mod.rs @@ -8,8 +8,8 @@ pub use controller::MsdController; pub use image::ImageManager; pub use monitor::MsdHealthMonitor; pub use types::{ - DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo, - DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia, + DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveFileAccess, + DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia, MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS, }; pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; diff --git a/src/msd/types.rs b/src/msd/types.rs index f8a001e8..1bc4efa3 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -156,26 +156,44 @@ impl DiskMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriveFileAccess { + Available, + Unsupported, + BlockedWhileConnected, + Unknown, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DriveInfo { pub size: u64, - pub used: u64, - pub free: u64, + pub used: Option, + pub free: Option, pub initialized: bool, + pub file_access: DriveFileAccess, #[serde(skip_serializing)] pub path: PathBuf, } impl DriveInfo { - pub fn new(path: PathBuf, size: u64) -> Self { + pub fn from_raw(path: PathBuf, size: u64, file_access: DriveFileAccess) -> Self { Self { size, - used: 0, - free: size, - initialized: false, + used: None, + free: None, + initialized: true, + file_access, path, } } + + pub fn with_file_access(mut self, file_access: DriveFileAccess) -> Self { + self.used = None; + self.free = None; + self.file_access = file_access; + self + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -267,4 +285,36 @@ mod tests { assert!(json.get("current_image").is_none()); assert!(json.get("slots").is_none()); } + + #[test] + fn drive_info_json_has_stable_nullable_space_and_file_access() { + let info = DriveInfo::from_raw( + PathBuf::from("/tmp/drive.img"), + 4096, + DriveFileAccess::Unsupported, + ); + + let value = serde_json::to_value(info).unwrap(); + assert_eq!(value["size"], 4096); + assert_eq!(value["used"], serde_json::Value::Null); + assert_eq!(value["free"], serde_json::Value::Null); + assert_eq!(value["initialized"], true); + assert_eq!(value["file_access"], "unsupported"); + assert!(value.get("path").is_none()); + } + + #[test] + fn drive_file_access_serializes_all_public_states() { + for (access, expected) in [ + (DriveFileAccess::Available, "available"), + (DriveFileAccess::Unsupported, "unsupported"), + ( + DriveFileAccess::BlockedWhileConnected, + "blocked_while_connected", + ), + (DriveFileAccess::Unknown, "unknown"), + ] { + assert_eq!(serde_json::to_value(access).unwrap(), expected); + } + } } diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index edeb4faf..d0e34468 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -5,7 +5,7 @@ use tracing::{info, warn}; use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage}; -use super::types::{DriveFile, DriveInfo}; +use super::types::{DriveFile, DriveFileAccess, DriveInfo}; use crate::error::{AppError, MsdErrorCode, Result}; const STREAM_CHUNK_SIZE: usize = 64 * 1024; @@ -35,11 +35,10 @@ impl VentoyDrive { &self.path } - /// Returns just the raw file size without attempting to parse the filesystem. - /// Used as a fallback when the image has been reformatted to an unsupported - /// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open. - pub fn raw_size(&self) -> Option { - std::fs::metadata(&self.path).ok().map(|m| m.len()) + /// Read and validate only the backing file metadata, without parsing its + /// partition table or filesystem. + pub fn raw_info(&self, file_access: DriveFileAccess) -> Result { + raw_drive_info(&self.path, file_access) } pub async fn init(&self, size_mb: u32) -> Result { @@ -60,9 +59,10 @@ impl VentoyDrive { Ok::(DriveInfo { size: metadata.len(), - used: 0, - free: metadata.len(), + used: Some(0), + free: Some(metadata.len()), initialized: true, + file_access: DriveFileAccess::Available, path, }) }) @@ -74,20 +74,23 @@ impl VentoyDrive { } pub async fn info(&self) -> Result { - if !self.exists() { - return Err(MsdErrorCode::MsdDriveNotInitialized.into()); - } - let path = self.path.clone(); let _lock = self.lock.read().await; tokio::task::spawn_blocking(move || { - let metadata = std::fs::metadata(&path) - .map_err(|error| drive_io_error("read drive metadata", error))?; + let raw = raw_drive_info(&path, DriveFileAccess::Unsupported)?; - let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?; + let image = match VentoyImage::open(&path) { + Ok(image) => image, + Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw), + Err(error) => return Err(ventoy_to_app_error(error)), + }; - let files = image.list_files_recursive().map_err(ventoy_to_app_error)?; + let files = match image.list_files_recursive() { + Ok(files) => files, + Err(error) if is_unsupported_filesystem_error(&error) => return Ok(raw), + Err(error) => return Err(ventoy_to_app_error(error)), + }; let used: u64 = files .iter() @@ -95,14 +98,15 @@ impl VentoyDrive { .map(|f| f.size) .sum(); - let size = metadata.len(); + let size = raw.size; let free = size.saturating_sub(used); Ok(DriveInfo { size, - used, - free, + used: Some(used), + free: Some(free), initialized: true, + file_access: DriveFileAccess::Available, path, }) }) @@ -332,6 +336,35 @@ impl VentoyDrive { } } +fn raw_drive_info(path: &Path, file_access: DriveFileAccess) -> Result { + let metadata = std::fs::metadata(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + AppError::from(MsdErrorCode::MsdDriveNotInitialized) + } else { + drive_io_error("read drive metadata", error) + } + })?; + + if !metadata.is_file() || metadata.len() == 0 { + return Err(MsdErrorCode::MsdDriveSizeInvalid.into()); + } + + Ok(DriveInfo::from_raw( + path.to_path_buf(), + metadata.len(), + file_access, + )) +} + +fn is_unsupported_filesystem_error(error: &VentoyError) -> bool { + matches!( + error, + VentoyError::FilesystemError(_) + | VentoyError::ImageError(_) + | VentoyError::PartitionError(_) + ) +} + fn ventoy_to_app_error(err: VentoyError) -> AppError { warn!(%err, "Virtual drive filesystem operation failed"); match err { @@ -575,9 +608,71 @@ mod tests { let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap(); assert!(info.initialized); + assert_eq!(info.file_access, DriveFileAccess::Available); + assert_eq!(info.used, Some(0)); + assert!(info.free.is_some()); assert!(drive.exists()); } + #[tokio::test] + async fn raw_bytes_are_reported_as_unsupported_with_capacity() { + let temp_dir = TempDir::new().unwrap(); + let drive_path = temp_dir.path().join("custom.img"); + std::fs::write(&drive_path, vec![0x5a; 1024 * 1024]).unwrap(); + let drive = VentoyDrive::new(drive_path); + + let info = drive.info().await.unwrap(); + assert_eq!(info.size, 1024 * 1024); + assert_eq!(info.used, None); + assert_eq!(info.free, None); + assert_eq!(info.file_access, DriveFileAccess::Unsupported); + + assert!(matches!( + drive.list_files("/").await.unwrap_err(), + AppError::Msd(error) + if error.code() == MsdErrorCode::MsdDriveFilesystemUnsupported + )); + } + + #[test] + fn raw_metadata_rejects_missing_empty_and_non_file_paths() { + let temp_dir = TempDir::new().unwrap(); + let missing = VentoyDrive::new(temp_dir.path().join("missing.img")); + assert!(matches!( + missing.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveNotInitialized + )); + + let empty_path = temp_dir.path().join("empty.img"); + std::fs::write(&empty_path, []).unwrap(); + let empty = VentoyDrive::new(empty_path); + assert!(matches!( + empty.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid + )); + + let directory = VentoyDrive::new(temp_dir.path().to_path_buf()); + assert!(matches!( + directory.raw_info(DriveFileAccess::Unknown).unwrap_err(), + AppError::Msd(error) if error.code() == MsdErrorCode::MsdDriveSizeInvalid + )); + } + + #[tokio::test] + async fn supported_drive_info_has_space_values() { + if !ensure_resources() { + return; + } + let temp_dir = TempDir::new().unwrap(); + let drive = VentoyDrive::new(temp_dir.path().join("supported.img")); + drive.init(MIN_DRIVE_SIZE_MB).await.unwrap(); + + let info = drive.info().await.unwrap(); + assert_eq!(info.file_access, DriveFileAccess::Available); + assert!(info.used.is_some()); + assert!(info.free.is_some()); + } + #[tokio::test] async fn test_drive_mkdir() { if !ensure_resources() { diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index 05ca0c60..a29b8c56 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -2,7 +2,7 @@ use super::config::apply::try_apply_lock; use super::*; use crate::msd::{ - DiskModeRequest, DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, + DiskModeRequest, DownloadProgress, DriveFile, DriveFileAccess, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageManager, ImageMountRequest, MsdErrorCode, MsdState, MsdStateResponse, VentoyDrive, MIN_DRIVE_SIZE_MB, }; @@ -420,16 +420,26 @@ pub async fn msd_drive_info(State(state): State>) -> Result controller.is_drive_connected().await, + None => false, + }; + + // Never parse the filesystem while the USB host owns it. Metadata is safe + // to read and still lets the UI show the backing image capacity. + let info = if connected { + drive.raw_info(DriveFileAccess::BlockedWhileConnected) + } else { + drive.info().await + } + .map_err(|error| operation_failed("read virtual drive info", error))?; + + if let Some(controller) = msd_guard.as_ref() { + controller.set_drive_info(Some(info.clone())).await; } - drive - .info() - .await - .map(Json) - .map_err(|error| operation_failed("read virtual drive info", error)) + Ok(Json(info)) } /// Initialize Ventoy drive @@ -439,7 +449,6 @@ pub async fn msd_drive_init( payload: std::result::Result, JsonRejection>, ) -> Result> { let req = parse_msd_json(payload)?; - assert_drive_not_connected(&state).await?; let config = state.config.get(); let msd_dir = config.msd.msd_dir_path(); @@ -449,6 +458,16 @@ pub async fn msd_drive_init( })?; validate_drive_init_size(req.size_mb, disk_space.available)?; + // Mount/unmount handlers also take this outer write lock. Holding it + // across image creation prevents a mount from racing the destructive + // reinitialization after the connected-state check. + let msd_guard = state.msd.write().await; + if let Some(controller) = msd_guard.as_ref() { + if controller.is_drive_connected().await { + return Err(MsdErrorCode::MsdDriveConnected.into()); + } + } + let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -456,6 +475,9 @@ pub async fn msd_drive_init( .init(req.size_mb) .await .map_err(|error| operation_failed("initialize virtual drive", error))?; + if let Some(controller) = msd_guard.as_ref() { + controller.set_drive_info(Some(info.clone())).await; + } Ok(Json(info)) } @@ -471,14 +493,15 @@ pub async fn msd_drive_delete(State(state): State>) -> Result