mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 11:42:02 +08:00
init
This commit is contained in:
597
src/msd/controller.rs
Normal file
597
src/msd/controller.rs
Normal file
@@ -0,0 +1,597 @@
|
||||
//! MSD Controller
|
||||
//!
|
||||
//! Manages the mass storage device lifecycle including:
|
||||
//! - Image mounting and unmounting
|
||||
//! - Virtual drive management
|
||||
//! - State tracking
|
||||
//! - Image downloads from URL
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::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, MsdHealthStatus};
|
||||
use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMode, MsdState};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
||||
|
||||
/// USB Gadget path (system constant)
|
||||
const GADGET_PATH: &str = "/sys/kernel/config/usb_gadget/one-kvm";
|
||||
|
||||
/// MSD Controller
|
||||
pub struct MsdController {
|
||||
/// OTG Service reference
|
||||
otg_service: Arc<OtgService>,
|
||||
/// MSD function manager (provided by OtgService)
|
||||
msd_function: RwLock<Option<MsdFunction>>,
|
||||
/// Current state
|
||||
state: RwLock<MsdState>,
|
||||
/// Images storage path
|
||||
images_path: PathBuf,
|
||||
/// Virtual drive path
|
||||
drive_path: PathBuf,
|
||||
/// Event bus for broadcasting state changes (optional)
|
||||
events: tokio::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
|
||||
/// Active downloads (download_id -> CancellationToken)
|
||||
downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
/// Operation mutex lock (prevents concurrent operations)
|
||||
operation_lock: Arc<RwLock<()>>,
|
||||
/// Health monitor for error tracking and recovery
|
||||
monitor: Arc<MsdHealthMonitor>,
|
||||
}
|
||||
|
||||
impl MsdController {
|
||||
/// Create new MSD controller
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `otg_service` - OTG service for gadget management
|
||||
/// * `images_path` - Directory path for storing ISO/IMG files
|
||||
/// * `drive_path` - File path for the virtual FAT32 drive
|
||||
pub fn new(
|
||||
otg_service: Arc<OtgService>,
|
||||
images_path: impl Into<PathBuf>,
|
||||
drive_path: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
otg_service,
|
||||
msd_function: RwLock::new(None),
|
||||
state: RwLock::new(MsdState::default()),
|
||||
images_path: images_path.into(),
|
||||
drive_path: drive_path.into(),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the MSD controller
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
info!("Initializing MSD controller");
|
||||
|
||||
// 1. Ensure images directory exists
|
||||
if let Err(e) = std::fs::create_dir_all(&self.images_path) {
|
||||
warn!("Failed to create images directory: {}", e);
|
||||
}
|
||||
|
||||
// 2. Request MSD function from OtgService
|
||||
info!("Requesting MSD function from OtgService");
|
||||
let msd_func = self.otg_service.enable_msd().await?;
|
||||
|
||||
// 3. Store function handle
|
||||
*self.msd_function.write().await = Some(msd_func);
|
||||
|
||||
// 4. Update state
|
||||
let mut state = self.state.write().await;
|
||||
state.available = true;
|
||||
|
||||
// 5. Check for existing virtual drive
|
||||
if self.drive_path.exists() {
|
||||
if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
|
||||
state.drive_info = Some(DriveInfo {
|
||||
size: metadata.len(),
|
||||
used: 0,
|
||||
free: metadata.len(),
|
||||
initialized: true,
|
||||
path: self.drive_path.clone(),
|
||||
});
|
||||
debug!("Found existing virtual drive: {}", self.drive_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
info!("MSD controller initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current state as SystemEvent
|
||||
pub async fn current_state_event(&self) -> crate::events::SystemEvent {
|
||||
let state = self.state.read().await;
|
||||
crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: state.mode.clone(),
|
||||
connected: state.connected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current MSD state
|
||||
pub async fn state(&self) -> MsdState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Set event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: std::sync::Arc<crate::events::EventBus>) {
|
||||
*self.events.write().await = Some(events.clone());
|
||||
// Also set event bus on the monitor for health notifications
|
||||
self.monitor.set_event_bus(events).await;
|
||||
}
|
||||
|
||||
/// Publish an event to the event bus
|
||||
async fn publish_event(&self, event: crate::events::SystemEvent) {
|
||||
if let Some(ref bus) = *self.events.read().await {
|
||||
bus.publish(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if MSD is available
|
||||
pub async fn is_available(&self) -> bool {
|
||||
self.state.read().await.available
|
||||
}
|
||||
|
||||
/// Connect an image file
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `image` - Image info to mount
|
||||
/// * `cdrom` - Mount as CD-ROM (read-only, removable)
|
||||
/// * `read_only` - Mount as read-only
|
||||
pub async fn connect_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> {
|
||||
// Acquire operation lock to prevent concurrent operations
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
if !state.available {
|
||||
let err = AppError::Internal("MSD not available".to_string());
|
||||
self.monitor.report_error("MSD not available", "not_available").await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if state.connected {
|
||||
return Err(AppError::Internal(
|
||||
"Already connected. Disconnect first.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Verify image exists
|
||||
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(AppError::Internal(error_msg));
|
||||
}
|
||||
|
||||
// Configure LUN
|
||||
let config = if cdrom {
|
||||
MsdLunConfig::cdrom(image.path.clone())
|
||||
} else {
|
||||
MsdLunConfig::disk(image.path.clone(), read_only)
|
||||
};
|
||||
|
||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
||||
if let Some(ref msd) = *self.msd_function.read().await {
|
||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||
self.monitor.report_error(&error_msg, "configfs_error").await;
|
||||
return Err(e);
|
||||
}
|
||||
} else {
|
||||
let err = AppError::Internal("MSD function not initialized".to_string());
|
||||
self.monitor.report_error("MSD function not initialized", "not_initialized").await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
state.connected = true;
|
||||
state.mode = MsdMode::Image;
|
||||
state.current_image = Some(image.clone());
|
||||
|
||||
info!(
|
||||
"Connected image: {} (cdrom={}, ro={})",
|
||||
image.name, cdrom, read_only
|
||||
);
|
||||
|
||||
// Release the lock before publishing events
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
|
||||
// Report recovery if we were in an error state
|
||||
if self.monitor.is_error().await {
|
||||
self.monitor.report_recovered().await;
|
||||
}
|
||||
|
||||
// Publish events
|
||||
self.publish_event(crate::events::SystemEvent::MsdImageMounted {
|
||||
image_id: image.id.clone(),
|
||||
image_name: image.name.clone(),
|
||||
size: image.size,
|
||||
cdrom,
|
||||
})
|
||||
.await;
|
||||
|
||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: MsdMode::Image,
|
||||
connected: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect the virtual drive
|
||||
pub async fn connect_drive(&self) -> Result<()> {
|
||||
// Acquire operation lock to prevent concurrent operations
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
if !state.available {
|
||||
let err = AppError::Internal("MSD not available".to_string());
|
||||
self.monitor.report_error("MSD not available", "not_available").await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if state.connected {
|
||||
return Err(AppError::Internal(
|
||||
"Already connected. Disconnect first.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check drive exists
|
||||
if !self.drive_path.exists() {
|
||||
let err = AppError::Internal(
|
||||
"Virtual drive not initialized. Call init first.".to_string(),
|
||||
);
|
||||
self.monitor.report_error("Virtual drive not initialized", "drive_not_found").await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Configure LUN as read-write disk
|
||||
let config = MsdLunConfig::disk(self.drive_path.clone(), false);
|
||||
|
||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
||||
if let Some(ref msd) = *self.msd_function.read().await {
|
||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, &config).await {
|
||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||
self.monitor.report_error(&error_msg, "configfs_error").await;
|
||||
return Err(e);
|
||||
}
|
||||
} else {
|
||||
let err = AppError::Internal("MSD function not initialized".to_string());
|
||||
self.monitor.report_error("MSD function not initialized", "not_initialized").await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
state.connected = true;
|
||||
state.mode = MsdMode::Drive;
|
||||
state.current_image = None;
|
||||
|
||||
info!("Connected virtual drive: {}", self.drive_path.display());
|
||||
|
||||
// Release the lock before publishing event
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
|
||||
// Report recovery if we were in an error state
|
||||
if self.monitor.is_error().await {
|
||||
self.monitor.report_recovered().await;
|
||||
}
|
||||
|
||||
// Publish event
|
||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: MsdMode::Drive,
|
||||
connected: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect current storage
|
||||
pub async fn disconnect(&self) -> Result<()> {
|
||||
// Acquire operation lock to prevent concurrent operations
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
if !state.connected {
|
||||
debug!("Nothing connected, skipping disconnect");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gadget_path = PathBuf::from(GADGET_PATH);
|
||||
if let Some(ref msd) = *self.msd_function.read().await {
|
||||
msd.disconnect_lun_async(&gadget_path, 0).await?;
|
||||
}
|
||||
|
||||
state.connected = false;
|
||||
state.mode = MsdMode::None;
|
||||
state.current_image = None;
|
||||
|
||||
info!("Disconnected storage");
|
||||
|
||||
// Release the lock before publishing events
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
|
||||
// Publish events
|
||||
self.publish_event(crate::events::SystemEvent::MsdImageUnmounted)
|
||||
.await;
|
||||
|
||||
self.publish_event(crate::events::SystemEvent::MsdStateChanged {
|
||||
mode: MsdMode::None,
|
||||
connected: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get images storage path
|
||||
pub fn images_path(&self) -> &PathBuf {
|
||||
&self.images_path
|
||||
}
|
||||
|
||||
/// Get virtual drive path
|
||||
pub fn drive_path(&self) -> &PathBuf {
|
||||
&self.drive_path
|
||||
}
|
||||
|
||||
/// Check if currently connected
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.state.read().await.connected
|
||||
}
|
||||
|
||||
/// Get current mode
|
||||
pub async fn mode(&self) -> MsdMode {
|
||||
self.state.read().await.mode.clone()
|
||||
}
|
||||
|
||||
/// Update drive info
|
||||
pub async fn update_drive_info(&self, info: DriveInfo) {
|
||||
let mut state = self.state.write().await;
|
||||
state.drive_info = Some(info);
|
||||
}
|
||||
|
||||
/// Start downloading an image from URL
|
||||
///
|
||||
/// Returns the download_id that can be used to track or cancel the download.
|
||||
/// Progress is reported via MsdDownloadProgress events.
|
||||
pub async fn download_image(
|
||||
&self,
|
||||
url: String,
|
||||
filename: Option<String>,
|
||||
) -> Result<DownloadProgress> {
|
||||
let download_id = uuid::Uuid::new_v4().to_string();
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
// Register download
|
||||
{
|
||||
let mut downloads = self.downloads.write().await;
|
||||
downloads.insert(download_id.clone(), cancel_token.clone());
|
||||
}
|
||||
|
||||
// Extract filename for initial response
|
||||
let display_filename = filename.clone().unwrap_or_else(|| {
|
||||
url.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("download")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
// Create initial progress
|
||||
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: None,
|
||||
};
|
||||
|
||||
// Publish started event
|
||||
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(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// Clone what we need for the spawned task
|
||||
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();
|
||||
|
||||
// Spawn download task
|
||||
tokio::spawn(async move {
|
||||
let manager = ImageManager::new(images_path);
|
||||
|
||||
// Create progress callback
|
||||
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(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Run download
|
||||
let result = manager
|
||||
.download_from_url(&url_clone, filename, progress_callback)
|
||||
.await;
|
||||
|
||||
// Remove from active downloads
|
||||
{
|
||||
let mut downloads_guard = downloads.write().await;
|
||||
downloads_guard.remove(&download_id_clone);
|
||||
}
|
||||
|
||||
// Publish completion event
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Download failed: {}", e);
|
||||
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: format!("failed: {}", e),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(initial_progress)
|
||||
}
|
||||
|
||||
/// Cancel an active download
|
||||
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(AppError::NotFound(format!(
|
||||
"Download not found: {}",
|
||||
download_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list of active download IDs
|
||||
pub async fn active_downloads(&self) -> Vec<String> {
|
||||
let downloads = self.downloads.read().await;
|
||||
downloads.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Shutdown the controller
|
||||
pub async fn shutdown(&self) -> Result<()> {
|
||||
info!("Shutting down MSD controller");
|
||||
|
||||
// 1. Disconnect if connected
|
||||
if let Err(e) = self.disconnect().await {
|
||||
warn!("Error disconnecting during shutdown: {}", e);
|
||||
}
|
||||
|
||||
// 2. Notify OtgService to disable MSD
|
||||
info!("Disabling MSD function in OtgService");
|
||||
self.otg_service.disable_msd().await?;
|
||||
|
||||
// 3. Clear local state
|
||||
*self.msd_function.write().await = None;
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
state.available = false;
|
||||
|
||||
info!("MSD controller shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the health monitor reference
|
||||
pub fn monitor(&self) -> &Arc<MsdHealthMonitor> {
|
||||
&self.monitor
|
||||
}
|
||||
|
||||
/// Get current health status
|
||||
pub async fn health_status(&self) -> MsdHealthStatus {
|
||||
self.monitor.status().await
|
||||
}
|
||||
|
||||
/// Check if the MSD is healthy
|
||||
pub async fn is_healthy(&self) -> bool {
|
||||
self.monitor.is_healthy().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MsdController {
|
||||
fn drop(&mut self) {
|
||||
// Cleanup is handled by OtgGadgetManager when the gadget is torn down
|
||||
// Individual controllers don't need to cleanup the ConfigFS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_controller_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let otg_service = Arc::new(OtgService::new());
|
||||
let images_path = temp_dir.path().join("images");
|
||||
let drive_path = temp_dir.path().join("ventoy.img");
|
||||
|
||||
let controller = MsdController::new(otg_service, &images_path, &drive_path);
|
||||
|
||||
// Check that MSD is not initialized (msd_function is None)
|
||||
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 test_state_default() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let otg_service = Arc::new(OtgService::new());
|
||||
let images_path = temp_dir.path().join("images");
|
||||
let drive_path = temp_dir.path().join("ventoy.img");
|
||||
|
||||
let controller = MsdController::new(otg_service, &images_path, &drive_path);
|
||||
|
||||
let state = controller.state().await;
|
||||
assert!(!state.available);
|
||||
assert!(!state.connected);
|
||||
assert_eq!(state.mode, MsdMode::None);
|
||||
}
|
||||
}
|
||||
654
src/msd/image.rs
Normal file
654
src/msd/image.rs
Normal file
@@ -0,0 +1,654 @@
|
||||
//! Image file manager
|
||||
//!
|
||||
//! Handles ISO/IMG image file operations:
|
||||
//! - List available images
|
||||
//! - Upload new images
|
||||
//! - Delete images
|
||||
//! - Metadata management
|
||||
//! - Download from URL
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tracing::info;
|
||||
|
||||
use super::types::ImageInfo;
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
/// Maximum image size (32 GB)
|
||||
const MAX_IMAGE_SIZE: u64 = 32 * 1024 * 1024 * 1024;
|
||||
|
||||
/// Progress report throttle interval (milliseconds)
|
||||
const PROGRESS_THROTTLE_MS: u64 = 200;
|
||||
|
||||
/// Progress report throttle bytes threshold (512 KB)
|
||||
const PROGRESS_THROTTLE_BYTES: u64 = 512 * 1024;
|
||||
|
||||
/// Image Manager
|
||||
pub struct ImageManager {
|
||||
/// Images storage directory
|
||||
images_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ImageManager {
|
||||
/// Create a new image manager
|
||||
pub fn new(images_path: PathBuf) -> Self {
|
||||
Self { images_path }
|
||||
}
|
||||
|
||||
/// Ensure images directory exists
|
||||
pub fn ensure_dir(&self) -> Result<()> {
|
||||
fs::create_dir_all(&self.images_path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to create images directory: {}", e))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all available images
|
||||
pub fn list(&self) -> Result<Vec<ImageInfo>> {
|
||||
self.ensure_dir()?;
|
||||
|
||||
let mut images = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&self.images_path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to read images directory: {}", e))
|
||||
})? {
|
||||
let entry = entry.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to read directory entry: {}", e))
|
||||
})?;
|
||||
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(info) = self.get_image_info(&path) {
|
||||
images.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
images.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
/// Get image info from path
|
||||
fn get_image_info(&self, path: &Path) -> Option<ImageInfo> {
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
let name = path.file_name()?.to_string_lossy().to_string();
|
||||
|
||||
// Use filename hash as ID (stable across restarts)
|
||||
let id = format!("{:x}", md5_hash(&name));
|
||||
|
||||
let created_at = metadata
|
||||
.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| {
|
||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
|
||||
.unwrap_or_else(|| Utc::now().into())
|
||||
})
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
Some(ImageInfo {
|
||||
id,
|
||||
name,
|
||||
path: path.to_path_buf(),
|
||||
size: metadata.len(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get image by ID
|
||||
pub fn get(&self, id: &str) -> Result<ImageInfo> {
|
||||
for image in self.list()? {
|
||||
if image.id == id {
|
||||
return Ok(image);
|
||||
}
|
||||
}
|
||||
Err(AppError::NotFound(format!("Image not found: {}", id)))
|
||||
}
|
||||
|
||||
/// Get image by name
|
||||
pub fn get_by_name(&self, name: &str) -> Result<ImageInfo> {
|
||||
let path = self.images_path.join(name);
|
||||
self.get_image_info(&path)
|
||||
.ok_or_else(|| AppError::NotFound(format!("Image not found: {}", name)))
|
||||
}
|
||||
|
||||
/// Create a new image from bytes
|
||||
pub fn create(&self, name: &str, data: &[u8]) -> Result<ImageInfo> {
|
||||
self.ensure_dir()?;
|
||||
|
||||
// Validate name
|
||||
let name = sanitize_filename(name);
|
||||
if name.is_empty() {
|
||||
return Err(AppError::Internal("Invalid filename".to_string()));
|
||||
}
|
||||
|
||||
// Check size
|
||||
if data.len() as u64 > MAX_IMAGE_SIZE {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Image too large. Maximum size: {} GB",
|
||||
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
|
||||
// Write file
|
||||
let path = self.images_path.join(&name);
|
||||
if path.exists() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Image already exists: {}",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
let mut file = File::create(&path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to create image file: {}", e))
|
||||
})?;
|
||||
|
||||
file.write_all(data).map_err(|e| {
|
||||
// Try to clean up on error
|
||||
let _ = fs::remove_file(&path);
|
||||
AppError::Internal(format!("Failed to write image data: {}", e))
|
||||
})?;
|
||||
|
||||
info!("Created image: {} ({} bytes)", name, data.len());
|
||||
|
||||
self.get_by_name(&name)
|
||||
}
|
||||
|
||||
/// Create a new image from a file stream (for chunked uploads)
|
||||
pub fn create_from_stream<R: Read>(
|
||||
&self,
|
||||
name: &str,
|
||||
reader: &mut R,
|
||||
expected_size: Option<u64>,
|
||||
) -> Result<ImageInfo> {
|
||||
self.ensure_dir()?;
|
||||
|
||||
let name = sanitize_filename(name);
|
||||
if name.is_empty() {
|
||||
return Err(AppError::Internal("Invalid filename".to_string()));
|
||||
}
|
||||
|
||||
if let Some(size) = expected_size {
|
||||
if size > MAX_IMAGE_SIZE {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Image too large. Maximum size: {} GB",
|
||||
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let path = self.images_path.join(&name);
|
||||
if path.exists() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Image already exists: {}",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
// Create file and copy data
|
||||
let mut file = File::create(&path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to create image file: {}", e))
|
||||
})?;
|
||||
|
||||
let bytes_written = io::copy(reader, &mut file).map_err(|e| {
|
||||
let _ = fs::remove_file(&path);
|
||||
AppError::Internal(format!("Failed to write image data: {}", e))
|
||||
})?;
|
||||
|
||||
info!("Created image: {} ({} bytes)", name, bytes_written);
|
||||
|
||||
self.get_by_name(&name)
|
||||
}
|
||||
|
||||
/// Create a new image from an async multipart field (streaming, memory-efficient)
|
||||
///
|
||||
/// This method streams data directly to disk without buffering the entire file in memory,
|
||||
/// making it suitable for large files (multi-GB ISOs).
|
||||
pub async fn create_from_multipart_field(
|
||||
&self,
|
||||
name: &str,
|
||||
mut field: axum::extract::multipart::Field<'_>,
|
||||
) -> Result<ImageInfo> {
|
||||
self.ensure_dir()?;
|
||||
|
||||
let name = sanitize_filename(name);
|
||||
if name.is_empty() {
|
||||
return Err(AppError::Internal("Invalid filename".to_string()));
|
||||
}
|
||||
|
||||
// Use a temporary file during upload
|
||||
let temp_name = format!(".upload_{}", uuid::Uuid::new_v4());
|
||||
let temp_path = self.images_path.join(&temp_name);
|
||||
let final_path = self.images_path.join(&name);
|
||||
|
||||
// Check if final file already exists
|
||||
if final_path.exists() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Image already exists: {}",
|
||||
name
|
||||
)));
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
let mut file = tokio::fs::File::create(&temp_path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
|
||||
|
||||
let mut bytes_written: u64 = 0;
|
||||
|
||||
// Stream chunks directly to disk
|
||||
while let Some(chunk) = field.chunk().await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to read upload chunk: {}", e))
|
||||
})? {
|
||||
// Check size limit
|
||||
bytes_written += chunk.len() as u64;
|
||||
if bytes_written > MAX_IMAGE_SIZE {
|
||||
// Cleanup and return error
|
||||
drop(file);
|
||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||
return Err(AppError::Internal(format!(
|
||||
"Image too large. Maximum size: {} GB",
|
||||
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
|
||||
// Write chunk to file
|
||||
file.write_all(&chunk).await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to write chunk: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Flush and close file
|
||||
file.flush().await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to flush file: {}", e))
|
||||
})?;
|
||||
drop(file);
|
||||
|
||||
// Move temp file to final location
|
||||
tokio::fs::rename(&temp_path, &final_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
AppError::Internal(format!("Failed to rename temp file: {}", e))
|
||||
})?;
|
||||
|
||||
info!("Created image (streaming): {} ({} bytes)", name, bytes_written);
|
||||
|
||||
self.get_by_name(&name)
|
||||
}
|
||||
|
||||
/// Delete an image by ID
|
||||
pub fn delete(&self, id: &str) -> Result<()> {
|
||||
let image = self.get(id)?;
|
||||
|
||||
fs::remove_file(&image.path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to delete image: {}", e))
|
||||
})?;
|
||||
|
||||
info!("Deleted image: {}", image.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an image by name
|
||||
pub fn delete_by_name(&self, name: &str) -> Result<()> {
|
||||
let path = self.images_path.join(name);
|
||||
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound(format!("Image not found: {}", name)));
|
||||
}
|
||||
|
||||
fs::remove_file(&path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to delete image: {}", e))
|
||||
})?;
|
||||
|
||||
info!("Deleted image: {}", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get total storage used
|
||||
pub fn used_space(&self) -> u64 {
|
||||
self.list()
|
||||
.map(|images| images.iter().map(|i| i.size).sum())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Check if storage has space for new image
|
||||
pub fn has_space(&self, size: u64) -> bool {
|
||||
// For now, just check against max size
|
||||
// In the future, could check disk space
|
||||
size <= MAX_IMAGE_SIZE
|
||||
}
|
||||
|
||||
/// Download image from URL with progress callback
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - The URL to download from
|
||||
/// * `filename` - Optional custom filename (extracted from URL or Content-Disposition if not provided)
|
||||
/// * `progress_callback` - Callback function called with (bytes_downloaded, total_bytes)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(ImageInfo)` - The downloaded image info
|
||||
/// * `Err(AppError)` - If download fails
|
||||
pub async fn download_from_url<F>(
|
||||
&self,
|
||||
url: &str,
|
||||
filename: Option<String>,
|
||||
progress_callback: F,
|
||||
) -> Result<ImageInfo>
|
||||
where
|
||||
F: Fn(u64, Option<u64>) + Send + 'static,
|
||||
{
|
||||
self.ensure_dir()?;
|
||||
|
||||
// Validate URL
|
||||
let parsed_url = reqwest::Url::parse(url)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid URL: {}", e)))?;
|
||||
|
||||
info!("Starting download from: {}", url);
|
||||
|
||||
// Create HTTP client with timeout
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(3600)) // 1 hour timeout for large files
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
// Send HEAD request first to get content info
|
||||
let head_response = client
|
||||
.head(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to connect: {}", e)))?;
|
||||
|
||||
if !head_response.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Server returned error: {}",
|
||||
head_response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let total_size = head_response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
|
||||
// Check file size
|
||||
if let Some(size) = total_size {
|
||||
if size > MAX_IMAGE_SIZE {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"File too large: {} bytes (max {} GB)",
|
||||
size,
|
||||
MAX_IMAGE_SIZE / 1024 / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
let final_filename = if let Some(name) = filename {
|
||||
sanitize_filename(&name)
|
||||
} else {
|
||||
// Try Content-Disposition header first
|
||||
let from_header = head_response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| extract_filename_from_content_disposition(s));
|
||||
|
||||
if let Some(name) = from_header {
|
||||
sanitize_filename(&name)
|
||||
} else {
|
||||
// Fall back to URL path
|
||||
let path = parsed_url.path();
|
||||
let name = path.rsplit('/').next().unwrap_or("download");
|
||||
let name = urlencoding::decode(name).unwrap_or_else(|_| name.into());
|
||||
sanitize_filename(&name)
|
||||
}
|
||||
};
|
||||
|
||||
if final_filename.is_empty() {
|
||||
return Err(AppError::BadRequest("Could not determine filename".to_string()));
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
let final_path = self.images_path.join(&final_filename);
|
||||
if final_path.exists() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Image already exists: {}",
|
||||
final_filename
|
||||
)));
|
||||
}
|
||||
|
||||
// Create temporary file for download
|
||||
let temp_filename = format!(".download_{}", uuid::Uuid::new_v4());
|
||||
let temp_path = self.images_path.join(&temp_filename);
|
||||
|
||||
// Start actual download
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Download failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Download failed: HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
// Get actual content length from response (may differ from HEAD)
|
||||
let content_length = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.or(total_size);
|
||||
|
||||
// Create temp file
|
||||
let mut file = tokio::fs::File::create(&temp_path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
|
||||
|
||||
// Stream download with progress (throttled)
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_report_time = Instant::now();
|
||||
let mut last_reported_bytes: u64 = 0;
|
||||
let throttle_interval = Duration::from_millis(PROGRESS_THROTTLE_MS);
|
||||
|
||||
// Report initial progress
|
||||
progress_callback(0, content_length);
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result
|
||||
.map_err(|e| AppError::Internal(format!("Download error: {}", e)))?;
|
||||
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Cleanup on error
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
AppError::Internal(format!("Failed to write data: {}", e))
|
||||
})?;
|
||||
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
// Throttled progress reporting: report if enough time or bytes have passed
|
||||
let now = Instant::now();
|
||||
let time_elapsed = now.duration_since(last_report_time) >= throttle_interval;
|
||||
let bytes_elapsed = downloaded - last_reported_bytes >= PROGRESS_THROTTLE_BYTES;
|
||||
|
||||
if time_elapsed || bytes_elapsed {
|
||||
progress_callback(downloaded, content_length);
|
||||
last_report_time = now;
|
||||
last_reported_bytes = downloaded;
|
||||
}
|
||||
}
|
||||
|
||||
// Always report final progress
|
||||
if downloaded != last_reported_bytes {
|
||||
progress_callback(downloaded, content_length);
|
||||
}
|
||||
|
||||
// Ensure all data is flushed
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to flush file: {}", e)))?;
|
||||
drop(file);
|
||||
|
||||
// Verify downloaded size
|
||||
let metadata = tokio::fs::metadata(&temp_path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to read file metadata: {}", e)))?;
|
||||
|
||||
if let Some(expected) = content_length {
|
||||
if metadata.len() != expected {
|
||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||
return Err(AppError::Internal(format!(
|
||||
"Download incomplete: got {} bytes, expected {}",
|
||||
metadata.len(),
|
||||
expected
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Move temp file to final location
|
||||
tokio::fs::rename(&temp_path, &final_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
AppError::Internal(format!("Failed to move file: {}", e))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Download complete: {} ({} bytes)",
|
||||
final_filename,
|
||||
metadata.len()
|
||||
);
|
||||
|
||||
// Return image info
|
||||
self.get_by_name(&final_filename)
|
||||
}
|
||||
|
||||
/// Get images storage path
|
||||
pub fn images_path(&self) -> &PathBuf {
|
||||
&self.images_path
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple hash function for generating stable IDs
|
||||
fn md5_hash(s: &str) -> u64 {
|
||||
let mut hash: u64 = 0;
|
||||
for (i, byte) in s.bytes().enumerate() {
|
||||
hash = hash.wrapping_add((byte as u64).wrapping_mul((i as u64).wrapping_add(1)));
|
||||
hash = hash.wrapping_mul(31);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Sanitize filename to prevent path traversal
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
let name = name.trim();
|
||||
let name = name.replace(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|'], "_");
|
||||
|
||||
// Remove leading dots (hidden files)
|
||||
let name = name.trim_start_matches('.');
|
||||
|
||||
// Limit length
|
||||
if name.len() > 255 {
|
||||
name[..255].to_string()
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract filename from Content-Disposition header
|
||||
fn extract_filename_from_content_disposition(header: &str) -> Option<String> {
|
||||
// Handle both:
|
||||
// Content-Disposition: attachment; filename="example.iso"
|
||||
// Content-Disposition: attachment; filename*=UTF-8''example.iso
|
||||
|
||||
// Try filename* first (RFC 5987)
|
||||
if let Some(pos) = header.find("filename*=") {
|
||||
let start = pos + 10;
|
||||
let value = &header[start..];
|
||||
// Format: charset'language'value
|
||||
if let Some(quote_start) = value.find("''") {
|
||||
let encoded = value[quote_start + 2..].split(';').next()?;
|
||||
let decoded = urlencoding::decode(encoded.trim()).ok()?;
|
||||
let name = decoded.trim_matches('"').to_string();
|
||||
if !name.is_empty() {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try filename next
|
||||
if let Some(pos) = header.find("filename=") {
|
||||
let start = pos + 9;
|
||||
let value = &header[start..];
|
||||
let name = value.split(';').next()?;
|
||||
let name = name.trim().trim_matches('"').to_string();
|
||||
if !name.is_empty() {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(sanitize_filename("test.iso"), "test.iso");
|
||||
assert_eq!(sanitize_filename("../test.iso"), "_test.iso"); // .. becomes empty after trim_start_matches('.')
|
||||
assert_eq!(sanitize_filename("test/file.iso"), "test_file.iso");
|
||||
assert_eq!(sanitize_filename(".hidden.iso"), "hidden.iso");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_manager_list_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = ImageManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let images = manager.list().unwrap();
|
||||
assert!(images.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_manager_create() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = ImageManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let data = vec![0u8; 1024];
|
||||
let image = manager.create("test.iso", &data).unwrap();
|
||||
|
||||
assert_eq!(image.name, "test.iso");
|
||||
assert_eq!(image.size, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_manager_delete() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = ImageManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let data = vec![0u8; 1024];
|
||||
let image = manager.create("test.iso", &data).unwrap();
|
||||
|
||||
manager.delete(&image.id).unwrap();
|
||||
|
||||
assert!(manager.list().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
33
src/msd/mod.rs
Normal file
33
src/msd/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! MSD (Mass Storage Device) module
|
||||
//!
|
||||
//! Provides virtual USB storage functionality with two modes:
|
||||
//! - Image mounting: Mount ISO/IMG files for system installation
|
||||
//! - Ventoy drive: Bootable exFAT drive for multiple ISO files
|
||||
//!
|
||||
//! Architecture:
|
||||
//! ```text
|
||||
//! Web API --> MSD Controller --> ConfigFS Mass Storage --> Target PC
|
||||
//! |
|
||||
//! ┌──────┴──────┐
|
||||
//! │ │
|
||||
//! Image Manager Ventoy Drive
|
||||
//! (ISO/IMG) (Bootable exFAT)
|
||||
//! ```
|
||||
|
||||
pub mod controller;
|
||||
pub mod ventoy_drive;
|
||||
pub mod image;
|
||||
pub mod monitor;
|
||||
pub mod types;
|
||||
|
||||
pub use controller::MsdController;
|
||||
pub use ventoy_drive::VentoyDrive;
|
||||
pub use image::ImageManager;
|
||||
pub use monitor::{MsdHealthMonitor, MsdHealthStatus, MsdMonitorConfig};
|
||||
pub use types::{
|
||||
DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest,
|
||||
ImageInfo, MsdConnectRequest, MsdMode, MsdState,
|
||||
};
|
||||
|
||||
// Re-export from otg module for backward compatibility
|
||||
pub use crate::otg::{MsdFunction, MsdLunConfig};
|
||||
284
src/msd/monitor.rs
Normal file
284
src/msd/monitor.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! MSD (Mass Storage Device) health monitoring
|
||||
//!
|
||||
//! This module provides health monitoring for MSD operations, including:
|
||||
//! - ConfigFS operation error tracking
|
||||
//! - Image mount/unmount error tracking
|
||||
//! - Error notification
|
||||
//! - Log throttling to prevent log flooding
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::events::{EventBus, SystemEvent};
|
||||
use crate::utils::LogThrottler;
|
||||
|
||||
/// MSD health status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MsdHealthStatus {
|
||||
/// Device is healthy and operational
|
||||
Healthy,
|
||||
/// Device has an error
|
||||
Error {
|
||||
/// Human-readable error reason
|
||||
reason: String,
|
||||
/// Error code for programmatic handling
|
||||
error_code: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for MsdHealthStatus {
|
||||
fn default() -> Self {
|
||||
Self::Healthy
|
||||
}
|
||||
}
|
||||
|
||||
/// MSD health monitor configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MsdMonitorConfig {
|
||||
/// Log throttle interval in seconds
|
||||
pub log_throttle_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for MsdMonitorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_throttle_secs: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSD health monitor
|
||||
///
|
||||
/// Monitors MSD operation health and manages error notifications.
|
||||
/// Publishes WebSocket events when operation status changes.
|
||||
pub struct MsdHealthMonitor {
|
||||
/// Current health status
|
||||
status: RwLock<MsdHealthStatus>,
|
||||
/// Event bus for notifications
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// Log throttler to prevent log flooding
|
||||
throttler: LogThrottler,
|
||||
/// Configuration
|
||||
#[allow(dead_code)]
|
||||
config: MsdMonitorConfig,
|
||||
/// Whether monitoring is active (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
running: AtomicBool,
|
||||
/// Error count (for tracking)
|
||||
error_count: AtomicU32,
|
||||
/// Last error code (for change detection)
|
||||
last_error_code: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl MsdHealthMonitor {
|
||||
/// Create a new MSD health monitor with the specified configuration
|
||||
pub fn new(config: MsdMonitorConfig) -> Self {
|
||||
let throttle_secs = config.log_throttle_secs;
|
||||
Self {
|
||||
status: RwLock::new(MsdHealthStatus::Healthy),
|
||||
events: RwLock::new(None),
|
||||
throttler: LogThrottler::with_secs(throttle_secs),
|
||||
config,
|
||||
running: AtomicBool::new(false),
|
||||
error_count: AtomicU32::new(0),
|
||||
last_error_code: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new MSD health monitor with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(MsdMonitorConfig::default())
|
||||
}
|
||||
|
||||
/// Set the event bus for broadcasting state changes
|
||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
|
||||
/// Report an error from MSD operations
|
||||
///
|
||||
/// This method is called when an MSD operation fails. It:
|
||||
/// 1. Updates the health status
|
||||
/// 2. Logs the error (with throttling)
|
||||
/// 3. Publishes a WebSocket event if the error is new or changed
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reason` - Human-readable error description
|
||||
/// * `error_code` - Error code for programmatic handling
|
||||
pub async fn report_error(&self, reason: &str, error_code: &str) {
|
||||
let count = self.error_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
// Check if error code changed
|
||||
let error_changed = {
|
||||
let last = self.last_error_code.read().await;
|
||||
last.as_ref().map(|s| s.as_str()) != Some(error_code)
|
||||
};
|
||||
|
||||
// Log with throttling (always log if error type changed)
|
||||
let throttle_key = format!("msd_{}", error_code);
|
||||
if error_changed || self.throttler.should_log(&throttle_key) {
|
||||
warn!("MSD error: {} (code: {}, count: {})", reason, error_code, count);
|
||||
}
|
||||
|
||||
// Update last error code
|
||||
*self.last_error_code.write().await = Some(error_code.to_string());
|
||||
|
||||
// Update status
|
||||
*self.status.write().await = MsdHealthStatus::Error {
|
||||
reason: reason.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
};
|
||||
|
||||
// Publish event (only if error changed or first occurrence)
|
||||
if error_changed || count == 1 {
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::MsdError {
|
||||
reason: reason.to_string(),
|
||||
error_code: error_code.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report that the MSD has recovered from error
|
||||
///
|
||||
/// This method is called when an MSD operation succeeds after errors.
|
||||
/// It resets the error state and publishes a recovery event.
|
||||
pub async fn report_recovered(&self) {
|
||||
let prev_status = self.status.read().await.clone();
|
||||
|
||||
// Only report recovery if we were in an error state
|
||||
if prev_status != MsdHealthStatus::Healthy {
|
||||
let error_count = self.error_count.load(Ordering::Relaxed);
|
||||
info!("MSD recovered after {} errors", error_count);
|
||||
|
||||
// Reset state
|
||||
self.error_count.store(0, Ordering::Relaxed);
|
||||
self.throttler.clear_all();
|
||||
*self.last_error_code.write().await = None;
|
||||
*self.status.write().await = MsdHealthStatus::Healthy;
|
||||
|
||||
// Publish recovery event
|
||||
if let Some(ref events) = *self.events.read().await {
|
||||
events.publish(SystemEvent::MsdRecovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current health status
|
||||
pub async fn status(&self) -> MsdHealthStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get the current error count
|
||||
pub fn error_count(&self) -> u32 {
|
||||
self.error_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Check if the monitor is in an error state
|
||||
pub async fn is_error(&self) -> bool {
|
||||
matches!(*self.status.read().await, MsdHealthStatus::Error { .. })
|
||||
}
|
||||
|
||||
/// Check if the monitor is healthy
|
||||
pub async fn is_healthy(&self) -> bool {
|
||||
matches!(*self.status.read().await, MsdHealthStatus::Healthy)
|
||||
}
|
||||
|
||||
/// Reset the monitor to healthy state without publishing events
|
||||
///
|
||||
/// This is useful during initialization.
|
||||
pub async fn reset(&self) {
|
||||
self.error_count.store(0, Ordering::Relaxed);
|
||||
*self.last_error_code.write().await = None;
|
||||
*self.status.write().await = MsdHealthStatus::Healthy;
|
||||
self.throttler.clear_all();
|
||||
}
|
||||
|
||||
/// Get the current error message if in error state
|
||||
pub async fn error_message(&self) -> Option<String> {
|
||||
match &*self.status.read().await {
|
||||
MsdHealthStatus::Error { reason, .. } => Some(reason.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MsdHealthMonitor {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initial_status() {
|
||||
let monitor = MsdHealthMonitor::with_defaults();
|
||||
assert!(monitor.is_healthy().await);
|
||||
assert!(!monitor.is_error().await);
|
||||
assert_eq!(monitor.error_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_report_error() {
|
||||
let monitor = MsdHealthMonitor::with_defaults();
|
||||
|
||||
monitor
|
||||
.report_error("ConfigFS write failed", "configfs_error")
|
||||
.await;
|
||||
|
||||
assert!(monitor.is_error().await);
|
||||
assert_eq!(monitor.error_count(), 1);
|
||||
|
||||
if let MsdHealthStatus::Error { reason, error_code } = monitor.status().await {
|
||||
assert_eq!(reason, "ConfigFS write failed");
|
||||
assert_eq!(error_code, "configfs_error");
|
||||
} else {
|
||||
panic!("Expected Error status");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_report_recovered() {
|
||||
let monitor = MsdHealthMonitor::with_defaults();
|
||||
|
||||
// First report an error
|
||||
monitor
|
||||
.report_error("Image not found", "image_not_found")
|
||||
.await;
|
||||
assert!(monitor.is_error().await);
|
||||
|
||||
// Then report recovery
|
||||
monitor.report_recovered().await;
|
||||
assert!(monitor.is_healthy().await);
|
||||
assert_eq!(monitor.error_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_count_increments() {
|
||||
let monitor = MsdHealthMonitor::with_defaults();
|
||||
|
||||
for i in 1..=5 {
|
||||
monitor.report_error("Error", "io_error").await;
|
||||
assert_eq!(monitor.error_count(), i);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reset() {
|
||||
let monitor = MsdHealthMonitor::with_defaults();
|
||||
|
||||
monitor.report_error("Error", "io_error").await;
|
||||
assert!(monitor.is_error().await);
|
||||
|
||||
monitor.reset().await;
|
||||
assert!(monitor.is_healthy().await);
|
||||
assert_eq!(monitor.error_count(), 0);
|
||||
}
|
||||
}
|
||||
229
src/msd/types.rs
Normal file
229
src/msd/types.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
//! MSD data types and structures
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// MSD operating mode
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MsdMode {
|
||||
/// No storage connected
|
||||
None,
|
||||
/// Image file mounted (ISO/IMG)
|
||||
Image,
|
||||
/// Virtual drive (FAT32) connected
|
||||
Drive,
|
||||
}
|
||||
|
||||
impl Default for MsdMode {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Image file metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageInfo {
|
||||
/// Unique image ID
|
||||
pub id: String,
|
||||
/// Display name
|
||||
pub name: String,
|
||||
/// File path on disk
|
||||
#[serde(skip_serializing)]
|
||||
pub path: PathBuf,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
/// Creation timestamp
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ImageInfo {
|
||||
/// Create new image info
|
||||
pub fn new(id: String, name: String, path: PathBuf, size: u64) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
size,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format size for display
|
||||
pub fn size_display(&self) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
if self.size >= GB {
|
||||
format!("{:.2} GB", self.size as f64 / GB as f64)
|
||||
} else if self.size >= MB {
|
||||
format!("{:.2} MB", self.size as f64 / MB as f64)
|
||||
} else if self.size >= KB {
|
||||
format!("{:.2} KB", self.size as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{} B", self.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSD state information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MsdState {
|
||||
/// Whether MSD feature is available
|
||||
pub available: bool,
|
||||
/// Current mode
|
||||
pub mode: MsdMode,
|
||||
/// Whether storage is connected to target
|
||||
pub connected: bool,
|
||||
/// Currently mounted image (if mode is Image)
|
||||
pub current_image: Option<ImageInfo>,
|
||||
/// Virtual drive info (if mode is Drive)
|
||||
pub drive_info: Option<DriveInfo>,
|
||||
}
|
||||
|
||||
impl Default for MsdState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
available: false,
|
||||
mode: MsdMode::None,
|
||||
connected: false,
|
||||
current_image: None,
|
||||
drive_info: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual drive information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DriveInfo {
|
||||
/// Drive size in bytes
|
||||
pub size: u64,
|
||||
/// Used space in bytes
|
||||
pub used: u64,
|
||||
/// Free space in bytes
|
||||
pub free: u64,
|
||||
/// Whether drive is initialized
|
||||
pub initialized: bool,
|
||||
/// Drive file path
|
||||
#[serde(skip_serializing)]
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl DriveInfo {
|
||||
/// Create new drive info
|
||||
pub fn new(path: PathBuf, size: u64) -> Self {
|
||||
Self {
|
||||
size,
|
||||
used: 0,
|
||||
free: size,
|
||||
initialized: false,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File entry in virtual drive
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DriveFile {
|
||||
/// File name
|
||||
pub name: String,
|
||||
/// Relative path from drive root
|
||||
pub path: String,
|
||||
/// File size in bytes (0 for directories)
|
||||
pub size: u64,
|
||||
/// Whether this is a directory
|
||||
pub is_dir: bool,
|
||||
/// Last modified timestamp
|
||||
pub modified: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// MSD connect request
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MsdConnectRequest {
|
||||
/// Connection mode: "image" or "drive"
|
||||
pub mode: MsdMode,
|
||||
/// Image ID to mount (required for image mode)
|
||||
pub image_id: Option<String>,
|
||||
/// Mount as CD-ROM (optional, defaults based on image type)
|
||||
#[serde(default)]
|
||||
pub cdrom: Option<bool>,
|
||||
/// Mount as read-only
|
||||
#[serde(default)]
|
||||
pub read_only: Option<bool>,
|
||||
}
|
||||
|
||||
/// Virtual drive init request
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveInitRequest {
|
||||
/// Drive size in megabytes (defaults to 16GB)
|
||||
#[serde(default = "default_drive_size")]
|
||||
pub size_mb: u32,
|
||||
/// Optional custom path for Ventoy installation
|
||||
pub ventoy_path: Option<String>,
|
||||
}
|
||||
|
||||
fn default_drive_size() -> u32 {
|
||||
16 * 1024 // 16GB
|
||||
}
|
||||
|
||||
/// Image download request
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ImageDownloadRequest {
|
||||
/// URL to download from
|
||||
pub url: String,
|
||||
/// Optional custom filename
|
||||
pub filename: Option<String>,
|
||||
}
|
||||
|
||||
/// Download status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DownloadStatus {
|
||||
/// Download has started
|
||||
Started,
|
||||
/// Download is in progress
|
||||
InProgress,
|
||||
/// Download completed successfully
|
||||
Completed,
|
||||
/// Download failed
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Download progress information
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DownloadProgress {
|
||||
/// Unique download ID
|
||||
pub download_id: String,
|
||||
/// Source URL
|
||||
pub url: String,
|
||||
/// Target filename
|
||||
pub filename: String,
|
||||
/// Bytes downloaded so far
|
||||
pub bytes_downloaded: u64,
|
||||
/// Total file size (None if unknown)
|
||||
pub total_bytes: Option<u64>,
|
||||
/// Progress percentage (0.0 - 100.0, None if total unknown)
|
||||
pub progress_pct: Option<f32>,
|
||||
/// Download status
|
||||
pub status: DownloadStatus,
|
||||
/// Error message if failed
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_size_display() {
|
||||
let info = ImageInfo::new(
|
||||
"test".into(),
|
||||
"test.iso".into(),
|
||||
PathBuf::from("/tmp/test.iso"),
|
||||
1024 * 1024 * 1024 * 2, // 2 GB
|
||||
);
|
||||
assert!(info.size_display().contains("GB"));
|
||||
}
|
||||
}
|
||||
690
src/msd/ventoy_drive.rs
Normal file
690
src/msd/ventoy_drive.rs
Normal file
@@ -0,0 +1,690 @@
|
||||
//! Ventoy Virtual Drive
|
||||
//!
|
||||
//! Replaces FAT32 VirtualDrive with a Ventoy bootable image.
|
||||
//! Provides a bootable USB with exFAT data partition for ISO files.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
use ventoy_img::{FileInfo as VentoyFileInfo, VentoyError, VentoyImage};
|
||||
|
||||
use super::types::{DriveFile, DriveInfo};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
/// Chunk size for streaming reads (64 KB)
|
||||
const STREAM_CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// Minimum drive size (1 GB) - Ventoy requires space for boot partition
|
||||
const MIN_DRIVE_SIZE_MB: u32 = 1024;
|
||||
|
||||
/// Maximum drive size (128 GB)
|
||||
const MAX_DRIVE_SIZE_MB: u32 = 128 * 1024;
|
||||
|
||||
/// Default drive label
|
||||
const DEFAULT_LABEL: &str = "ONE-KVM";
|
||||
|
||||
/// Ventoy Drive Manager
|
||||
///
|
||||
/// Thread-safe wrapper around VentoyImage providing async file operations.
|
||||
/// Uses spawn_blocking for all ventoy-img-rs operations since they are synchronous.
|
||||
/// Uses RwLock to allow concurrent read operations while serializing writes.
|
||||
pub struct VentoyDrive {
|
||||
/// Drive image path
|
||||
path: PathBuf,
|
||||
/// RwLock for concurrent reads, exclusive writes
|
||||
/// (ventoy-img-rs operations are synchronous and not thread-safe)
|
||||
lock: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
impl VentoyDrive {
|
||||
/// Create new Ventoy drive manager
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
lock: Arc::new(RwLock::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if drive image exists
|
||||
pub fn exists(&self) -> bool {
|
||||
self.path.exists()
|
||||
}
|
||||
|
||||
/// Get drive path
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Initialize a new Ventoy drive image
|
||||
///
|
||||
/// Creates a bootable Ventoy image with the specified size.
|
||||
/// The image includes boot partitions and an exFAT data partition.
|
||||
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
|
||||
let size_mb = size_mb.clamp(MIN_DRIVE_SIZE_MB, MAX_DRIVE_SIZE_MB);
|
||||
let size_str = format!("{}M", size_mb);
|
||||
let path = self.path.clone();
|
||||
let _lock = self.lock.write().await; // Write lock for initialization
|
||||
|
||||
info!("Creating {} MB Ventoy drive at {}", size_mb, path.display());
|
||||
|
||||
// Run Ventoy creation in blocking task
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
VentoyImage::create(&path, &size_str, DEFAULT_LABEL)
|
||||
.map_err(ventoy_to_app_error)?;
|
||||
|
||||
// Get file metadata for DriveInfo
|
||||
let metadata = std::fs::metadata(&path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to read drive metadata: {}", e))
|
||||
})?;
|
||||
|
||||
Ok::<DriveInfo, AppError>(DriveInfo {
|
||||
size: metadata.len(),
|
||||
used: 0,
|
||||
free: metadata.len(), // Approximate - exFAT overhead not calculated
|
||||
initialized: true,
|
||||
path,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??;
|
||||
|
||||
info!("Ventoy drive created successfully");
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Get drive information
|
||||
pub async fn info(&self) -> Result<DriveInfo> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let path = self.path.clone();
|
||||
let _lock = self.lock.read().await; // Read lock for info query
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let metadata = std::fs::metadata(&path).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to read drive metadata: {}", e))
|
||||
})?;
|
||||
|
||||
// Open image to get file list and calculate used space
|
||||
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||
|
||||
let files = image
|
||||
.list_files_recursive()
|
||||
.map_err(ventoy_to_app_error)?;
|
||||
|
||||
let used: u64 = files
|
||||
.iter()
|
||||
.filter(|f| !f.is_directory)
|
||||
.map(|f| f.size)
|
||||
.sum();
|
||||
|
||||
// Note: This is approximate since we don't have exact exFAT overhead
|
||||
let size = metadata.len();
|
||||
let free = size.saturating_sub(used);
|
||||
|
||||
Ok(DriveInfo {
|
||||
size,
|
||||
used,
|
||||
free,
|
||||
initialized: true,
|
||||
path,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
||||
}
|
||||
|
||||
/// List files at a given path (or root if empty/"/")
|
||||
pub async fn list_files(&self, dir_path: &str) -> Result<Vec<DriveFile>> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let path = self.path.clone();
|
||||
let dir_path = dir_path.to_string();
|
||||
let _lock = self.lock.read().await; // Read lock for listing
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||
|
||||
let files = if dir_path.is_empty() || dir_path == "/" {
|
||||
image.list_files()
|
||||
} else {
|
||||
image.list_files_at(&dir_path)
|
||||
}
|
||||
.map_err(ventoy_to_app_error)?;
|
||||
|
||||
Ok(files
|
||||
.into_iter()
|
||||
.map(|f| ventoy_file_to_drive_file(f, &dir_path))
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
||||
}
|
||||
|
||||
/// Write a file to the drive from multipart upload (streaming)
|
||||
///
|
||||
/// Streams the file directly into the Ventoy image's exFAT partition.
|
||||
pub async fn write_file_from_multipart_field(
|
||||
&self,
|
||||
file_path: &str,
|
||||
mut field: axum::extract::multipart::Field<'_>,
|
||||
) -> Result<u64> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
// First, stream to a temporary file (to get the size)
|
||||
let temp_dir = self.path.parent().unwrap_or(Path::new("/tmp"));
|
||||
let temp_name = format!(".upload_ventoy_{}", uuid::Uuid::new_v4());
|
||||
let temp_path = temp_dir.join(&temp_name);
|
||||
|
||||
// Stream upload to temp file
|
||||
let mut temp_file = tokio::fs::File::create(&temp_path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create temp file: {}", e)))?;
|
||||
|
||||
let mut bytes_written: u64 = 0;
|
||||
|
||||
while let Some(chunk) = field.chunk().await.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to read upload chunk: {}", e))
|
||||
})? {
|
||||
bytes_written += chunk.len() as u64;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut temp_file, &chunk)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write chunk: {}", e)))?;
|
||||
}
|
||||
|
||||
tokio::io::AsyncWriteExt::flush(&mut temp_file)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to flush temp file: {}", e)))?;
|
||||
drop(temp_file);
|
||||
|
||||
// Now copy from temp file to Ventoy image
|
||||
let path = self.path.clone();
|
||||
let file_path = file_path.to_string();
|
||||
let temp_path_clone = temp_path.clone();
|
||||
let _lock = self.lock.write().await; // Write lock for file write
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||
|
||||
// Use add_file_to_path which handles streaming internally
|
||||
image
|
||||
.add_file_to_path(
|
||||
&temp_path_clone,
|
||||
&file_path,
|
||||
true, // create_parents
|
||||
true, // overwrite
|
||||
)
|
||||
.map_err(ventoy_to_app_error)?;
|
||||
|
||||
Ok::<(), AppError>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?;
|
||||
|
||||
// Cleanup temp file
|
||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||
|
||||
result?;
|
||||
Ok(bytes_written)
|
||||
}
|
||||
|
||||
/// Read a file from the drive (for download)
|
||||
pub async fn read_file(&self, file_path: &str) -> Result<Vec<u8>> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let path = self.path.clone();
|
||||
let file_path = file_path.to_string();
|
||||
let _lock = self.lock.read().await; // Read lock for file read
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||
|
||||
image
|
||||
.read_file(&file_path)
|
||||
.map_err(ventoy_to_app_error)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
||||
}
|
||||
|
||||
/// Get file information without reading content
|
||||
///
|
||||
/// Returns file size, name, and other metadata.
|
||||
/// Returns None if the file doesn't exist.
|
||||
pub async fn get_file_info(&self, file_path: &str) -> Result<Option<DriveFile>> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let path = self.path.clone();
|
||||
let file_path_owned = file_path.to_string();
|
||||
let _lock = self.lock.read().await; // Read lock for file info
|
||||
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
let image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||
image
|
||||
.get_file_info(&file_path_owned)
|
||||
.map_err(ventoy_to_app_error)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))??;
|
||||
|
||||
Ok(info.map(|f| DriveFile {
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
size: f.size,
|
||||
is_dir: f.is_directory,
|
||||
modified: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read a file from the drive as a stream (for large file downloads)
|
||||
///
|
||||
/// Returns an async channel receiver that yields chunks of file data.
|
||||
/// This avoids loading the entire file into memory.
|
||||
pub async fn read_file_stream(
|
||||
&self,
|
||||
file_path: &str,
|
||||
) -> Result<(
|
||||
u64,
|
||||
tokio::sync::mpsc::Receiver<std::result::Result<bytes::Bytes, std::io::Error>>,
|
||||
)> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
// First, get the file size
|
||||
let file_info = self
|
||||
.get_file_info(file_path)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("File not found: {}", file_path)))?;
|
||||
|
||||
if file_info.is_dir {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"'{}' is a directory",
|
||||
file_path
|
||||
)));
|
||||
}
|
||||
|
||||
let file_size = file_info.size;
|
||||
let path = self.path.clone();
|
||||
let file_path_owned = file_path.to_string();
|
||||
let lock = self.lock.clone();
|
||||
|
||||
// Create a channel for streaming data
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<bytes::Bytes, std::io::Error>>(8);
|
||||
|
||||
// Spawn blocking task to read and send chunks
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Hold read lock for the entire read operation
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
let _lock = rt.block_on(lock.read()); // Read lock for streaming
|
||||
|
||||
let image = match VentoyImage::open(&path) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
let _ = rt.block_on(tx.send(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a channel writer that sends chunks
|
||||
let mut chunk_writer = ChannelWriter::new(tx.clone(), rt.clone());
|
||||
|
||||
// Stream the file through the writer
|
||||
if let Err(e) = image.read_file_to_writer(&file_path_owned, &mut chunk_writer) {
|
||||
let _ = rt.block_on(tx.send(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
))));
|
||||
}
|
||||
});
|
||||
|
||||
Ok((file_size, rx))
|
||||
}
|
||||
|
||||
/// Create a directory
|
||||
pub async fn mkdir(&self, dir_path: &str) -> Result<()> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let path = self.path.clone();
|
||||
let dir_path = dir_path.to_string();
|
||||
let _lock = self.lock.write().await; // Write lock for mkdir
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||
|
||||
image
|
||||
.create_directory(&dir_path, true)
|
||||
.map_err(ventoy_to_app_error)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
||||
}
|
||||
|
||||
/// Delete a file or directory
|
||||
pub async fn delete(&self, path_to_delete: &str) -> Result<()> {
|
||||
if !self.exists() {
|
||||
return Err(AppError::Internal("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let path = self.path.clone();
|
||||
let path_to_delete = path_to_delete.to_string();
|
||||
let _lock = self.lock.write().await; // Write lock for delete
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut image = VentoyImage::open(&path).map_err(ventoy_to_app_error)?;
|
||||
|
||||
// Use recursive delete to handle directories
|
||||
image
|
||||
.remove_recursive(&path_to_delete)
|
||||
.map_err(ventoy_to_app_error)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert VentoyError to AppError
|
||||
fn ventoy_to_app_error(err: VentoyError) -> AppError {
|
||||
match err {
|
||||
VentoyError::Io(e) => AppError::Io(e),
|
||||
VentoyError::InvalidSize(s) => AppError::BadRequest(format!("Invalid size: {}", s)),
|
||||
VentoyError::SizeParseError(s) => {
|
||||
AppError::BadRequest(format!("Size parse error: {}", s))
|
||||
}
|
||||
VentoyError::FilesystemError(s) => {
|
||||
AppError::Internal(format!("Filesystem error: {}", s))
|
||||
}
|
||||
VentoyError::ImageError(s) => AppError::Internal(format!("Image error: {}", s)),
|
||||
VentoyError::FileNotFound(s) => AppError::NotFound(format!("File not found: {}", s)),
|
||||
VentoyError::ResourceNotFound(s) => {
|
||||
AppError::Internal(format!("Resource not found: {}", s))
|
||||
}
|
||||
VentoyError::PartitionError(s) => {
|
||||
AppError::Internal(format!("Partition error: {}", s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert VentoyFileInfo to DriveFile
|
||||
fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile {
|
||||
let full_path = if parent_path.is_empty() || parent_path == "/" {
|
||||
format!("/{}", info.name)
|
||||
} else {
|
||||
format!("{}/{}", parent_path.trim_end_matches('/'), info.name)
|
||||
};
|
||||
|
||||
DriveFile {
|
||||
name: info.name,
|
||||
path: full_path,
|
||||
size: info.size,
|
||||
is_dir: info.is_directory,
|
||||
modified: None, // Ventoy FileInfo doesn't include timestamps
|
||||
}
|
||||
}
|
||||
|
||||
/// A writer that sends chunks to an async channel
|
||||
///
|
||||
/// This bridges the sync Write trait with async channels for streaming.
|
||||
struct ChannelWriter {
|
||||
tx: tokio::sync::mpsc::Sender<std::result::Result<bytes::Bytes, std::io::Error>>,
|
||||
rt: tokio::runtime::Handle,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ChannelWriter {
|
||||
fn new(
|
||||
tx: tokio::sync::mpsc::Sender<std::result::Result<bytes::Bytes, std::io::Error>>,
|
||||
rt: tokio::runtime::Handle,
|
||||
) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
rt,
|
||||
buffer: Vec::with_capacity(STREAM_CHUNK_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_buffer(&mut self) -> std::io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chunk = bytes::Bytes::copy_from_slice(&self.buffer);
|
||||
self.buffer.clear();
|
||||
|
||||
self.rt
|
||||
.block_on(self.tx.send(Ok(chunk)))
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "Channel closed"))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for ChannelWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let mut written = 0;
|
||||
|
||||
while written < buf.len() {
|
||||
let space = STREAM_CHUNK_SIZE - self.buffer.len();
|
||||
let to_copy = std::cmp::min(space, buf.len() - written);
|
||||
|
||||
self.buffer.extend_from_slice(&buf[written..written + to_copy]);
|
||||
written += to_copy;
|
||||
|
||||
if self.buffer.len() >= STREAM_CHUNK_SIZE {
|
||||
self.flush_buffer()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.flush_buffer()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChannelWriter {
|
||||
fn drop(&mut self) {
|
||||
// Flush any remaining data when the writer is dropped
|
||||
let _ = self.flush_buffer();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drive_init() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let drive_path = temp_dir.path().join("test_ventoy.img");
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
let info = drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
||||
assert!(info.initialized);
|
||||
assert!(drive.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drive_mkdir() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let drive_path = temp_dir.path().join("test_ventoy.img");
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
||||
drive.mkdir("/isos").await.unwrap();
|
||||
|
||||
let files = drive.list_files("/").await.unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].is_dir);
|
||||
assert_eq!(files[0].name, "isos");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drive_file_write_and_read() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let drive_path = temp_dir.path().join("test_ventoy.img");
|
||||
let drive = VentoyDrive::new(drive_path.clone());
|
||||
|
||||
// Initialize drive
|
||||
drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
||||
|
||||
// Write a test file
|
||||
let test_content = b"Hello, Ventoy!";
|
||||
let test_file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&test_file_path, test_content).unwrap();
|
||||
|
||||
// Add file to drive using ventoy-img directly
|
||||
let path = drive.path().clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut image = VentoyImage::open(&path).unwrap();
|
||||
image.add_file(&test_file_path).unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Read file from drive
|
||||
let read_data = drive.read_file("/test.txt").await.unwrap();
|
||||
assert_eq!(read_data, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drive_get_file_info() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let drive_path = temp_dir.path().join("test_ventoy.img");
|
||||
let drive = VentoyDrive::new(drive_path.clone());
|
||||
|
||||
// Initialize drive
|
||||
drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
||||
|
||||
// Create a directory
|
||||
drive.mkdir("/mydir").await.unwrap();
|
||||
|
||||
// Write a test file
|
||||
let test_content = b"Test file content for info check";
|
||||
let test_file_path = temp_dir.path().join("info_test.txt");
|
||||
std::fs::write(&test_file_path, test_content).unwrap();
|
||||
|
||||
// Add file to drive
|
||||
let path = drive.path().clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut image = VentoyImage::open(&path).unwrap();
|
||||
image.add_file(&test_file_path).unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Test get_file_info for file
|
||||
let file_info = drive.get_file_info("/info_test.txt").await.unwrap();
|
||||
assert!(file_info.is_some());
|
||||
let file_info = file_info.unwrap();
|
||||
assert_eq!(file_info.name, "info_test.txt");
|
||||
assert_eq!(file_info.size, test_content.len() as u64);
|
||||
assert!(!file_info.is_dir);
|
||||
|
||||
// Test get_file_info for directory
|
||||
let dir_info = drive.get_file_info("/mydir").await.unwrap();
|
||||
assert!(dir_info.is_some());
|
||||
let dir_info = dir_info.unwrap();
|
||||
assert_eq!(dir_info.name, "mydir");
|
||||
assert!(dir_info.is_dir);
|
||||
|
||||
// Test get_file_info for non-existent file
|
||||
let not_found = drive.get_file_info("/nonexistent.txt").await.unwrap();
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drive_stream_read() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let drive_path = temp_dir.path().join("test_ventoy.img");
|
||||
let drive = VentoyDrive::new(drive_path.clone());
|
||||
|
||||
// Initialize drive
|
||||
drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
||||
|
||||
// Create test data that spans multiple chunks (>64KB)
|
||||
let test_size = 200 * 1024; // 200 KB
|
||||
let test_content: Vec<u8> = (0..test_size).map(|i| (i % 256) as u8).collect();
|
||||
let test_file_path = temp_dir.path().join("large_file.bin");
|
||||
std::fs::write(&test_file_path, &test_content).unwrap();
|
||||
|
||||
// Add file to drive
|
||||
let path = drive.path().clone();
|
||||
let file_path_clone = test_file_path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut image = VentoyImage::open(&path).unwrap();
|
||||
image.add_file(&file_path_clone).unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Stream read the file
|
||||
let (file_size, mut rx) = drive.read_file_stream("/large_file.bin").await.unwrap();
|
||||
assert_eq!(file_size, test_size as u64);
|
||||
|
||||
// Collect all chunks
|
||||
let mut received_data = Vec::new();
|
||||
while let Some(chunk_result) = rx.recv().await {
|
||||
let chunk = chunk_result.expect("Chunk should not be an error");
|
||||
received_data.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Verify data matches
|
||||
assert_eq!(received_data.len(), test_content.len());
|
||||
assert_eq!(received_data, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_drive_stream_read_small_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let drive_path = temp_dir.path().join("test_ventoy.img");
|
||||
let drive = VentoyDrive::new(drive_path.clone());
|
||||
|
||||
// Initialize drive
|
||||
drive.init(MIN_DRIVE_SIZE_MB).await.unwrap();
|
||||
|
||||
// Create a small test file
|
||||
let test_content = b"Small file for streaming test";
|
||||
let test_file_path = temp_dir.path().join("small.txt");
|
||||
std::fs::write(&test_file_path, test_content).unwrap();
|
||||
|
||||
// Add file to drive
|
||||
let path = drive.path().clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut image = VentoyImage::open(&path).unwrap();
|
||||
image.add_file(&test_file_path).unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Stream read the file
|
||||
let (file_size, mut rx) = drive.read_file_stream("/small.txt").await.unwrap();
|
||||
assert_eq!(file_size, test_content.len() as u64);
|
||||
|
||||
// Collect all chunks
|
||||
let mut received_data = Vec::new();
|
||||
while let Some(chunk_result) = rx.recv().await {
|
||||
let chunk = chunk_result.expect("Chunk should not be an error");
|
||||
received_data.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Verify data matches
|
||||
assert_eq!(received_data.as_slice(), test_content);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user