diff --git a/README.md b/README.md index 0a40c4a..ba958a2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Dis - **Encrypted volumes:** opens BitLocker, LUKS and FileVault 2 with your password or key and scans inside. - **RAID and LVM:** reassembles arrays from loose disks (RAID 0, 1, 4, 5, 6, 10, LVM2), working out level, stripe size and order by itself when there is no metadata. - **Fragmented files:** reassembles JPEGs stored in pieces and rebuilds the index of MP4/MOV videos that lost their `moov`. -- **Forensic images:** reads and writes raw images, **E01/Ex01** and **AFF4**. +- **Forensic images:** reads and writes raw images, **E01/Ex01** and **AFF4**, and reads **QCOW2, VMDK, VHD, VHDX** and **DMG** virtual disks. - **Lost partitions:** reads MBR/GPT and searches for volumes by signature where the table failed. - **Merge without duplicates:** a file found by more than one method appears once, with the best evidence. - **Per-file confidence** (high, medium, low): intact structure, reused clusters, zeroed content (TRIM) or content that does not match the extension. diff --git a/README.pt-BR.md b/README.pt-BR.md index 0bf21ac..2692e21 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -17,7 +17,7 @@ O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec, - **Volumes cifrados:** abre BitLocker, LUKS e FileVault 2 com a sua senha ou chave e varre por dentro. - **RAID e LVM:** remonta arrays a partir de discos soltos (RAID 0, 1, 4, 5, 6, 10, LVM2), descobrindo nível, faixa e ordem sozinho quando não há metadados. - **Arquivos fragmentados:** remonta JPEG em pedaços e refaz o índice de vídeos MP4/MOV sem `moov`. -- **Imagens forenses:** lê e grava imagem bruta, **E01/Ex01** e **AFF4**. +- **Imagens forenses:** lê e grava imagem bruta, **E01/Ex01** e **AFF4**, e lê discos virtuais **QCOW2, VMDK, VHD, VHDX** e **DMG**. - **Partições perdidas:** lê MBR/GPT e procura volumes por assinatura onde a tabela falhou. - **Fusão sem duplicatas:** um arquivo achado por mais de um método aparece uma vez, com a melhor evidência. - **Confiança por arquivo** (alta, média, baixa): estrutura íntegra, clusters reutilizados, conteúdo zerado (TRIM) ou que não confere com a extensão. diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 732e60c..3068f53 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -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}; diff --git a/crates/guara-core/src/source.rs b/crates/guara-core/src/source.rs index 06ae13c..2916889 100644 --- a/crates/guara-core/src/source.rs +++ b/crates/guara-core/src/source.rs @@ -286,7 +286,13 @@ pub fn open_path(path: &Path) -> io::Result> { .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 diff --git a/crates/guara-core/src/vdisk/dmg.rs b/crates/guara-core/src/vdisk/dmg.rs new file mode 100644 index 0000000..baad42d --- /dev/null +++ b/crates/guara-core/src/vdisk/dmg.rs @@ -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, + cache: Mutex)>>, +} + +fn base64(s: &str) -> Vec { + 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> { + let mut out: Vec = 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 { + 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("blkx") + .ok_or_else(|| bad("tabela blkx não encontrada no DMG"))?; + let rest = &xml[from..]; + let rest = &rest[..rest.find("").unwrap_or(rest.len())]; + let mut chunks: Vec = vec![]; + let mut cur = rest; + while let Some(a) = cur.find("") { + let after = &cur[a + 6..]; + let Some(b) = after.find("") 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> { + 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 { + 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) + } +} diff --git a/crates/guara-core/src/vdisk/mod.rs b/crates/guara-core/src/vdisk/mod.rs new file mode 100644 index 0000000..000d253 --- /dev/null +++ b/crates/guara-core/src/vdisk/mod.rs @@ -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; +} + +/// Disco virtual aberto de uma imagem de contêiner. +pub struct VDisk { + inner: Box, + 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 { + 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) -> 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> { + open_depth(path, 0) +} + +pub(crate) fn open_depth(path: &Path, depth: u32) -> Result> { + 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 = 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> { + 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::>() + .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> { + 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 { + 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 = 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 { + let mut out: Vec = 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>, + 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(()) +} diff --git a/crates/guara-core/src/vdisk/qcow2.rs b/crates/guara-core/src/vdisk/qcow2.rs new file mode 100644 index 0000000..b84b31a --- /dev/null +++ b/crates/guara-core/src/vdisk/qcow2.rs @@ -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, + zstd: bool, + /// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados). + cache: Mutex)>>, +} + +impl Qcow2 { + pub(super) fn open(file: FileSource) -> io::Result { + 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 { + 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> { + 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 { + 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) + } +} diff --git a/crates/guara-core/src/vdisk/vhd.rs b/crates/guara-core/src/vdisk/vhd.rs new file mode 100644 index 0000000..204cb43 --- /dev/null +++ b/crates/guara-core/src/vdisk/vhd.rs @@ -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, + block_size: u64, + bitmap_size: u64, + }, +} + +pub(super) struct Vhd { + file: FileSource, + size: u64, + kind: Kind, + parent: Option>, +} + +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 { + 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 { + 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) + } +} diff --git a/crates/guara-core/src/vdisk/vhdx.rs b/crates/guara-core/src/vdisk/vhdx.rs new file mode 100644 index 0000000..ff8063b --- /dev/null +++ b/crates/guara-core/src/vdisk/vhdx.rs @@ -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 = 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, + parent: Option>, +} + +impl Vhdx { + pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result { + 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 { + 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) + } +} diff --git a/crates/guara-core/src/vdisk/vmdk.rs b/crates/guara-core/src/vdisk/vmdk.rs new file mode 100644 index 0000000..c6baf22 --- /dev/null +++ b/crates/guara-core/src/vdisk/vmdk.rs @@ -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, + compressed: bool, + cache: Mutex)>>, +} + +enum Kind { + Zero, + Flat { file: FileSource, offset: u64 }, + Sparse(Sparse), +} + +struct Extent { + start: u64, + len: u64, + kind: Kind, +} + +pub(super) struct Vmdk { + extents: Vec, + 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)> { + 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 { + 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> { + 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::() 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::().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 { + 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 { + 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]), + } + } +} diff --git a/crates/guara-core/tests/fixtures/vdisk/disk-stream.vmdk b/crates/guara-core/tests/fixtures/vdisk/disk-stream.vmdk new file mode 100644 index 0000000..2b8e269 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/disk-stream.vmdk differ diff --git a/crates/guara-core/tests/fixtures/vdisk/disk.raw b/crates/guara-core/tests/fixtures/vdisk/disk.raw new file mode 100644 index 0000000..41d6312 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/disk.raw differ diff --git a/crates/guara-core/tests/fixtures/vdisk/disk.vhd b/crates/guara-core/tests/fixtures/vdisk/disk.vhd new file mode 100644 index 0000000..9dc5fc3 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/disk.vhd differ diff --git a/crates/guara-core/tests/fixtures/vdisk/disk.vmdk b/crates/guara-core/tests/fixtures/vdisk/disk.vmdk new file mode 100644 index 0000000..77d0ac3 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/disk.vmdk differ diff --git a/crates/guara-core/tests/fixtures/vdisk/ext2.qcow2 b/crates/guara-core/tests/fixtures/vdisk/ext2.qcow2 new file mode 100644 index 0000000..4d230b4 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/ext2.qcow2 differ diff --git a/crates/guara-core/tests/fixtures/vdisk/hfsplus_zlib.dmg b/crates/guara-core/tests/fixtures/vdisk/hfsplus_zlib.dmg new file mode 100644 index 0000000..fd9f55b Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/hfsplus_zlib.dmg differ diff --git a/crates/guara-core/tests/vdisk.rs b/crates/guara-core/tests/vdisk.rs new file mode 100644 index 0000000..211254b --- /dev/null +++ b/crates/guara-core/tests/vdisk.rs @@ -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 { + std::fs::read(fx("disk.raw")).unwrap() +} + +fn dfvfs(name: &str) -> Option { + 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 { + let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() +} + +fn zlib(data: &[u8]) -> Vec { + 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 { + 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, Vec) { + 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 = (0..len).map(|i| pat[i % 8]).collect(); + (enc, want) +} + +struct DmgChunk { + ty: u32, + sectors: u64, + payload: Vec, +} + +fn build_dmg(chunks: &[DmgChunk], total_sectors: u64) -> Vec { + 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!( + "\nresource-fork\ + blkxData\n{}\nNameteste\ + plstDataAAAA", + 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::>(); + 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 = 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)>], + parent: Option<&str>, + bitmap: Option>, +) -> Vec { + 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)> = 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 = "relative_path" + .encode_utf16() + .flat_map(|u| u.to_le_bytes()) + .collect(); + let val: Vec = 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); +} diff --git a/docs/architecture.md b/docs/architecture.md index c46dd1e..01a0c7f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,7 @@ recover copies with MD5, SHA-1 and SHA-256 and writes the | `source`, `device` | Open devices and images read-only; list disks | | `image` | Raw image tolerant of bad sectors, with an error map | | `ewf`, `aff4` | Read and write E01/Ex01 and AFF4 | +| `vdisk/` | Read QCOW2, VMDK, VHD, VHDX and DMG as the disk they contain | | `assemble` | Builds the virtual disk: image, RAID, LVM and encrypted volume | | `raid`, `raid_detect`, `lvm` | RAID reassembly (with automatic parameter discovery) and LVM2 | | `crypt/` | BitLocker, LUKS and FileVault 2 | diff --git a/docs/pt-BR/arquitetura.md b/docs/pt-BR/arquitetura.md index 944a571..dba10cd 100644 --- a/docs/pt-BR/arquitetura.md +++ b/docs/pt-BR/arquitetura.md @@ -43,6 +43,7 @@ recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto | `source`, `device` | Abrir dispositivos e imagens somente para leitura; listar discos | | `image` | Imagem bruta tolerante a setores ruins, com mapa de erros | | `ewf`, `aff4` | Ler e gravar E01/Ex01 e AFF4 | +| `vdisk/` | Ler QCOW2, VMDK, VHD, VHDX e DMG como o disco que contêm | | `assemble` | Monta o disco virtual: imagem, RAID, LVM e volume cifrado | | `raid`, `raid_detect`, `lvm` | Remontagem de RAID (com descoberta automática de parâmetros) e LVM2 | | `crypt/` | BitLocker, LUKS e FileVault 2 | diff --git a/docs/pt-BR/suporte.md b/docs/pt-BR/suporte.md index 7eb945c..b2f9a2d 100644 --- a/docs/pt-BR/suporte.md +++ b/docs/pt-BR/suporte.md @@ -55,6 +55,12 @@ As senhas **nunca** vão para o arquivo de sessão. Com `--remember-keys` a sess | Bruta (dd, `.img`) | ✅ | ✅ com mapa de erros de setores ruins | | E01 e Ex01 (Expert Witness) | ✅ | ✅ com metadados do caso e segmentação | | AFF4 | ✅ | ✅ | +| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) | ❌ | +| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized` e descritores com extensões flat/zero | ❌ | +| VHD e VHDX (Hyper-V) | ✅ fixo, dinâmico e diferencial (segue a cadeia de pais) | ❌ | +| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib e bzip2 | ❌ | + +Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 com arquivo de apoio ou cifrado, VMDK com pai (snapshot) ou em SEsparse, DMG protegido por senha e DMG com chunks LZFSE. O log de transações do VHDX não é reproduzido (nada é gravado na imagem). `guara verify` recalcula o MD5 de uma imagem E01 ou Ex01 e compara com o gravado nela. diff --git a/docs/support.md b/docs/support.md index ff7222d..5b61db7 100644 --- a/docs/support.md +++ b/docs/support.md @@ -55,6 +55,12 @@ Passwords are **never** written to the session file. With `--remember-keys` the | Raw (dd, `.img`) | ✅ | ✅ with a bad sector error map | | E01 and Ex01 (Expert Witness) | ✅ | ✅ with case metadata and segmentation | | AFF4 | ✅ | ✅ | +| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) | ❌ | +| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized` and descriptors with flat/zero extents | ❌ | +| VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ | +| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib and bzip2 chunks | ❌ | + +Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: QCOW2 with a backing file or encryption, VMDK with a parent (snapshot) or in SEsparse, password-protected DMG and DMG with LZFSE chunks. The VHDX transaction log is not replayed (nothing is ever written to the image). `guara verify` recomputes the MD5 of an E01 or Ex01 image and compares it with the one stored in it. diff --git a/scripts/fetch-testdata.sh b/scripts/fetch-testdata.sh index ec0104f..2cfaf27 100755 --- a/scripts/fetch-testdata.sh +++ b/scripts/fetch-testdata.sh @@ -13,7 +13,7 @@ for n in bitlk-images fvault2-images; do rm -rf "$tmp" done mkdir -p "$dest/dfvfs" -for f in lvm.raw apfs.raw apfs_encrypted.dmg hfsplus.raw hfs.raw xfs.raw xfs-bigtime.raw ext4.raw ext2.raw fvdetest.qcow2 luks1.raw luks2.raw bdetogo.raw; do +for f in lvm.raw apfs.raw apfs_encrypted.dmg hfsplus.raw hfs.raw xfs.raw xfs-bigtime.raw ext4.raw ext2.raw fvdetest.qcow2 ext2.vhd ext2.vhdx ext2.vmdk windows_volume.qcow2 apm.dmg ntfs-dynamic.vhd ntfs-fixed.vhd ntfs-dynamic.vhdx ntfs-fixed.vhdx ntfs-parent.vhd ntfs-differential.vhd ntfs-parent.vhdx ntfs-differential.vhdx fat-parent.vhd fat-differential.vhd fat-parent.vhdx fat-differential.vhdx luks1.raw luks2.raw bdetogo.raw; do [ -f "$dest/dfvfs/$f" ] || curl -fsSL "https://raw.githubusercontent.com/log2timeline/dfvfs/main/test_data/$f" -o "$dest/dfvfs/$f" done mkdir -p "$dest/aff4"