From db2410cc7f5d97dff7e13ffe5cd61bad1cc61093 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Tue, 7 Jul 2026 23:52:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20MSD=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E9=AA=8C=E5=92=8C=E6=8E=A7=E4=BB=B6=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/ventoy-img-rs/src/exfat/format.rs | 210 +++++-- libs/ventoy-img-rs/src/exfat/ops.rs | 756 ++++++++++++++++++++----- src/msd/mod.rs | 2 +- src/msd/types.rs | 2 +- src/msd/ventoy_drive.rs | 18 +- src/otg/msd.rs | 6 +- src/web/handlers/msd_api.rs | 151 ++++- web/src/api/index.ts | 25 +- web/src/components/MsdDialog.vue | 395 +++++++++---- web/src/i18n/en-US.ts | 12 +- web/src/i18n/zh-CN.ts | 12 +- 11 files changed, 1281 insertions(+), 308 deletions(-) diff --git a/libs/ventoy-img-rs/src/exfat/format.rs b/libs/ventoy-img-rs/src/exfat/format.rs index f0717e0d..f8aba5b3 100644 --- a/libs/ventoy-img-rs/src/exfat/format.rs +++ b/libs/ventoy-img-rs/src/exfat/format.rs @@ -97,14 +97,14 @@ impl ExfatBootSector { let heap_sectors = volume_length as u32 - cluster_heap_offset; let cluster_count = heap_sectors / sectors_per_cluster; - // Calculate root directory cluster based on upcase table size - // Cluster 2: Bitmap (1 cluster) - // Cluster 3...: Upcase table (128KB, may span multiple clusters) - // Next available: Root directory + // Calculate root directory cluster based on bitmap and upcase table size. const UPCASE_TABLE_SIZE: u64 = 128 * 1024; + let bitmap_size = ((cluster_count + 7) / 8) as u64; + let bitmap_clusters = + ((bitmap_size + cluster_size as u64 - 1) / cluster_size as u64).max(1) as u32; let upcase_clusters = ((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32; - let first_cluster_of_root = 3 + upcase_clusters; + let first_cluster_of_root = 2 + bitmap_clusters + upcase_clusters; Self { jump_boot: [0xEB, 0x76, 0x90], @@ -211,6 +211,15 @@ const ENTRY_TYPE_VOLUME_LABEL: u8 = 0x83; const ENTRY_TYPE_BITMAP: u8 = 0x81; const ENTRY_TYPE_UPCASE: u8 = 0x82; +fn set_cluster_allocated(bitmap: &mut [u8], cluster: u32) { + let index = (cluster - 2) as usize; + let byte_idx = index / 8; + let bit_idx = index % 8; + if byte_idx < bitmap.len() { + bitmap[byte_idx] |= 1 << bit_idx; + } +} + /// Create volume label directory entry fn create_volume_label_entry(label: &str) -> [u8; 32] { let mut entry = [0u8; 32]; @@ -301,27 +310,42 @@ pub fn format_exfat( let fat_offset = partition_offset + boot_sector.fat_offset as u64 * 512; writer.seek(SeekFrom::Start(fat_offset))?; + let bitmap_size = (boot_sector.cluster_count + 7) / 8; + let bitmap_clusters = + ((bitmap_size as u64 + cluster_size as u64 - 1) / cluster_size as u64).max(1) as u32; + // Calculate how many clusters the upcase table needs (128KB) const UPCASE_TABLE_SIZE: u64 = 128 * 1024; let upcase_clusters = ((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32; - let root_cluster = 3 + upcase_clusters; // Root comes after bitmap and upcase + let bitmap_start_cluster = 2; + let upcase_start_cluster = bitmap_start_cluster + bitmap_clusters; + let root_cluster = upcase_start_cluster + upcase_clusters; // FAT entries: cluster 0 and 1 are reserved // 0: Media type (0xFFFFFFF8) // 1: Reserved (0xFFFFFFFF) - // 2: Bitmap cluster (single cluster, end of chain) - // 3..3+upcase_clusters-1: Upcase table cluster chain - // 3+upcase_clusters: Root directory cluster (end of chain) + // 2..2+bitmap_clusters-1: Bitmap cluster chain + // upcase_start_cluster..upcase_start_cluster+upcase_clusters-1: Upcase table cluster chain + // root_cluster: Root directory cluster (end of chain) let mut fat_entries = vec![ 0xFFFFFFF8, // Media type 0xFFFFFFFF, // Reserved - 0xFFFFFFFF, // Bitmap (single cluster, end of chain) ]; + // Build allocation bitmap cluster chain + for i in 0..bitmap_clusters { + let cluster_num = bitmap_start_cluster + i; + if i == bitmap_clusters - 1 { + fat_entries.push(0xFFFFFFFF); + } else { + fat_entries.push(cluster_num + 1); + } + } + // Build upcase table cluster chain for i in 0..upcase_clusters { - let cluster_num = 3 + i; + let cluster_num = upcase_start_cluster + i; if i == upcase_clusters - 1 { // Last cluster in chain fat_entries.push(0xFFFFFFFF); @@ -345,38 +369,26 @@ pub fn format_exfat( // Calculate cluster heap offset let heap_offset = partition_offset + boot_sector.cluster_heap_offset as u64 * 512; - // Cluster 2: Allocation Bitmap - let bitmap_size = (boot_sector.cluster_count + 7) / 8; - let _bitmap_clusters = - ((bitmap_size as u64 + cluster_size as u64 - 1) / cluster_size as u64).max(1); - let mut bitmap = vec![0u8; cluster_size as usize]; + // Allocation Bitmap + let mut bitmap = vec![0u8; bitmap_clusters as usize * cluster_size as usize]; - // Mark clusters 2, 3..3+upcase_clusters-1, root_cluster as used - // Cluster 2: bitmap - bitmap[0] |= 0b00000100; // Bit 2 - // Clusters 3..3+upcase_clusters-1: upcase table + // Mark bitmap, upcase, and root directory clusters as used. + // exFAT allocation bitmap bit 0 describes cluster 2. + for i in 0..bitmap_clusters { + set_cluster_allocated(&mut bitmap, bitmap_start_cluster + i); + } for i in 0..upcase_clusters { - let cluster = 3 + i; - let byte_idx = (cluster / 8) as usize; - let bit_idx = cluster % 8; - if byte_idx < bitmap.len() { - bitmap[byte_idx] |= 1 << bit_idx; - } - } - // Root directory cluster - let byte_idx = (root_cluster / 8) as usize; - let bit_idx = root_cluster % 8; - if byte_idx < bitmap.len() { - bitmap[byte_idx] |= 1 << bit_idx; + set_cluster_allocated(&mut bitmap, upcase_start_cluster + i); } + set_cluster_allocated(&mut bitmap, root_cluster); writer.seek(SeekFrom::Start(heap_offset))?; writer.write_all(&bitmap)?; - // Cluster 3..3+upcase_clusters-1: Upcase table + // Upcase table let upcase_data = generate_upcase_table(); let upcase_checksum = calculate_upcase_checksum(&upcase_data); - let upcase_offset = heap_offset + cluster_size as u64; // Start at cluster 3 + let upcase_offset = heap_offset + bitmap_clusters as u64 * cluster_size as u64; writer.seek(SeekFrom::Start(upcase_offset))?; writer.write_all(&upcase_data)?; @@ -388,13 +400,18 @@ pub fn format_exfat( } // Root directory cluster - let root_offset = heap_offset + (1 + upcase_clusters as u64) * cluster_size as u64; + let root_offset = + heap_offset + (bitmap_clusters as u64 + upcase_clusters as u64) * cluster_size as u64; writer.seek(SeekFrom::Start(root_offset))?; // Write directory entries let volume_label_entry = create_volume_label_entry(label); - let bitmap_entry = create_bitmap_entry(2, bitmap_size as u64); - let upcase_entry = create_upcase_entry(3, upcase_data.len() as u64, upcase_checksum); + let bitmap_entry = create_bitmap_entry(bitmap_start_cluster, bitmap_size as u64); + let upcase_entry = create_upcase_entry( + upcase_start_cluster, + upcase_data.len() as u64, + upcase_checksum, + ); writer.write_all(&volume_label_entry)?; writer.write_all(&bitmap_entry)?; @@ -413,6 +430,9 @@ pub fn format_exfat( #[cfg(test)] mod tests { use super::*; + use crate::partition::PartitionLayout; + use std::io::Read; + use tempfile::NamedTempFile; #[test] fn test_cluster_size() { @@ -428,4 +448,120 @@ mod tests { assert_eq!(get_cluster_size(8 * 1024 * 1024 * 1024 / 512), 131072); // 8GB → 128KB assert_eq!(get_cluster_size(16 * 1024 * 1024 * 1024 / 512), 131072); // 16GB → 128KB } + + #[test] + fn test_format_bitmap_uses_cluster_heap_bit_indices() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST").unwrap(); + + let mut boot_sector = [0u8; 512]; + file.seek(SeekFrom::Start(layout.data_offset())).unwrap(); + file.read_exact(&mut boot_sector).unwrap(); + let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap()); + let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap()); + let sectors_per_cluster = 1u64 << boot_sector[109]; + let cluster_size = sectors_per_cluster * 512; + + let bitmap_offset = layout.data_offset() + cluster_heap_offset as u64 * 512; + let mut bitmap = vec![0u8; cluster_size as usize]; + file.seek(SeekFrom::Start(bitmap_offset)).unwrap(); + file.read_exact(&mut bitmap).unwrap(); + + let is_allocated = |cluster: u32| { + let index = (cluster - 2) as usize; + (bitmap[index / 8] & (1 << (index % 8))) != 0 + }; + + assert!( + is_allocated(2), + "allocation bitmap cluster must be allocated" + ); + assert!( + is_allocated(3), + "upcase table first cluster must be allocated" + ); + assert!( + is_allocated(first_cluster_of_root), + "root directory cluster must be allocated" + ); + assert!( + !is_allocated(first_cluster_of_root + 1), + "first data cluster after root should be free after formatting" + ); + } + + #[test] + fn test_format_supports_multi_cluster_allocation_bitmap() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 240 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST").unwrap(); + + let mut boot_sector = [0u8; 512]; + file.seek(SeekFrom::Start(layout.data_offset())).unwrap(); + file.read_exact(&mut boot_sector).unwrap(); + let fat_offset = u32::from_le_bytes(boot_sector[80..84].try_into().unwrap()); + let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap()); + let cluster_count = u32::from_le_bytes(boot_sector[92..96].try_into().unwrap()); + let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap()); + let sectors_per_cluster = 1u64 << boot_sector[109]; + let cluster_size = sectors_per_cluster * 512; + + let bitmap_size = ((cluster_count + 7) / 8) as u64; + let bitmap_clusters = bitmap_size.div_ceil(cluster_size) as u32; + assert!( + bitmap_clusters > 1, + "test volume should require a multi-cluster allocation bitmap" + ); + + let upcase_clusters = (128 * 1024u64).div_ceil(cluster_size) as u32; + assert_eq!(first_cluster_of_root, 2 + bitmap_clusters + upcase_clusters); + + let read_fat = |file: &mut std::fs::File, cluster: u32| -> u32 { + let offset = layout.data_offset() + fat_offset as u64 * 512 + cluster as u64 * 4; + let mut bytes = [0u8; 4]; + file.seek(SeekFrom::Start(offset)).unwrap(); + file.read_exact(&mut bytes).unwrap(); + u32::from_le_bytes(bytes) + }; + assert_eq!(read_fat(&mut file, 2), 3); + assert_eq!(read_fat(&mut file, 2 + bitmap_clusters - 1), 0xFFFFFFFF); + + let root_offset = layout.data_offset() + + cluster_heap_offset as u64 * 512 + + (first_cluster_of_root - 2) as u64 * cluster_size; + let mut root = vec![0u8; cluster_size as usize]; + file.seek(SeekFrom::Start(root_offset)).unwrap(); + file.read_exact(&mut root).unwrap(); + + assert_eq!(root[32], ENTRY_TYPE_BITMAP); + assert_eq!(u32::from_le_bytes(root[52..56].try_into().unwrap()), 2); + assert_eq!( + u64::from_le_bytes(root[56..64].try_into().unwrap()), + bitmap_size + ); + assert_eq!(root[64], ENTRY_TYPE_UPCASE); + assert_eq!( + u32::from_le_bytes(root[84..88].try_into().unwrap()), + 2 + bitmap_clusters + ); + } } diff --git a/libs/ventoy-img-rs/src/exfat/ops.rs b/libs/ventoy-img-rs/src/exfat/ops.rs index b05ba956..ebe79040 100644 --- a/libs/ventoy-img-rs/src/exfat/ops.rs +++ b/libs/ventoy-img-rs/src/exfat/ops.rs @@ -14,9 +14,13 @@ use std::path::Path; /// FAT entry values const FAT_ENTRY_FREE: u32 = 0x00000000; const FAT_ENTRY_END_OF_CHAIN: u32 = 0xFFFFFFFF; +const VOLUME_DIRTY_FLAG: u16 = 0x0002; +const VOLUME_FLAGS_OFFSET: u64 = 106; /// Directory entry types const ENTRY_TYPE_END: u8 = 0x00; +const ENTRY_TYPE_BITMAP: u8 = 0x81; +const ENTRY_TYPE_UPCASE: u8 = 0x82; const ENTRY_TYPE_FILE: u8 = 0x85; const ENTRY_TYPE_STREAM: u8 = 0xC0; const ENTRY_TYPE_FILE_NAME: u8 = 0xC1; @@ -137,6 +141,11 @@ pub struct ExfatFs { cluster_heap_offset: u32, cluster_count: u32, first_cluster_of_root: u32, + allocation_bitmap_first_cluster: u32, + allocation_bitmap_size: u64, + upcase_table_first_cluster: u32, + upcase_table_size: u64, + upcase_table_checksum: u32, // Performance caches /// FAT table segment cache fat_cache: FatCache, @@ -182,7 +191,7 @@ impl ExfatFs { let sectors_per_cluster = 1u32 << sectors_per_cluster_shift; let cluster_size = bytes_per_sector * sectors_per_cluster; - Ok(Self { + let mut fs = Self { file, partition_offset, bytes_per_sector, @@ -193,11 +202,151 @@ impl ExfatFs { cluster_heap_offset, cluster_count, first_cluster_of_root, + allocation_bitmap_first_cluster: 2, + allocation_bitmap_size: ((cluster_count + 7) / 8) as u64, + upcase_table_first_cluster: 0, + upcase_table_size: 0, + upcase_table_checksum: 0, // Initialize caches fat_cache: FatCache::new(), bitmap_cache: None, bitmap_dirty: false, - }) + }; + fs.discover_root_metadata_entries()?; + Ok(fs) + } + + fn discover_root_metadata_entries(&mut self) -> Result<()> { + let mut bitmap_found = false; + let mut upcase_found = false; + let root_clusters = self.read_cluster_chain(self.first_cluster_of_root)?; + + 'outer: for &cluster in &root_clusters { + let cluster_data = self.read_cluster(cluster)?; + let mut i = 0; + + while i + 32 <= cluster_data.len() { + let entry_type = cluster_data[i]; + match entry_type { + ENTRY_TYPE_END => break 'outer, + ENTRY_TYPE_BITMAP => { + let first_cluster = + u32::from_le_bytes(cluster_data[i + 20..i + 24].try_into().unwrap()); + let size = + u64::from_le_bytes(cluster_data[i + 24..i + 32].try_into().unwrap()); + if first_cluster < 2 || size == 0 { + return Err(VentoyError::FilesystemError( + "Invalid exFAT allocation bitmap entry".to_string(), + )); + } + self.allocation_bitmap_first_cluster = first_cluster; + self.allocation_bitmap_size = size; + bitmap_found = true; + } + ENTRY_TYPE_UPCASE => { + let checksum = + u32::from_le_bytes(cluster_data[i + 4..i + 8].try_into().unwrap()); + let first_cluster = + u32::from_le_bytes(cluster_data[i + 20..i + 24].try_into().unwrap()); + let size = + u64::from_le_bytes(cluster_data[i + 24..i + 32].try_into().unwrap()); + if first_cluster < 2 || size == 0 { + return Err(VentoyError::FilesystemError( + "Invalid exFAT upcase table entry".to_string(), + )); + } + self.upcase_table_first_cluster = first_cluster; + self.upcase_table_size = size; + self.upcase_table_checksum = checksum; + upcase_found = true; + } + ENTRY_TYPE_FILE => { + let secondary_count = cluster_data[i + 1] as usize; + i += (1 + secondary_count) * 32; + continue; + } + _ => {} + } + + i += 32; + } + } + + if !bitmap_found { + return Err(VentoyError::FilesystemError( + "exFAT allocation bitmap entry not found".to_string(), + )); + } + if !upcase_found { + return Err(VentoyError::FilesystemError( + "exFAT upcase table entry not found".to_string(), + )); + } + + let min_bitmap_size = ((self.cluster_count + 7) / 8) as u64; + if self.allocation_bitmap_size < min_bitmap_size { + return Err(VentoyError::FilesystemError(format!( + "exFAT allocation bitmap too small: {} bytes, need at least {}", + self.allocation_bitmap_size, min_bitmap_size + ))); + } + + Ok(()) + } + + fn volume_flags_offset(&self) -> u64 { + self.partition_offset + VOLUME_FLAGS_OFFSET + } + + fn read_volume_flags(&mut self) -> Result { + let mut bytes = [0u8; 2]; + self.file + .seek(SeekFrom::Start(self.volume_flags_offset()))?; + self.file.read_exact(&mut bytes)?; + Ok(u16::from_le_bytes(bytes)) + } + + fn is_volume_dirty(&mut self) -> Result { + Ok((self.read_volume_flags()? & VOLUME_DIRTY_FLAG) != 0) + } + + fn set_volume_dirty(&mut self, dirty: bool) -> Result<()> { + let mut flags = self.read_volume_flags()?; + if dirty { + flags |= VOLUME_DIRTY_FLAG; + } else { + flags &= !VOLUME_DIRTY_FLAG; + } + + self.file + .seek(SeekFrom::Start(self.volume_flags_offset()))?; + self.file.write_all(&flags.to_le_bytes())?; + self.file.flush()?; + Ok(()) + } + + fn begin_write_transaction(&mut self) -> Result { + let was_dirty = self.is_volume_dirty()?; + if !was_dirty { + self.set_volume_dirty(true)?; + } + Ok(was_dirty) + } + + fn finish_write_transaction(&mut self, was_dirty: bool, result: Result) -> Result { + match result { + Ok(value) => { + self.file.flush()?; + if !was_dirty { + self.set_volume_dirty(false)?; + } + Ok(value) + } + Err(err) => { + let _ = self.file.flush(); + Err(err) + } + } } // ==================== Cluster I/O Operations ==================== @@ -328,13 +477,89 @@ impl ExfatFs { // ==================== Allocation Bitmap Operations ==================== + fn read_cluster_chain_bytes(&mut self, first_cluster: u32, byte_len: u64) -> Result> { + let chain = self.read_cluster_chain(first_cluster)?; + if chain.is_empty() { + return Err(VentoyError::FilesystemError( + "Empty cluster chain".to_string(), + )); + } + + let capacity = byte_len.min(chain.len() as u64 * self.cluster_size as u64) as usize; + let mut data = Vec::with_capacity(capacity); + for &cluster in &chain { + let cluster_data = self.read_cluster(cluster)?; + data.extend_from_slice(&cluster_data); + if data.len() >= byte_len as usize { + data.truncate(byte_len as usize); + break; + } + } + + if data.len() < byte_len as usize { + return Err(VentoyError::FilesystemError(format!( + "Cluster chain for {} is shorter than expected: {} < {} bytes", + first_cluster, + data.len(), + byte_len + ))); + } + + Ok(data) + } + + fn write_cluster_chain_bytes( + &mut self, + first_cluster: u32, + byte_len: u64, + data: &[u8], + ) -> Result<()> { + if data.len() < byte_len as usize { + return Err(VentoyError::FilesystemError(format!( + "Not enough data to write cluster chain: {} < {} bytes", + data.len(), + byte_len + ))); + } + + let chain = self.read_cluster_chain(first_cluster)?; + if chain.is_empty() { + return Err(VentoyError::FilesystemError( + "Empty cluster chain".to_string(), + )); + } + + let mut bytes_written = 0usize; + let bytes_to_write = byte_len as usize; + for &cluster in &chain { + let end = (bytes_written + self.cluster_size as usize).min(bytes_to_write); + if bytes_written >= end { + break; + } + self.write_cluster(cluster, &data[bytes_written..end])?; + bytes_written = end; + } + + if bytes_written < bytes_to_write { + return Err(VentoyError::FilesystemError(format!( + "Cluster chain for {} is shorter than expected: {} < {} bytes", + first_cluster, bytes_written, bytes_to_write + ))); + } + + Ok(()) + } + /// Read the allocation bitmap (with caching) fn read_bitmap(&mut self) -> Result> { if let Some(ref bitmap) = self.bitmap_cache { return Ok(bitmap.clone()); } - let bitmap = self.read_cluster(2)?; + let bitmap = self.read_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + )?; self.bitmap_cache = Some(bitmap.clone()); Ok(bitmap) } @@ -342,7 +567,10 @@ impl ExfatFs { /// Get a mutable reference to the cached bitmap, loading if necessary fn get_bitmap_mut(&mut self) -> Result<&mut Vec> { if self.bitmap_cache.is_none() { - let bitmap = self.read_cluster(2)?; + let bitmap = self.read_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + )?; self.bitmap_cache = Some(bitmap); } Ok(self.bitmap_cache.as_mut().unwrap()) @@ -351,7 +579,11 @@ impl ExfatFs { /// Write the allocation bitmap (with cache management) #[allow(dead_code)] fn write_bitmap(&mut self, bitmap: &[u8]) -> Result<()> { - self.write_cluster(2, bitmap)?; + self.write_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + bitmap, + )?; self.bitmap_cache = Some(bitmap.to_vec()); self.bitmap_dirty = false; Ok(()) @@ -362,7 +594,11 @@ impl ExfatFs { fn flush_bitmap(&mut self) -> Result<()> { if self.bitmap_dirty { if let Some(bitmap) = self.bitmap_cache.take() { - self.write_cluster(2, &bitmap)?; + self.write_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + &bitmap, + )?; self.bitmap_cache = Some(bitmap); self.bitmap_dirty = false; } @@ -400,10 +636,7 @@ impl ExfatFs { let bitmap = self.read_bitmap()?; let mut free_clusters = Vec::with_capacity(count); - // Start from cluster after root directory - // (root is at first_cluster_of_root, which varies based on cluster size) - let start_cluster = self.first_cluster_of_root + 1; - for cluster in start_cluster..self.cluster_count + 2 { + for cluster in 2..self.cluster_count + 2 { if !Self::is_cluster_allocated(&bitmap, cluster) { free_clusters.push(cluster); if free_clusters.len() >= count { @@ -482,7 +715,11 @@ impl ExfatFs { /// Flush bitmap to disk immediately fn flush_bitmap_now(&mut self) -> Result<()> { if let Some(bitmap) = self.bitmap_cache.take() { - self.write_cluster(2, &bitmap)?; + self.write_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + &bitmap, + )?; self.bitmap_cache = Some(bitmap); } Ok(()) @@ -823,10 +1060,12 @@ impl ExfatFs { // Exclude the newly added cluster let mut cluster_data = self.read_cluster(cluster)?; - // Scan for END markers and replace them with 0xFF (invalid entry, will be skipped) + // Scan for END markers and replace them with inactive entries. Leaving an + // END marker before later directory clusters makes hosts stop early; using + // an in-use invalid type can make strict hosts treat the directory as bad. for i in (0..cluster_data.len()).step_by(32) { if cluster_data[i] == ENTRY_TYPE_END { - cluster_data[i] = 0xFF; // Invalid entry type + cluster_data[i] = ENTRY_TYPE_DELETED_FILE; } } @@ -905,8 +1144,16 @@ impl ExfatFs { let empty_cluster = vec![0u8; self.cluster_size as usize]; self.write_cluster(dir_cluster, &empty_cluster)?; - // Create directory entry in parent - self.create_entry_in_directory(parent_cluster, name, dir_cluster, 0, true)?; + // exFAT directories have allocated data. A zero-length directory stream + // makes Windows treat the directory as corrupt even when the cluster + // chain and child entries are otherwise valid. + self.create_entry_in_directory( + parent_cluster, + name, + dir_cluster, + self.cluster_size as u64, + true, + )?; self.file.flush()?; Ok(dir_cluster) @@ -1120,46 +1367,54 @@ impl ExfatFs { /// Write a file to the filesystem (root directory, no overwrite) pub fn write_file(&mut self, name: &str, data: &[u8]) -> Result<()> { - // Validate filename - if name.is_empty() || name.len() > 255 { - return Err(VentoyError::FilesystemError( - "Invalid filename length".to_string(), - )); - } + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + // Validate filename + if name.is_empty() || name.len() > 255 { + return Err(VentoyError::FilesystemError( + "Invalid filename length".to_string(), + )); + } - // Check if file already exists - if self.find_file_entry(name)?.is_some() { - return Err(VentoyError::FilesystemError(format!( - "File '{}' already exists", - name - ))); - } - - self.write_file_data_and_entry(self.first_cluster_of_root, name, data) - } - - /// Write a file to the filesystem with overwrite option - pub fn write_file_overwrite(&mut self, name: &str, data: &[u8], overwrite: bool) -> Result<()> { - // Validate filename - if name.is_empty() || name.len() > 255 { - return Err(VentoyError::FilesystemError( - "Invalid filename length".to_string(), - )); - } - - // Check if file already exists - if self.find_file_entry(name)?.is_some() { - if overwrite { - self.delete_file(name)?; - } else { + // Check if file already exists + if self.find_file_entry(name)?.is_some() { return Err(VentoyError::FilesystemError(format!( "File '{}' already exists", name ))); } - } - self.write_file_data_and_entry(self.first_cluster_of_root, name, data) + self.write_file_data_and_entry(self.first_cluster_of_root, name, data) + })(); + self.finish_write_transaction(was_dirty, result) + } + + /// Write a file to the filesystem with overwrite option + pub fn write_file_overwrite(&mut self, name: &str, data: &[u8], overwrite: bool) -> Result<()> { + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + // Validate filename + if name.is_empty() || name.len() > 255 { + return Err(VentoyError::FilesystemError( + "Invalid filename length".to_string(), + )); + } + + // Check if file already exists + if self.find_file_entry(name)?.is_some() { + if overwrite { + self.delete_file(name)?; + } else { + return Err(VentoyError::FilesystemError(format!( + "File '{}' already exists", + name + ))); + } + } + + self.write_file_data_and_entry(self.first_cluster_of_root, name, data) + })(); + self.finish_write_transaction(was_dirty, result) } /// Write a file to a specific path @@ -1174,38 +1429,42 @@ impl ExfatFs { create_parents: bool, overwrite: bool, ) -> Result<()> { - let resolved = self.resolve_path(path, create_parents)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, create_parents)?; - // Validate filename - if resolved.name.is_empty() || resolved.name.len() > 255 { - return Err(VentoyError::FilesystemError( - "Invalid filename length".to_string(), - )); - } - - // Handle existing file - if let Some(location) = resolved.location { - if location.is_directory { - return Err(VentoyError::FilesystemError(format!( - "'{}' is a directory", - path - ))); + // Validate filename + if resolved.name.is_empty() || resolved.name.len() > 255 { + return Err(VentoyError::FilesystemError( + "Invalid filename length".to_string(), + )); } - if overwrite { - // Delete existing file - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; + + // Handle existing file + if let Some(location) = resolved.location { + if location.is_directory { + return Err(VentoyError::FilesystemError(format!( + "'{}' is a directory", + path + ))); + } + if overwrite { + // Delete existing file + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } + self.delete_file_entry(&location)?; + } else { + return Err(VentoyError::FilesystemError(format!( + "File '{}' already exists", + path + ))); } - self.delete_file_entry(&location)?; - } else { - return Err(VentoyError::FilesystemError(format!( - "File '{}' already exists", - path - ))); } - } - self.write_file_data_and_entry(resolved.parent_cluster, &resolved.name, data) + self.write_file_data_and_entry(resolved.parent_cluster, &resolved.name, data) + })(); + self.finish_write_transaction(was_dirty, result) } /// Read file data from a location @@ -1276,99 +1535,115 @@ impl ExfatFs { /// Delete a file from the filesystem (root directory) pub fn delete_file(&mut self, name: &str) -> Result<()> { - let location = self - .find_file_entry(name)? - .ok_or_else(|| VentoyError::FilesystemError(format!("File '{}' not found", name)))?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let location = self.find_file_entry(name)?.ok_or_else(|| { + VentoyError::FilesystemError(format!("File '{}' not found", name)) + })?; - // Free cluster chain - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; - } + // Free cluster chain + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } - // Delete directory entry - self.delete_file_entry(&location)?; + // Delete directory entry + self.delete_file_entry(&location)?; - self.file.flush()?; - Ok(()) + self.file.flush()?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } /// Delete a file or directory at a specific path pub fn delete_path(&mut self, path: &str) -> Result<()> { - let resolved = self.resolve_path(path, false)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, false)?; - let location = resolved - .location - .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; + let location = resolved + .location + .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; - // If it's a directory, check if it's empty - if location.is_directory { - let contents = self.list_files_in_directory(location.first_cluster, "")?; - if !contents.is_empty() { - return Err(VentoyError::FilesystemError(format!( - "Directory '{}' is not empty", - path - ))); + // If it's a directory, check if it's empty + if location.is_directory { + let contents = self.list_files_in_directory(location.first_cluster, "")?; + if !contents.is_empty() { + return Err(VentoyError::FilesystemError(format!( + "Directory '{}' is not empty", + path + ))); + } } - } - // Free cluster chain - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; - } + // Free cluster chain + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } - // Delete directory entry - self.delete_file_entry(&location)?; + // Delete directory entry + self.delete_file_entry(&location)?; - self.file.flush()?; - Ok(()) + self.file.flush()?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } /// Delete a directory and all its contents recursively pub fn delete_recursive(&mut self, path: &str) -> Result<()> { - let resolved = self.resolve_path(path, false)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, false)?; - let location = resolved - .location - .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; + let location = resolved + .location + .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; - if location.is_directory { - // Get all contents and delete them first - let contents = self.list_files_in_directory(location.first_cluster, "")?; - for item in contents { - let item_path = if path.ends_with('/') { - format!("{}{}", path, item.name) - } else { - format!("{}/{}", path, item.name) - }; - self.delete_recursive(&item_path)?; + if location.is_directory { + // Get all contents and delete them first + let contents = self.list_files_in_directory(location.first_cluster, "")?; + for item in contents { + let item_path = if path.ends_with('/') { + format!("{}{}", path, item.name) + } else { + format!("{}/{}", path, item.name) + }; + self.delete_recursive(&item_path)?; + } } - } - // Now delete the item itself - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; - } - self.delete_file_entry(&location)?; + // Now delete the item itself + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } + self.delete_file_entry(&location)?; - self.file.flush()?; - Ok(()) + self.file.flush()?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } /// Create a directory at a specific path /// /// If create_parents is true, creates all intermediate directories (mkdir -p behavior) pub fn create_directory(&mut self, path: &str, create_parents: bool) -> Result<()> { - let resolved = self.resolve_path(path, create_parents)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, create_parents)?; - if resolved.location.is_some() { - return Err(VentoyError::FilesystemError(format!( - "'{}' already exists", - path - ))); - } + if resolved.location.is_some() { + return Err(VentoyError::FilesystemError(format!( + "'{}' already exists", + path + ))); + } - self.create_directory_in(resolved.parent_cluster, &resolved.name)?; - Ok(()) + self.create_directory_in(resolved.parent_cluster, &resolved.name)?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } } @@ -1805,9 +2080,13 @@ impl ExfatFs { reader: &mut R, size: u64, ) -> Result<()> { - let mut writer = ExfatFileWriter::create(self, name, size)?; - Self::do_stream_write(&mut writer, reader)?; - writer.finish() + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let mut writer = ExfatFileWriter::create(self, name, size)?; + Self::do_stream_write(&mut writer, reader)?; + writer.finish() + })(); + self.finish_write_transaction(was_dirty, result) } /// Write a file from a reader with overwrite option @@ -1818,9 +2097,13 @@ impl ExfatFs { size: u64, overwrite: bool, ) -> Result<()> { - let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?; - Self::do_stream_write(&mut writer, reader)?; - writer.finish() + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?; + Self::do_stream_write(&mut writer, reader)?; + writer.finish() + })(); + self.finish_write_transaction(was_dirty, result) } /// Write a file from a reader to a specific path @@ -1835,10 +2118,14 @@ impl ExfatFs { create_parents: bool, overwrite: bool, ) -> Result<()> { - let mut writer = - ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?; - Self::do_stream_write(&mut writer, reader)?; - writer.finish() + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let mut writer = + ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?; + Self::do_stream_write(&mut writer, reader)?; + writer.finish() + })(); + self.finish_write_transaction(was_dirty, result) } /// Internal: Stream write from reader to writer @@ -1861,9 +2148,18 @@ impl ExfatFs { mod tests { use super::*; use crate::partition::PartitionLayout; - use std::io::Cursor; + use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use tempfile::NamedTempFile; + fn cluster_offset( + partition_offset: u64, + cluster_heap_offset: u32, + cluster_size: u64, + cluster: u32, + ) -> u64 { + partition_offset + cluster_heap_offset as u64 * 512 + (cluster - 2) as u64 * cluster_size + } + /// Test directory extension when filling up a directory cluster #[test] fn test_directory_extension() -> Result<()> { @@ -2055,6 +2351,168 @@ mod tests { Ok(()) } + #[test] + fn test_write_transactions_clear_volume_dirty_on_success() -> Result<()> { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + crate::exfat::format::format_exfat( + &mut file, + layout.data_offset(), + layout.data_size(), + "TEST", + ) + .unwrap(); + drop(file); + + let mut fs = ExfatFs::open(path, &layout).unwrap(); + assert!(!fs.is_volume_dirty()?); + + let data = b"uploaded from web"; + let mut cursor = Cursor::new(data); + fs.write_file_from_reader_path( + "/uploads/test.txt", + &mut cursor, + data.len() as u64, + true, + true, + )?; + assert!(!fs.is_volume_dirty()?); + + fs.delete_recursive("/uploads")?; + assert!(!fs.is_volume_dirty()?); + + Ok(()) + } + + #[test] + fn test_created_directory_has_allocated_data_length() -> Result<()> { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + crate::exfat::format::format_exfat( + &mut file, + layout.data_offset(), + layout.data_size(), + "TEST", + ) + .unwrap(); + drop(file); + + let mut fs = ExfatFs::open(path, &layout).unwrap(); + fs.create_directory("/uploads", true)?; + + let location = fs + .find_entry_in_directory(fs.first_cluster_of_root, "uploads")? + .expect("created directory entry should exist"); + + assert!(location.is_directory); + assert!(location.first_cluster >= 2); + assert_eq!(location.data_length, fs.cluster_size as u64); + + Ok(()) + } + + #[test] + fn test_open_uses_bitmap_location_from_root_directory() -> Result<()> { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + crate::exfat::format::format_exfat( + &mut file, + layout.data_offset(), + layout.data_size(), + "TEST", + ) + .unwrap(); + + let mut boot_sector = [0u8; 512]; + file.seek(SeekFrom::Start(layout.data_offset())).unwrap(); + file.read_exact(&mut boot_sector).unwrap(); + let fat_offset = u32::from_le_bytes(boot_sector[80..84].try_into().unwrap()); + let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap()); + let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap()); + let cluster_size = (1u64 << boot_sector[109]) * 512; + + let relocated_bitmap_cluster = first_cluster_of_root + 1; + let mut bitmap = vec![0u8; cluster_size as usize]; + file.seek(SeekFrom::Start(cluster_offset( + layout.data_offset(), + cluster_heap_offset, + cluster_size, + 2, + ))) + .unwrap(); + file.read_exact(&mut bitmap).unwrap(); + let relocated_index = (relocated_bitmap_cluster - 2) as usize; + bitmap[relocated_index / 8] |= 1 << (relocated_index % 8); + + file.seek(SeekFrom::Start(cluster_offset( + layout.data_offset(), + cluster_heap_offset, + cluster_size, + relocated_bitmap_cluster, + ))) + .unwrap(); + file.write_all(&bitmap).unwrap(); + + let relocated_fat_offset = + layout.data_offset() + fat_offset as u64 * 512 + relocated_bitmap_cluster as u64 * 4; + file.seek(SeekFrom::Start(relocated_fat_offset)).unwrap(); + file.write_all(&FAT_ENTRY_END_OF_CHAIN.to_le_bytes()) + .unwrap(); + + let root_offset = cluster_offset( + layout.data_offset(), + cluster_heap_offset, + cluster_size, + first_cluster_of_root, + ); + file.seek(SeekFrom::Start(root_offset + 32 + 20)).unwrap(); + file.write_all(&relocated_bitmap_cluster.to_le_bytes()) + .unwrap(); + file.flush().unwrap(); + drop(file); + + let mut fs = ExfatFs::open(path, &layout).unwrap(); + assert_eq!(fs.allocation_bitmap_first_cluster, relocated_bitmap_cluster); + assert!(ExfatFs::is_cluster_allocated( + &fs.read_bitmap()?, + relocated_bitmap_cluster + )); + + let data = b"uses relocated bitmap"; + let mut cursor = Cursor::new(data); + fs.write_file_from_reader("relocated.txt", &mut cursor, data.len() as u64)?; + assert_eq!(fs.read_file("relocated.txt")?, &data[..]); + + Ok(()) + } + /// Test Unicode file names (CJK, Cyrillic, emoji, etc.) #[test] fn test_unicode_filenames() -> Result<()> { diff --git a/src/msd/mod.rs b/src/msd/mod.rs index fc162890..a5656b4b 100644 --- a/src/msd/mod.rs +++ b/src/msd/mod.rs @@ -11,6 +11,6 @@ pub use types::{ DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, MsdConnectRequest, MsdMode, MsdState, }; -pub use ventoy_drive::VentoyDrive; +pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; pub use crate::otg::{MsdFunction, MsdLunConfig}; diff --git a/src/msd/types.rs b/src/msd/types.rs index 903ce326..296fab35 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -120,7 +120,7 @@ pub struct DriveInitRequest { } fn default_drive_size() -> u32 { - 16 * 1024 + 64 } #[derive(Debug, Clone, Deserialize)] diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index e3d3dc44..8c9748c7 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -10,9 +10,7 @@ use crate::error::{AppError, Result}; const STREAM_CHUNK_SIZE: usize = 64 * 1024; -const MIN_DRIVE_SIZE_MB: u32 = 1024; - -const MAX_DRIVE_SIZE_MB: u32 = 128 * 1024; +pub const MIN_DRIVE_SIZE_MB: u32 = 64; const DEFAULT_LABEL: &str = "ONE-KVM"; @@ -37,8 +35,20 @@ impl VentoyDrive { &self.path } + /// Returns just the raw file size without attempting to parse the filesystem. + /// Used as a fallback when the image has been reformatted to an unsupported + /// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open. + pub fn raw_size(&self) -> Option { + std::fs::metadata(&self.path).ok().map(|m| m.len()) + } + pub async fn init(&self, size_mb: u32) -> Result { - let size_mb = size_mb.clamp(MIN_DRIVE_SIZE_MB, MAX_DRIVE_SIZE_MB); + if size_mb < MIN_DRIVE_SIZE_MB { + return Err(AppError::BadRequest(format!( + "Drive size must be at least {} MB", + MIN_DRIVE_SIZE_MB + ))); + } let size_str = format!("{}M", size_mb); let path = self.path.clone(); let _lock = self.lock.write().await; diff --git a/src/otg/msd.rs b/src/otg/msd.rs index a762d74b..89be3617 100644 --- a/src/otg/msd.rs +++ b/src/otg/msd.rs @@ -44,7 +44,11 @@ impl MsdLunConfig { cdrom: false, ro: read_only, removable: true, - nofua: true, + // nofua=false: enforce Force Unit Access so the USB host (e.g. Windows) + // gets proper write-completion acknowledgements when writing to the + // virtual .img file. nofua=true can cause write-verify failures + // that manifest as Windows error 0x80070570 on writable drives. + nofua: false, } } } diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index 430a0b23..9eaebe01 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -2,7 +2,7 @@ use super::*; use crate::msd::{ DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, - ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, + ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, MIN_DRIVE_SIZE_MB, }; #[cfg(unix)] use axum::body::Body; @@ -15,6 +15,63 @@ use axum::response::Response; #[cfg(unix)] use std::collections::HashMap; +#[cfg(unix)] +const MIB: u64 = 1024 * 1024; + +/// Return an error if the virtual drive is currently connected to the USB host. +/// When connected, the USB host (e.g. Windows) has the filesystem mounted. +/// Any concurrent access from the server side (via VentoyImage::open) would +/// cause double-access corruption, manifesting as Windows error 0x80070570. +#[cfg(unix)] +async fn assert_drive_not_connected(state: &Arc) -> Result<()> { + let msd_guard = state.msd.read().await; + if let Some(controller) = msd_guard.as_ref() { + let msd_state = controller.state().await; + if msd_state.connected && msd_state.mode == crate::msd::types::MsdMode::Drive { + return Err(AppError::BadRequest( + "Virtual drive is connected to the USB host; disconnect it before modifying files" + .to_string(), + )); + } + } + Ok(()) +} + +#[cfg(unix)] +fn validate_drive_init_size(size_mb: u32, available_bytes: u64) -> Result<()> { + let requested_bytes = size_mb as u64 * MIB; + if size_mb < MIN_DRIVE_SIZE_MB { + return Err(AppError::BadRequest(format!( + "Virtual drive size must be at least {} MB", + MIN_DRIVE_SIZE_MB + ))); + } + if requested_bytes > available_bytes { + return Err(AppError::BadRequest(format!( + "Virtual drive size cannot exceed available space on the MSD directory filesystem (available {} MB, requested {} MB)", + available_bytes / MIB, + size_mb + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn is_unsupported_drive_filesystem(error: &str) -> bool { + error.contains("Filesystem error") + || error.contains("Image error") + || error.contains("Partition error") +} + +#[cfg(unix)] +fn unsupported_drive_filesystem_error(error: &str) -> AppError { + tracing::warn!( + error = %error, + "Virtual drive filesystem is not supported" + ); + AppError::BadRequest("Unsupported drive filesystem".to_string()) +} + /// MSD status response #[cfg(unix)] #[derive(Serialize)] @@ -226,11 +283,24 @@ pub async fn msd_drive_info(State(state): State>) -> Result Ok(Json(info)), + Err(e) => { + let msg = e.to_string(); + // Detect filesystem-level failures (unrecognized format, bad partition table, etc.) + // These mean the drive FILE exists but was formatted to an unsupported type + // (e.g. the controlled machine reformatted it as NTFS/exFAT). + // Return 400 so the frontend can distinguish this from 404 (file missing). + if is_unsupported_drive_filesystem(&msg) { + return Err(unsupported_drive_filesystem_error(&msg)); + } + Err(e) + } + } } /// Initialize Ventoy drive @@ -240,6 +310,16 @@ pub async fn msd_drive_init( Json(req): Json, ) -> Result> { let config = state.config.get(); + let msd_dir = config.msd.msd_dir_path(); + + let disk_space = get_disk_space(&msd_dir).map_err(|e| { + AppError::BadRequest(format!( + "Failed to read available space for the MSD directory filesystem: {}", + e + )) + })?; + validate_drive_init_size(req.size_mb, disk_space.available)?; + let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -283,12 +363,24 @@ pub async fn msd_drive_files( State(state): State>, Query(params): Query>, ) -> Result>> { + // Block when connected: concurrent access corrupts the filesystem + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); let dir_path = params.get("path").map(|s| s.as_str()).unwrap_or("/"); - let files = drive.list_files(dir_path).await?; + let files = drive.list_files(dir_path).await.map_err(|e| { + // Provide a friendly message when the filesystem format is unrecognized + // (e.g. user formatted it as NTFS/exFAT from the controlled machine) + let msg = e.to_string(); + if is_unsupported_drive_filesystem(&msg) { + unsupported_drive_filesystem_error(&msg) + } else { + e + } + })?; Ok(Json(files)) } @@ -299,6 +391,10 @@ pub async fn msd_drive_upload( Query(params): Query>, mut multipart: Multipart, ) -> Result> { + // Block when connected: writing to image while USB host has it mounted + // causes filesystem corruption (Windows error 0x80070570) + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -345,6 +441,10 @@ pub async fn msd_drive_download( State(state): State>, AxumPath(file_path): AxumPath, ) -> Result { + // Block when connected: concurrent read from server side can cause + // filesystem inconsistency while USB host has the image mounted + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -380,6 +480,10 @@ pub async fn msd_drive_file_delete( State(state): State>, AxumPath(file_path): AxumPath, ) -> Result> { + // Block when connected: deleting from image while USB host has it mounted + // causes filesystem corruption + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -398,6 +502,10 @@ pub async fn msd_drive_mkdir( State(state): State>, AxumPath(dir_path): AxumPath, ) -> Result> { + // Block when connected: modifying image while USB host has it mounted + // causes filesystem corruption + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -409,3 +517,38 @@ pub async fn msd_drive_mkdir( message: Some(format!("Directory created: {}", dir_path)), })) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn validate_drive_init_size_accepts_64mb() { + validate_drive_init_size(MIN_DRIVE_SIZE_MB, MIN_DRIVE_SIZE_MB as u64 * MIB).unwrap(); + } + + #[test] + fn validate_drive_init_size_rejects_below_64mb() { + let err = validate_drive_init_size(MIN_DRIVE_SIZE_MB - 1, 1024 * MIB).unwrap_err(); + assert!(err.to_string().contains("at least 64 MB")); + } + + #[test] + fn validate_drive_init_size_rejects_available_space_overflow() { + let err = validate_drive_init_size(65, 64 * MIB).unwrap_err(); + assert!(err.to_string().contains("cannot exceed available space")); + } + + #[test] + fn detects_unsupported_drive_filesystem_errors() { + assert!(is_unsupported_drive_filesystem( + "Internal error: Filesystem error: Invalid exFAT signature" + )); + assert!(is_unsupported_drive_filesystem( + "Internal error: Partition error: invalid partition table" + )); + assert!(!is_unsupported_drive_filesystem( + "IO error: permission denied" + )); + } +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 7b371ab6..84c467cd 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -613,19 +613,32 @@ export const msdApi = { used: number free: number initialized: boolean - }>('/msd/drive'), + }>('/msd/drive', {}, { toastOnError: false }), initDrive: (sizeMb?: number) => - request<{ path: string; size_mb: number }>('/msd/drive/init', { - method: 'POST', - body: JSON.stringify({ size_mb: sizeMb }), - }), + request<{ + size: number + used: number + free: number + initialized: boolean + }>( + '/msd/drive/init', + { + method: 'POST', + body: JSON.stringify({ size_mb: sizeMb }), + }, + { toastOnError: false }, + ), deleteDrive: () => request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }), listDriveFiles: (path = '/') => - request(`/msd/drive/files?path=${encodeURIComponent(path)}`), + request( + `/msd/drive/files?path=${encodeURIComponent(path)}`, + {}, + { toastOnError: false }, + ), uploadDriveFile: async (file: File, targetPath = '/', onProgress?: (progress: number) => void) => { const formData = new FormData() diff --git a/web/src/components/MsdDialog.vue b/web/src/components/MsdDialog.vue index 78a8ac2d..a334b4bf 100644 --- a/web/src/components/MsdDialog.vue +++ b/web/src/components/MsdDialog.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' import { useSystemStore } from '@/stores/system' import { msdApi, type MsdImage, type DriveFile } from '@/api' +import { ApiError } from '@/api/request' import { useWebSocket } from '@/composables/useWebSocket' import { Dialog, @@ -25,8 +26,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Progress } from '@/components/ui/progress' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { Slider } from '@/components/ui/slider' import { Separator } from '@/components/ui/separator' import { HardDrive, @@ -45,6 +46,7 @@ import { Globe, X, AlertCircle, + Info, } from 'lucide-vue-next' import HelpTooltip from '@/components/HelpTooltip.vue' @@ -68,7 +70,8 @@ const uploadProgress = ref(0) const uploading = ref(false) const mountMode = ref<'cdrom' | 'flash'>('flash') -const accessMode = ref<'readonly' | 'readwrite'>('readonly') +// Default to readwrite for flash mode; cdrom forces readonly anyway +const accessMode = ref<'readonly' | 'readwrite'>('readwrite') const cdromMode = computed(() => mountMode.value === 'cdrom') const readOnly = computed(() => accessMode.value === 'readonly') @@ -84,6 +87,7 @@ const driveInfo = ref<{ size: number; used: number; free: number; initialized: b const driveInitialized = ref(false) const uploadingFile = ref(false) const fileUploadProgress = ref(0) +const driveError = ref(null) // filesystem error (e.g. unsupported format) const showDeleteDialog = ref(false) const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null) @@ -92,8 +96,42 @@ const newFolderName = ref('') const showDriveInitDialog = ref(false) const showDeleteDriveDialog = ref(false) -const selectedDriveSize = ref(256) // Default 256MB -const customDriveSize = ref(undefined) + +const MIN_DRIVE_SIZE_MB = 64 +const DEFAULT_DRIVE_SIZE_MB = 256 +const BYTES_PER_MB = 1024 * 1024 + +const driveSizeMB = ref(DEFAULT_DRIVE_SIZE_MB) +const availableDriveSizeMB = computed(() => { + if (!systemStore.diskSpace) return null + return Math.floor(systemStore.diskSpace.available / BYTES_PER_MB) +}) +const canInitializeDrive = computed(() => { + return availableDriveSizeMB.value !== null && availableDriveSizeMB.value >= MIN_DRIVE_SIZE_MB +}) +const sliderMaxDriveSizeMB = computed(() => { + return Math.max(MIN_DRIVE_SIZE_MB, availableDriveSizeMB.value ?? MIN_DRIVE_SIZE_MB) +}) + +function normalizeDriveSize(value: number) { + const max = availableDriveSizeMB.value + if (max === null || max < MIN_DRIVE_SIZE_MB) return MIN_DRIVE_SIZE_MB + const next = Number.isFinite(value) ? Math.trunc(value) : DEFAULT_DRIVE_SIZE_MB + return Math.max(MIN_DRIVE_SIZE_MB, Math.min(next, max)) +} + +function updateDriveSizeFromSlider(value: number[] | undefined) { + driveSizeMB.value = normalizeDriveSize(value?.[0] ?? MIN_DRIVE_SIZE_MB) +} + +const finalDriveSize = computed(() => { + return normalizeDriveSize(driveSizeMB.value) +}) + +watch(availableDriveSizeMB, () => { + driveSizeMB.value = finalDriveSize.value +}) + const initializingDrive = ref(false) const deletingDrive = ref(false) @@ -114,15 +152,10 @@ const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024 const msdConnected = computed(() => systemStore.msd?.connected ?? false) const msdMode = computed(() => systemStore.msd?.mode ?? 'none') +// Drive is currently mounted on the target machine via USB — file ops are blocked +const driveConnectedToTarget = computed(() => msdConnected.value && msdMode.value === 'drive') + -const connectedImageName = computed(() => { - if (!msdConnected.value) return null - if (msdMode.value === 'drive') return t('msd.drive') - const imageId = systemStore.msd?.imageId - if (!imageId) return null - const image = images.value.find(i => i.id === imageId) - return image?.name ?? null -}) const operationInProgress = computed(() => { return connecting.value || @@ -155,7 +188,21 @@ watch(() => props.open, async (isOpen) => { } }) +watch(driveConnectedToTarget, async (isConnected, wasConnected) => { + if (!wasConnected || isConnected || !props.open) return + await refreshDriveBrowser() +}) + +async function refreshDiskSpace() { + try { + await systemStore.fetchSystemInfo() + } catch (e) { + console.error('Failed to refresh disk space:', e) + } +} + async function loadData() { + await refreshDiskSpace() await systemStore.fetchMsdState() await loadImages() await loadDriveInfo() @@ -188,6 +235,7 @@ async function handleImageUpload(e: Event) { uploadProgress.value = progress }) images.value.push(image) + await refreshDiskSpace() } catch (e) { console.error('Failed to upload image:', e) } finally { @@ -255,17 +303,27 @@ function confirmDelete(type: 'image' | 'file', id: string, name: string) { async function executeDelete() { if (!deleteTarget.value || deleting.value) return + // Guard: never delete drive files while connected to target + if (deleteTarget.value.type === 'file' && driveConnectedToTarget.value) { + toast.error(t('msd.driveConnectedBlocked')) + showDeleteDialog.value = false + deleteTarget.value = null + return + } + deleting.value = true try { if (deleteTarget.value.type === 'image') { await msdApi.deleteImage(deleteTarget.value.id) images.value = images.value.filter(i => i.id !== deleteTarget.value!.id) + await refreshDiskSpace() } else { await msdApi.deleteDriveFile(deleteTarget.value.id) await loadDriveFiles() } - } catch (e) { + } catch (e: any) { console.error('Failed to delete:', e) + toast.error(t('common.error'), { description: e?.message }) } finally { showDeleteDialog.value = false deleteTarget.value = null @@ -274,42 +332,57 @@ async function executeDelete() { } async function loadDriveInfo() { + driveError.value = null try { driveInfo.value = await msdApi.driveInfo() driveInitialized.value = true - } catch { - driveInitialized.value = false + } catch (e: any) { + if (e instanceof ApiError) { + if (e.status === 404) { + // Drive image file does not exist — truly not initialized + driveInitialized.value = false + driveInfo.value = null + } else { + // Drive file exists but unreadable (e.g. wrong filesystem format after + // being reformatted by the controlled machine). Show the drive tab with + // an error banner instead of the misleading "Initialize Drive" button. + driveInitialized.value = true + driveError.value = e.message + driveInfo.value = null + } + } else { + driveInitialized.value = false + driveInfo.value = null + } + console.error('Failed to load drive info:', e) } } -const driveSizeOptions = computed(() => [ - { value: 64, label: '64 MB' }, - { value: 128, label: '128 MB' }, - { value: 256, label: `256 MB (${t('common.recommended')})`, recommended: true }, - { value: 512, label: '512 MB' }, - { value: 1024, label: '1 GB' }, - { value: 2048, label: '2 GB' }, - { value: 4096, label: '4 GB' }, - { value: 8192, label: '8 GB' }, -]) - -const finalDriveSize = computed(() => { - return customDriveSize.value || selectedDriveSize.value -}) - -function initializeDrive() { +async function initializeDrive() { + await refreshDiskSpace() + driveSizeMB.value = finalDriveSize.value showDriveInitDialog.value = true } async function createDrive() { + await refreshDiskSpace() + driveSizeMB.value = finalDriveSize.value + if (!canInitializeDrive.value) { + toast.error(t('msd.driveSpaceUnavailable')) + return + } + initializingDrive.value = true try { - await msdApi.initDrive(finalDriveSize.value) + const sizeMb = finalDriveSize.value + await msdApi.initDrive(sizeMb) await loadDriveInfo() await loadDriveFiles() + await refreshDiskSpace() showDriveInitDialog.value = false } catch (e) { console.error('Failed to initialize drive:', e) + toast.error(t('msd.driveCreateFailed')) } finally { initializingDrive.value = false } @@ -324,6 +397,7 @@ async function deleteDrive() { driveFiles.value = [] currentPath.value = '/' showDeleteDriveDialog.value = false + await refreshDiskSpace() } catch (e) { console.error('Failed to delete drive:', e) } finally { @@ -332,16 +406,36 @@ async function deleteDrive() { } async function loadDriveFiles() { + // Do not read image file while it is mounted on the target machine: + // concurrent access causes filesystem corruption (Windows error 0x80070570) + if (driveConnectedToTarget.value) { + driveFiles.value = [] + return + } loadingDrive.value = true + driveError.value = null try { driveFiles.value = await msdApi.listDriveFiles(currentPath.value) - } catch (e) { + } catch (e: any) { console.error('Failed to load drive files:', e) + // Surface the error — could be unsupported filesystem format + driveError.value = e?.message ?? String(e) + driveFiles.value = [] } finally { loadingDrive.value = false } } +async function refreshDriveBrowser() { + await loadDriveInfo() + if (driveInitialized.value) { + await loadDriveFiles() + } else { + driveFiles.value = [] + } + await refreshDiskSpace() +} + function navigateTo(path: string) { currentPath.value = path loadDriveFiles() @@ -359,6 +453,13 @@ async function handleFileUpload(e: Event) { const file = input.files?.[0] if (!file) return + // Guard: never upload while drive is connected to target + if (driveConnectedToTarget.value) { + toast.error(t('msd.driveConnectedBlocked')) + input.value = '' + return + } + uploadingFile.value = true fileUploadProgress.value = 0 @@ -367,8 +468,9 @@ async function handleFileUpload(e: Event) { fileUploadProgress.value = progress }) await loadDriveFiles() - } catch (e) { + } catch (e: any) { console.error('Failed to upload file:', e) + toast.error(t('msd.uploadFailed'), { description: e?.message }) } finally { uploadingFile.value = false fileUploadProgress.value = 0 @@ -379,14 +481,23 @@ async function handleFileUpload(e: Event) { async function createFolder() { if (!newFolderName.value.trim()) return + // Guard: never create folders while drive is connected to target + if (driveConnectedToTarget.value) { + toast.error(t('msd.driveConnectedBlocked')) + showNewFolderDialog.value = false + newFolderName.value = '' + return + } + try { const path = currentPath.value === '/' ? '/' + newFolderName.value : currentPath.value + '/' + newFolderName.value await msdApi.createDirectory(path) await loadDriveFiles() - } catch (e) { + } catch (e: any) { console.error('Failed to create folder:', e) + toast.error(t('common.error'), { description: e?.message }) } finally { showNewFolderDialog.value = false newFolderName.value = '' @@ -494,16 +605,8 @@ onUnmounted(() => { {{ msdConnected ? t('common.connected') : t('common.disconnected') }} -