Files
One-KVM/src/msd/controller.rs
mofeng-git b573f478fc fix(msd): 区分虚拟磁盘挂载能力与文件访问状态
使用原始文件元数据挂载虚拟磁盘,允许主机使用网页文件管理不支持的文件系统。连接期间只读取元数据;初始化和删除持有外层写锁,并同步控制器缓存。

API 的 used/free 改为可空值,新增 file_access 表示 available、unsupported、blocked_while_connected 或 unknown,需要前端配套处理。补充状态序列化、元数据校验和文件系统兼容测试。
2026-09-05 14:10:53 +08:00

1008 lines
35 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use super::image::ImageManager;
use super::monitor::MsdHealthMonitor;
use super::types::{
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};
pub struct MsdController {
otg_service: Arc<OtgService>,
msd_function: RwLock<Option<MsdFunction>>,
state: RwLock<MsdState>,
images_path: PathBuf,
ventoy_dir: PathBuf,
drive_path: PathBuf,
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
operation_lock: Arc<RwLock<()>>,
monitor: Arc<MsdHealthMonitor>,
}
impl MsdController {
pub fn new(otg_service: Arc<OtgService>, msd_dir: impl Into<PathBuf>) -> Self {
let msd_dir = msd_dir.into();
let images_path = msd_dir.join("images");
let ventoy_dir = msd_dir.join("ventoy");
let drive_path = ventoy_dir.join("ventoy.img");
Self {
otg_service,
msd_function: RwLock::new(None),
state: RwLock::new(MsdState::default()),
images_path,
ventoy_dir,
drive_path,
events: tokio::sync::RwLock::new(None),
downloads: Arc::new(RwLock::new(HashMap::new())),
operation_lock: Arc::new(RwLock::new(())),
monitor: Arc::new(MsdHealthMonitor::with_defaults()),
}
}
pub async fn init(&self, ventoy_resource_dir: &Path) -> Result<()> {
info!("Initializing MSD controller");
match ventoy_img::init_resources(ventoy_resource_dir) {
Ok(()) => info!(
"Ventoy resources ready from {}",
ventoy_resource_dir.display()
),
Err(e) => warn!(
"Failed to initialize Ventoy resources from {}: {}. Ventoy drive creation will be unavailable, but regular ISO/IMG MSD remains available",
ventoy_resource_dir.display(),
e
),
}
tokio::fs::create_dir_all(&self.images_path).await?;
tokio::fs::create_dir_all(&self.ventoy_dir).await?;
info!("Fetching MSD function from OtgService");
let msd_func = self
.otg_service
.msd_function()
.await
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
*self.msd_function.write().await = Some(msd_func);
let mut state = self.state.write().await;
state.disk_mode = if self.otg_service.msd_lun_capacity().await == 1 {
DiskMode::Single
} else {
DiskMode::Multi
};
state.available = true;
if self.drive_path.exists() {
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: {}",
self.drive_path.display()
);
}
}
info!("MSD controller initialized");
Ok(())
}
pub async fn state(&self) -> MsdState {
self.state.read().await.clone()
}
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
*self.events.write().await = Some(events);
}
async fn publish_event(&self, event: crate::events::SystemEvent) {
if let Some(ref bus) = *self.events.read().await {
bus.publish(event);
}
}
async fn mark_device_info_dirty(&self) {
if let Some(ref bus) = *self.events.read().await {
bus.mark_device_info_dirty();
}
}
pub async fn mount_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> {
self.mount_image_in_slot(image, cdrom, read_only, None)
.await
}
pub async fn mount_image_at_lun(
&self,
image: &ImageInfo,
cdrom: bool,
read_only: bool,
lun: u8,
) -> Result<()> {
self.mount_image_in_slot(image, cdrom, read_only, Some(lun))
.await
}
async fn mount_image_in_slot(
&self,
image: &ImageInfo,
cdrom: bool,
read_only: bool,
requested_lun: Option<u8>,
) -> Result<()> {
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
let previous_state = state.clone();
self.assert_available(&state).await?;
if !image.path.exists() {
let error_msg = format!("Image file not found: {}", image.path.display());
self.monitor
.report_error(&error_msg, "image_not_found")
.await;
return Err(MsdErrorCode::MsdResourceNotFound.into());
}
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Image && media.id == image.id)
{
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
}
let lun = Self::select_lun(&state, requested_lun)?;
let media = MountedMedia::image(lun, image, cdrom, read_only);
if let Err(e) = self.configure_media(&media).await {
*state = previous_state;
return Err(e);
}
state.mounted_media.push(media);
info!(
"Mounted image: {} on LUN {} (cdrom={}, ro={})",
image.name,
lun,
cdrom,
cdrom || read_only
);
drop(state);
drop(_op_guard);
self.finish_connect_success().await;
Ok(())
}
pub async fn mount_drive(&self) -> Result<()> {
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
let previous_state = state.clone();
self.assert_available(&state).await?;
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
{
return Err(MsdErrorCode::MsdMediaAlreadyMounted.into());
}
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))?;
let media = MountedMedia::drive(lun, &drive_info);
if let Err(e) = self.configure_media(&media).await {
*state = previous_state;
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 {}: {}",
lun,
self.drive_path.display()
);
drop(state);
drop(_op_guard);
self.finish_connect_success().await;
Ok(())
}
fn drive_mount_info(&self) -> Result<DriveInfo> {
VentoyDrive::new(self.drive_path.clone()).raw_info(DriveFileAccess::Unknown)
}
pub async fn set_drive_info(&self, drive_info: Option<DriveInfo>) {
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
.report_error("MSD not available", "not_available")
.await;
return Err(MsdErrorCode::MsdUnavailable.into());
}
Ok(())
}
fn media_config(media: &MountedMedia) -> MsdLunConfig {
if media.cdrom {
MsdLunConfig::cdrom(media.path.clone())
} else {
MsdLunConfig::disk(media.path.clone(), media.read_only)
}
}
fn lowest_free_lun(state: &MsdState) -> Option<u8> {
(0..state.disk_mode.capacity())
.find(|lun| !state.mounted_media.iter().any(|media| media.lun == *lun))
}
fn select_lun(state: &MsdState, requested_lun: Option<u8>) -> Result<u8> {
let Some(lun) = requested_lun else {
return Self::lowest_free_lun(state)
.ok_or_else(|| AppError::from(MsdErrorCode::MsdMediaSlotsFull));
};
if lun >= state.disk_mode.capacity() {
return Err(MsdErrorCode::MsdInvalidRequest.into());
}
if state.mounted_media.iter().any(|media| media.lun == lun) {
return Err(MsdErrorCode::MsdMediaSlotsFull.into());
}
Ok(lun)
}
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();
}
pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result<bool> {
let _op_guard = self.try_operation()?;
let previous_state = {
let mut state = self.state.write().await;
self.assert_available(&state).await?;
if state.disk_mode == disk_mode {
return Ok(false);
}
let previous_state = state.clone();
state.usb_reenumerating = true;
previous_state
};
self.mark_device_info_dirty().await;
let switch_result = async {
self.otg_service
.set_msd_lun_capacity(disk_mode.capacity())
.await?;
self.otg_service
.msd_function()
.await
.ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed))
}
.await;
let msd_function = match switch_result {
Ok(msd_function) => msd_function,
Err(switch_error) => {
if let Err(rollback_error) = self.rollback_mode_switch(&previous_state).await {
let mut state = self.state.write().await;
state.available = false;
state.mounted_media.clear();
state.usb_reenumerating = false;
*self.msd_function.write().await = None;
let error_msg = format!(
"Failed to switch MSD disk mode: {switch_error}; rollback failed: {rollback_error}"
);
self.monitor
.report_error(&error_msg, "disk_mode_rollback_failed")
.await;
self.mark_device_info_dirty().await;
return Err(MsdErrorCode::MsdOperationFailed.into());
}
let mut state = self.state.write().await;
*state = previous_state;
state.usb_reenumerating = false;
let error_msg = format!("Failed to switch MSD disk mode: {switch_error}");
self.monitor
.report_error(&error_msg, "disk_mode_switch_failed")
.await;
self.mark_device_info_dirty().await;
return Err(MsdErrorCode::MsdOperationFailed.into());
}
};
*self.msd_function.write().await = Some(msd_function);
let mut state = self.state.write().await;
Self::reset_mounts_for_mode(&mut state, disk_mode);
state.usb_reenumerating = false;
info!("Switched MSD disk mode to {:?}", disk_mode);
drop(state);
drop(_op_guard);
self.mark_device_info_dirty().await;
Ok(true)
}
pub async fn unmount_image(&self, image_id: &str) -> Result<()> {
self.unmount_media(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
.await
.map(|_| ())
}
pub async fn unmount_drive(&self) -> Result<()> {
self.unmount_media(|media| media.kind == MountedMediaKind::Drive)
.await
.map(|_| ())
}
pub async fn unmount_lun(&self, lun: u8) -> Result<bool> {
self.unmount_media(|media| media.lun == lun).await
}
async fn unmount_media<F>(&self, predicate: F) -> Result<bool>
where
F: Fn(&MountedMedia) -> bool,
{
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
let Some(index) = state.mounted_media.iter().position(predicate) else {
debug!("Requested media was not mounted, skipping unmount");
return Ok(false);
};
let media = state.mounted_media[index].clone();
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);
drop(_op_guard);
self.mark_device_info_dirty().await;
Ok(true)
}
async fn configure_media(&self, media: &MountedMedia) -> Result<()> {
if let Err(e) = self
.otg_service
.configure_msd_lun(media.lun, &Self::media_config(media))
.await
{
let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e);
self.monitor
.report_error(&error_msg, "configfs_error")
.await;
return Err(match e {
AppError::Msd(error) => AppError::Msd(error),
error => {
warn!(%error, "Unclassified MSD media configuration failure");
MsdErrorCode::MsdOperationFailed.into()
}
});
}
Ok(())
}
async fn disconnect_lun(&self, lun: u8) -> Result<()> {
let gadget_path = self.active_gadget_path().await?;
let msd_hold = self.msd_function.read().await;
let msd = msd_hold
.as_ref()
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))?;
msd.disconnect_lun_async(&gadget_path, lun).await
}
async fn rollback_mode_switch(&self, previous_state: &MsdState) -> Result<()> {
self.otg_service
.set_msd_lun_capacity(previous_state.disk_mode.capacity())
.await?;
let msd_function = self
.otg_service
.msd_function()
.await
.ok_or_else(|| AppError::from(MsdErrorCode::MsdOperationFailed))?;
*self.msd_function.write().await = Some(msd_function);
for media in &previous_state.mounted_media {
self.configure_media(media).await?;
}
Ok(())
}
async fn finish_connect_success(&self) {
if self.monitor.is_error().await {
self.monitor.report_recovered().await;
}
self.mark_device_info_dirty().await;
}
pub async fn disconnect(&self) -> Result<()> {
let _op_guard = self.try_operation()?;
let mut state = self.state.write().await;
if state.mounted_media.is_empty() {
debug!("Nothing mounted, skipping disconnect");
return Ok(());
}
let mounted_media = state.mounted_media.clone();
let mut disconnected = Vec::new();
for media in &mounted_media {
if let Err(error) = self.disconnect_lun(media.lun).await {
for prior in &disconnected {
if let Err(restore_error) = self.configure_media(prior).await {
state.available = false;
warn!(
lun = media.lun,
disconnect_error = %error,
%restore_error,
"Failed to restore MSD media after disconnect failure"
);
return Err(MsdErrorCode::MsdDisconnectFailed.into());
}
}
return Err(error);
}
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");
drop(state);
drop(_op_guard);
self.mark_device_info_dirty().await;
Ok(())
}
pub async fn is_drive_connected(&self) -> bool {
self.state
.read()
.await
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive)
}
pub async fn delete_image(&self, image_id: &str) -> Result<()> {
let _op_guard = self.try_operation()?;
let state = self.state.read().await;
if state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
{
return Err(MsdErrorCode::MsdMediaInUse.into());
}
ImageManager::new(self.images_path.clone()).delete(image_id)
}
pub async fn download_image(
&self,
url: String,
filename: Option<String>,
) -> Result<DownloadProgress> {
let parsed_url =
reqwest::Url::parse(&url).map_err(|_| AppError::from(MsdErrorCode::MsdInvalidUrl))?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(MsdErrorCode::MsdInvalidUrl.into());
}
let download_id = uuid::Uuid::new_v4().to_string();
let cancel_token = CancellationToken::new();
{
let mut downloads = self.downloads.write().await;
downloads.insert(download_id.clone(), cancel_token.clone());
}
let display_filename = filename
.clone()
.unwrap_or_else(|| url.rsplit('/').next().unwrap_or("download").to_string());
let initial_progress = DownloadProgress {
download_id: download_id.clone(),
url: url.clone(),
filename: display_filename.clone(),
bytes_downloaded: 0,
total_bytes: None,
progress_pct: None,
status: DownloadStatus::Started,
error_code: None,
};
self.publish_event(crate::events::SystemEvent::MsdDownloadProgress {
download_id: download_id.clone(),
url: url.clone(),
filename: display_filename.clone(),
bytes_downloaded: 0,
total_bytes: None,
progress_pct: None,
status: "started".to_string(),
error_code: None,
})
.await;
let images_path = self.images_path.clone();
let events = self.events.read().await.clone();
let downloads = self.downloads.clone();
let download_id_clone = download_id.clone();
let url_clone = url.clone();
tokio::spawn(async move {
let manager = ImageManager::new(images_path);
let events_for_callback = events.clone();
let download_id_for_callback = download_id_clone.clone();
let url_for_callback = url_clone.clone();
let filename_for_callback = display_filename.clone();
let progress_callback = move |downloaded: u64, total: Option<u64>| {
let progress_pct = total.map(|t| (downloaded as f32 / t as f32) * 100.0);
if let Some(ref bus) = events_for_callback {
bus.publish(crate::events::SystemEvent::MsdDownloadProgress {
download_id: download_id_for_callback.clone(),
url: url_for_callback.clone(),
filename: filename_for_callback.clone(),
bytes_downloaded: downloaded,
total_bytes: total,
progress_pct,
status: "in_progress".to_string(),
error_code: None,
});
}
};
let result = manager
.download_from_url(&url_clone, filename, progress_callback)
.await;
{
let mut downloads_guard = downloads.write().await;
downloads_guard.remove(&download_id_clone);
}
match result {
Ok(image_info) => {
if let Some(ref bus) = events {
bus.publish(crate::events::SystemEvent::MsdDownloadProgress {
download_id: download_id_clone,
url: url_clone,
filename: image_info.name,
bytes_downloaded: image_info.size,
total_bytes: Some(image_info.size),
progress_pct: Some(100.0),
status: "completed".to_string(),
error_code: None,
});
}
}
Err(e) => {
warn!(error = %e, "MSD image download failed");
let code = match e {
AppError::Msd(error) => error.code(),
_ => MsdErrorCode::MsdOperationFailed,
};
if let Some(ref bus) = events {
bus.publish(crate::events::SystemEvent::MsdDownloadProgress {
download_id: download_id_clone,
url: url_clone,
filename: display_filename,
bytes_downloaded: 0,
total_bytes: None,
progress_pct: None,
status: "failed".to_string(),
error_code: Some(code.as_str().to_string()),
});
}
}
}
});
Ok(initial_progress)
}
pub async fn cancel_download(&self, download_id: &str) -> Result<()> {
let mut downloads = self.downloads.write().await;
if let Some(token) = downloads.remove(download_id) {
token.cancel();
info!("Download cancelled: {}", download_id);
Ok(())
} else {
Err(MsdErrorCode::MsdResourceNotFound.into())
}
}
async fn active_gadget_path(&self) -> Result<PathBuf> {
self.otg_service
.gadget_path()
.await
.ok_or_else(|| AppError::from(MsdErrorCode::MsdUnavailable))
}
fn try_operation(&self) -> Result<tokio::sync::RwLockWriteGuard<'_, ()>> {
self.operation_lock
.try_write()
.map_err(|_| MsdErrorCode::MsdOperationInProgress.into())
}
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down MSD controller");
if let Err(e) = self.disconnect().await {
warn!("Error disconnecting during shutdown: {}", e);
}
*self.msd_function.write().await = None;
let mut state = self.state.write().await;
state.available = false;
state.mounted_media.clear();
state.usb_reenumerating = false;
info!("MSD controller shutdown complete");
Ok(())
}
pub fn monitor(&self) -> &Arc<MsdHealthMonitor> {
&self.monitor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::msd::MULTI_DISK_MSD_LUNS;
use tempfile::TempDir;
#[tokio::test]
async fn test_controller_creation() {
let temp_dir = TempDir::new().unwrap();
let otg_service = Arc::new(OtgService::new());
let msd_dir = temp_dir.path().join("msd");
let controller = MsdController::new(otg_service, &msd_dir);
let state = controller.state().await;
assert!(!state.available);
assert!(controller.images_path.ends_with("images"));
assert!(controller.drive_path.ends_with("ventoy.img"));
}
#[tokio::test]
async fn concurrent_operations_have_a_stable_error_code() {
let temp_dir = TempDir::new().unwrap();
let controller = MsdController::new(Arc::new(OtgService::new()), temp_dir.path());
let _guard = controller.operation_lock.write().await;
assert!(matches!(
controller.try_operation().unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdOperationInProgress
));
}
#[tokio::test]
async fn test_state_default() {
let temp_dir = TempDir::new().unwrap();
let otg_service = Arc::new(OtgService::new());
let msd_dir = temp_dir.path().join("msd");
let controller = MsdController::new(otg_service, &msd_dir);
let state = controller.state().await;
assert!(!state.available);
assert_eq!(state.disk_mode, DiskMode::Single);
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();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single);
assert_eq!(state.disk_mode.capacity(), 1);
assert_eq!(MsdController::lowest_free_lun(&state), Some(0));
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.iso");
std::fs::write(&image_path, b"iso").unwrap();
let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3);
state
.mounted_media
.push(MountedMedia::image(0, &image, true, false));
assert_eq!(MsdController::lowest_free_lun(&state), None);
let config = MsdController::media_config(&state.mounted_media[0]);
assert!(config.cdrom);
assert!(config.ro);
}
#[test]
fn multi_disk_mode_allocates_lowest_free_lun() {
let temp_dir = TempDir::new().unwrap();
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
for lun in [0, 1, 3] {
let image_path = temp_dir.path().join(format!("test{lun}.img"));
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new(
format!("test{lun}"),
format!("test{lun}.img"),
image_path,
3,
);
state
.mounted_media
.push(MountedMedia::image(lun, &image, false, false));
}
assert_eq!(MsdController::lowest_free_lun(&state), Some(2));
}
#[test]
fn explicit_lun_selection_rejects_occupied_and_out_of_range_slots() {
let temp_dir = TempDir::new().unwrap();
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 mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state
.mounted_media
.push(MountedMedia::image(3, &image, false, true));
assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5);
assert!(matches!(
MsdController::select_lun(&state, Some(3)).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdMediaSlotsFull
));
assert!(matches!(
MsdController::select_lun(&state, Some(8)).unwrap_err(),
AppError::Msd(error) if error.code() == MsdErrorCode::MsdInvalidRequest
));
}
#[test]
fn multi_disk_mode_supports_eight_images_and_rejects_ninth_slot() {
let temp_dir = TempDir::new().unwrap();
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
for lun in 0..MULTI_DISK_MSD_LUNS {
let image_path = temp_dir.path().join(format!("test{lun}.img"));
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new(
format!("test{lun}"),
format!("test{lun}.img"),
image_path,
3,
);
let next_lun = MsdController::lowest_free_lun(&state).unwrap();
assert_eq!(next_lun, lun);
state
.mounted_media
.push(MountedMedia::image(next_lun, &image, false, false));
}
assert_eq!(state.mounted_media.len(), 8);
assert_eq!(MsdController::lowest_free_lun(&state), None);
}
#[test]
fn multi_disk_mode_supports_drive_plus_seven_images() {
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::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));
for lun in 1..MULTI_DISK_MSD_LUNS {
let image_path = temp_dir.path().join(format!("test{lun}.img"));
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new(
format!("test{lun}"),
format!("test{lun}.img"),
image_path,
3,
);
state
.mounted_media
.push(MountedMedia::image(lun, &image, false, false));
}
assert_eq!(state.mounted_media.len(), 8);
assert_eq!(MsdController::lowest_free_lun(&state), None);
assert!(state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive));
}
#[test]
fn mode_switch_clears_mount_state() {
let temp_dir = TempDir::new().unwrap();
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 mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state
.mounted_media
.push(MountedMedia::image(0, &image, false, false));
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single);
assert_eq!(state.disk_mode, DiskMode::Single);
assert_eq!(state.disk_mode.capacity(), 1);
assert!(state.mounted_media.is_empty());
}
#[test]
fn duplicate_image_and_drive_detection_use_media_identity() {
let temp_dir = TempDir::new().unwrap();
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::from_raw(
temp_dir.path().join("ventoy.img"),
5,
DriveFileAccess::Unknown,
);
let mut state = MsdState::default();
state
.mounted_media
.push(MountedMedia::image(0, &image, false, false));
state.mounted_media.push(MountedMedia::drive(1, &drive));
assert!(state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Image && media.id == "test"));
assert!(state
.mounted_media
.iter()
.any(|media| media.kind == MountedMediaKind::Drive));
}
#[tokio::test]
async fn delete_image_is_serialized_with_mount_operations() {
let temp_dir = TempDir::new().unwrap();
let otg_service = Arc::new(OtgService::new());
let controller = MsdController::new(otg_service, temp_dir.path());
std::fs::create_dir_all(&controller.images_path).unwrap();
let image_path = controller.images_path.join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageManager::new(controller.images_path.clone())
.get_by_name("test.img")
.unwrap();
controller
.state
.write()
.await
.mounted_media
.push(MountedMedia::image(0, &image, false, false));
assert!(controller.delete_image(&image.id).await.is_err());
assert!(image_path.exists());
controller.state.write().await.mounted_media.clear();
controller.delete_image(&image.id).await.unwrap();
assert!(!image_path.exists());
}
#[test]
fn slot_configs_force_cdrom_read_only() {
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.iso");
std::fs::write(&image_path, b"iso").unwrap();
let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3);
let mut state = MsdState::default();
state
.mounted_media
.push(MountedMedia::image(0, &image, true, false));
let config = MsdController::media_config(&state.mounted_media[0]);
assert!(config.cdrom);
assert!(config.ro);
}
}