diff --git a/.gitignore b/.gitignore index 65a7303..a3c3401 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ *.ntfs *.exfat flamegraph.svg +CLAUDE.md +memory/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9dae751 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# fsdecrypt + +Decryptor and extractor for SEGA arcade filesystem containers (fscrypt format). Works on Windows and Linux, no admin privileges required. + +## Installation + +Requires [Rust](https://www.rust-lang.org/tools/install). + +```bash +cargo build --release +``` + +The binary will be at `target/release/fsdecrypt` (or `fsdecrypt.exe` on Windows). + +## Usage + +```bash +fsdecrypt ... +``` + +### Examples + +```bash +# Extract a game APP container +fsdecrypt ABCD_1.00.00_20240101120000_0.app + +# Extract an OPTION/DLC container +fsdecrypt ABCD_A001_20240101120000_0.opt + +# Extract multiple files at once +fsdecrypt game_v1.app game_v2.app extras.opt + +# Decrypt only (outputs raw .ntfs/.exfat image, no extraction) +fsdecrypt --no-extract ABCD_1.00.00_20240101120000_0.app +``` + +### Delta Updates + +Games often ship incremental updates as a separate `.app` file. Just pass the update file — fsdecrypt will automatically find the base in the same folder and merge them: + +```bash +fsdecrypt ABCD_1.01.00_20240215143000_1_1.00.00.app +``` + +You can also pass both explicitly: + +```bash +fsdecrypt ABCD_1.00.00_20240101120000_0.app ABCD_1.01.00_20240215143000_1_1.00.00.app +``` + +The output folder is named after the input file (e.g. `ABCD_1.01.00_20240215143000_1_1.00.00/`). + +## External Key Files + +For games not in the built-in key database, place a file named `{GAME_ID}.bin` in the working directory: + +- **16 bytes** for key only (IV derived automatically) +- **32 bytes** for key + IV + +## License + +[BSD Zero Clause License](LICENSE) (0BSD) diff --git a/src/main.rs b/src/main.rs index fded235..1d2d676 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fs::{create_dir_all, File, FileTimes}, io::{BufRead, BufReader, BufWriter, Write}, path::{Path, PathBuf}, @@ -22,28 +23,43 @@ use crate::stream::FscryptDecryptor; mod bootid; mod crypto; mod stream; +mod vhd; fn exfat_timestamp_to_system_time( timestamp: &exfat_fs::timestamp::Timestamp, ) -> Result { let exfat_date = timestamp.date(); let exfat_time = timestamp.time(); - // exFAT UTC offset is in 15-minute intervals, so 1 = UTC+00:15, 2 = UTC+00:30, etc. - let exfat_utc_offset = timestamp.utc_offset() as i32 * 15 * 60; - let chrono_date_time = FixedOffset::east_opt(exfat_utc_offset) - .ok_or_else(|| anyhow!("invaid utc offset: {}", timestamp.utc_offset()))? - .with_ymd_and_hms( - exfat_date.year as i32, - exfat_date.month as u32, - exfat_date.day as u32, - exfat_time.hour as u32, - exfat_time.minute as u32, - exfat_time.second as u32, - ) - .unwrap(); - return Ok(SystemTime::UNIX_EPOCH - + Duration::from_micros(chrono_date_time.timestamp_micros().try_into()?)); + // The exFAT UtcOffset byte packs an OffsetValid flag (bit 7) with a 7-bit + // two's-complement OffsetFromUtc in 15-minute units. When OffsetValid is 0 + // the timestamp has no timezone info and the offset bits must be ignored. + let raw = timestamp.utc_offset() as u8; + let offset_seconds = if raw & 0x80 == 0 { + 0 + } else { + let offset_quarters = (((raw & 0x7F) << 1) as i8) >> 1; + offset_quarters as i32 * 15 * 60 + }; + let fixed_offset = FixedOffset::east_opt(offset_seconds).unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()); + + let chrono_date_time = match fixed_offset.with_ymd_and_hms( + exfat_date.year as i32, + exfat_date.month as u32, + exfat_date.day as u32, + exfat_time.hour as u32, + exfat_time.minute as u32, + exfat_time.second as u32, + ) { + chrono::LocalResult::Single(dt) => dt, + _ => return Ok(SystemTime::UNIX_EPOCH), + }; + + let micros: u64 = chrono_date_time + .timestamp_micros() + .try_into() + .unwrap_or(0); + Ok(SystemTime::UNIX_EPOCH + Duration::from_micros(micros)) } fn extract_exfat_contents(exfat_path: &Path) -> Result<()> { @@ -94,23 +110,31 @@ fn extract_exfat_elements( match element { FsElement::F(ref mut file) => { let dest_path = output_dir.join(file.name()); - let mut dest = File::create(dest_path)?; + let mut dest = File::create(&dest_path)?; - dest.set_times( - FileTimes::new() - .set_accessed(exfat_timestamp_to_system_time( - file.timestamps().accessed(), - )?) - .set_modified(exfat_timestamp_to_system_time( - file.timestamps().modified(), - )?), - )?; - - let mut writer = BufWriter::with_capacity(256 * 1024, &mut dest); - - std::io::copy(file, &mut writer)?; - writer.flush()?; + { + let mut writer = BufWriter::with_capacity(256 * 1024, &mut dest); + std::io::copy(file, &mut writer)?; + writer.flush()?; + } pb.inc(file.len()); + + // set_times must run after writes — otherwise the kernel + // updates mtime/atime back to "now" when bytes are flushed. + let accessed = exfat_timestamp_to_system_time(file.timestamps().accessed()); + let modified = exfat_timestamp_to_system_time(file.timestamps().modified()); + if let (Ok(accessed), Ok(modified)) = (accessed, modified) { + if let Err(e) = dest.set_times( + FileTimes::new() + .set_accessed(accessed) + .set_modified(modified), + ) { + println!( + "WARNING: Failed to set times on {}: {e}", + dest_path.display() + ); + } + } } FsElement::D(directory) => { let dest_path = output_dir.join(directory.name()); @@ -208,6 +232,81 @@ fn extract_internal_vhd(image_path: &Path, sequence_number: u8) -> Result Option { + std::fs::read_dir(dir).ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + name.starts_with(prefix) && name.ends_with(suffix) + }) +} + +/// Resolve the base VHD for a delta: check extracted VHDs, then look for +/// an existing .vhd or .app in the same directory. +fn resolve_base_vhd<'a>( + base: Option<&'a ExtractedVhd>, + game_id: &str, + delta_dir: &Path, + out: &'a mut Option, +) -> Option<&'a ExtractedVhd> { + if let Some(b) = base { + return Some(b); + } + + let prefix = format!("{game_id}_"); + + // Look for existing base VHD + if let Some(vhd_path) = find_sibling(delta_dir, &prefix, "_0.vhd") { + println!("Found existing base VHD: {}", vhd_path.display()); + *out = Some(ExtractedVhd { + vhd_path, input_path: PathBuf::new(), + sequence_number: 0, game_id: game_id.into(), + }); + return out.as_ref(); + } + + // Look for base .app and extract + if let Some(app_path) = find_sibling(delta_dir, &prefix, "_0.app") { + println!("Found base APP, extracting: {}", app_path.display()); + if let Ok(f) = File::open(&app_path) + .and_then(|f| FscryptDecryptor::new(f).map_err(|e| std::io::Error::other(e))) + { + let seq = f.bootid.sequence_number; + drop(f); + if let Ok(vhd_path) = extract_internal_vhd(&app_path, seq) { + *out = Some(ExtractedVhd { + vhd_path, input_path: app_path, + sequence_number: 0, game_id: game_id.into(), + }); + return out.as_ref(); + } + } + println!("WARNING: Failed to extract base VHD from {}", app_path.display()); + } + + println!("WARNING: No base (seq=0) found for {game_id}."); + println!(" Place the base .app or .vhd in the same directory."); + None +} + +// --------------------------------------------------------------------------- +// CLI & main +// --------------------------------------------------------------------------- + #[derive(Parser)] #[command(version, about = "decryptor for some SEGA containers", long_about = None)] struct Cli { @@ -221,7 +320,18 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); + // Pre-read bootids to sort by sequence number (base first) + let mut inputs: Vec<(PathBuf, u8)> = Vec::new(); for path in &cli.files { + let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; + inputs.push((path.clone(), file.bootid.sequence_number)); + } + inputs.sort_by_key(|(_, seq)| *seq); + + // Track extracted VHDs for post-extraction merge + let mut extracted_vhds: Vec = Vec::new(); + + for (path, _) in &inputs { let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; let bootid = file.bootid.clone(); let output_filename = file.filename()?; @@ -263,7 +373,16 @@ fn main() -> Result<()> { match bootid.container_type { ContainerType::OS | ContainerType::APP => { match extract_internal_vhd(&path, bootid.sequence_number) { - Ok(_) => {} + Ok(vhd_path) => { + let game_id = + std::str::from_utf8(&bootid.game_id)?.trim_end().to_string(); + extracted_vhds.push(ExtractedVhd { + vhd_path, + input_path: path.clone(), + sequence_number: bootid.sequence_number, + game_id, + }); + } Err(e) => { println!("WARNING: Failed to extract internal VHD: {e:#?}"); } @@ -282,5 +401,116 @@ fn main() -> Result<()> { } } + // Post-extraction: merge deltas and extract VHD contents + if !cli.no_extract && !extracted_vhds.is_empty() { + let mut by_game: HashMap> = HashMap::new(); + for vhd in &extracted_vhds { + by_game.entry(vhd.game_id.clone()).or_default().push(vhd); + } + + for (game_id, vhds) in &by_game { + let base = vhds.iter().find(|v| v.sequence_number == 0).copied(); + let deltas: Vec<_> = vhds + .iter() + .filter(|v| v.sequence_number > 0) + .copied() + .collect(); + + if deltas.is_empty() { + // Standalone base VHD, just extract. + if let Some(base) = base { + let output_dir = base.input_path.with_extension(""); + if let Err(e) = vhd::extract_vhd(&base.vhd_path, &output_dir) { + println!("WARNING: VHD extraction failed: {e:#}"); + } + } + continue; + } + + let delta_dir = deltas[0].input_path.parent().unwrap_or(Path::new(".")); + let mut resolved = None; + let Some(base) = resolve_base_vhd(base, game_id, delta_dir, &mut resolved) else { + continue; + }; + + if let Err(e) = process_chain(base, &deltas) { + println!("WARNING: VHD chain processing failed for {game_id}: {e:#}"); + } + } + } + + Ok(()) +} + +/// Order deltas into a single parent→child chain by matching each delta's +/// Parent Unique ID to the previous VHD's own Unique Id, then extract each +/// patch level and finally delete the intermediate .vhd files. +fn process_chain(base: &ExtractedVhd, deltas: &[&ExtractedVhd]) -> Result<()> { + // Collect GUID info for base + all deltas up front (cheap — reads at most + // ~1.5 KiB per VHD). Track each VHD by its own GUID. + let base_info = vhd::read_vhd_guid_info(&base.vhd_path) + .map_err(|e| anyhow!("reading base {}: {e}", base.vhd_path.display()))?; + + let mut remaining: Vec<(vhd::VhdGuidInfo, &ExtractedVhd)> = Vec::with_capacity(deltas.len()); + for d in deltas { + match vhd::read_vhd_guid_info(&d.vhd_path) { + Ok(info) => remaining.push((info, *d)), + Err(e) => { + println!( + "WARNING: could not read VHD metadata for {}: {e} — skipping this delta", + d.vhd_path.display() + ); + } + } + } + + // Walk the chain: repeatedly look for the delta whose parent_id matches + // the last VHD's own_id. Stop if the link breaks so we can warn clearly. + let mut chain: Vec<&ExtractedVhd> = vec![base]; + let mut last_own_id = base_info.own_id; + while !remaining.is_empty() { + let pos = remaining + .iter() + .position(|(info, _)| info.parent_id == Some(last_own_id)); + let Some(pos) = pos else { break }; + let (info, vhd) = remaining.remove(pos); + chain.push(vhd); + last_own_id = info.own_id; + } + + if !remaining.is_empty() { + println!( + "WARNING: {} delta(s) could not be linked into the chain (missing parent VHD). \ + Make sure every intermediate patch is provided.", + remaining.len() + ); + for (_, v) in &remaining { + println!(" orphan: {}", v.input_path.display()); + } + } + + // chain[0] is the base; chain[i>=1] is a differencing VHD whose correct + // merged view is `chain[0..=i]`. Extract each patch level against its + // full parent chain. + for i in 1..chain.len() { + let layers: Vec<&Path> = chain[..=i].iter().map(|v| v.vhd_path.as_path()).collect(); + let output_dir = chain[i].input_path.with_extension(""); + if let Err(e) = vhd::extract_chained_vhd(&layers, &output_dir) { + println!( + "WARNING: chained VHD extraction failed for {}: {e:#}", + chain[i].input_path.display() + ); + } + } + + // All extractions done — now safe to delete the intermediate .vhd files. + // Includes the base (matches the previous auto-merge behavior of consuming + // the extracted VHD once done with it). + for v in &chain { + if let Err(e) = std::fs::remove_file(&v.vhd_path) { + println!("WARNING: Could not delete {}: {e}", v.vhd_path.display()); + } + } + Ok(()) } diff --git a/src/vhd.rs b/src/vhd.rs new file mode 100644 index 0000000..554ca2b --- /dev/null +++ b/src/vhd.rs @@ -0,0 +1,750 @@ +use std::{ + fs::{create_dir_all, File, FileTimes, OpenOptions}, + io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write}, + path::Path, + time::{Duration, SystemTime}, +}; + +use anyhow::{anyhow, Result}; +use indicatif::{ProgressBar, ProgressStyle}; +use ntfs::{structured_values::NtfsStandardInformation, Ntfs, NtfsAttributeType, NtfsTime}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SECTOR_SIZE: u64 = 512; +const BUF_SIZE: usize = 256 * 1024; + +// VHD format +const VHD_COOKIE: &[u8; 8] = b"conectix"; +const VHD_TYPE_FIXED: u32 = 2; +const VHD_TYPE_DYNAMIC: u32 = 3; +const VHD_TYPE_DIFFERENCING: u32 = 4; +const VHD_FOOTER_DISK_TYPE_OFFSET: usize = 0x3C; +const VHD_FOOTER_DATA_OFFSET: usize = 0x10; +/// VHD footer Unique Id (GUID), 16 bytes at offset 68 (0x44) — identifies this VHD. +const VHD_FOOTER_UNIQUE_ID_OFFSET: usize = 0x44; + +// Dynamic/differencing VHD header +const DYNAMIC_HEADER_COOKIE: &[u8; 8] = b"cxsparse"; +const DYNAMIC_HEADER_SIZE: usize = 1024; +const DYNAMIC_BAT_OFFSET_FIELD: usize = 0x10; +const DYNAMIC_MAX_ENTRIES_FIELD: usize = 0x18; +const DYNAMIC_BLOCK_SIZE_FIELD: usize = 0x20; +/// Dynamic header Parent Unique ID (GUID), 16 bytes at offset 40 (0x28) — only +/// meaningful for differencing VHDs; points at the parent VHD's footer Unique Id. +const DYNAMIC_PARENT_UNIQUE_ID_OFFSET: usize = 0x28; +const BAT_UNUSED: u32 = 0xFFFFFFFF; + +pub type VhdGuid = [u8; 16]; + +/// Chain-linking info read from a VHD. `parent_id` is `Some` only for differencing +/// disks (type 4), and points at the parent VHD's `own_id`. +#[derive(Debug, Clone)] +pub struct VhdGuidInfo { + pub own_id: VhdGuid, + pub parent_id: Option, + /// Kept for diagnostics / future validation; not every caller inspects it. + #[allow(dead_code)] + pub disk_type: u32, +} + +/// Read a VHD's Unique Id and (for differencing VHDs) its Parent Unique ID. +/// Cheap — only reads the 512-byte footer plus, if differencing, the 1024-byte +/// dynamic header. Used to build chains by matching child.parent_id -> parent.own_id. +pub fn read_vhd_guid_info(path: &Path) -> Result { + let mut f = File::open(path)?; + let size = f.seek(SeekFrom::End(0))?; + if size < SECTOR_SIZE { + return Err(VhdError::InvalidCookie); + } + f.seek(SeekFrom::Start(size - SECTOR_SIZE))?; + let mut footer = [0u8; SECTOR_SIZE as usize]; + f.read_exact(&mut footer)?; + if &footer[..8] != VHD_COOKIE { + return Err(VhdError::InvalidCookie); + } + let own_id: VhdGuid = footer[VHD_FOOTER_UNIQUE_ID_OFFSET..VHD_FOOTER_UNIQUE_ID_OFFSET + 16] + .try_into() + .unwrap(); + let disk_type = read_be_u32(&footer, VHD_FOOTER_DISK_TYPE_OFFSET); + + let parent_id = if disk_type == VHD_TYPE_DIFFERENCING { + let header_offset = read_be_u64(&footer, VHD_FOOTER_DATA_OFFSET); + f.seek(SeekFrom::Start(header_offset))?; + let mut hdr = [0u8; DYNAMIC_HEADER_SIZE]; + f.read_exact(&mut hdr)?; + if &hdr[..8] != DYNAMIC_HEADER_COOKIE { + return Err(VhdError::InvalidDynamicHeader); + } + let guid: VhdGuid = hdr + [DYNAMIC_PARENT_UNIQUE_ID_OFFSET..DYNAMIC_PARENT_UNIQUE_ID_OFFSET + 16] + .try_into() + .unwrap(); + Some(guid) + } else { + None + }; + + Ok(VhdGuidInfo { own_id, parent_id, disk_type }) +} + +// MBR +const MBR_SIGNATURE: [u8; 2] = [0x55, 0xAA]; +const MBR_PARTITION_TABLE_OFFSET: usize = 0x1BE; +const MBR_PARTITION_ENTRY_SIZE: usize = 16; +const MBR_MAX_PARTITIONS: usize = 4; +const NTFS_PARTITION_TYPE: u8 = 0x07; + +// NTFS boot sector magic +const NTFS_MAGIC: [u8; 4] = [0xEB, 0x52, 0x90, 0x4E]; +/// Common virtual offsets where NTFS boot sector might start. +const NTFS_PROBE_OFFSETS: [u64; 4] = [0, 32_256, 1_048_576, 512]; + +// Progress bar +const PROGRESS_STYLE: &str = + "{prefix} [{bar:20!.bright.yellow/dim.white}] {bytes:>8} [{elapsed}<{eta}, {bytes_per_sec}]"; + +// Windows epoch -> Unix epoch offset (100ns intervals) +const WINDOWS_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; + +// --------------------------------------------------------------------------- +// VHD error type +// --------------------------------------------------------------------------- + +#[derive(Debug, thiserror::Error)] +pub enum VhdError { + #[error(transparent)] + Io(#[from] io::Error), + #[error("Not a valid VHD file")] + InvalidCookie, + #[error("Unsupported VHD type {0}")] + UnsupportedType(u32), + #[error("Invalid dynamic VHD header")] + InvalidDynamicHeader, + #[error("No NTFS partition found in VHD")] + NoNtfsPartition, +} + +// --------------------------------------------------------------------------- +// VHD layout: how to map virtual offsets to file offsets +// --------------------------------------------------------------------------- + +enum VhdLayout { + /// Data is contiguous from offset 0 to (file_size - 512). + Fixed, + /// Data is in blocks addressed via a Block Allocation Table. + /// Used for both dynamic (type 3) and differencing (type 4) VHDs. + Sparse { bat: Vec, block_size: u64 }, +} + +impl VhdLayout { + /// Parse the dynamic/differencing header and BAT. + fn parse_sparse(inner: &mut R, footer: &[u8]) -> Result<(Self, u64), VhdError> { + let header_offset = read_be_u64(footer, VHD_FOOTER_DATA_OFFSET); + + inner.seek(SeekFrom::Start(header_offset))?; + let mut hdr = [0u8; DYNAMIC_HEADER_SIZE]; + inner.read_exact(&mut hdr)?; + if &hdr[..8] != DYNAMIC_HEADER_COOKIE { + return Err(VhdError::InvalidDynamicHeader); + } + + let bat_offset = read_be_u64(&hdr, DYNAMIC_BAT_OFFSET_FIELD); + let max_entries = read_be_u32(&hdr, DYNAMIC_MAX_ENTRIES_FIELD) as usize; + let block_size = read_be_u32(&hdr, DYNAMIC_BLOCK_SIZE_FIELD) as u64; + + inner.seek(SeekFrom::Start(bat_offset))?; + let mut raw = vec![0u8; max_entries * 4]; + inner.read_exact(&mut raw)?; + let bat: Vec = (0..max_entries).map(|i| read_be_u32(&raw, i * 4)).collect(); + + Ok((VhdLayout::Sparse { bat, block_size }, max_entries as u64 * block_size)) + } + + /// Read bytes from a virtual offset according to this layout. + fn read_at( + &self, + inner: &mut R, + virt_off: u64, + virtual_size: u64, + buf: &mut [u8], + ) -> io::Result { + if virt_off >= virtual_size { + return Ok(0); + } + let cap = std::cmp::min(buf.len() as u64, virtual_size - virt_off) as usize; + + match self { + VhdLayout::Fixed => { + inner.seek(SeekFrom::Start(virt_off))?; + inner.read(&mut buf[..cap]) + } + VhdLayout::Sparse { bat, block_size } => { + let bi = (virt_off / block_size) as usize; + let bo = virt_off % block_size; + let n = std::cmp::min(cap, (block_size - bo) as usize); + + if bi >= bat.len() || bat[bi] == BAT_UNUSED { + buf[..n].fill(0); + Ok(n) + } else { + // Each block: bitmap sector + data. Skip bitmap. + let file_off = bat[bi] as u64 * SECTOR_SIZE + SECTOR_SIZE + bo; + inner.seek(SeekFrom::Start(file_off))?; + inner.read(&mut buf[..n]) + } + } + } + } + + /// Read 4 bytes from a virtual offset (for magic-byte probing). + fn read_magic( + &self, + inner: &mut R, + offset: u64, + ) -> io::Result<[u8; 4]> { + let mut buf = [0u8; 4]; + match self { + VhdLayout::Fixed => { + inner.seek(SeekFrom::Start(offset))?; + inner.read_exact(&mut buf)?; + } + VhdLayout::Sparse { bat, block_size } => { + let bi = (offset / block_size) as usize; + if bi < bat.len() && bat[bi] != BAT_UNUSED { + let file_off = bat[bi] as u64 * SECTOR_SIZE + SECTOR_SIZE + offset % block_size; + inner.seek(SeekFrom::Start(file_off))?; + inner.read_exact(&mut buf)?; + } + } + } + Ok(buf) + } +} + +// --------------------------------------------------------------------------- +// VHD reader (single VHD) +// --------------------------------------------------------------------------- + +/// Transparently presents the NTFS partition within a fixed or dynamic VHD. +pub struct VhdReader { + inner: R, + layout: VhdLayout, + ntfs_offset: u64, + virtual_size: u64, + pos: u64, +} + +impl VhdReader { + pub fn new(mut inner: R) -> Result { + let file_size = inner.seek(SeekFrom::End(0))?; + if file_size < SECTOR_SIZE { + return Err(VhdError::InvalidCookie); + } + + inner.seek(SeekFrom::Start(file_size - SECTOR_SIZE))?; + let mut footer = [0u8; SECTOR_SIZE as usize]; + inner.read_exact(&mut footer)?; + if &footer[..8] != VHD_COOKIE { + return Err(VhdError::InvalidCookie); + } + + let disk_type = read_be_u32(&footer, VHD_FOOTER_DISK_TYPE_OFFSET); + let (layout, virtual_size) = match disk_type { + VHD_TYPE_FIXED => (VhdLayout::Fixed, file_size - SECTOR_SIZE), + VHD_TYPE_DYNAMIC | VHD_TYPE_DIFFERENCING => { + VhdLayout::parse_sparse(&mut inner, &footer)? + } + t => return Err(VhdError::UnsupportedType(t)), + }; + + let ntfs_offset = find_ntfs_offset(&mut inner, &layout, virtual_size)?; + Ok(Self { inner, layout, ntfs_offset, virtual_size, pos: 0 }) + } + + fn ntfs_size(&self) -> u64 { + self.virtual_size - self.ntfs_offset + } +} + +impl Read for VhdReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let remaining = self.ntfs_size().saturating_sub(self.pos); + if remaining == 0 { + return Ok(0); + } + let cap = std::cmp::min(buf.len() as u64, remaining) as usize; + let n = self.layout.read_at( + &mut self.inner, self.ntfs_offset + self.pos, self.virtual_size, &mut buf[..cap], + )?; + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for VhdReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let target = match pos { + SeekFrom::Start(o) => o as i64, + SeekFrom::Current(o) => self.pos as i64 + o, + SeekFrom::End(o) => self.ntfs_size() as i64 + o, + }; + if target < 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek before start")); + } + self.pos = target as u64; + Ok(self.pos) + } +} + +// --------------------------------------------------------------------------- +// Chained VHD reader (base + N deltas overlaid, no on-disk merge needed) +// --------------------------------------------------------------------------- + +/// One layer of a VHD chain: a file handle plus its parsed layout. +struct VhdLayer { + inner: R, + layout: VhdLayout, +} + +/// Reads from a chain of VHDs where `layers[0]` is the base (dynamic/fixed) +/// and `layers[1..]` are differencing VHDs in parent→child order. +/// +/// For each read, walks layers from top delta down to base. At each layer, +/// if the sector is present-and-modified (BAT allocated + bitmap bit set), +/// that layer's bytes win; otherwise the read falls through to the layer +/// below. The base layer's own `read_at` handles zero-fill for unallocated +/// dynamic blocks. +pub struct ChainedVhdReader { + layers: Vec>, + ntfs_offset: u64, + virtual_size: u64, + pos: u64, +} + +impl ChainedVhdReader { + /// Build a chain reader. `readers` must be ordered base-first, top-most delta last. + pub fn new(readers: Vec) -> Result { + if readers.is_empty() { + return Err(VhdError::InvalidCookie); + } + + let mut layers: Vec> = Vec::with_capacity(readers.len()); + let mut virtual_size = 0u64; + + for (idx, mut r) in readers.into_iter().enumerate() { + let file_size = r.seek(SeekFrom::End(0))?; + if file_size < SECTOR_SIZE { + return Err(VhdError::InvalidCookie); + } + r.seek(SeekFrom::Start(file_size - SECTOR_SIZE))?; + let mut footer = [0u8; SECTOR_SIZE as usize]; + r.read_exact(&mut footer)?; + if &footer[..8] != VHD_COOKIE { + return Err(VhdError::InvalidCookie); + } + let disk_type = read_be_u32(&footer, VHD_FOOTER_DISK_TYPE_OFFSET); + let (layout, vsize) = match disk_type { + VHD_TYPE_FIXED => (VhdLayout::Fixed, file_size - SECTOR_SIZE), + VHD_TYPE_DYNAMIC | VHD_TYPE_DIFFERENCING => { + VhdLayout::parse_sparse(&mut r, &footer)? + } + t => return Err(VhdError::UnsupportedType(t)), + }; + if idx == 0 { + virtual_size = vsize; + } + layers.push(VhdLayer { inner: r, layout }); + } + + let ntfs_offset = find_ntfs_offset_chain(&mut layers, virtual_size)?; + + Ok(Self { layers, ntfs_offset, virtual_size, pos: 0 }) + } + + fn ntfs_size(&self) -> u64 { + self.virtual_size - self.ntfs_offset + } +} + +/// If `layer` has the sector for `virt_off` present AND marked modified in +/// its bitmap, read from it and return `Some(bytes_read)`. Otherwise `None` +/// signals "fall through to the layer below". +/// +/// Reads are capped at the current sector boundary. The VHD bitmap is +/// per-sector: a single block can have a mixed 1/0 pattern, so a larger read +/// might cross a sector that belongs to a different layer. The Read +/// implementation loops until `buf` is filled, amortising the extra calls. +fn try_read_from_layer( + layer: &mut VhdLayer, + virt_off: u64, + buf: &mut [u8], +) -> io::Result> { + match &layer.layout { + VhdLayout::Fixed => Ok(None), // Fixed deltas make no sense; fall through. + VhdLayout::Sparse { bat, block_size } => { + let bi = (virt_off / block_size) as usize; + let bo = virt_off % block_size; + if bi >= bat.len() || bat[bi] == BAT_UNUSED { + return Ok(None); + } + + // Cap at the current sector to honour per-sector bitmap semantics. + let sector_remaining = (SECTOR_SIZE - (virt_off % SECTOR_SIZE)) as usize; + let n = std::cmp::min(buf.len(), sector_remaining); + let block_file_offset = bat[bi] as u64 * SECTOR_SIZE; + + // Read the block's bitmap sector. + layer.inner.seek(SeekFrom::Start(block_file_offset))?; + let mut bitmap = [0u8; SECTOR_SIZE as usize]; + layer.inner.read_exact(&mut bitmap)?; + + let sector_in_block = (bo / SECTOR_SIZE) as usize; + let bitmap_byte = bitmap[sector_in_block / 8]; + let bitmap_bit = 7 - (sector_in_block % 8); // MSB first + if (bitmap_byte >> bitmap_bit) & 1 == 0 { + return Ok(None); + } + + let file_off = block_file_offset + SECTOR_SIZE + bo; + layer.inner.seek(SeekFrom::Start(file_off))?; + let got = layer.inner.read(&mut buf[..n])?; + Ok(Some(got)) + } + } +} + +/// Walk layers top-to-bottom; first layer that owns the sector wins. +/// The base layer (index 0) always answers (possibly with zeros for +/// unallocated dynamic blocks). +fn read_chain( + layers: &mut [VhdLayer], + vsize: u64, + virt_off: u64, + buf: &mut [u8], +) -> io::Result { + if virt_off >= vsize { + return Ok(0); + } + let cap = std::cmp::min(buf.len() as u64, vsize - virt_off) as usize; + + // Try deltas from top (last) down to just above base (index 1). + for i in (1..layers.len()).rev() { + if let Some(n) = try_read_from_layer(&mut layers[i], virt_off, &mut buf[..cap])? { + return Ok(n); + } + } + // Fall through to base. + let base = &mut layers[0]; + base.layout.read_at(&mut base.inner, virt_off, vsize, &mut buf[..cap]) +} + +impl Read for ChainedVhdReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let remaining = self.ntfs_size().saturating_sub(self.pos); + if remaining == 0 { + return Ok(0); + } + let cap = std::cmp::min(buf.len() as u64, remaining) as usize; + let virt_off = self.ntfs_offset + self.pos; + let n = read_chain(&mut self.layers, self.virtual_size, virt_off, &mut buf[..cap])?; + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for ChainedVhdReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let target = match pos { + SeekFrom::Start(o) => o as i64, + SeekFrom::Current(o) => self.pos as i64 + o, + SeekFrom::End(o) => self.ntfs_size() as i64 + o, + }; + if target < 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek before start")); + } + self.pos = target as u64; + Ok(self.pos) + } +} + +// --------------------------------------------------------------------------- +// NTFS partition detection (shared between single and merged readers) +// --------------------------------------------------------------------------- + +/// Find NTFS offset in a single VHD. +fn find_ntfs_offset( + inner: &mut R, + layout: &VhdLayout, + vsize: u64, +) -> Result { + // Try MBR + if vsize >= SECTOR_SIZE { + let mut mbr = [0u8; SECTOR_SIZE as usize]; + let _ = layout.read_at(inner, 0, vsize, &mut mbr); + + if mbr[510..512] == MBR_SIGNATURE { + for i in 0..MBR_MAX_PARTITIONS { + let eo = MBR_PARTITION_TABLE_OFFSET + i * MBR_PARTITION_ENTRY_SIZE; + if mbr[eo + 4] == NTFS_PARTITION_TYPE { + let lba = u32::from_le_bytes(mbr[eo + 8..eo + 12].try_into().unwrap()); + let offset = lba as u64 * SECTOR_SIZE; + if offset + 4 <= vsize && layout.read_magic(inner, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + } + } + } + + // Probe common offsets + for offset in NTFS_PROBE_OFFSETS { + if offset + 4 <= vsize && layout.read_magic(inner, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + + Err(VhdError::NoNtfsPartition) +} + +/// Find NTFS offset in a chained view (base + N deltas). +fn find_ntfs_offset_chain( + layers: &mut [VhdLayer], + vsize: u64, +) -> Result { + // If the chain has only a base, defer to the single-VHD finder — it's simpler + // and avoids the bitmap machinery for a pure dynamic/fixed disk. + if layers.len() == 1 { + let base = &mut layers[0]; + return find_ntfs_offset(&mut base.inner, &base.layout, vsize); + } + + let read_magic = |layers: &mut [VhdLayer], offset: u64| -> io::Result<[u8; 4]> { + let mut buf = [0u8; 4]; + read_chain(layers, vsize, offset, &mut buf)?; + Ok(buf) + }; + + // Try MBR from merged view. + if vsize >= SECTOR_SIZE { + let mut mbr = [0u8; SECTOR_SIZE as usize]; + read_chain(layers, vsize, 0, &mut mbr)?; + + if mbr[510..512] == MBR_SIGNATURE { + for i in 0..MBR_MAX_PARTITIONS { + let eo = MBR_PARTITION_TABLE_OFFSET + i * MBR_PARTITION_ENTRY_SIZE; + if mbr[eo + 4] == NTFS_PARTITION_TYPE { + let lba = u32::from_le_bytes(mbr[eo + 8..eo + 12].try_into().unwrap()); + let offset = lba as u64 * SECTOR_SIZE; + if offset + 4 <= vsize && read_magic(layers, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + } + } + } + + for offset in NTFS_PROBE_OFFSETS { + if offset + 4 <= vsize && read_magic(layers, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + + Err(VhdError::NoNtfsPartition) +} + +// --------------------------------------------------------------------------- +// NTFS extraction (shared logic) +// --------------------------------------------------------------------------- + +fn is_ntfs_system_entry(name: &str) -> bool { + name.starts_with('$') || name == "." || name == ".." || name == "System Volume Information" +} + +fn ntfs_time_to_system_time(t: NtfsTime) -> SystemTime { + let nanos = (t.nt_timestamp() - WINDOWS_EPOCH_OFFSET) * 100; + SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos) +} + +fn set_ntfs_timestamps(fs: &mut T, file: &ntfs::NtfsFile, path: &Path) { + let mut attrs = file.attributes(); + while let Some(Ok(attr)) = attrs.next(fs) { + if let Ok(attr) = attr.to_attribute() { + if let Ok(NtfsAttributeType::StandardInformation) = attr.ty() { + if let Ok(info) = attr.resident_structured_value::() { + let _ = OpenOptions::new().write(true).open(path).and_then(|h| { + h.set_times( + FileTimes::new() + .set_accessed(ntfs_time_to_system_time(info.access_time())) + .set_modified(ntfs_time_to_system_time(info.modification_time())), + ) + }); + } + break; + } + } + } +} + +fn extract_ntfs_dir( + ntfs: &Ntfs, + fs: &mut T, + dir: &ntfs::NtfsFile, + out: &Path, + pb: &ProgressBar, +) -> Result<()> { + let index = dir.directory_index(fs)?; + let mut iter = index.entries(); + + while let Some(entry) = iter.next(fs) { + let entry = entry?; + let key = entry.key().ok_or_else(|| anyhow!("missing key"))??; + let name = key.name().to_string_lossy(); + if is_ntfs_system_entry(&name) { + continue; + } + + let file = entry.to_file(ntfs, fs)?; + let dest = out.join(&*name); + + if key.is_directory() { + create_dir_all(&dest)?; + extract_ntfs_dir(ntfs, fs, &file, &dest, pb)?; + set_ntfs_timestamps(fs, &file, &dest); + } else if let Some(data) = file.data(fs, "") { + let data_item = data?; + let attr = data_item.to_attribute()?; + let mut reader = BufReader::with_capacity(BUF_SIZE, attr.value(fs)?.attach(fs)); + let mut out_file = File::create(&dest)?; + + loop { + let buf = reader.fill_buf()?; + if buf.is_empty() { + break; + } + out_file.write_all(buf)?; + let n = buf.len(); + reader.consume(n); + pb.inc(n as u64); + } + out_file.flush()?; + drop(reader); + set_ntfs_timestamps(fs, &file, &dest); + } + } + Ok(()) +} + +fn calculate_ntfs_size( + ntfs: &Ntfs, + fs: &mut T, + dir: &ntfs::NtfsFile, +) -> Result { + let mut total = 0u64; + let index = dir.directory_index(fs)?; + let mut iter = index.entries(); + + while let Some(entry) = iter.next(fs) { + let entry = entry?; + let key = entry.key().ok_or_else(|| anyhow!("missing key"))??; + if is_ntfs_system_entry(&key.name().to_string_lossy().as_ref()) { + continue; + } + let file = entry.to_file(ntfs, fs)?; + if key.is_directory() { + total += calculate_ntfs_size(ntfs, fs, &file)?; + } else if let Some(data) = file.data(fs, "") { + total += data?.to_attribute()?.value_length(); + } + } + Ok(total) +} + +/// Shared extraction logic: given an NTFS-bearing Read+Seek, extract to output_dir. +fn extract_ntfs_to_dir(fs: &mut T, output_dir: &Path, prefix: &str) -> Result<()> { + let mut ntfs = Ntfs::new(fs)?; + ntfs.read_upcase_table(fs)?; + + let root = ntfs.root_directory(fs)?; + let total = calculate_ntfs_size(&ntfs, fs, &root)?; + + let pb = ProgressBar::new(total) + .with_style(ProgressStyle::default_bar().template(PROGRESS_STYLE)?); + pb.set_prefix(prefix.to_string()); + + create_dir_all(output_dir)?; + let root = ntfs.root_directory(fs)?; + extract_ntfs_dir(&ntfs, fs, &root, output_dir, &pb)?; + pb.finish(); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Extract all files from a single VHD's NTFS filesystem, then delete the VHD. +/// The `output_dir` is where files are extracted to. +pub fn extract_vhd(vhd_path: &Path, output_dir: &Path) -> Result<()> { + println!("Extracting VHD: {}", vhd_path.display()); + + let mut vhd = VhdReader::new(File::open(vhd_path)?).map_err(|e| anyhow!(e))?; + let prefix = output_dir.file_name().unwrap_or_default().to_string_lossy().to_string(); + extract_ntfs_to_dir(&mut vhd, output_dir, &prefix)?; + + println!("Extracted to: {}", output_dir.display()); + + drop(vhd); + if let Err(e) = std::fs::remove_file(vhd_path) { + println!("WARNING: Could not delete VHD: {e}"); + } + Ok(()) +} + +/// Extract files from a chained view of a base + N differencing VHDs. +/// +/// `chain` must be ordered base-first, top-most delta last. A chain of length 1 +/// is equivalent to extracting just the base. Unlike [`extract_vhd`], this does +/// **not** delete the inputs — the caller is responsible, since a single VHD +/// in a chain is typically consumed by multiple extractions (one per patch +/// level) and must not be removed until all of them have completed. +pub fn extract_chained_vhd(chain: &[&Path], output_dir: &Path) -> Result<()> { + if chain.is_empty() { + return Err(anyhow!("extract_chained_vhd: empty chain")); + } + + let paths_disp = chain + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(" + "); + println!("Extracting chained VHD: {paths_disp}"); + + let readers: Vec = chain + .iter() + .map(|p| File::open(p)) + .collect::>()?; + let mut reader = ChainedVhdReader::new(readers).map_err(|e| anyhow!(e))?; + + let prefix = output_dir.file_name().unwrap_or_default().to_string_lossy().to_string(); + extract_ntfs_to_dir(&mut reader, output_dir, &prefix)?; + + println!("Extracted to: {}", output_dir.display()); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn read_be_u32(buf: &[u8], offset: usize) -> u32 { + u32::from_be_bytes(buf[offset..offset + 4].try_into().unwrap()) +} + +fn read_be_u64(buf: &[u8], offset: usize) -> u64 { + u64::from_be_bytes(buf[offset..offset + 8].try_into().unwrap()) +}