mirror of
https://gitea.tendokyu.moe/beerpsi/fsdecrypt.git
synced 2026-10-01 19:27:57 +03:00
feat: auto-extract and merge delta .app updates in pure Rust
Reviewed-on: https://gitea.tendokyu.moe/beerpsi/fsdecrypt/pulls/3 Reviewed-by: beerpsi <beerpsi@noreply.gitea.tendokyu.moe>
This commit is contained in:
@@ -7,3 +7,5 @@
|
||||
*.ntfs
|
||||
*.exfat
|
||||
flamegraph.svg
|
||||
CLAUDE.md
|
||||
memory/
|
||||
|
||||
@@ -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 <FILES>...
|
||||
```
|
||||
|
||||
### 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)
|
||||
+261
-31
@@ -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<SystemTime> {
|
||||
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<PathBu
|
||||
Ok(output_path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delta merge support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Info about an extracted VHD, used for delta merge.
|
||||
struct ExtractedVhd {
|
||||
vhd_path: PathBuf,
|
||||
input_path: PathBuf,
|
||||
sequence_number: u8,
|
||||
game_id: String,
|
||||
}
|
||||
|
||||
/// Search a directory for a file matching `{prefix}*{suffix}`.
|
||||
fn find_sibling(dir: &Path, prefix: &str, suffix: &str) -> Option<PathBuf> {
|
||||
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<ExtractedVhd>,
|
||||
) -> 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<ExtractedVhd> = 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<String, Vec<&ExtractedVhd>> = 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(())
|
||||
}
|
||||
|
||||
+750
@@ -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<VhdGuid>,
|
||||
/// 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<VhdGuidInfo, VhdError> {
|
||||
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<u32>, block_size: u64 },
|
||||
}
|
||||
|
||||
impl VhdLayout {
|
||||
/// Parse the dynamic/differencing header and BAT.
|
||||
fn parse_sparse<R: Read + Seek>(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<u32> = (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<R: Read + Seek>(
|
||||
&self,
|
||||
inner: &mut R,
|
||||
virt_off: u64,
|
||||
virtual_size: u64,
|
||||
buf: &mut [u8],
|
||||
) -> io::Result<usize> {
|
||||
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<R: Read + Seek>(
|
||||
&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<R> {
|
||||
inner: R,
|
||||
layout: VhdLayout,
|
||||
ntfs_offset: u64,
|
||||
virtual_size: u64,
|
||||
pos: u64,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> VhdReader<R> {
|
||||
pub fn new(mut inner: R) -> Result<Self, VhdError> {
|
||||
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<R: Read + Seek> Read for VhdReader<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
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<R: Read + Seek> Seek for VhdReader<R> {
|
||||
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
|
||||
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<R> {
|
||||
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<R> {
|
||||
layers: Vec<VhdLayer<R>>,
|
||||
ntfs_offset: u64,
|
||||
virtual_size: u64,
|
||||
pos: u64,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> ChainedVhdReader<R> {
|
||||
/// Build a chain reader. `readers` must be ordered base-first, top-most delta last.
|
||||
pub fn new(readers: Vec<R>) -> Result<Self, VhdError> {
|
||||
if readers.is_empty() {
|
||||
return Err(VhdError::InvalidCookie);
|
||||
}
|
||||
|
||||
let mut layers: Vec<VhdLayer<R>> = 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<R: Read + Seek>(
|
||||
layer: &mut VhdLayer<R>,
|
||||
virt_off: u64,
|
||||
buf: &mut [u8],
|
||||
) -> io::Result<Option<usize>> {
|
||||
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<R: Read + Seek>(
|
||||
layers: &mut [VhdLayer<R>],
|
||||
vsize: u64,
|
||||
virt_off: u64,
|
||||
buf: &mut [u8],
|
||||
) -> io::Result<usize> {
|
||||
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<R: Read + Seek> Read for ChainedVhdReader<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
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<R: Read + Seek> Seek for ChainedVhdReader<R> {
|
||||
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
|
||||
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<R: Read + Seek>(
|
||||
inner: &mut R,
|
||||
layout: &VhdLayout,
|
||||
vsize: u64,
|
||||
) -> Result<u64, VhdError> {
|
||||
// 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<R: Read + Seek>(
|
||||
layers: &mut [VhdLayer<R>],
|
||||
vsize: u64,
|
||||
) -> Result<u64, VhdError> {
|
||||
// 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<R>], 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<T: Read + Seek>(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::<NtfsStandardInformation>() {
|
||||
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<T: Read + Seek>(
|
||||
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<T: Read + Seek>(
|
||||
ntfs: &Ntfs,
|
||||
fs: &mut T,
|
||||
dir: &ntfs::NtfsFile,
|
||||
) -> Result<u64> {
|
||||
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<T: Read + Seek>(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::<Vec<_>>()
|
||||
.join(" + ");
|
||||
println!("Extracting chained VHD: {paths_disp}");
|
||||
|
||||
let readers: Vec<File> = chain
|
||||
.iter()
|
||||
.map(|p| File::open(p))
|
||||
.collect::<io::Result<_>>()?;
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user