Discos virtuais: leitura de QCOW2, VMDK, VHD, VHDX e DMG

Cada contêiner é aberto como o disco que contém, somente leitura, pela mesma
open_path das imagens E01 e AFF4. VHD e VHDX diferenciais seguem a cadeia de pais.
QCOW2 com arquivo de apoio, VMDK com pai, DMG cifrado e LZFSE são recusados com o
motivo. Conferido por hash contra o 7-Zip em imagens reais do dfvfs e do VirtualBox.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 16:00:48 -03:00
1 parent c1c4f442d7
commit 213a5a3df5
22 files changed
+2023 -4

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ pub mod source;
pub mod sqlite;
pub mod thumb;
pub mod types;
pub mod vdisk;
pub mod volume;
pub use error::{Error, Result};
+7 -1
View File
@@ -286,7 +286,13 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
return Ok(Arc::new(e));
}
Ok(Arc::new(file))
drop(file);
if let Some(v) = crate::vdisk::open(path)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?
{
return Ok(Arc::new(v));
}
Ok(Arc::new(FileSource::open(path)?))
}
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
+233
View File
@@ -0,0 +1,233 @@
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
//! zlib e bzip2. Imagens cifradas (`encrcdsa`) e chunks LZFSE (`ULFO`) são recusados com
//! mensagem clara.
use super::{bad, be32, be64, read_exact, read_vec, Backend};
use crate::source::{FileSource, Source};
use std::io::{self, Read};
use std::sync::Mutex;
const SECTOR: u64 = 512;
pub(super) fn is_dmg(file: &FileSource) -> bool {
let size = file.size();
if size < 1024 {
return false;
}
let mut t = [0u8; 512];
read_exact(file, size - SECTOR, &mut t).is_ok()
&& &t[..4] == b"koly"
&& be32(&t, 4) == 4
&& be32(&t, 8) == 512
}
/// Imagem cifrada pelo `hdiutil`: começa com `encrcdsa` ou `cdsaencr`.
pub(super) fn is_encrypted(head: &[u8]) -> bool {
head.starts_with(b"encrcdsa") || head.starts_with(b"cdsaencr")
}
#[derive(Clone, Copy)]
struct Chunk {
start: u64,
len: u64,
ty: u32,
off: u64,
comp_len: u64,
}
pub(super) struct Dmg {
file: FileSource,
size: u64,
chunks: Vec<Chunk>,
cache: Mutex<Option<(usize, Vec<u8>)>>,
}
fn base64(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let (mut acc, mut bits) = (0u32, 0);
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => continue,
};
acc = acc << 6 | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
out
}
/// Descompacta ADC (Apple Data Compression).
fn adc(src: &[u8], max: usize) -> io::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(max);
let mut i = 0;
while i < src.len() && out.len() < max {
let c = src[i];
i += 1;
if c & 0x80 != 0 {
let n = (c & 0x7F) as usize + 1;
let lit = src.get(i..i + n).ok_or_else(|| bad("chunk ADC truncado"))?;
out.extend_from_slice(lit);
i += n;
} else {
let (len, off) = if c & 0x40 != 0 {
let b = src.get(i..i + 2).ok_or_else(|| bad("chunk ADC truncado"))?;
i += 2;
(
(c & 0x3F) as usize + 4,
(b[0] as usize) << 8 | b[1] as usize,
)
} else {
let b = *src.get(i).ok_or_else(|| bad("chunk ADC truncado"))?;
i += 1;
((c >> 2) as usize + 3, ((c & 3) as usize) << 8 | b as usize)
};
let off = off + 1;
if off > out.len() {
return Err(bad("referência ADC inválida"));
}
for _ in 0..len {
out.push(out[out.len() - off]);
}
}
}
Ok(out)
}
impl Dmg {
pub(super) fn open(file: FileSource) -> io::Result<Self> {
let mut head = [0u8; 8];
let _ = file.read_at(0, &mut head);
if is_encrypted(&head) {
return Err(bad(
"DMG cifrado (encrcdsa) não é suportado; desproteja a imagem antes",
));
}
let size = file.size();
let t = read_vec(&file, size - SECTOR, SECTOR as usize)?;
let fork = be64(&t, 24);
let xml_off = be64(&t, 216);
let xml_len = be64(&t, 224);
let sectors = be64(&t, 492);
if xml_len == 0 || xml_len > 256 << 20 {
return Err(bad("plist do DMG ausente ou grande demais"));
}
let xml = read_vec(&file, xml_off, xml_len as usize)?;
let xml = String::from_utf8_lossy(&xml);
let from = xml
.find("<key>blkx</key>")
.ok_or_else(|| bad("tabela blkx não encontrada no DMG"))?;
let rest = &xml[from..];
let rest = &rest[..rest.find("</array>").unwrap_or(rest.len())];
let mut chunks: Vec<Chunk> = vec![];
let mut cur = rest;
while let Some(a) = cur.find("<data>") {
let after = &cur[a + 6..];
let Some(b) = after.find("</data>") else {
break;
};
let mish = base64(&after[..b]);
cur = &after[b + 7..];
if mish.len() < 204 || &mish[..4] != b"mish" {
continue;
}
let first = be64(&mish, 8);
let data_off = be64(&mish, 24);
let n = be32(&mish, 200) as usize;
for i in 0..n {
let Some(c) = mish.get(204 + i * 40..204 + (i + 1) * 40) else {
break;
};
let ty = be32(c, 0);
if ty == 0x7FFF_FFFE || ty == 0xFFFF_FFFF {
continue;
}
chunks.push(Chunk {
start: (first + be64(c, 8)) * SECTOR,
len: be64(c, 16) * SECTOR,
ty,
off: fork + data_off + be64(c, 24),
comp_len: be64(c, 32),
});
}
}
if chunks.is_empty() {
return Err(bad("DMG sem chunks"));
}
chunks.sort_by_key(|c| c.start);
let end = chunks.iter().map(|c| c.start + c.len).max().unwrap_or(0);
Ok(Self {
file,
size: end.max(sectors * SECTOR),
chunks,
cache: Mutex::new(None),
})
}
fn decode(&self, c: &Chunk) -> io::Result<Vec<u8>> {
if c.len > 64 << 20 || c.comp_len > 64 << 20 {
return Err(bad("chunk de DMG grande demais"));
}
let raw = read_vec(&self.file, c.off, c.comp_len as usize)?;
let mut out = Vec::with_capacity(c.len as usize);
match c.ty {
0x8000_0004 => out = adc(&raw, c.len as usize)?,
0x8000_0005 => {
flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut out)?;
}
0x8000_0006 => {
bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?;
}
0x8000_0007 => {
return Err(bad("chunk LZFSE (formato ULFO) ainda não é suportado"));
}
t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))),
}
out.resize(c.len as usize, 0);
Ok(out)
}
}
impl Backend for Dmg {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let i = self.chunks.partition_point(|c| c.start + c.len <= voff);
let Some(c) = self.chunks.get(i).copied() else {
let n = buf.len().min((self.size - voff) as usize);
buf[..n].fill(0);
return Ok(n);
};
if voff < c.start {
// espaço que nenhuma tabela cobre
let n = buf.len().min((c.start - voff) as usize);
buf[..n].fill(0);
return Ok(n);
}
let rel = voff - c.start;
let n = buf.len().min((c.len - rel) as usize);
match c.ty {
0 | 2 => buf[..n].fill(0),
1 => read_exact(&self.file, c.off + rel, &mut buf[..n])?,
_ => {
let mut cache = self.cache.lock().unwrap();
if cache.as_ref().map(|x| x.0) != Some(i) {
*cache = Some((i, self.decode(&c)?));
}
let data = &cache.as_ref().unwrap().1;
buf[..n].copy_from_slice(&data[rel as usize..rel as usize + n]);
}
}
Ok(n)
}
}
+245
View File
@@ -0,0 +1,245 @@
//! Imagens de disco virtual e contêineres: QCOW2, VMDK, VHD, VHDX e DMG (UDIF).
//!
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
//! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos
//! diferenciais (VHD e VHDX com pai) seguem a cadeia até o disco base; QCOW2 e VMDK com
//! arquivo de apoio são recusados com mensagem clara, porque ler só a camada filha
//! devolveria dados errados.
mod dmg;
mod qcow2;
mod vhd;
mod vhdx;
mod vmdk;
use crate::error::{Error, Result};
use crate::source::{FileSource, Source};
use std::io;
use std::path::Path;
pub(crate) fn be16(b: &[u8], o: usize) -> u16 {
u16::from_be_bytes(b[o..o + 2].try_into().unwrap())
}
pub(crate) fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
}
pub(crate) fn be64(b: &[u8], o: usize) -> u64 {
u64::from_be_bytes(b[o..o + 8].try_into().unwrap())
}
pub(crate) fn le16(b: &[u8], o: usize) -> u16 {
u16::from_le_bytes(b[o..o + 2].try_into().unwrap())
}
pub(crate) fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
pub(crate) fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
/// Leitura dentro de um único bloco do formato; devolve quantos bytes entregou (pode ser
/// menos que `buf.len()`, nunca zero antes do fim do disco).
pub(crate) trait Backend: Send + Sync {
fn size(&self) -> u64;
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize>;
}
/// Disco virtual aberto de uma imagem de contêiner.
pub struct VDisk {
inner: Box<dyn Backend>,
format: &'static str,
}
impl VDisk {
/// Nome do formato (`QCOW2`, `VMDK`, `VHD`, `VHDX`, `DMG`).
pub fn format(&self) -> &'static str {
self.format
}
}
impl Source for VDisk {
fn size(&self) -> u64 {
self.inner.size()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
let size = self.inner.size();
if offset >= size {
return Ok(0);
}
let want = buf.len().min((size - offset) as usize);
let mut done = 0;
while done < want {
let n = self
.inner
.read_chunk(offset + done as u64, &mut buf[done..want])?;
if n == 0 {
break;
}
done += n;
}
Ok(done)
}
}
pub(crate) fn bad(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.into())
}
pub(crate) fn io_err(e: io::Error, what: &str) -> Error {
Error::msg(format!("{what}: {e}"))
}
/// Cadeias de arquivos pai (VHD/VHDX diferenciais) não passam deste limite, o que também
/// corta ciclos.
const MAX_DEPTH: u32 = 16;
/// Formato de contêiner reconhecido pelos primeiros bytes (e, para VHD fixo e DMG, pelo
/// fim do arquivo). `None` quando é um disco bruto.
pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
if head.starts_with(b"QFI\xfb") {
return Some("QCOW2");
}
if head.starts_with(b"KDMV") || head.starts_with(b"# Disk DescriptorFile") {
return Some("VMDK");
}
if head.starts_with(b"vhdxfile") {
return Some("VHDX");
}
if head.starts_with(b"conectix") || vhd::is_fixed(file) {
return Some("VHD");
}
if dmg::is_encrypted(head) || dmg::is_dmg(file) {
return Some("DMG");
}
None
}
/// Abre `path` como disco virtual, ou `Ok(None)` se não for um contêiner conhecido.
pub fn open(path: &Path) -> Result<Option<VDisk>> {
open_depth(path, 0)
}
pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
if depth > MAX_DEPTH {
return Err(Error::msg("cadeia de discos pai longa demais ou circular"));
}
let file = FileSource::open(path)?;
let mut head = [0u8; 96];
let got = file.read_at(0, &mut head).unwrap_or(0);
let Some(format) = detect(&file, &head[..got]) else {
return Ok(None);
};
let name = format!("{format} {}", path.display());
let inner: Box<dyn Backend> = match format {
"QCOW2" => Box::new(qcow2::Qcow2::open(file).map_err(|e| io_err(e, &name))?),
"VMDK" => Box::new(vmdk::Vmdk::open(file, path).map_err(|e| io_err(e, &name))?),
"VHD" => Box::new(vhd::Vhd::open(file, path, depth).map_err(|e| io_err(e, &name))?),
"VHDX" => Box::new(vhdx::Vhdx::open(file, path, depth).map_err(|e| io_err(e, &name))?),
_ => Box::new(dmg::Dmg::open(file).map_err(|e| io_err(e, &name))?),
};
Ok(Some(VDisk { inner, format }))
}
/// Abre o disco pai de um VHD/VHDX diferencial.
pub(crate) fn open_parent(
candidates: &[std::path::PathBuf],
depth: u32,
) -> io::Result<Box<dyn Source>> {
for p in candidates {
if !p.is_file() {
continue;
}
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
return Ok(Box::new(v));
}
let f = FileSource::open(p)?;
return Ok(Box::new(f));
}
Err(bad(format!(
"disco pai não encontrado (procurado: {})",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)))
}
/// Lê de um arquivo por posição e exige todos os bytes.
pub(crate) fn read_exact(f: &FileSource, off: u64, buf: &mut [u8]) -> io::Result<()> {
crate::source::read_exact_at(f, off, buf)
}
pub(crate) fn read_vec(f: &FileSource, off: u64, len: usize) -> io::Result<Vec<u8>> {
let mut v = vec![0u8; len];
read_exact(f, off, &mut v)?;
Ok(v)
}
/// Tabelas em memória têm teto: um cabeçalho adulterado não pode pedir gigabytes.
pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Result<usize> {
const MAX: u64 = 512 * 1024 * 1024;
if entries.saturating_mul(entry_size) > MAX {
return Err(bad(format!("{what} grande demais ({entries} entradas)")));
}
Ok(entries as usize)
}
/// UTF-16 (big ou little endian) até o primeiro nulo.
pub(crate) fn utf16(b: &[u8], big: bool) -> String {
let units: Vec<u16> = b
.chunks_exact(2)
.map(|c| if big { be16(c, 0) } else { le16(c, 0) })
.take_while(|&u| u != 0)
.collect();
String::from_utf16_lossy(&units)
}
/// Caminhos onde procurar o disco pai: o caminho gravado (relativo ao arquivo filho, com `\`
/// virando `/`) e, como alternativa, só o nome do arquivo na mesma pasta.
pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::PathBuf> {
let mut out: Vec<std::path::PathBuf> = vec![];
for n in names {
let n = n.trim().replace('\\', "/");
let n = n.strip_prefix("file:///").unwrap_or(&n).to_string();
if n.is_empty() {
continue;
}
let rel = n.trim_start_matches("./");
let full = std::path::PathBuf::from(rel);
for p in [
if full.is_absolute() {
full.clone()
} else {
dir.join(rel)
},
dir.join(rel.rsplit('/').next().unwrap_or(rel)),
] {
if !out.contains(&p) {
out.push(p);
}
}
}
out
}
/// Lê do disco pai (ou zeros, sem pai); o que o pai não cobre também é zero.
pub(crate) fn read_parent(
parent: &Option<Box<dyn Source>>,
voff: u64,
buf: &mut [u8],
) -> io::Result<()> {
let Some(p) = parent else {
buf.fill(0);
return Ok(());
};
let mut done = 0;
while done < buf.len() {
let n = p.read_at(voff + done as u64, &mut buf[done..])?;
if n == 0 {
buf[done..].fill(0);
break;
}
done += n;
}
Ok(())
}
+149
View File
@@ -0,0 +1,149 @@
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd) e
//! clusters zerados. Imagens cifradas, com arquivo de apoio, com arquivo de dados externo ou
//! com L2 estendido são recusadas.
use super::{bad, be32, be64, check_table, read_exact, read_vec, Backend};
use crate::source::{FileSource, Source};
use std::io::{self, Read};
use std::sync::Mutex;
const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00;
const COMPRESSED: u64 = 1 << 62;
pub(super) struct Qcow2 {
file: FileSource,
size: u64,
cluster_bits: u32,
l1: Vec<u64>,
zstd: bool,
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
cache: Mutex<Option<(u64, Vec<u8>)>>,
}
impl Qcow2 {
pub(super) fn open(file: FileSource) -> io::Result<Self> {
let h = read_vec(&file, 0, 112.min(file.size() as usize))?;
if h.len() < 72 {
return Err(bad("cabeçalho QCOW2 truncado"));
}
let version = be32(&h, 4);
if version != 2 && version != 3 {
return Err(bad(format!("versão QCOW {version} não suportada")));
}
if be64(&h, 8) != 0 {
return Err(bad(
"a imagem QCOW2 usa arquivo de apoio (backing file); abrir só a camada filha daria dados errados",
));
}
let cluster_bits = be32(&h, 20);
if !(9..=21).contains(&cluster_bits) {
return Err(bad(format!(
"tamanho de cluster inválido (2^{cluster_bits})"
)));
}
let size = be64(&h, 24);
match be32(&h, 32) {
0 => {}
1 => return Err(bad("imagem QCOW2 cifrada (AES legado) não é suportada")),
_ => return Err(bad("imagem QCOW2 cifrada (LUKS) não é suportada; abra o volume LUKS depois de convertê-la")),
}
let mut zstd = false;
if version == 3 && h.len() >= 108 {
let incompat = be64(&h, 72);
if incompat & (1 << 2) != 0 {
return Err(bad("QCOW2 com arquivo de dados externo não é suportado"));
}
if incompat & (1 << 4) != 0 {
return Err(bad("QCOW2 com L2 estendido não é suportado"));
}
if incompat & (1 << 3) != 0 {
zstd = h[104] == 1;
if h[104] > 1 {
return Err(bad("tipo de compressão QCOW2 desconhecido"));
}
}
}
let l1_size = be32(&h, 36) as u64;
let l1_off = be64(&h, 40);
let n = check_table(l1_size, 8, "tabela L1")?;
let raw = read_vec(&file, l1_off, n * 8)?;
let l1 = raw.chunks_exact(8).map(|c| be64(c, 0)).collect();
Ok(Self {
file,
size,
cluster_bits,
l1,
zstd,
cache: Mutex::new(None),
})
}
fn cluster_size(&self) -> u64 {
1 << self.cluster_bits
}
fn l2_entry(&self, cluster_idx: u64) -> io::Result<u64> {
let per_l2 = self.cluster_size() / 8;
let l1_idx = (cluster_idx / per_l2) as usize;
let l2_idx = cluster_idx % per_l2;
let Some(&l1e) = self.l1.get(l1_idx) else {
return Ok(0);
};
let l2_off = l1e & OFFSET_MASK;
if l2_off == 0 {
return Ok(0);
}
let mut b = [0u8; 8];
read_exact(&self.file, l2_off + l2_idx * 8, &mut b)?;
Ok(u64::from_be_bytes(b))
}
fn inflate(&self, entry: u64) -> io::Result<Vec<u8>> {
let shift = 62 - (self.cluster_bits - 8);
let host = entry & ((1u64 << shift) - 1);
let nsect = ((entry >> shift) & ((1u64 << (self.cluster_bits - 8)) - 1)) + 1;
let csize = (nsect * 512 - (host & 511)) as usize;
let avail = self.file.size().saturating_sub(host) as usize;
let raw = read_vec(&self.file, host, csize.min(avail))?;
let mut out = Vec::with_capacity(self.cluster_size() as usize);
if self.zstd {
let mut d = ruzstd::decoding::StreamingDecoder::new(&raw[..])
.map_err(|e| bad(format!("cluster zstd inválido: {e}")))?;
d.read_to_end(&mut out)?;
} else {
flate2::read::DeflateDecoder::new(&raw[..]).read_to_end(&mut out)?;
}
out.resize(self.cluster_size() as usize, 0);
Ok(out)
}
}
impl Backend for Qcow2 {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let cs = self.cluster_size();
let idx = voff / cs;
let within = voff % cs;
let n = buf.len().min((cs - within) as usize);
let entry = self.l2_entry(idx)?;
if entry & COMPRESSED != 0 {
let mut cache = self.cache.lock().unwrap();
if cache.as_ref().map(|c| c.0) != Some(idx) {
*cache = Some((idx, self.inflate(entry)?));
}
let data = &cache.as_ref().unwrap().1;
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
return Ok(n);
}
let off = entry & OFFSET_MASK;
if off == 0 || entry & 1 != 0 {
buf[..n].fill(0);
} else {
read_exact(&self.file, off + within, &mut buf[..n])?;
}
Ok(n)
}
}
+175
View File
@@ -0,0 +1,175 @@
//! VHD (Microsoft Virtual PC e Hyper-V): discos fixos, dinâmicos e diferenciais. O disco
//! diferencial lê o pai (achado pelos localizadores do cabeçalho) nos setores que a camada
//! filha não preencheu.
use super::{
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
utf16, Backend,
};
use crate::source::{FileSource, Source};
use std::io;
use std::path::Path;
const SECTOR: u64 = 512;
const NONE: u32 = 0xFFFF_FFFF;
enum Kind {
Fixed,
Dynamic {
bat: Vec<u32>,
block_size: u64,
bitmap_size: u64,
},
}
pub(super) struct Vhd {
file: FileSource,
size: u64,
kind: Kind,
parent: Option<Box<dyn Source>>,
}
fn footer_ok(f: &[u8]) -> bool {
if f.len() < 512 || &f[..8] != b"conectix" {
return false;
}
let mut sum = 0u32;
for (i, &b) in f[..512].iter().enumerate() {
if !(64..68).contains(&i) {
sum = sum.wrapping_add(b as u32);
}
}
!sum == be32(f, 64)
}
/// VHD fixo: disco bruto seguido de um rodapé de 512 bytes.
pub(super) fn is_fixed(file: &FileSource) -> bool {
let size = file.size();
if size < 2 * SECTOR {
return false;
}
let mut f = [0u8; 512];
read_exact(file, size - SECTOR, &mut f).is_ok()
&& footer_ok(&f)
&& be32(&f, 60) == 2
&& be64(&f, 16) == u64::MAX
}
impl Vhd {
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
let size = file.size();
let mut footer = [0u8; 512];
let tail_ok = size >= SECTOR && {
read_exact(&file, size - SECTOR, &mut footer)?;
footer_ok(&footer)
};
if !tail_ok {
read_exact(&file, 0, &mut footer)?;
if !footer_ok(&footer) {
return Err(bad("rodapé VHD inválido"));
}
}
let current = be64(&footer, 48);
let disk_type = be32(&footer, 60);
match disk_type {
2 => {
return Ok(Self {
file,
size: current,
kind: Kind::Fixed,
parent: None,
});
}
3 | 4 => {}
t => return Err(bad(format!("tipo de VHD {t} desconhecido"))),
}
let dh = read_vec(&file, be64(&footer, 16), 1024)?;
if &dh[..8] != b"cxsparse" {
return Err(bad("cabeçalho de disco dinâmico VHD inválido"));
}
let bat_off = be64(&dh, 16);
let entries = be32(&dh, 28) as u64;
let block_size = be32(&dh, 32) as u64;
if block_size < SECTOR || !block_size.is_power_of_two() {
return Err(bad("tamanho de bloco VHD inválido"));
}
let n = check_table(entries, 4, "tabela de alocação")?;
let raw = read_vec(&file, bat_off, n * 4)?;
let bat = raw.chunks_exact(4).map(|c| be32(c, 0)).collect();
let bitmap_size = (block_size / SECTOR / 8).div_ceil(SECTOR) * SECTOR;
let parent = if disk_type == 4 {
let dir = path.parent().unwrap_or(Path::new("."));
let mut names = vec![];
for i in 0..8 {
let o = 576 + i * 24;
let code = &dh[o..o + 4];
let len = be32(&dh, o + 8) as usize;
let off = be64(&dh, o + 16);
if matches!(code, b"W2ru" | b"W2ku" | b"Wi2r" | b"Wi2k") && len > 0 && len < 4096 {
if let Ok(b) = read_vec(&file, off, len) {
names.push(utf16(&b, false));
}
}
}
names.push(utf16(&dh[64..64 + 512], true));
Some(open_parent(&candidate_paths(dir, &names), depth)?)
} else {
None
};
Ok(Self {
file,
size: current,
kind: Kind::Dynamic {
bat,
block_size,
bitmap_size,
},
parent,
})
}
}
impl Backend for Vhd {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let Kind::Dynamic {
bat,
block_size,
bitmap_size,
} = &self.kind
else {
let n = buf.len().min((self.size - voff) as usize);
read_exact(&self.file, voff, &mut buf[..n])?;
return Ok(n);
};
let block = (voff / block_size) as usize;
let within = voff % block_size;
let mut n = buf.len().min((block_size - within) as usize);
let entry = bat.get(block).copied().unwrap_or(NONE);
if entry == NONE {
read_parent(&self.parent, voff, &mut buf[..n])?;
return Ok(n);
}
let base = entry as u64 * SECTOR;
let first = within / SECTOR;
let last = (within + n as u64 - 1) / SECTOR;
let nbytes = (last / 8 - first / 8 + 1) as usize;
let bm = read_vec(&self.file, base + first / 8, nbytes)?;
let bit = |s: u64| bm[(s / 8 - first / 8) as usize] & (0x80 >> (s % 8)) != 0;
let present = bit(first);
let mut s = first;
while s <= last && bit(s) == present {
s += 1;
}
n = n.min((s * SECTOR - within) as usize);
if present {
read_exact(&self.file, base + bitmap_size + within, &mut buf[..n])?;
} else {
read_parent(&self.parent, voff, &mut buf[..n])?;
}
Ok(n)
}
}
+206
View File
@@ -0,0 +1,206 @@
//! VHDX (Hyper-V): tabela de regiões, metadados, BAT com blocos de bitmap intercalados,
//! discos fixos, dinâmicos e diferenciais (pai achado pelo localizador de pai).
//!
//! O log de transações não é reproduzido: abrir a imagem nunca a escreve. Se o disco foi
//! desligado no meio de uma escrita, os dados ainda no log não aparecem.
use super::{
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
read_vec, utf16, Backend,
};
use crate::source::{FileSource, Source};
use std::io;
use std::path::Path;
const MB: u64 = 1 << 20;
/// GUID em texto para os 16 bytes gravados em disco (três primeiros campos little-endian).
fn guid(s: &str) -> [u8; 16] {
let hex: Vec<u8> = s
.replace('-', "")
.as_bytes()
.chunks(2)
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
.collect();
let mut g = [0u8; 16];
g[..4].copy_from_slice(&[hex[3], hex[2], hex[1], hex[0]]);
g[4..6].copy_from_slice(&[hex[5], hex[4]]);
g[6..8].copy_from_slice(&[hex[7], hex[6]]);
g[8..].copy_from_slice(&hex[8..]);
g
}
pub(super) struct Vhdx {
file: FileSource,
size: u64,
block_size: u64,
sector_size: u64,
chunk_ratio: u64,
bat: Vec<u64>,
parent: Option<Box<dyn Source>>,
}
impl Vhdx {
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
let rt = read_vec(&file, 192 * 1024, 64 * 1024)?;
if &rt[..4] != b"regi" {
return Err(bad("tabela de regiões VHDX inválida"));
}
let count = le32(&rt, 8).min(2047) as usize;
let (mut bat_at, mut meta_at) = (None, None);
for i in 0..count {
let e = &rt[16 + i * 32..16 + (i + 1) * 32];
let region = (le64(e, 16), le32(e, 24) as u64);
if e[..16] == guid("2DC27766-F623-4200-9D64-115E9BFD4A08") {
bat_at = Some(region);
} else if e[..16] == guid("8B7CA206-4790-4B9A-B8FE-575F050F886E") {
meta_at = Some(region);
}
}
let (Some((bat_off, bat_len)), Some((meta_off, meta_len))) = (bat_at, meta_at) else {
return Err(bad("regiões BAT e metadados não encontradas"));
};
let meta = read_vec(&file, meta_off, meta_len.min(MB) as usize)?;
if &meta[..8] != b"metadata" {
return Err(bad("região de metadados VHDX inválida"));
}
let items = le16(&meta, 10) as usize;
let find = |id: [u8; 16]| -> Option<&[u8]> {
(0..items).find_map(|i| {
let e = meta.get(32 + i * 32..32 + (i + 1) * 32)?;
if e[..16] != id {
return None;
}
let (o, l) = (le32(e, 16) as usize, le32(e, 20) as usize);
meta.get(o..o + l)
})
};
let params = find(guid("CAA16737-FA36-4D43-B3B6-33F0AA44E76B"))
.filter(|p| p.len() >= 8)
.ok_or_else(|| bad("parâmetros do arquivo VHDX ausentes"))?;
let block_size = le32(params, 0) as u64;
let has_parent = le32(params, 4) & 2 != 0;
let size = find(guid("2FA54224-CD1B-4876-B211-5DBED83BF4B8"))
.filter(|p| p.len() >= 8)
.map(|p| le64(p, 0))
.ok_or_else(|| bad("tamanho do disco VHDX ausente"))?;
let sector_size = find(guid("8141BF1D-A96F-4709-BA47-F233A8FAAB5F"))
.filter(|p| p.len() >= 4)
.map(|p| le32(p, 0) as u64)
.unwrap_or(512);
if block_size < MB || !block_size.is_power_of_two() || !matches!(sector_size, 512 | 4096) {
return Err(bad("parâmetros VHDX inválidos"));
}
let chunk_ratio = (1u64 << 23) * sector_size / block_size;
let payload = size.div_ceil(block_size);
// Com pai, cada trecho termina com a entrada do bitmap de setores, mesmo que o disco acabe antes.
let want = if has_parent {
payload.div_ceil(chunk_ratio) * (chunk_ratio + 1)
} else {
payload + payload.div_ceil(chunk_ratio)
};
let n = check_table(want.min(bat_len / 8), 8, "BAT")?;
let raw = read_vec(&file, bat_off, n * 8)?;
let bat = raw.chunks_exact(8).map(|c| le64(c, 0)).collect();
let parent = if has_parent {
let loc = find(guid("A8D35F2D-B30B-454D-ABF7-D3D84834AB0C"))
.ok_or_else(|| bad("localizador de pai VHDX ausente"))?;
let count = if loc.len() >= 20 {
le16(loc, 18) as usize
} else {
0
};
let mut names = vec![];
for i in 0..count {
let Some(e) = loc.get(20 + i * 12..20 + (i + 1) * 12) else {
break;
};
let (ko, vo) = (le32(e, 0) as usize, le32(e, 4) as usize);
let (ks, vs) = (le16(e, 8) as usize, le16(e, 10) as usize);
let (Some(k), Some(v)) = (loc.get(ko..ko + ks), loc.get(vo..vo + vs)) else {
continue;
};
let key = utf16(k, false);
if matches!(
key.as_str(),
"relative_path" | "absolute_win32_path" | "volume_path"
) {
names.push(utf16(v, false));
}
}
let dir = path.parent().unwrap_or(Path::new("."));
Some(open_parent(&candidate_paths(dir, &names), depth)?)
} else {
None
};
Ok(Self {
file,
size,
block_size,
sector_size,
chunk_ratio,
bat,
parent,
})
}
fn bat(&self, i: u64) -> u64 {
self.bat.get(i as usize).copied().unwrap_or(0)
}
}
impl Backend for Vhdx {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let block = voff / self.block_size;
let within = voff % self.block_size;
let mut n = buf.len().min((self.block_size - within) as usize);
let e = self.bat(block + block / self.chunk_ratio);
let off = e & 0xFFFF_FFFF_FFF0_0000;
match e & 7 {
6 => {
read_exact(&self.file, off + within, &mut buf[..n])?;
}
7 => {
// Parcialmente presente: o bitmap do trecho diz, setor a setor, onde está o dado.
let chunk = block / self.chunk_ratio;
let bm_entry = self.bat(chunk * (self.chunk_ratio + 1) + self.chunk_ratio);
let ss = self.sector_size;
let per_block = self.block_size / ss;
let first = within / ss;
let last = (within + n as u64 - 1) / ss;
let base_bit = (block % self.chunk_ratio) * per_block;
let bm_off = bm_entry & 0xFFFF_FFFF_FFF0_0000;
if bm_entry & 7 != 6 {
read_parent(&self.parent, voff, &mut buf[..n])?;
return Ok(n);
}
let b0 = (base_bit + first) / 8;
let b1 = (base_bit + last) / 8;
let bm = read_vec(&self.file, bm_off + b0, (b1 - b0 + 1) as usize)?;
let bit = |s: u64| {
let p = base_bit + s;
bm[(p / 8 - b0) as usize] >> (p % 8) & 1 == 1
};
let present = bit(first);
let mut s = first;
while s <= last && bit(s) == present {
s += 1;
}
n = n.min((s * ss - within) as usize);
if present {
read_exact(&self.file, off + within, &mut buf[..n])?;
} else {
read_parent(&self.parent, voff, &mut buf[..n])?;
}
}
2 | 3 => buf[..n].fill(0),
_ => read_parent(&self.parent, voff, &mut buf[..n])?,
}
Ok(n)
}
}
+267
View File
@@ -0,0 +1,267 @@
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) e o formato SEsparse
//! são recusados.
use super::{bad, check_table, le16, le32, le64, read_exact, read_vec, Backend};
use crate::source::{FileSource, Source};
use std::io::{self, Read};
use std::path::Path;
use std::sync::Mutex;
const SECTOR: u64 = 512;
const GD_AT_END: u64 = u64::MAX;
struct Sparse {
file: FileSource,
grain: u64,
gtes: u64,
gd: Vec<u32>,
compressed: bool,
cache: Mutex<Option<(u64, Vec<u8>)>>,
}
enum Kind {
Zero,
Flat { file: FileSource, offset: u64 },
Sparse(Sparse),
}
struct Extent {
start: u64,
len: u64,
kind: Kind,
}
pub(super) struct Vmdk {
extents: Vec<Extent>,
size: u64,
}
fn parent_in(descriptor: &str) -> bool {
descriptor.lines().any(|l| {
let l = l.trim();
l.strip_prefix("parentCID")
.and_then(|r| r.trim_start().strip_prefix('='))
.is_some_and(|v| {
let v = v.trim().trim_matches('"');
!v.is_empty() && !v.eq_ignore_ascii_case("ffffffff")
})
})
}
impl Sparse {
fn open(file: FileSource) -> io::Result<(Self, u64, Option<String>)> {
let mut h = read_vec(&file, 0, SECTOR as usize)?;
if &h[..4] == b"SESp" || &h[..4] == b"seSp" {
return Err(bad("VMDK no formato SEsparse (vSphere) não é suportado"));
}
if &h[..4] != b"KDMV" {
return Err(bad("extensão VMDK sem assinatura KDMV"));
}
if le64(&h, 56) == GD_AT_END {
let size = file.size();
if size < 3 * SECTOR {
return Err(bad("VMDK truncado"));
}
h = read_vec(&file, size - 2 * SECTOR, SECTOR as usize)?;
if &h[..4] != b"KDMV" {
return Err(bad("rodapé do VMDK streamOptimized ausente"));
}
}
let flags = le32(&h, 8);
let capacity = le64(&h, 12);
let grain = le64(&h, 20);
let desc_off = le64(&h, 28);
let desc_len = le64(&h, 36);
let gtes = le32(&h, 44) as u64;
let gd_off = le64(&h, 56);
let algo = le16(&h, 77);
if grain == 0 || !grain.is_power_of_two() || gtes == 0 {
return Err(bad("cabeçalho VMDK inválido"));
}
let compressed = flags & (1 << 16) != 0;
if compressed && algo != 1 {
return Err(bad("compressão de grão VMDK desconhecida"));
}
let descriptor = if desc_off != 0 && desc_len != 0 {
let raw = read_vec(
&file,
desc_off * SECTOR,
(desc_len * SECTOR).min(1 << 20) as usize,
)?;
let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
Some(String::from_utf8_lossy(&raw[..end]).into_owned())
} else {
None
};
if descriptor.as_deref().is_some_and(parent_in) {
return Err(bad(
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
));
}
let gde = capacity.div_ceil(gtes * grain);
let n = check_table(gde, 4, "diretório de grãos")?;
let raw = read_vec(&file, gd_off * SECTOR, n * 4)?;
let gd = raw.chunks_exact(4).map(|c| le32(c, 0)).collect();
Ok((
Self {
file,
grain: grain * SECTOR,
gtes,
gd,
compressed,
cache: Mutex::new(None),
},
capacity * SECTOR,
descriptor,
))
}
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<usize> {
let g = off / self.grain;
let within = off % self.grain;
let n = buf.len().min((self.grain - within) as usize);
let gt_sector = self.gd.get((g / self.gtes) as usize).copied().unwrap_or(0) as u64;
let mut sector = 0u64;
if gt_sector > 1 {
let mut b = [0u8; 4];
read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?;
sector = u32::from_le_bytes(b) as u64;
}
if sector <= 1 {
buf[..n].fill(0);
return Ok(n);
}
if !self.compressed {
read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?;
return Ok(n);
}
let mut cache = self.cache.lock().unwrap();
if cache.as_ref().map(|c| c.0) != Some(g) {
let mut m = [0u8; 12];
read_exact(&self.file, sector * SECTOR, &mut m)?;
let csize = le32(&m, 8) as usize;
if csize == 0 || csize > self.grain as usize * 2 {
return Err(bad("grão VMDK comprimido com tamanho inválido"));
}
let raw = read_vec(&self.file, sector * SECTOR + 12, csize)?;
let mut out = Vec::with_capacity(self.grain as usize);
flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut out)?;
out.resize(self.grain as usize, 0);
*cache = Some((g, out));
}
let data = &cache.as_ref().unwrap().1;
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
Ok(n)
}
}
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
if parent_in(text) {
return Err(bad(
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
));
}
let mut out = vec![];
let mut start = 0u64;
for line in text.lines() {
let l = line.trim();
let mut it = l.split_whitespace();
let (Some(access), Some(sectors), Some(kind)) = (it.next(), it.next(), it.next()) else {
continue;
};
if !matches!(access, "RW" | "RDONLY" | "NOACCESS") {
continue;
}
let Ok(sectors) = sectors.parse::<u64>() else {
continue;
};
let len = sectors * SECTOR;
let name = match (l.find('"'), l.rfind('"')) {
(Some(a), Some(b)) if b > a => Some(&l[a + 1..b]),
_ => None,
};
let kind = match kind {
"ZERO" => Kind::Zero,
"FLAT" | "VMFS" => {
let name = name.ok_or_else(|| bad("extensão FLAT sem nome de arquivo"))?;
let offset = l
.rsplit_once('"')
.and_then(|(_, r)| r.trim().parse::<u64>().ok())
.unwrap_or(0);
Kind::Flat {
file: FileSource::open(&dir.join(name))?,
offset: offset * SECTOR,
}
}
"SPARSE" => {
let name = name.ok_or_else(|| bad("extensão SPARSE sem nome de arquivo"))?;
let (sp, cap, _) = Sparse::open(FileSource::open(&dir.join(name))?)?;
if cap != len {
return Err(bad("tamanho da extensão VMDK difere do descritor"));
}
Kind::Sparse(sp)
}
other => return Err(bad(format!("tipo de extensão VMDK {other} não suportado"))),
};
out.push(Extent { start, len, kind });
start += len;
}
if out.is_empty() {
return Err(bad("descritor VMDK sem extensões"));
}
Ok(out)
}
impl Vmdk {
pub(super) fn open(file: FileSource, path: &Path) -> io::Result<Self> {
let mut head = [0u8; 4];
let _ = file.read_at(0, &mut head);
let extents = if &head == b"KDMV" {
let (sp, size, _) = Sparse::open(file)?;
vec![Extent {
start: 0,
len: size,
kind: Kind::Sparse(sp),
}]
} else {
if file.size() > 1 << 20 {
return Err(bad("descritor VMDK grande demais"));
}
let raw = read_vec(&file, 0, file.size() as usize)?;
let text = String::from_utf8_lossy(&raw);
let dir = path.parent().unwrap_or(Path::new("."));
parse_descriptor(&text, dir)?
};
let size = extents.iter().map(|e| e.len).sum();
Ok(Self { extents, size })
}
}
impl Backend for Vmdk {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let i = self.extents.partition_point(|e| e.start + e.len <= voff);
let Some(e) = self.extents.get(i) else {
return Ok(0);
};
let rel = voff - e.start;
let n = buf.len().min((e.len - rel) as usize);
match &e.kind {
Kind::Zero => {
buf[..n].fill(0);
Ok(n)
}
Kind::Flat { file, offset } => {
read_exact(file, offset + rel, &mut buf[..n])?;
Ok(n)
}
Kind::Sparse(s) => s.read(rel, &mut buf[..n]),
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+723
View File
@@ -0,0 +1,723 @@
//! Imagens de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG.
//!
//! Os arquivos de `tests/fixtures/vdisk` foram feitos por ferramentas reais (VirtualBox para
//! VMDK/VHD, qemu para QCOW2, hdiutil para DMG) e conferidos contra o conteúdo bruto. VHDX, DMG
//! sintético e discos diferenciais são montados aqui, byte a byte, a partir da especificação.
//! As imagens do dfvfs em target/testdata (scripts/fetch-testdata.sh) são conferidas por hash,
//! calculado com um leitor independente (7-Zip), e pulam quando ausentes.
use guara_core::source::{open_path, read_vec, Source};
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::{Path, PathBuf};
fn fx(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/vdisk")
.join(name)
}
fn raw() -> Vec<u8> {
std::fs::read(fx("disk.raw")).unwrap()
}
fn dfvfs(name: &str) -> Option<PathBuf> {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../target/testdata/dfvfs")
.join(name);
p.exists().then_some(p)
}
fn sha(src: &dyn Source) -> String {
let mut h = Sha256::new();
let mut off = 0;
while off < src.size() {
let n = (1 << 20).min((src.size() - off) as usize);
h.update(read_vec(src, off, n).unwrap());
off += n as u64;
}
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
/// Lê o disco inteiro e também em pedaços que cruzam blocos, comparando com `want`.
fn check_content(path: &Path, want: &[u8]) {
let s = open_path(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
assert_eq!(s.size(), want.len() as u64, "{}", path.display());
assert!(
read_vec(s.as_ref(), 0, want.len()).unwrap() == want,
"{}: conteúdo difere",
path.display()
);
let mut x = 88172645463325252u64;
for _ in 0..200 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let off = x % want.len() as u64;
let len = 1 + (x >> 20) as usize % 70_000;
let end = (off as usize + len).min(want.len());
let got = read_vec(s.as_ref(), off, end - off as usize).unwrap();
assert!(
got == want[off as usize..end],
"{}: @{off}+{len}",
path.display()
);
}
// leitura além do fim devolve só o que existe
let mut buf = [0u8; 64];
assert_eq!(s.read_at(want.len() as u64 - 10, &mut buf).unwrap(), 10);
assert_eq!(s.read_at(want.len() as u64, &mut buf).unwrap(), 0);
}
// ---------- imagens feitas por ferramentas reais ----------
#[test]
fn virtualbox_vmdk_sparse_and_stream_optimized() {
let want = raw();
check_content(&fx("disk.vmdk"), &want);
// streamOptimized: grãos comprimidos com zlib e cabeçalho no rodapé
check_content(&fx("disk-stream.vmdk"), &want);
}
#[test]
fn virtualbox_vhd_dynamic() {
check_content(&fx("disk.vhd"), &raw());
}
#[test]
fn qemu_qcow2_and_hdiutil_dmg_match_a_reference_reader() {
let s = open_path(&fx("ext2.qcow2")).unwrap();
assert_eq!(s.size(), 4 << 20);
assert_eq!(
sha(s.as_ref()),
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80"
);
// UDIF com chunks zlib, zerados e ignorados
let d = open_path(&fx("hfsplus_zlib.dmg")).unwrap();
assert_eq!(d.size(), 1_954_304);
assert_eq!(
sha(d.as_ref()),
"c86d3616818e175caca2f6022f49e190e5b1cfe39b2e8f9373119e22aa7eaf58"
);
}
// ---------- montagem de imagens sintéticas ----------
fn be(v: &mut [u8], o: usize, x: u64, n: usize) {
v[o..o + n].copy_from_slice(&x.to_be_bytes()[8 - n..]);
}
fn le(v: &mut [u8], o: usize, x: u64, n: usize) {
v[o..o + n].copy_from_slice(&x.to_le_bytes()[..n]);
}
fn deflate(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
fn zlib(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
/// QCOW2 v3 com clusters de 64 KiB: zerados (sem entrada e com a flag), brutos e comprimidos.
fn build_qcow2(data: &[u8]) -> Vec<u8> {
const CS: usize = 1 << 16;
let clusters = data.len() / CS;
let mut img = vec![0u8; 3 * CS];
img[..4].copy_from_slice(b"QFI\xfb");
be(&mut img, 4, 3, 4);
be(&mut img, 20, 16, 4);
be(&mut img, 24, data.len() as u64, 8);
be(&mut img, 36, 1, 4);
be(&mut img, 40, CS as u64, 8);
be(&mut img, 100, 112, 4);
be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8); // L1 -> L2 no cluster 2
for i in 0..clusters {
let c = &data[i * CS..(i + 1) * CS];
let entry: u64 = if c.iter().all(|&b| b == 0) {
if i % 2 == 0 {
0
} else {
1 // zerado explícito
}
} else if i % 3 == 0 {
let z = deflate(c);
let host = img.len() as u64;
let sectors = z.len().div_ceil(512);
img.extend_from_slice(&z);
img.resize(img.len().div_ceil(512) * 512, 0);
1 << 62 | ((sectors as u64 - 1) << (62 - 8)) | host
} else {
let host = img.len() as u64;
img.extend_from_slice(c);
host | 1 << 63
};
be(&mut img, 2 * CS + i * 8, entry, 8);
}
img
}
fn vhd_footer(size: u64, kind: u32, data_offset: u64) -> [u8; 512] {
let mut f = [0u8; 512];
f[..8].copy_from_slice(b"conectix");
be(&mut f, 8, 2, 4);
be(&mut f, 12, 0x0001_0000, 4);
be(&mut f, 16, data_offset, 8);
f[28..32].copy_from_slice(b"guar");
be(&mut f, 40, size, 8);
be(&mut f, 48, size, 8);
be(&mut f, 60, kind as u64, 4);
let sum = f.iter().fold(0u32, |a, &b| a.wrapping_add(b as u32));
be(&mut f, 64, !sum as u64, 4);
f
}
fn base64(d: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in d.chunks(3) {
let n = (c[0] as u32) << 16
| (*c.get(1).unwrap_or(&0) as u32) << 8
| *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
if i <= c.len() {
s.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
s.push('=');
}
}
if s.len() % 76 == 0 {
s.push('\n');
}
}
s
}
/// ADC só com literais e cópias de 3 bytes (deslocamento 8), como o `hdiutil` produz.
fn adc_pattern(len: usize) -> (Vec<u8>, Vec<u8>) {
let pat = *b"ADC-test";
let mut enc = vec![0x80 | 7];
enc.extend_from_slice(&pat);
let mut made = 8;
while made < len {
let rem = len - made;
let mut n = rem.min(67);
if rem - n > 0 && rem - n < 3 {
n = rem - 3;
}
if n >= 4 {
enc.extend_from_slice(&[0x40 | (n as u8 - 4), 0, 7]);
} else {
enc.extend_from_slice(&[((n as u8 - 3) << 2), 7]); // 2 bytes, comprimento 3
}
made += n;
}
let want: Vec<u8> = (0..len).map(|i| pat[i % 8]).collect();
(enc, want)
}
struct DmgChunk {
ty: u32,
sectors: u64,
payload: Vec<u8>,
}
fn build_dmg(chunks: &[DmgChunk], total_sectors: u64) -> Vec<u8> {
let mut fork = vec![];
let mut table = vec![0u8; 204];
table[..4].copy_from_slice(b"mish");
be(&mut table, 4, 1, 4);
be(&mut table, 16, total_sectors, 8);
be(&mut table, 200, chunks.len() as u64 + 1, 4);
let mut sector = 0;
for c in chunks {
let mut e = [0u8; 40];
be(&mut e, 0, c.ty as u64, 4);
be(&mut e, 8, sector, 8);
be(&mut e, 16, c.sectors, 8);
be(&mut e, 24, fork.len() as u64, 8);
be(&mut e, 32, c.payload.len() as u64, 8);
table.extend_from_slice(&e);
fork.extend_from_slice(&c.payload);
sector += c.sectors;
}
let mut end = [0u8; 40];
be(&mut end, 0, 0xFFFF_FFFF, 4);
be(&mut end, 8, sector, 8);
table.extend_from_slice(&end);
let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>resource-fork</key><dict>\
<key>blkx</key><array><dict><key>Data</key><data>\n{}\n</data><key>Name</key><string>teste</string></dict></array>\
<key>plst</key><array><dict><key>Data</key><data>AAAA</data></dict></array></dict></dict></plist>",
base64(&table)
);
let mut img = fork;
let xml_off = img.len() as u64;
img.extend_from_slice(xml.as_bytes());
let mut k = vec![0u8; 512];
k[..4].copy_from_slice(b"koly");
be(&mut k, 4, 4, 4);
be(&mut k, 8, 512, 4);
be(&mut k, 32, xml_off, 8);
be(&mut k, 216, xml_off, 8);
be(&mut k, 224, xml.len() as u64, 8);
be(&mut k, 492, total_sectors, 8);
img.extend_from_slice(&k);
img
}
// ---------- QCOW2 ----------
#[test]
fn qcow2_with_zero_raw_and_compressed_clusters() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.qcow2");
std::fs::write(&p, build_qcow2(&want)).unwrap();
check_content(&p, &want);
}
#[test]
fn qcow2_with_backing_file_or_encryption_is_refused() {
let dir = tempfile::tempdir().unwrap();
let mut img = build_qcow2(&raw());
let p = dir.path().join("b.qcow2");
be(&mut img, 8, 4096, 8);
be(&mut img, 16, 3, 4);
std::fs::write(&p, &img).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("arquivo de apoio"), "{e}");
let mut img = build_qcow2(&raw());
be(&mut img, 32, 2, 4);
let p = dir.path().join("e.qcow2");
std::fs::write(&p, &img).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("LUKS"), "{e}");
}
// ---------- VMDK ----------
#[test]
fn vmdk_descriptor_with_flat_and_zero_extents() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
let half = want.len() / 2;
std::fs::write(dir.path().join("a-f001.vmdk"), &want[..half]).unwrap();
std::fs::write(
dir.path().join("a-f002.vmdk"),
&want[half..want.len() - 4096],
)
.unwrap();
let desc = format!(
"# Disk DescriptorFile\nversion=1\nCID=fffffffe\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentFlat\"\n\n# Extent description\nRW {} FLAT \"a-f001.vmdk\" 0\nRW {} FLAT \"a-f002.vmdk\" 0\nRW 8 ZERO\n",
half / 512,
(want.len() - 4096 - half) / 512
);
let p = dir.path().join("a.vmdk");
std::fs::write(&p, desc).unwrap();
let mut expect = want.clone();
expect[want.len() - 4096..].fill(0);
check_content(&p, &expect);
}
#[test]
fn vmdk_with_a_parent_is_refused() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("c.vmdk");
std::fs::write(
&p,
"# Disk DescriptorFile\nversion=1\nparentCID=12345678\ncreateType=\"monolithicFlat\"\nRW 8 ZERO\n",
)
.unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("disco pai"), "{e}");
}
// ---------- VHD ----------
#[test]
fn vhd_fixed_hides_the_footer() {
let want = raw();
let mut img = want.clone();
img.extend_from_slice(&vhd_footer(want.len() as u64, 2, u64::MAX));
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("f.vhd");
std::fs::write(&p, img).unwrap();
check_content(&p, &want);
}
/// VHD diferencial: a filha só traz o setor 100 e o bloco inteiro do fim; o resto vem do pai.
#[test]
fn vhd_differencing_reads_the_parent_for_missing_sectors() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
let mut parent = want.clone();
parent.extend_from_slice(&vhd_footer(want.len() as u64, 2, u64::MAX));
std::fs::write(dir.path().join("pai.vhd"), &parent).unwrap();
const BLOCK: usize = 1 << 20; // um bloco só
let mut f = vec![0u8; 512 + 1024 + 512];
let hdr = vhd_footer(want.len() as u64, 4, 512);
f[..512].copy_from_slice(&hdr);
let mut dh = [0u8; 1024];
dh[..8].copy_from_slice(b"cxsparse");
be(&mut dh, 8, u64::MAX, 8);
be(&mut dh, 16, 1536, 8); // BAT
be(&mut dh, 28, 1, 4);
be(&mut dh, 32, BLOCK as u64, 4);
let name = "pai.vhd"
.encode_utf16()
.flat_map(|u| u.to_be_bytes())
.collect::<Vec<u8>>();
dh[64..64 + name.len()].copy_from_slice(&name);
f[512..1536].copy_from_slice(&dh);
be(&mut f, 1536, 4, 4); // bloco 0 no setor 4
f.resize(4 * 512, 0);
// bitmap (4096 setores = 512 bytes) com o setor 100 presente, depois os dados
let mut bitmap = vec![0u8; 512];
bitmap[100 / 8] |= 0x80 >> (100 % 8);
f.extend_from_slice(&bitmap);
let mut block = vec![0u8; BLOCK];
let marker = [0xA5u8; 512];
block[100 * 512..101 * 512].copy_from_slice(&marker);
f.extend_from_slice(&block);
f.extend_from_slice(&hdr);
let p = dir.path().join("filho.vhd");
std::fs::write(&p, f).unwrap();
let mut expect = want.clone();
expect[100 * 512..101 * 512].copy_from_slice(&marker);
check_content(&p, &expect);
// sem o pai a abertura falha com o motivo
std::fs::remove_file(dir.path().join("pai.vhd")).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("disco pai não encontrado"), "{e}");
}
// ---------- VHDX ----------
fn guid(s: &str) -> [u8; 16] {
let h: Vec<u8> = s
.replace('-', "")
.as_bytes()
.chunks(2)
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
.collect();
let mut g = [0u8; 16];
g[..4].copy_from_slice(&[h[3], h[2], h[1], h[0]]);
g[4..6].copy_from_slice(&[h[5], h[4]]);
g[6..8].copy_from_slice(&[h[7], h[6]]);
g[8..].copy_from_slice(&h[8..]);
g
}
const MB: usize = 1 << 20;
/// VHDX com blocos de 1 MiB. `blocks[i]`: `None` = não presente, `Some((estado, dados))`.
/// Com `parent`, acrescenta o localizador de pai e o bitmap de setores (`bitmap`).
fn build_vhdx(
size: u64,
blocks: &[Option<(u64, Vec<u8>)>],
parent: Option<&str>,
bitmap: Option<Vec<u8>>,
) -> Vec<u8> {
let mut img = vec![0u8; 4 * MB];
img[..8].copy_from_slice(b"vhdxfile");
img[64 * 1024..64 * 1024 + 4].copy_from_slice(b"head");
img[128 * 1024..128 * 1024 + 4].copy_from_slice(b"head");
// tabela de regiões: BAT em 2 MiB, metadados em 1 MiB
let rt = 192 * 1024;
img[rt..rt + 4].copy_from_slice(b"regi");
le(&mut img, rt + 8, 2, 4);
img[rt + 16..rt + 32].copy_from_slice(&guid("2DC27766-F623-4200-9D64-115E9BFD4A08"));
le(&mut img, rt + 32, 2 * MB as u64, 8);
le(&mut img, rt + 40, MB as u64, 4);
img[rt + 48..rt + 64].copy_from_slice(&guid("8B7CA206-4790-4B9A-B8FE-575F050F886E"));
le(&mut img, rt + 64, MB as u64, 8);
le(&mut img, rt + 72, MB as u64, 4);
// metadados
let m = MB;
img[m..m + 8].copy_from_slice(b"metadata");
let mut items: Vec<([u8; 16], Vec<u8>)> = vec![];
let mut params = vec![0u8; 8];
le(&mut params, 0, MB as u64, 4);
le(&mut params, 4, if parent.is_some() { 2 } else { 0 }, 4);
items.push((guid("CAA16737-FA36-4D43-B3B6-33F0AA44E76B"), params));
items.push((
guid("2FA54224-CD1B-4876-B211-5DBED83BF4B8"),
size.to_le_bytes().to_vec(),
));
items.push((
guid("8141BF1D-A96F-4709-BA47-F233A8FAAB5F"),
512u32.to_le_bytes().to_vec(),
));
if let Some(p) = parent {
let key: Vec<u8> = "relative_path"
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let val: Vec<u8> = p.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let mut loc = vec![0u8; 20 + 12];
loc[..16].copy_from_slice(&guid("B04AEFB7-D19E-4A81-B789-25B8E9445913"));
le(&mut loc, 18, 1, 2);
let (ko, vo) = (32, 32 + key.len());
le(&mut loc, 20, ko as u64, 4);
le(&mut loc, 24, vo as u64, 4);
le(&mut loc, 28, key.len() as u64, 2);
le(&mut loc, 30, val.len() as u64, 2);
loc.extend_from_slice(&key);
loc.extend_from_slice(&val);
items.push((guid("A8D35F2D-B30B-454D-ABF7-D3D84834AB0C"), loc));
}
le(&mut img, m + 10, items.len() as u64, 2);
let mut at = 64 * 1024;
for (i, (id, data)) in items.iter().enumerate() {
let e = m + 32 + i * 32;
img[e..e + 16].copy_from_slice(id);
le(&mut img, e + 16, at as u64, 4);
le(&mut img, e + 20, data.len() as u64, 4);
img[m + at..m + at + data.len()].copy_from_slice(data);
at += data.len().div_ceil(8) * 8;
}
// blocos de dados a partir de 4 MiB; o BAT guarda estado e deslocamento (em MiB)
let ratio = (1usize << 23) * 512 / MB;
let bat = 2 * MB;
for (i, b) in blocks.iter().enumerate() {
let Some((state, data)) = b else { continue };
let mut off = 0;
if !data.is_empty() {
off = img.len();
let mut d = data.clone();
d.resize(MB, 0);
img.extend_from_slice(&d);
}
le(
&mut img,
bat + (i + i / ratio) * 8,
state | (off as u64 >> 20) << 20,
8,
);
}
if let Some(bm) = bitmap {
let off = img.len();
let mut d = bm;
d.resize(MB, 0);
img.extend_from_slice(&d);
le(&mut img, bat + ratio * 8, 6 | (off as u64 >> 20) << 20, 8);
}
img
}
#[test]
fn vhdx_dynamic_with_zero_and_missing_blocks() {
let want = raw(); // 1 MiB = um bloco
let mut expect = want.clone();
expect.extend_from_slice(&vec![0u8; 3 * MB]);
let tail = vec![0x5Au8; 100_000];
expect[3 * MB..3 * MB + tail.len()].copy_from_slice(&tail);
let img = build_vhdx(
4 * MB as u64,
&[
Some((6, want.clone())),
Some((2, vec![])), // zerado
None, // não presente
Some((6, tail)),
],
None,
None,
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.vhdx");
std::fs::write(&p, img).unwrap();
check_content(&p, &expect);
}
#[test]
fn vhdx_differencing_uses_the_sector_bitmap() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pai.raw"), &want).unwrap();
// filho: bloco 0 parcialmente presente, só com os setores 3 e 2000..2010 no filho
let mut data = vec![0u8; MB];
let mut bm = vec![0u8; 512];
let mut expect = want.clone();
for s in std::iter::once(3usize).chain(2000..2010) {
data[s * 512..(s + 1) * 512].fill(0x77);
expect[s * 512..(s + 1) * 512].fill(0x77);
bm[s / 8] |= 1 << (s % 8);
}
let img = build_vhdx(MB as u64, &[Some((7, data))], Some("pai.raw"), Some(bm));
let p = dir.path().join("filho.vhdx");
std::fs::write(&p, img).unwrap();
check_content(&p, &expect);
}
// ---------- DMG ----------
#[test]
fn dmg_with_every_chunk_type() {
let want = raw();
let sec = 512usize;
// 8 chunks de 128 setores (64 KiB)
let part = |i: usize| want[i * 128 * sec..(i + 1) * 128 * sec].to_vec();
let (adc_enc, adc_want) = adc_pattern(128 * sec);
let mut bz = bzip2::write::BzEncoder::new(vec![], bzip2::Compression::default());
bz.write_all(&part(3)).unwrap();
let chunks = vec![
DmgChunk {
ty: 1,
sectors: 128,
payload: part(0),
}, // bruto
DmgChunk {
ty: 0,
sectors: 128,
payload: vec![],
}, // zerado
DmgChunk {
ty: 0x8000_0005,
sectors: 128,
payload: zlib(&part(2)),
},
DmgChunk {
ty: 0x8000_0006,
sectors: 128,
payload: bz.finish().unwrap(),
},
DmgChunk {
ty: 0x8000_0004,
sectors: 128,
payload: adc_enc,
},
DmgChunk {
ty: 2,
sectors: 128,
payload: vec![],
}, // ignorado
DmgChunk {
ty: 0x8000_0005,
sectors: 128,
payload: zlib(&part(6)),
},
];
let mut expect = vec![];
expect.extend(part(0));
expect.extend(vec![0u8; 128 * sec]);
expect.extend(part(2));
expect.extend(part(3));
expect.extend(adc_want);
expect.extend(vec![0u8; 128 * sec]);
expect.extend(part(6));
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.dmg");
std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap();
check_content(&p, &expect);
}
#[test]
fn dmg_lzfse_and_encrypted_are_refused_with_a_reason() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("l.dmg");
let chunks = [DmgChunk {
ty: 0x8000_0007,
sectors: 8,
payload: vec![1, 2, 3],
}];
std::fs::write(&p, build_dmg(&chunks, 8)).unwrap();
let s = open_path(&p).unwrap();
let e = read_vec(s.as_ref(), 0, 512).unwrap_err().to_string();
assert!(e.contains("LZFSE"), "{e}");
let p = dir.path().join("e.dmg");
let mut img = b"encrcdsa".to_vec();
img.resize(8192, 0);
std::fs::write(&p, img).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("cifrado"), "{e}");
}
// ---------- imagens reais do dfvfs (target/testdata) ----------
#[test]
fn dfvfs_images_match_a_reference_reader() {
// hashes do disco virtual inteiro, conferidos com o 7-Zip (árvore de arquivos e partições)
let table: &[(&str, &str)] = &[
(
"ext2.vhdx",
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80",
),
(
"ext2.vmdk",
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80",
),
(
"ext2.vhd",
"870be7ae16c1fa8faab05c6eb9205dc9a7ae35c5f552c5cf8a267c0bc6a5cb99",
),
(
"fvdetest.qcow2",
"732091e57632b7a2332f218d2d3a9ce1355a836ebacce91615a2c30a178e4ec3",
),
(
"windows_volume.qcow2",
"44abe9deca9658f331a2bdba2deb53192d04f35d157a1fb3ba0597b03f0524f2",
),
(
"apm.dmg",
"897f487f4971e72c762e00460935e3b25176cda35620b508b490f65a7e0118ff",
),
(
"ntfs-dynamic.vhd",
"8b10b52e3d1e76075d05a5101a6536b9fa8376a5b8f7c22f502d0987d165c876",
),
(
"ntfs-fixed.vhd",
"cd81fade3878f1b2957f1ece168201876bdf4e90c9b30f60c2f232eba5fea76c",
),
(
"ntfs-dynamic.vhdx",
"b92731f8e4bf3532418444d22a9086823a8122569500acd09cd8d2ce20eb98e9",
),
(
"ntfs-fixed.vhdx",
"5237dd895f38052344e2ef35a207fe9df08cd18d17b6ea857a1b93cc475e1f2e",
),
(
"fat-differential.vhd",
"f4b61a33ee2f7e8c19f737792a4107e46340026a9a829e4169b751901cc3ef02",
),
(
"ntfs-differential.vhd",
"b7922a46a6b08fa4f4fc0c51a25d5587d1d49f15cfade45ca740dd07110ae144",
),
(
"fat-differential.vhdx",
"a538161f34bc44c12bf9a542ce9218741c62728723ea18d0e4f6ea92e9f32e93",
),
(
"ntfs-differential.vhdx",
"1ef1fb5360247aa7d36a38415bc7bf6e1e52f62af5e6bd1283815e82584c7efc",
),
];
for (name, hash) in table {
let Some(p) = dfvfs(name) else { continue };
let s = open_path(&p).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(&sha(s.as_ref()), hash, "{name}");
}
}
#[test]
fn partition_table_is_found_inside_a_qcow2() {
let Some(p) = dfvfs("fvdetest.qcow2") else {
return;
};
let s = open_path(&p).unwrap();
let layout = guara_core::partition::analyze(s.as_ref(), false);
assert_eq!(layout.table, "gpt");
assert_eq!(layout.partitions.len(), 1);
}