diff --git a/fatxlib/build.rs b/fatxlib/build.rs index 0e02626..efe8282 100644 --- a/fatxlib/build.rs +++ b/fatxlib/build.rs @@ -38,6 +38,14 @@ fn main() { let xbox360 = read_xbox360("data/xbox360_titles.json"); let xbox_og = read_xbox_originals("data/xbox_originals.tsv"); + assert!( + xbox360.len() > 4000, + "xbox360 title catalog unexpectedly small" + ); + assert!( + xbox_og.len() > 800, + "xbox original title catalog unexpectedly small" + ); let (merged, conflicts) = merge(&xbox360, &xbox_og); write_merged(&out_dir.join("titles.rs"), &merged); @@ -197,11 +205,14 @@ fn strip_ws(s: &str) -> String { fn write_merged(dst: &PathBuf, map: &BTreeMap) { let mut builder = phf_codegen::Map::::new(); - let literals: Vec = map - .values() - .map(|(name, src)| format!("TitleInfo {{ name: {name:?}, source: {src} }}")) - .collect(); - for ((id, _), lit) in map.iter().zip(literals.iter()) { + let mut entries = Vec::with_capacity(map.len()); + for (id, (name, src)) in map { + entries.push(( + *id, + format!("TitleInfo {{ name: {name:?}, source: {src} }}"), + )); + } + for (id, lit) in &entries { builder.entry(*id, lit); } diff --git a/fatxlib/src/executable/mod.rs b/fatxlib/src/executable/mod.rs index df4c29b..e354b49 100644 --- a/fatxlib/src/executable/mod.rs +++ b/fatxlib/src/executable/mod.rs @@ -43,7 +43,7 @@ impl TitleExecutionInfo { reader.seek(SeekFrom::Current(8)).map_err(FatxError::Io)?; let title_id = reader.read_u32::().map_err(FatxError::Io)?; - reader.seek(SeekFrom::Current(164)).map_err(FatxError::Io)?; + reader.seek(SeekFrom::Current(160)).map_err(FatxError::Io)?; let version = reader.read_u32::().map_err(FatxError::Io)?; Ok(TitleExecutionInfo { @@ -106,3 +106,28 @@ impl TitleInfo { } } } + +#[cfg(test)] +mod tests { + use super::xbe::XbeHeader; + + #[test] + fn xbe_certificate_version_is_read_from_correct_offset() { + let mut data = vec![0u8; 0x200]; + data[0..4].copy_from_slice(b"XBEH"); + data[0x104..0x108].copy_from_slice(&0x0001_0000u32.to_le_bytes()); + data[0x118..0x11c].copy_from_slice(&0x0001_0100u32.to_le_bytes()); + + let title_id = 0x4D53_07E6u32; + let version = 0x1122_3344u32; + let wrong_version = 0x5566_7788u32; + data[0x108..0x10c].copy_from_slice(&title_id.to_le_bytes()); + data[0x1ac..0x1b0].copy_from_slice(&version.to_le_bytes()); + data[0x1b0..0x1b4].copy_from_slice(&wrong_version.to_le_bytes()); + + let header = XbeHeader::read(std::io::Cursor::new(data)).expect("parse synthetic xbe"); + let info = header.fields.execution_info.expect("execution info"); + assert_eq!(info.title_id, title_id); + assert_eq!(info.version, version); + } +} diff --git a/fatxlib/src/iso/god/con_header.rs b/fatxlib/src/iso/god/con_header.rs index 4c648dc..ca58966 100644 --- a/fatxlib/src/iso/god/con_header.rs +++ b/fatxlib/src/iso/god/con_header.rs @@ -53,8 +53,13 @@ impl ConHeaderBuilder { self.buffer[offset..offset + buf.len()].copy_from_slice(buf); } - fn write_utf16_be(&mut self, offset: usize, s: &str) { - for (i, c) in s.encode_utf16().chain([0]).enumerate() { + fn write_utf16_be(&mut self, offset: usize, s: &str, max_units: usize) { + for (i, c) in s + .encode_utf16() + .take(max_units.saturating_sub(1)) + .chain([0]) + .enumerate() + { self.write_u16_be(offset + i * 2, c); } } @@ -99,8 +104,8 @@ impl ConHeaderBuilder { } pub fn with_game_title(mut self, game_title: &str) -> Self { - self.write_utf16_be(0x0411, game_title); - self.write_utf16_be(0x1691, game_title); + self.write_utf16_be(0x0411, game_title, 0x40); + self.write_utf16_be(0x1691, game_title, 0x40); self } @@ -120,3 +125,16 @@ impl ConHeaderBuilder { self.buffer } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn game_title_is_truncated_to_field_size() { + let title = "X".repeat(80); + let bytes = ConHeaderBuilder::new().with_game_title(&title).finalize(); + assert_eq!(&bytes[0x0411 + 126..0x0411 + 128], &[0, 0]); + assert_eq!(&bytes[0x1691 + 126..0x1691 + 128], &[0, 0]); + } +} diff --git a/fatxlib/src/iso/god/core.rs b/fatxlib/src/iso/god/core.rs index 579bbbf..38748b8 100644 --- a/fatxlib/src/iso/god/core.rs +++ b/fatxlib/src/iso/god/core.rs @@ -130,3 +130,139 @@ pub(crate) fn part_payload_bytes(data_size: u64, part_index: u64) -> u64 { .saturating_sub(part_start) .min(BLOCKS_PER_PART * BLOCK_SIZE) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::executable::TitleExecutionInfo; + use crate::iso::god::prepare::test_prepared_source; + use std::io::Cursor; + + struct TestSink { + masters: Vec, + writes: Vec<(u64, [u8; 20])>, + con_header: Option>, + last_part_size: u64, + } + + impl TestSink { + fn new(masters: Vec, last_part_size: u64) -> Self { + Self { + masters, + writes: Vec::new(), + con_header: None, + last_part_size, + } + } + } + + impl GodSink for TestSink { + fn begin(&mut self, _source: &PreparedSource) -> Result<()> { + Ok(()) + } + + fn write_part<'a>( + &mut self, + _source: &PreparedSource, + _part_index: u64, + _opts: &mut ConvertOptions<'a>, + ) -> Result<()> { + Ok(()) + } + + fn read_master_hash( + &mut self, + _source: &PreparedSource, + part_index: u64, + ) -> Result { + self.masters + .get(part_index as usize) + .cloned() + .ok_or_else(|| FatxError::Other("missing master".to_string())) + } + + fn write_master_hash( + &mut self, + _source: &PreparedSource, + part_index: u64, + mht: &HashList, + ) -> Result<()> { + self.writes.push((part_index, mht.digest())); + if let Some(slot) = self.masters.get_mut(part_index as usize) { + *slot = mht.clone(); + } + Ok(()) + } + + fn last_part_size(&self, _source: &PreparedSource) -> Result { + Ok(self.last_part_size) + } + + fn write_con_header(&mut self, _source: &PreparedSource, con_bytes: Vec) -> Result<()> { + self.con_header = Some(con_bytes); + Ok(()) + } + } + + fn hash_list_from_seed(seed: u8) -> HashList { + let mut bytes = [0u8; 4096]; + bytes[..20].fill(seed); + HashList::read(Cursor::new(bytes)).expect("seeded hash list") + } + + #[test] + fn mht_back_chain_updates_previous_part() { + let report = ConvertReport { + title_id: 0x4D53_07E6, + media_id: 0x1234_5678, + content_type: super::super::ContentType::GamesOnDemand, + part_count: 3, + block_count: 3, + data_size: BLOCK_SIZE * BLOCKS_PER_PART * 2, + }; + let source = test_prepared_source( + report, + TitleExecutionInfo { + media_id: 0x1234_5678, + version: 1, + base_version: 0, + title_id: 0x4D53_07E6, + platform: 0xFF, + executable_type: 0, + disc_number: 1, + disc_count: 1, + }, + super::super::ContentType::GamesOnDemand, + ); + let part0 = hash_list_from_seed(0x11); + let part1 = hash_list_from_seed(0x22); + let part2 = hash_list_from_seed(0x33); + let mut sink = TestSink::new( + vec![part0.clone(), part1.clone(), part2.clone()], + BLOCK_SIZE, + ); + let mut opts = ConvertOptions::default(); + + let result = run_conversion(&source, &mut sink, &mut opts, "test").expect("conversion"); + assert_eq!(result.part_count, 3); + assert_eq!(sink.writes.len(), 2); + + assert_eq!(sink.writes[0].0, 1); + assert_eq!( + sink.writes[0].1, + [ + 0x6a, 0xb0, 0xca, 0x46, 0x1a, 0x72, 0xcf, 0x70, 0x51, 0x79, 0xa1, 0xf3, 0x08, 0x8b, + 0x54, 0xfa, 0x4d, 0xd4, 0xfa, 0x24, + ] + ); + assert_eq!(sink.writes[1].0, 0); + assert_eq!( + sink.writes[1].1, + [ + 0xe9, 0x63, 0x9e, 0x2e, 0x25, 0xf0, 0x3c, 0x58, 0xa2, 0x07, 0xb7, 0xad, 0x9b, 0x27, + 0x4e, 0xa5, 0x5b, 0x61, 0x15, 0x0d, + ] + ); + assert!(sink.con_header.is_some()); + } +} diff --git a/fatxlib/src/iso/god/hash_list.rs b/fatxlib/src/iso/god/hash_list.rs index b94de04..4003275 100644 --- a/fatxlib/src/iso/god/hash_list.rs +++ b/fatxlib/src/iso/god/hash_list.rs @@ -59,3 +59,49 @@ impl HashList { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn read_preserves_fixed_bytes_and_len() { + let mut bytes = [0u8; 4096]; + bytes[..20].fill(0x11); + bytes[40..60].fill(0x22); + + let list = HashList::read(Cursor::new(bytes)).expect("read hash list"); + assert_eq!(&list.bytes()[..20], &[0x11; 20]); + assert_eq!(&list.bytes()[20..40], &[0x00; 20]); + assert_eq!(&list.bytes()[40..60], &[0x22; 20]); + } + + #[test] + fn write_emits_exact_fixed_buffer() { + let mut list = HashList::new(); + list.add_hash(&[0x11; 20]); + list.add_hash(&[0x22; 20]); + + let mut out = Vec::new(); + list.write(&mut out).expect("write hash list"); + + assert_eq!(out.len(), 4096); + assert_eq!(&out[..20], &[0x11; 20]); + assert_eq!(&out[20..40], &[0x22; 20]); + assert!(out[40..].iter().all(|b| *b == 0)); + } + + #[test] + fn digest_matches_known_zero_page() { + let list = HashList::new(); + assert_eq!( + list.digest(), + [ + 0x1c, 0xea, 0xf7, 0x3d, 0xf4, 0x0e, 0x53, 0x1d, 0xf3, 0xbf, 0xb2, 0x6b, 0x4f, 0xb7, + 0xcd, 0x95, 0xfb, 0x7b, 0xff, 0x1d, + ] + ); + } +} diff --git a/fatxlib/src/iso/god/prepare.rs b/fatxlib/src/iso/god/prepare.rs index bb185fe..e2a6173 100644 --- a/fatxlib/src/iso/god/prepare.rs +++ b/fatxlib/src/iso/god/prepare.rs @@ -137,6 +137,23 @@ pub(crate) fn prepare_source( }) } +#[cfg(test)] +pub(crate) fn test_prepared_source( + report: ConvertReport, + exe_info: crate::executable::TitleExecutionInfo, + content_type: ContentType, +) -> PreparedSource { + PreparedSource { + report, + exe_info, + content_type, + reader: ReaderSource::Raw { + source_iso: PathBuf::new(), + root_offset: 0, + }, + } +} + fn build_report( title_id: u32, media_id: u32, diff --git a/fatxlib/src/partition.rs b/fatxlib/src/partition.rs index 2d13aba..b223c2d 100644 --- a/fatxlib/src/partition.rs +++ b/fatxlib/src/partition.rs @@ -8,6 +8,7 @@ use std::io::{Read, Seek, SeekFrom, Write}; use log::{debug, info}; +use crate::error::FatxError; use crate::error::Result; use crate::types::*; @@ -27,16 +28,16 @@ pub struct DetectedPartition { /// /// `device_size` is the total device size in bytes. On macOS you should get this /// via `platform::get_block_device_size()` for raw devices, since `seek(End(0))` -/// returns 0 on `/dev/rdiskN`. Pass 0 to auto-detect via seek (works for files). +/// returns 0 on `/dev/rdiskN`. pub fn detect_xbox_partitions( device: &mut T, device_size: u64, ) -> Result> { - let device_size = if device_size > 0 { - device_size - } else { - device.seek(SeekFrom::End(0))? - }; + if device_size == 0 { + return Err(FatxError::Other( + "device size must be supplied for raw devices on macOS".to_string(), + )); + } info!( "Device size: {} (0x{:X} bytes)", format_size(device_size), @@ -150,8 +151,20 @@ fn probe_magic(device: &mut T, offset: u64) -> Result<(bool, Str /// Scan a device sector-by-sector for FATX/XTAF magic signatures. /// This is a brute-force approach for non-standard partition layouts. -pub fn scan_for_fatx(device: &mut T, max_offset: u64) -> Result> { - let device_size = device.seek(SeekFrom::End(0))?; +/// +/// `device_size` is the total device size in bytes. On macOS raw devices +/// should pass `platform::get_block_device_size()` instead of relying on +/// `seek(End(0))`. +pub fn scan_for_fatx( + device: &mut T, + device_size: u64, + max_offset: u64, +) -> Result> { + if device_size == 0 { + return Err(FatxError::Other( + "device size must be supplied for raw devices on macOS".to_string(), + )); + } let scan_limit = max_offset.min(device_size); let mut found = Vec::new(); @@ -192,3 +205,48 @@ pub fn format_size(bytes: u64) -> String { format!("{} bytes", bytes) } } + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn detect_partitions_requires_explicit_size_when_autodetect_is_zero() { + let mut cursor = Cursor::new(vec![]); + assert!(detect_xbox_partitions(&mut cursor, 0).is_err()); + } + + #[test] + fn scan_for_fatx_requires_explicit_size_when_autodetect_is_zero() { + let mut cursor = Cursor::new(vec![]); + assert!(scan_for_fatx(&mut cursor, 0, 0).is_err()); + } + + #[test] + fn detect_partitions_finds_valid_magic_with_explicit_size() { + let mut image = vec![0u8; 0x90000]; + image[0x80000..0x80004].copy_from_slice(&FATX_MAGIC); + let mut cursor = Cursor::new(image); + + let parts = detect_xbox_partitions(&mut cursor, 0x90000).expect("detect partitions"); + let part = parts + .iter() + .find(|part| part.offset == 0x80000) + .expect("360 System Cache partition"); + + assert!(part.has_valid_magic); + assert_eq!(part.magic, "FATX"); + } + + #[test] + fn scan_for_fatx_finds_valid_magic_with_explicit_size() { + let mut image = vec![0u8; 0x90000]; + image[0x80000..0x80004].copy_from_slice(&FATX_MAGIC); + let mut cursor = Cursor::new(image); + + let offsets = scan_for_fatx(&mut cursor, 0x90000, 0x90000).expect("scan"); + assert!(offsets.contains(&0x80000)); + } +} diff --git a/fatxlib/src/volume.rs b/fatxlib/src/volume.rs index 1169290..7e9ddcd 100644 --- a/fatxlib/src/volume.rs +++ b/fatxlib/src/volume.rs @@ -4,7 +4,7 @@ //! and provides methods to navigate directories, read files, and perform write operations. use std::fs::File; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{BufReader, Read, Seek, SeekFrom, Write}; use log::{info, warn}; @@ -61,6 +61,7 @@ pub struct FatxVolume { /// Progress callback: `(fatx_path, file_size, total_bytes_so_far)`. type ProgressFn<'a> = &'a dyn Fn(&str, u64, u64); +type CopyStats = (usize, usize, u64); struct CopyFromHostState<'a> { progress: Option>, @@ -104,11 +105,17 @@ impl FatxVolume { /// /// - `inner`: A seekable read/write handle to the device or image file. /// - `partition_offset`: Byte offset where the FATX partition begins. - /// - `partition_size`: Size of the partition in bytes (0 = auto-detect from stream length). + /// - `partition_size`: Size of the partition in bytes. + /// Pass the explicit size for raw block devices on macOS. pub fn open(mut inner: T, partition_offset: u64, partition_size: u64) -> Result { // Determine actual partition size if not provided. let partition_size = if partition_size == 0 { let end = inner.seek(SeekFrom::End(0))?; + if end == 0 { + return Err(FatxError::Other( + "partition size must be supplied for raw devices on macOS".to_string(), + )); + } end.saturating_sub(partition_offset) } else { partition_size @@ -188,15 +195,11 @@ impl FatxVolume { let total_sectors = (partition_size / SECTOR_SIZE) - (SUPERBLOCK_SIZE / SECTOR_SIZE); let spc = sectors_per_cluster as u64; - // Determine FAT type using the original driver's formula: - // if (total_sectors - 260) / sectors_per_cluster >= 65525 => FAT32 + // Determine FAT type using the FATX driver's formula: + // if (total_sectors - 260) / sectors_per_cluster >= 65520 => FAT32 // The "260" accounts for the root directory overhead estimate. let cluster_estimate = total_sectors.saturating_sub(260) / spc; - let fat_type = if cluster_estimate >= 65_525 { - FatType::Fat32 - } else { - FatType::Fat16 - }; + let fat_type = fat_type_for_cluster_estimate(cluster_estimate); let entry_size = fat_type.entry_size(); @@ -769,50 +772,13 @@ impl FatxVolume { return Err(FatxError::DiskFull); } - let end = FIRST_CLUSTER + self.total_clusters; - let start_from = if self.prev_free + 1 >= end { - FIRST_CLUSTER - } else { - self.prev_free + 1 - }; - - let mut allocated = Vec::with_capacity(count); - let mut cursor = start_from; - - // Pass 1: from prev_free+1 to end - while allocated.len() < count { - match self.bitmap_find_free(cursor, end) { - Some(cluster) => { - allocated.push(cluster); - cursor = cluster + 1; - } - None => break, - } - } - - // Pass 2: wraparound from beginning - if allocated.len() < count && start_from > FIRST_CLUSTER { - cursor = FIRST_CLUSTER; - while allocated.len() < count { - match self.bitmap_find_free(cursor, start_from) { - Some(cluster) => { - allocated.push(cluster); - cursor = cluster + 1; - } - None => break, - } - } - } + let allocated = self.reserve_free_clusters(count)?; if allocated.len() < count { return Err(FatxError::DiskFull); } - // Chain them together - for i in 0..allocated.len() - 1 { - self.write_fat_entry(allocated[i], FatEntry::Next(allocated[i + 1]))?; - } - self.write_fat_entry(*allocated.last().unwrap(), FatEntry::EndOfChain)?; + self.link_allocated_clusters(&allocated)?; // Update prev_free to the last allocated cluster self.prev_free = *allocated.last().unwrap(); @@ -1077,8 +1043,9 @@ impl FatxVolume { /// Validate a filename for FATX. fn validate_filename(name: &str) -> Result<()> { - if name.len() > MAX_FILENAME_LEN { - return Err(FatxError::FilenameTooLong(name.len(), MAX_FILENAME_LEN)); + let char_len = name.chars().count(); + if char_len > MAX_FILENAME_LEN { + return Err(FatxError::FilenameTooLong(char_len, MAX_FILENAME_LEN)); } if name.is_empty() { return Err(FatxError::FilenameTooLong(0, MAX_FILENAME_LEN)); @@ -1538,56 +1505,18 @@ impl FatxVolume { // ── Phase 1: Extend chain if file grew ── if clusters_needed > old_count { let extra = clusters_needed - old_count; - // Find the last cluster in the existing chain let last_old = *old_chain.last().unwrap(); - - // Allocate additional clusters using bitmap scan from prev_free - let mut new_clusters = Vec::with_capacity(extra); - let end = FIRST_CLUSTER + self.total_clusters; - let start_from = if self.prev_free + 1 >= end { - FIRST_CLUSTER - } else { - self.prev_free + 1 - }; - let mut cursor = start_from; - - // Pass 1: from prev_free+1 to end - while new_clusters.len() < extra { - match self.bitmap_find_free(cursor, end) { - Some(cluster) => { - new_clusters.push(cluster); - cursor = cluster + 1; - } - None => break, - } - } - // Pass 2: wraparound - if new_clusters.len() < extra && start_from > FIRST_CLUSTER { - cursor = FIRST_CLUSTER; - while new_clusters.len() < extra { - match self.bitmap_find_free(cursor, start_from) { - Some(cluster) => { - new_clusters.push(cluster); - cursor = cluster + 1; - } - None => break, - } - } - } + let new_clusters = self.reserve_free_clusters(extra)?; if new_clusters.len() < extra { return Err(FatxError::DiskFull); } - // Update prev_free if let Some(&last) = new_clusters.last() { self.prev_free = last; } - // Link: old_last -> new_clusters[0] -> ... -> EOC self.write_fat_entry(last_old, FatEntry::Next(new_clusters[0]))?; - for i in 0..new_clusters.len() - 1 { - self.write_fat_entry(new_clusters[i], FatEntry::Next(new_clusters[i + 1]))?; - } - self.write_fat_entry(*new_clusters.last().unwrap(), FatEntry::EndOfChain)?; + self.link_allocated_clusters(&new_clusters)?; + self.flush()?; // Re-read chain after the extension is linked into the FAT cache. chain = self.read_chain(target.first_cluster)?; @@ -1655,37 +1584,7 @@ impl FatxVolume { if clusters_needed > old_count { let extra = clusters_needed - old_count; let last_old = *old_chain.last().unwrap(); - - let end = FIRST_CLUSTER + self.total_clusters; - let start_from = if self.prev_free + 1 >= end { - FIRST_CLUSTER - } else { - self.prev_free + 1 - }; - let mut new_clusters = Vec::with_capacity(extra); - let mut cursor = start_from; - - while new_clusters.len() < extra { - match self.bitmap_find_free(cursor, end) { - Some(c) => { - new_clusters.push(c); - cursor = c + 1; - } - None => break, - } - } - if new_clusters.len() < extra && start_from > FIRST_CLUSTER { - cursor = FIRST_CLUSTER; - while new_clusters.len() < extra { - match self.bitmap_find_free(cursor, start_from) { - Some(c) => { - new_clusters.push(c); - cursor = c + 1; - } - None => break, - } - } - } + let new_clusters = self.reserve_free_clusters(extra)?; if new_clusters.len() < extra { return Err(FatxError::DiskFull); } @@ -1694,10 +1593,7 @@ impl FatxVolume { } self.write_fat_entry(last_old, FatEntry::Next(new_clusters[0]))?; - for i in 0..new_clusters.len() - 1 { - self.write_fat_entry(new_clusters[i], FatEntry::Next(new_clusters[i + 1]))?; - } - self.write_fat_entry(*new_clusters.last().unwrap(), FatEntry::EndOfChain)?; + self.link_allocated_clusters(&new_clusters)?; } let planned_chain = if clusters_needed > old_count { @@ -1712,6 +1608,54 @@ impl FatxVolume { )) } + fn reserve_free_clusters(&mut self, count: usize) -> Result> { + let end = FIRST_CLUSTER + self.total_clusters; + let start_from = if self.prev_free + 1 >= end { + FIRST_CLUSTER + } else { + self.prev_free + 1 + }; + + let mut allocated = Vec::with_capacity(count); + let mut cursor = start_from; + + while allocated.len() < count { + match self.bitmap_find_free(cursor, end) { + Some(cluster) => { + allocated.push(cluster); + cursor = cluster + 1; + } + None => break, + } + } + + if allocated.len() < count && start_from > FIRST_CLUSTER { + cursor = FIRST_CLUSTER; + while allocated.len() < count { + match self.bitmap_find_free(cursor, start_from) { + Some(cluster) => { + allocated.push(cluster); + cursor = cluster + 1; + } + None => break, + } + } + } + + Ok(allocated) + } + + fn link_allocated_clusters(&mut self, clusters: &[u32]) -> Result<()> { + if clusters.is_empty() { + return Err(FatxError::DiskFull); + } + for pair in clusters.windows(2) { + self.write_fat_entry(pair[0], FatEntry::Next(pair[1]))?; + } + self.write_fat_entry(*clusters.last().unwrap(), FatEntry::EndOfChain)?; + Ok(()) + } + fn find_entry_in_parent_by_cluster( &mut self, parent_cluster: u32, @@ -2000,7 +1944,7 @@ impl FatxVolume { local_path: &std::path::Path, dest_path: &str, progress: Option>, - ) -> Result<(usize, usize, u64)> { + ) -> Result { self.copy_from_host_with_control(local_path, dest_path, progress, None, 0, 0) } @@ -2012,7 +1956,7 @@ impl FatxVolume { should_abort: Option<&dyn Fn() -> bool>, flush_every_files: usize, flush_every_bytes: u64, - ) -> Result<(usize, usize, u64)> { + ) -> Result { // A trailing slash means "--to is the parent"; without it, the caller // is naming the target directory itself and we preserve the old behavior. let effective_dest = if dest_path.ends_with('/') { @@ -2045,14 +1989,13 @@ impl FatxVolume { self.copy_from_host_inner(local_path, &effective_dest, &mut state, 0) } - #[allow(clippy::type_complexity)] fn copy_from_host_inner( &mut self, local_path: &std::path::Path, dest_path: &str, state: &mut CopyFromHostState<'_>, base_bytes: u64, - ) -> Result<(usize, usize, u64)> { + ) -> Result { use std::fs; let dest_path = if dest_path == "/" { @@ -2121,20 +2064,20 @@ impl FatxVolume { dir_count += dc; total_bytes += tb; } else if local_child.is_file() { - let data = fs::read(&local_child).map_err(|e| { + let file = fs::File::open(&local_child).map_err(|e| { FatxError::Io(std::io::Error::other(format!( - "Cannot read '{}': {}", + "Cannot open '{}': {}", local_child.display(), e ))) })?; - let file_size = data.len() as u64; + let file_size = file.metadata().map_err(FatxError::Io)?.len(); if let Some(cb) = &state.progress { cb(&fatx_child, file_size, base_bytes + total_bytes); } - self.create_file(&fatx_child, &data)?; + self.create_file_from_reader(&fatx_child, file_size, BufReader::new(file), None)?; file_count += 1; total_bytes += file_size; state.files_since_flush += 1; @@ -2335,7 +2278,10 @@ impl FatxVolume { pub fn stats(&self) -> Result { let free_clusters = self.free_cluster_count; let bad_clusters = self.bad_cluster_count; - let used_clusters = self.total_clusters - free_clusters - bad_clusters; + let used_clusters = self + .total_clusters + .saturating_sub(free_clusters) + .saturating_sub(bad_clusters); let cluster_size = self.superblock.cluster_size(); Ok(VolumeStats { @@ -2506,9 +2452,21 @@ fn split_path(path: &str) -> (&str, &str) { } } +fn fat_type_for_cluster_estimate(cluster_estimate: u64) -> FatType { + if cluster_estimate >= FAT16_CLUSTER_THRESHOLD as u64 { + FatType::Fat32 + } else { + FatType::Fat16 + } +} + #[cfg(test)] mod tests { use super::*; + use std::fs::OpenOptions; + use std::io::{Seek, SeekFrom, Write}; + + use tempfile::NamedTempFile; #[test] fn test_split_path() { @@ -2517,4 +2475,52 @@ mod tests { assert_eq!(split_path("bar.txt"), ("/", "bar.txt")); assert_eq!(split_path("/a/b/c"), ("/a/b", "c")); } + + #[test] + fn stats_saturates_when_cached_counts_are_corrupt() { + let tmp = NamedTempFile::new().expect("temp file"); + let partition_size = 4 * 1024 * 1024u64; + let mut file = tmp.reopen().expect("reopen temp file"); + file.set_len(partition_size).expect("set len"); + file.seek(SeekFrom::Start(0)).expect("seek"); + + let mut sb = [0u8; SUPERBLOCK_SIZE as usize]; + sb[0..4].copy_from_slice(&FATX_MAGIC); + sb[4..8].copy_from_slice(&0x1234_5678u32.to_le_bytes()); + sb[8..12].copy_from_slice(&1u32.to_le_bytes()); + sb[12..14].copy_from_slice(&1u16.to_le_bytes()); + file.write_all(&sb).expect("write superblock"); + file.sync_all().expect("sync"); + + let mut vol = FatxVolume::open( + OpenOptions::new() + .read(true) + .write(true) + .open(tmp.path()) + .expect("open temp volume"), + 0, + partition_size, + ) + .expect("open volume"); + let total = vol.total_clusters; + + vol.free_cluster_count = total; + vol.bad_cluster_count = total; + + let stats = vol.stats().expect("volume stats"); + assert_eq!(stats.used_clusters, 0); + assert_eq!(stats.free_clusters, total); + } + + #[test] + fn fat_type_boundary_uses_fatx_threshold() { + assert_eq!( + fat_type_for_cluster_estimate((FAT16_CLUSTER_THRESHOLD - 1) as u64), + FatType::Fat16 + ); + assert_eq!( + fat_type_for_cluster_estimate(FAT16_CLUSTER_THRESHOLD as u64), + FatType::Fat32 + ); + } } diff --git a/fatxlib/src/xuids/account.rs b/fatxlib/src/xuids/account.rs index 6af879a..42825ba 100644 --- a/fatxlib/src/xuids/account.rs +++ b/fatxlib/src/xuids/account.rs @@ -120,17 +120,17 @@ fn read_gamertag(plaintext: &[u8]) -> Option { /// Xbox Live gamertags: 1–15 chars, start with a letter, contain /// letters/digits/spaces. This guard rejects junk that happens to decrypt /// to non-empty UTF-16 (rare with the wrong key but possible). -fn looks_like_gamertag(s: &str) -> bool { +pub(crate) fn looks_like_gamertag(s: &str) -> bool { let len = s.chars().count(); if !(1..=15).contains(&len) { return false; } let mut chars = s.chars(); let first = chars.next().unwrap(); - if !first.is_ascii_alphabetic() { + if !first.is_alphabetic() { return false; } - s.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ') + s.chars().all(|c| c.is_alphanumeric() || c == ' ') } #[cfg(test)] @@ -203,6 +203,7 @@ pub(crate) mod tests { fn looks_like_gamertag_rules() { assert!(looks_like_gamertag("Bob")); assert!(looks_like_gamertag("MLG Pro 42")); + assert!(looks_like_gamertag("Игрок 7")); assert!(!looks_like_gamertag("")); assert!(!looks_like_gamertag("1Bob")); // must start with letter assert!(!looks_like_gamertag("WayTooLongGamertag")); // > 15 chars diff --git a/fatxlib/src/xuids/mod.rs b/fatxlib/src/xuids/mod.rs index c7b7e9e..94e979e 100644 --- a/fatxlib/src/xuids/mod.rs +++ b/fatxlib/src/xuids/mod.rs @@ -130,7 +130,10 @@ fn scan_for_account_gamertag(bytes: &[u8]) -> Option { fn pick_profile_name(xuid: &str, display_name: &str, title_name: &str) -> Option { for candidate in [display_name, title_name] { let trimmed = candidate.trim().trim_matches('\u{FEFF}'); - if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case(xuid) { + if !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case(xuid) + && account::looks_like_gamertag(trimmed) + { return Some(trimmed.to_string()); } } @@ -251,6 +254,14 @@ mod tests { assert_eq!(pick_profile_name("E00012A9B73ABE44", "", ""), None); } + #[test] + fn pick_profile_name_rejects_overlong_dashboard_title() { + assert_eq!( + pick_profile_name("E00012A9B73ABE44", "", "Xbox 360 Dashboard"), + None + ); + } + #[test] fn scan_for_account_gamertag_finds_synthetic_block() { // Build a buffer with an encrypted Account block embedded at a diff --git a/fatxlib/tests/fixture_test.rs b/fatxlib/tests/fixture_test.rs deleted file mode 100644 index 49bf8db..0000000 --- a/fatxlib/tests/fixture_test.rs +++ /dev/null @@ -1,43 +0,0 @@ -mod common; - -#[test] -fn test_fixture_creates_fatx_volume() { - let (_tmp, vol) = common::create_fatx_image(4); - assert!(vol.superblock.is_valid()); - assert!(vol.total_clusters > 0); - let stats = vol.stats().unwrap(); - assert!(stats.free_clusters > 0); - assert_eq!(stats.bad_clusters, 0); -} - -#[test] -fn test_fixture_creates_xtaf_volume() { - let (_tmp, vol) = common::create_xtaf_image(4); - assert!(vol.superblock.is_valid()); - assert!(vol.total_clusters > 0); -} - -#[test] -fn test_fixture_creates_populated_volume() { - let (_tmp, mut vol) = common::create_populated_image(256); - let entries = vol.read_root_directory().unwrap(); - assert!( - !entries.is_empty(), - "populated image should have files in root" - ); - - // Check expected directories from mkimage --populate - let names: Vec = entries.iter().map(|e| e.filename()).collect(); - assert!( - names.contains(&"Content".to_string()), - "should have Content dir" - ); - assert!( - names.contains(&"Cache".to_string()), - "should have Cache dir" - ); - assert!( - names.contains(&"name.txt".to_string()), - "should have name.txt" - ); -} diff --git a/fatxlib/tests/integration.rs b/fatxlib/tests/integration.rs index f0e5f46..4a922ff 100644 --- a/fatxlib/tests/integration.rs +++ b/fatxlib/tests/integration.rs @@ -6,6 +6,7 @@ mod common; use std::cell::RefCell; +use std::fs::File; use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::rc::Rc; use std::thread::sleep; @@ -19,6 +20,8 @@ use fatxlib::volume::FatxVolume; struct FailingWriteState { fail_on_write: Option, writes_seen: usize, + block_writes_after_flush: bool, + seen_flush: bool, } struct FailingWriteCursor { @@ -26,6 +29,11 @@ struct FailingWriteCursor { state: Rc>, } +struct FailingWriteFile { + inner: File, + state: Rc>, +} + impl Read for FailingWriteCursor { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { self.inner.read(buf) @@ -41,6 +49,43 @@ impl Seek for FailingWriteCursor { impl Write for FailingWriteCursor { fn write(&mut self, buf: &[u8]) -> std::io::Result { let mut state = self.state.borrow_mut(); + if state.block_writes_after_flush && state.seen_flush { + return Err(std::io::Error::other("injected post-flush write failure")); + } + state.writes_seen += 1; + if state.fail_on_write == Some(state.writes_seen) { + return Err(std::io::Error::other("injected write failure")); + } + self.inner.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let result = self.inner.flush(); + if result.is_ok() { + self.state.borrow_mut().seen_flush = true; + } + result + } +} + +impl Read for FailingWriteFile { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.inner.read(buf) + } +} + +impl Seek for FailingWriteFile { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + self.inner.seek(pos) + } +} + +impl Write for FailingWriteFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut state = self.state.borrow_mut(); + if state.block_writes_after_flush && state.seen_flush { + return Err(std::io::Error::other("injected post-flush write failure")); + } state.writes_seen += 1; if state.fail_on_write == Some(state.writes_seen) { return Err(std::io::Error::other("injected write failure")); @@ -49,7 +94,11 @@ impl Write for FailingWriteCursor { } fn flush(&mut self) -> std::io::Result<()> { - self.inner.flush() + let result = self.inner.flush(); + if result.is_ok() { + self.state.borrow_mut().seen_flush = true; + } + result } } @@ -889,6 +938,58 @@ fn test_write_in_place_file_grows() { assert!(stats_big.free_clusters < stats_small.free_clusters); } +#[test] +fn test_write_in_place_flushes_fat_before_data_write() { + let (tmp, mut vol) = common::create_fatx_image(4); + + vol.create_file("/grow.bin", &[0x11; 100]).expect("create"); + vol.flush().expect("flush initial image"); + let original_entry = vol.resolve_path("/grow.bin").expect("resolve original"); + let original_chain_len = vol + .read_chain(original_entry.first_cluster) + .expect("read original chain") + .len(); + drop(vol); + + let img_path = common::image_path(&tmp); + let state = Rc::new(RefCell::new(FailingWriteState { + block_writes_after_flush: true, + ..Default::default() + })); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&img_path) + .expect("open image"); + let wrapper = FailingWriteFile { + inner: file, + state: Rc::clone(&state), + }; + let mut vol = FatxVolume::open(wrapper, 0, 0).expect("open with flush-failing wrapper"); + + let result = vol.write_file_in_place("/grow.bin", &vec![0x22; 50000]); + assert!( + result.is_err(), + "injected post-flush failure should abort write" + ); + drop(vol); + + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&img_path) + .expect("reopen image"); + let mut reopened = FatxVolume::open(file, 0, 0).expect("reopen volume"); + let entry = reopened.resolve_path("/grow.bin").expect("resolve after"); + let chain_len = reopened + .read_chain(entry.first_cluster) + .expect("read chain after"); + assert!( + chain_len.len() > original_chain_len, + "FAT extension should persist before data write failure" + ); +} + /// In-place write where file shrinks — must free excess clusters. #[test] fn test_write_in_place_file_shrinks() { diff --git a/fatxlib/tests/optimization.rs b/fatxlib/tests/optimization.rs index 76a8fc6..f270aad 100644 --- a/fatxlib/tests/optimization.rs +++ b/fatxlib/tests/optimization.rs @@ -91,45 +91,6 @@ fn test_bitmap_matches_stats_on_open() { ); } -#[test] -fn test_bitmap_consistent_after_allocations() { - let (_tmp, mut vol) = common::create_fatx_image(4); - - let stats_before = vol.stats().expect("stats"); - - // Allocate 10 clusters - for _ in 0..10 { - vol.allocate_cluster().expect("alloc"); - } - - let stats_after = vol.stats().expect("stats"); - assert_eq!( - stats_after.free_clusters, - stats_before.free_clusters - 10, - "free count should decrease by exactly 10" - ); -} - -#[test] -fn test_bitmap_consistent_after_free_chain() { - let (_tmp, mut vol) = common::create_fatx_image(4); - - let stats_before = vol.stats().expect("stats"); - - // Allocate a chain of 5 clusters - let first = vol.allocate_chain(5).expect("alloc chain"); - let stats_during = vol.stats().expect("stats"); - assert_eq!(stats_during.free_clusters, stats_before.free_clusters - 5); - - // Free the chain - vol.free_chain(first).expect("free chain"); - let stats_after = vol.stats().expect("stats"); - assert_eq!( - stats_after.free_clusters, stats_before.free_clusters, - "free count should return to original after free_chain" - ); -} - #[test] fn test_bitmap_after_create_delete_cycle() { let (_tmp, mut vol) = common::create_fatx_image(4); @@ -154,14 +115,6 @@ fn test_bitmap_after_create_delete_cycle() { // Dirty-range FAT tracking // =========================================================================== -#[test] -fn test_flush_after_no_changes_is_noop() { - let (_tmp, mut vol) = common::create_fatx_image(4); - - // Flush without any changes — should succeed and be a no-op - vol.flush().expect("flush no-op"); -} - #[test] fn test_flush_after_create_preserves_data() { let (_tmp, mut vol) = common::create_fatx_image(4); @@ -263,50 +216,6 @@ fn test_stats_matches_manual_count() { ); } -// =========================================================================== -// I/O alignment -// =========================================================================== - -#[test] -fn test_default_alignment_works() { - // File-backed images use default 512-byte alignment - let (_tmp, mut vol) = common::create_fatx_image(4); - - // Basic read/write should work with default alignment - vol.create_file("/align.txt", b"alignment test") - .expect("create"); - let data = vol.read_file_by_path("/align.txt").expect("read"); - assert_eq!(data, b"alignment test"); -} - -#[test] -fn test_read_write_at_various_offsets() { - let (_tmp, mut vol) = common::create_fatx_image(4); - - // Create files of various sizes to exercise different alignment scenarios - vol.create_file("/tiny.txt", b"x").expect("create tiny"); - vol.create_file("/small.txt", &vec![0xBB; 511]) - .expect("create small"); - vol.create_file("/aligned.txt", &vec![0xCC; 512]) - .expect("create aligned"); - vol.create_file("/large.txt", &vec![0xDD; 4097]) - .expect("create large"); - - assert_eq!(vol.read_file_by_path("/tiny.txt").expect("read"), b"x"); - assert_eq!( - vol.read_file_by_path("/small.txt").expect("read"), - vec![0xBB; 511] - ); - assert_eq!( - vol.read_file_by_path("/aligned.txt").expect("read"), - vec![0xCC; 512] - ); - assert_eq!( - vol.read_file_by_path("/large.txt").expect("read"), - vec![0xDD; 4097] - ); -} - // =========================================================================== // XTAF (big-endian) optimization tests // =========================================================================== diff --git a/fatxlib/tests/title_lookup.rs b/fatxlib/tests/title_lookup.rs index f32d8ee..d257f71 100644 --- a/fatxlib/tests/title_lookup.rs +++ b/fatxlib/tests/title_lookup.rs @@ -38,7 +38,11 @@ fn returns_none_for_unknown_id() { #[test] fn merged_map_coverage_floor() { // 5133 (360) + 990 (OG) - 613 (overlap) = 5510. Floor at 5400 for slack. - // ENTRY_COUNT is a const, so this is essentially a compile-time guard. - #[allow(clippy::assertions_on_constants)] - const _: () = assert!(ENTRY_COUNT >= 5400, "merged map looks too small"); + const fn assert_title_catalog_floor(count: usize) { + if count < 5400 { + panic!("title catalog unexpectedly small"); + } + } + + const _: () = assert_title_catalog_floor(ENTRY_COUNT); } diff --git a/src/main.rs b/src/main.rs index 43a1110..0942cf4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -236,7 +236,7 @@ fn guided_partition_selection() -> Option { // Check for sudo if !running_as_root() { println!("[!] You're not running as root. Raw device access requires sudo."); - println!(" Re-run with: sudo fatx"); + println!(" Re-run with: sudo xtafkit"); println!(); print!("Continue anyway? (y/n): "); io::stdout().flush().unwrap(); @@ -876,7 +876,7 @@ fn main() { } if deep && !json { println!("\nDeep scanning up to 0x{:X}...", deep_limit); - match fatxlib::partition::scan_for_fatx(&mut file, deep_limit) { + match fatxlib::partition::scan_for_fatx(&mut file, dev_size, deep_limit) { Ok(offsets) => { if offsets.is_empty() { println!("No additional signatures found."); diff --git a/src/mkimage.rs b/src/mkimage.rs index 451d4c7..e2d2e07 100644 --- a/src/mkimage.rs +++ b/src/mkimage.rs @@ -193,7 +193,9 @@ fn format_image(file: &mut File, size: u64, is_xtaf: bool, spc: u32) -> Result<( let total_clusters = if is_xtaf { ((size - SUPERBLOCK_SIZE) / cluster_size) as u32 } else { - let entry_size_est = if total_sectors.saturating_sub(260) / spc as u64 >= 65_525 { + let entry_size_est = if total_sectors.saturating_sub(260) / spc as u64 + >= fatxlib::types::FAT16_CLUSTER_THRESHOLD as u64 + { 4u64 } else { 2u64 @@ -201,7 +203,9 @@ fn format_image(file: &mut File, size: u64, is_xtaf: bool, spc: u32) -> Result<( (total_sectors * SECTOR_SIZE / (cluster_size + entry_size_est)) as u32 }; - let fat_type = if total_sectors.saturating_sub(260) / spc as u64 >= 65_525 { + let fat_type = if total_sectors.saturating_sub(260) / spc as u64 + >= fatxlib::types::FAT16_CLUSTER_THRESHOLD as u64 + { FatType::Fat32 } else { FatType::Fat16 diff --git a/src/tui.rs b/src/tui.rs index 458f73d..5ad9b73 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -47,7 +47,7 @@ use std::io::{self, stdout}; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc; +use std::sync::mpsc::{self, TryRecvError}; use std::time::Duration; use crossterm::{ @@ -244,7 +244,7 @@ enum IoResp { // App state // =========================================================================== -#[derive(PartialEq)] +#[derive(Clone, Copy, PartialEq)] #[allow(dead_code)] enum InputMode { Normal, @@ -281,6 +281,8 @@ struct App { cancel_flag: Arc, /// Pending cleanup paths awaiting user confirmation. pending_cleanup: Vec<(String, bool, u64)>, + /// Pending single-delete target captured when the prompt is opened. + pending_delete: Option<(String, bool)>, /// Local XISO path + default action stashed between the upload prompt /// and the three-way confirmation prompt (extract / GoD / raw). pending_xiso_upload: Option<(PathBuf, XisoUploadAction)>, @@ -390,6 +392,7 @@ impl App { is_busy: false, cancel_flag, pending_cleanup: Vec::new(), + pending_delete: None, pending_xiso_upload: None, sort_mode: SortMode::ByName, } @@ -562,15 +565,16 @@ fn io_worker( let size = data.len() as u64; match vol.create_or_replace_file(&fatx_path, &data) { Ok(_) => { - let _ = vol.flush(); - let _ = resp_tx.send(IoResp::Done { - message: format!( - "Uploaded '{}' → {} ({})", - local_path.display(), - fatx_path, - format_size(size) - ), - }); + if !flush_or_error(&mut vol, &resp_tx, "Upload flush failed") { + let _ = resp_tx.send(IoResp::Done { + message: format!( + "Uploaded '{}' → {} ({})", + local_path.display(), + fatx_path, + format_size(size) + ), + }); + } } Err(e) => { let _ = resp_tx.send(IoResp::Error { @@ -606,6 +610,7 @@ fn io_worker( let mut bytes_done = 0u64; let mut files_done = 0usize; let mut cancelled = false; + let mut failed = false; let mut files_since_flush = 0usize; let mut bytes_since_flush = 0u64; @@ -661,11 +666,8 @@ fn io_worker( } if files_since_flush >= 100 || bytes_since_flush >= 256 * 1024 * 1024 { - if let Err(e) = vol.flush() { - let _ = resp_tx.send(IoResp::Error { - message: format!("Periodic flush failed: {}", e), - }); - cancelled = true; + if flush_or_error(&mut vol, &resp_tx, "Periodic flush failed") { + failed = true; break; } files_since_flush = 0; @@ -673,7 +675,9 @@ fn io_worker( } } - let _ = vol.flush(); + if flush_or_error(&mut vol, &resp_tx, "Final flush failed") { + failed = true; + } if cancelled { let _ = resp_tx.send(IoResp::Cancelled { @@ -684,6 +688,8 @@ fn io_worker( format_size(bytes_done) ), }); + } else if failed { + // Error already reported by flush_or_error. } else { let _ = resp_tx.send(IoResp::Done { message: format!( @@ -818,10 +824,7 @@ fn io_worker( // Flush periodically so a long extract survives a yank. if files_since_flush >= 100 || bytes_since_flush >= 256 * 1024 * 1024 { - if let Err(e) = vol.flush() { - let _ = resp_tx.send(IoResp::Error { - message: format!("Periodic flush failed: {}", e), - }); + if flush_or_error(&mut vol, &resp_tx, "Periodic flush failed") { failed = true; break; } @@ -830,7 +833,9 @@ fn io_worker( } } - let _ = vol.flush(); + if flush_or_error(&mut vol, &resp_tx, "Final flush failed") { + failed = true; + } if cancelled { let _ = resp_tx.send(IoResp::Cancelled { @@ -987,7 +992,9 @@ fn io_worker( &source, &mut vol, &dest_dir, &mut opts, ) { Ok(r) => { - let _ = vol.flush(); + if flush_or_error(&mut vol, &resp_tx, "GoD flush failed") { + continue; + } // Rough total: per-part overhead (4 KiB master + // 4 KiB × subparts) plus the CON header. Reporting // the source-side data size is close enough. @@ -1004,7 +1011,7 @@ fn io_worker( }); } Err(e) => { - let _ = vol.flush(); + let _ = flush_or_error(&mut vol, &resp_tx, "GoD flush failed"); let msg = format!("{}", e); if msg.contains("cancelled") { let _ = resp_tx.send(IoResp::Cancelled { @@ -1021,10 +1028,11 @@ fn io_worker( IoCmd::Mkdir { path } => match vol.create_directory(&path) { Ok(_) => { - let _ = vol.flush(); - let _ = resp_tx.send(IoResp::Done { - message: format!("Created directory '{}'", path), - }); + if !flush_or_error(&mut vol, &resp_tx, "Mkdir flush failed") { + let _ = resp_tx.send(IoResp::Done { + message: format!("Created directory '{}'", path), + }); + } } Err(e) => { let _ = resp_tx.send(IoResp::Error { @@ -1041,10 +1049,11 @@ fn io_worker( }; match result { Ok(_) => { - let _ = vol.flush(); - let _ = resp_tx.send(IoResp::Done { - message: format!("Deleted '{}'", path), - }); + if !flush_or_error(&mut vol, &resp_tx, "Delete flush failed") { + let _ = resp_tx.send(IoResp::Done { + message: format!("Deleted '{}'", path), + }); + } } Err(e) => { let _ = resp_tx.send(IoResp::Error { @@ -1056,10 +1065,11 @@ fn io_worker( IoCmd::Rename { path, new_name } => match vol.rename(&path, &new_name) { Ok(_) => { - let _ = vol.flush(); - let _ = resp_tx.send(IoResp::Done { - message: format!("Renamed → '{}'", new_name), - }); + if !flush_or_error(&mut vol, &resp_tx, "Rename flush failed") { + let _ = resp_tx.send(IoResp::Done { + message: format!("Renamed → '{}'", new_name), + }); + } } Err(e) => { let _ = resp_tx.send(IoResp::Error { @@ -1121,15 +1131,16 @@ fn io_worker( } } } - let _ = vol.flush(); - let _ = resp_tx.send(IoResp::Done { - message: format!( - "Removed {} file(s), {} dir(s), freed {}", - files, - dirs, - format_size(bytes) - ), - }); + if !flush_or_error(&mut vol, &resp_tx, "Cleanup flush failed") { + let _ = resp_tx.send(IoResp::Done { + message: format!( + "Removed {} file(s), {} dir(s), freed {}", + files, + dirs, + format_size(bytes) + ), + }); + } } IoCmd::ResolveTitle { path } => { @@ -1168,7 +1179,9 @@ fn io_worker( } IoCmd::Flush => { - let _ = vol.flush(); + if flush_or_error(&mut vol, &resp_tx, "Flush failed") { + continue; + } let _ = resp_tx.send(IoResp::Flushed); } @@ -1261,6 +1274,42 @@ fn create_dirs_recursive(vol: &mut FatxVolume, local_dir: &PathBu } } +fn flush_or_error( + vol: &mut FatxVolume, + resp_tx: &mpsc::Sender, + context: &str, +) -> bool { + match vol.flush() { + Ok(()) => false, + Err(e) => { + let _ = resp_tx.send(IoResp::Error { + message: format!("{}: {}", context, e), + }); + true + } + } +} + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = stdout().execute(LeaveAlternateScreen); + let _ = stdout().execute(Show); + } +} + +fn install_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = stdout().execute(LeaveAlternateScreen); + let _ = stdout().execute(Show); + default_hook(panic_info); + })); +} + // =========================================================================== // Main entry point // =========================================================================== @@ -1283,9 +1332,11 @@ pub fn run_browser( }); // Setup terminal + install_panic_hook(); enable_raw_mode()?; stdout().execute(EnterAlternateScreen)?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + let _terminal_guard = TerminalGuard; let mut app = App::new(partition_name, device_display, Arc::clone(&cancel_flag)); @@ -1311,8 +1362,17 @@ pub fn run_browser( } // Process all pending I/O responses (non-blocking) - while let Ok(resp) = resp_rx.try_recv() { - handle_io_response(&mut app, &cmd_tx, resp); + loop { + match resp_rx.try_recv() { + Ok(resp) => handle_io_response(&mut app, &cmd_tx, resp), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + app.is_busy = false; + app.should_quit = true; + app.set_error("I/O worker stopped unexpectedly"); + break; + } + } } // Poll for key events (50ms timeout — ~20fps refresh) @@ -1337,11 +1397,6 @@ pub fn run_browser( // Shutdown I/O worker let _ = cmd_tx.send(IoCmd::Shutdown); let _ = worker_handle.join(); - - // Restore terminal - disable_raw_mode()?; - stdout().execute(LeaveAlternateScreen)?; - Ok(()) } @@ -1614,6 +1669,7 @@ fn handle_normal_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) }; app.input_prompt = msg; app.input_buffer.clear(); + app.pending_delete = Some((name, is_dir)); app.input_mode = InputMode::ConfirmDelete; } } @@ -1647,225 +1703,244 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) // If the user was answering the XISO extract/raw prompt, drop the // stashed path so the next upload starts clean. app.pending_xiso_upload = None; + app.pending_delete = None; app.set_status("Cancelled."); } KeyCode::Enter => { let input = app.input_buffer.clone(); + let mode = app.input_mode; app.input_mode = InputMode::Normal; - // Dispatch based on what mode we were in (using the prompt to determine) - if app.input_prompt.starts_with("Save '") { - // Download - let name = app.selected_name().unwrap_or_default(); - let fatx_path = app.full_path(&name); - let local_path = PathBuf::from(unescape_path(&input)); - app.download_dir = local_path - .parent() - .unwrap_or(&PathBuf::from(".")) - .to_path_buf(); - app.set_status(&format!("Downloading '{}'...", name)); - let _ = cmd_tx.send(IoCmd::ReadFile { - fatx_path, - local_path, - }); - app.is_busy = true; - } else if app.input_prompt.starts_with("Upload ") { - // Upload file or directory — unescape shell backslashes - // (e.g. Call\ of\ Duty) and trim leading/trailing whitespace - // (drag-and-drop into the terminal often appends a space). - let path = PathBuf::from(unescape_path(input.trim())); - if !path.exists() { - app.set_error(&format!("Not found: {}", input)); - return; - } - let filename = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "file.dat".to_string()); - - if path.is_dir() { - let fatx_dest = app.full_path(&filename); - app.set_status(&format!("Uploading directory '{}'...", filename)); - let _ = cmd_tx.send(IoCmd::CopyDir { - local_path: path.clone(), - fatx_dest, - }); - app.download_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - app.is_busy = true; - } else if is_xiso(&path) { - // Detected an Xbox disc image. Pick the default action - // based on cwd: inside a per-user Content folder (where - // GoD packages live), default to GoD; everywhere else - // default to extract (works for alt dashboards). - let default = if fatxlib::display::folder_slot(&app.cwd) - == fatxlib::display::FolderSlot::TitleId - { - XisoUploadAction::God - } else { - XisoUploadAction::Extract - }; - let prompt = match default { - XisoUploadAction::Extract => format!( - "Detected XISO '{}'. e(X)tract / (g)oD / (r)aw / Esc:", - filename - ), - XisoUploadAction::God => format!( - "Detected XISO '{}'. e(x)tract / (G)oD / (r)aw / Esc:", - filename - ), - XisoUploadAction::Raw => format!( - "Detected XISO '{}'. e(x)tract / (g)oD / (R)aw / Esc:", - filename - ), - }; - app.pending_xiso_upload = Some((path.clone(), default)); - app.download_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - app.input_mode = InputMode::ConfirmXisoUpload; - app.input_prompt = prompt; - app.input_buffer.clear(); - } else { - let fatx_path = app.full_path(&filename); - app.set_status(&format!("Uploading '{}'...", filename)); - let _ = cmd_tx.send(IoCmd::WriteFile { - local_path: path.clone(), + match mode { + InputMode::DownloadPath => { + // Download + let name = app.selected_name().unwrap_or_default(); + let fatx_path = app.full_path(&name); + let local_path = PathBuf::from(unescape_path(&input)); + app.download_dir = local_path + .parent() + .unwrap_or(&PathBuf::from(".")) + .to_path_buf(); + app.set_status(&format!("Downloading '{}'...", name)); + let _ = cmd_tx.send(IoCmd::ReadFile { fatx_path, + local_path, }); - app.download_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); app.is_busy = true; } - } else if app.input_prompt.starts_with("Detected XISO") { - let (path, default) = match app.pending_xiso_upload.take() { - Some(pair) => pair, - None => { - app.set_error("Internal: missing pending XISO path."); + InputMode::UploadPath => { + // Upload file or directory — unescape shell backslashes + // (e.g. Call\ of\ Duty) and trim leading/trailing whitespace + // (drag-and-drop into the terminal often appends a space). + let path = PathBuf::from(unescape_path(input.trim())); + if !path.exists() { + app.set_error(&format!("Not found: {}", input)); return; } - }; - let trimmed = input.trim(); - let action = if trimmed.is_empty() { - default - } else { - match trimmed.chars().next().map(|c| c.to_ascii_lowercase()) { - Some('x') => XisoUploadAction::Extract, - Some('g') => XisoUploadAction::God, - Some('r') => XisoUploadAction::Raw, - _ => { - app.set_error(&format!( - "Unknown choice {:?} — expected x, g, or r.", - trimmed - )); - return; - } - } - }; - let filename = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "iso".to_string()); - - match action { - XisoUploadAction::Extract => { - // Prefer a catalog-resolved folder name over the - // local filename stem: a disc named `disc1.iso` - // with TitleID 0x4D5307E6 should land at - // `/Halo 3 [4D5307E6]/` rather than `/disc1/`. - // Falls back to the file stem on catalog miss or - // unreadable XEX/XBE. - let stem = path - .file_stem() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| filename.clone()); - let resolved = xiso_folder_name(&path).unwrap_or(stem); - let dest_dir = app.full_path(&resolved); - app.set_status(&format!("Extracting '{}' → {}...", filename, dest_dir)); - let _ = cmd_tx.send(IoCmd::ExtractXiso { - source: path, - dest_dir, - }); - app.is_busy = true; - } - XisoUploadAction::God => { - let dest_dir = app.cwd.clone(); - app.set_status(&format!( - "Converting '{}' to GoD under {}...", - filename, dest_dir - )); - let _ = cmd_tx.send(IoCmd::ConvertXisoToGod { - source: path, - dest_dir, + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "file.dat".to_string()); + + if path.is_dir() { + let fatx_dest = app.full_path(&filename); + app.set_status(&format!("Uploading directory '{}'...", filename)); + let _ = cmd_tx.send(IoCmd::CopyDir { + local_path: path.clone(), + fatx_dest, }); + app.download_dir = + path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); app.is_busy = true; - } - XisoUploadAction::Raw => { + } else if is_xiso(&path) { + // Detected an Xbox disc image. Pick the default action + // based on cwd: inside a per-user Content folder (where + // GoD packages live), default to GoD; everywhere else + // default to extract (works for alt dashboards). + let default = if fatxlib::display::folder_slot(&app.cwd) + == fatxlib::display::FolderSlot::TitleId + { + XisoUploadAction::God + } else { + XisoUploadAction::Extract + }; + let prompt = match default { + XisoUploadAction::Extract => format!( + "Detected XISO '{}'. e(X)tract / (g)oD / (r)aw / Esc:", + filename + ), + XisoUploadAction::God => format!( + "Detected XISO '{}'. e(x)tract / (G)oD / (r)aw / Esc:", + filename + ), + XisoUploadAction::Raw => format!( + "Detected XISO '{}'. e(x)tract / (g)oD / (R)aw / Esc:", + filename + ), + }; + app.pending_xiso_upload = Some((path.clone(), default)); + app.download_dir = + path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + app.input_mode = InputMode::ConfirmXisoUpload; + app.input_prompt = prompt; + app.input_buffer.clear(); + } else { let fatx_path = app.full_path(&filename); - app.set_status(&format!("Uploading '{}' (raw)...", filename)); + app.set_status(&format!("Uploading '{}'...", filename)); let _ = cmd_tx.send(IoCmd::WriteFile { - local_path: path, + local_path: path.clone(), fatx_path, }); + app.download_dir = + path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); app.is_busy = true; } } - } else if app.input_prompt.starts_with("New directory") { - // Mkdir - if !input.is_empty() { - let path = app.full_path(&input); - app.set_status(&format!("Creating '{}'...", input)); - let _ = cmd_tx.send(IoCmd::Mkdir { path }); - app.is_busy = true; + InputMode::ConfirmXisoUpload => { + let (path, default) = match app.pending_xiso_upload.take() { + Some(pair) => pair, + None => { + app.set_error("Internal: missing pending XISO path."); + return; + } + }; + let trimmed = input.trim(); + let action = if trimmed.is_empty() { + default + } else { + match trimmed.chars().next().map(|c| c.to_ascii_lowercase()) { + Some('x') => XisoUploadAction::Extract, + Some('g') => XisoUploadAction::God, + Some('r') => XisoUploadAction::Raw, + _ => { + app.set_error(&format!( + "Unknown choice {:?} — expected x, g, or r.", + trimmed + )); + return; + } + } + }; + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "iso".to_string()); + + match action { + XisoUploadAction::Extract => { + // Prefer a catalog-resolved folder name over the + // local filename stem: a disc named `disc1.iso` + // with TitleID 0x4D5307E6 should land at + // `/Halo 3 [4D5307E6]/` rather than `/disc1/`. + // Falls back to the file stem on catalog miss or + // unreadable XEX/XBE. + let stem = path + .file_stem() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.clone()); + let resolved = xiso_folder_name(&path).unwrap_or(stem); + let dest_dir = app.full_path(&resolved); + app.set_status(&format!("Extracting '{}' → {}...", filename, dest_dir)); + let _ = cmd_tx.send(IoCmd::ExtractXiso { + source: path, + dest_dir, + }); + app.is_busy = true; + } + XisoUploadAction::God => { + let dest_dir = app.cwd.clone(); + app.set_status(&format!( + "Converting '{}' to GoD under {}...", + filename, dest_dir + )); + let _ = cmd_tx.send(IoCmd::ConvertXisoToGod { + source: path, + dest_dir, + }); + app.is_busy = true; + } + XisoUploadAction::Raw => { + let fatx_path = app.full_path(&filename); + app.set_status(&format!("Uploading '{}' (raw)...", filename)); + let _ = cmd_tx.send(IoCmd::WriteFile { + local_path: path, + fatx_path, + }); + app.is_busy = true; + } + } } - } else if app.input_prompt.starts_with("Rename '") { - // Rename - let old_name = app.selected_name().unwrap_or_default(); - if !input.is_empty() { - let path = app.full_path(&old_name); - let _ = cmd_tx.send(IoCmd::Rename { - path, - new_name: input.clone(), - }); - app.is_busy = true; + InputMode::MkdirName => { + // Mkdir + if !input.is_empty() { + let path = app.full_path(&input); + app.set_status(&format!("Creating '{}'...", input)); + let _ = cmd_tx.send(IoCmd::Mkdir { path }); + app.is_busy = true; + } } - } else if app.input_prompt.starts_with("Delete '") { - // Confirm delete - if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") { - let name = app.selected_name().unwrap_or_default(); - let is_dir = app.selected_entry().map(|e| e.is_dir).unwrap_or(false); - let path = app.full_path(&name); - app.set_status(&format!("Deleting '{}'...", name)); - let _ = cmd_tx.send(IoCmd::Delete { - path, - recursive: is_dir, - }); - app.is_busy = true; - } else { - app.set_status("Delete cancelled."); + InputMode::RenameName => { + // Rename + let old_name = app.selected_name().unwrap_or_default(); + if !input.is_empty() { + let path = app.full_path(&old_name); + let _ = cmd_tx.send(IoCmd::Rename { + path, + new_name: input.clone(), + }); + app.is_busy = true; + } } - } else if app.input_prompt.contains("Delete? (y/n):") { - // Confirm cleanup - if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") { - let paths: Vec = app - .pending_cleanup - .drain(..) - .map(|(path, _, _)| path) - .collect(); - let count = paths.len(); - app.set_status(&format!("Deleting {} metadata entries...", count)); - let _ = cmd_tx.send(IoCmd::DeleteCleanup { paths }); - app.is_busy = true; - } else { - app.pending_cleanup.clear(); - app.set_status("Cleanup cancelled."); + InputMode::ConfirmDelete => { + // Confirm delete + if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") { + let (name, is_dir) = match app.pending_delete.take() { + Some(pair) => pair, + None => { + app.set_error("Internal: missing pending delete target."); + return; + } + }; + let path = app.full_path(&name); + app.set_status(&format!("Deleting '{}'...", name)); + let _ = cmd_tx.send(IoCmd::Delete { + path, + recursive: is_dir, + }); + app.is_busy = true; + } else { + app.pending_delete = None; + app.set_status("Delete cancelled."); + } } + InputMode::ConfirmCleanup => { + // Confirm cleanup + if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") { + let paths: Vec = app + .pending_cleanup + .drain(..) + .map(|(path, _, _)| path) + .collect(); + let count = paths.len(); + app.set_status(&format!("Deleting {} metadata entries...", count)); + let _ = cmd_tx.send(IoCmd::DeleteCleanup { paths }); + app.is_busy = true; + } else { + app.pending_cleanup.clear(); + app.set_status("Cleanup cancelled."); + } + } + InputMode::Normal => {} } } KeyCode::Backspace => { app.input_buffer.pop(); } KeyCode::Char(c) => { - if app.input_mode == InputMode::ConfirmDelete - || app.input_mode == InputMode::ConfirmCleanup - || app.input_mode == InputMode::ConfirmXisoUpload - { + if matches!( + app.input_mode, + InputMode::ConfirmDelete | InputMode::ConfirmCleanup | InputMode::ConfirmXisoUpload + ) { app.input_buffer = c.to_string(); } else { app.input_buffer.push(c); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 1b2ccfb..1b3458c 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -240,6 +240,9 @@ fn test_scan_image() { .output() .expect("run scan on image"); - // Scan on a small image may or may not find partitions, but should not crash. - let _ = output.status; + assert!( + output.status.success(), + "scan on image failed: {}", + String::from_utf8_lossy(&output.stderr) + ); }