diff --git a/Cargo.lock b/Cargo.lock index 0f6513e..b0be34d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,7 @@ dependencies = [ "jpeg-encoder", "libc", "lz4_flex", + "lzfse_rust", "md-5", "mp4", "pbkdf2", @@ -582,6 +583,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" +[[package]] +name = "lzfse_rust" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6" + [[package]] name = "md-5" version = "0.11.0" diff --git a/ROADMAP.md b/ROADMAP.md index 5c24832..4824956 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,8 @@ Today Guará opens volumes when you **already have** the password or key. Passwo | E01 and Ex01: read, write and `guara verify` | ✅ | | AFF4: read and write | ✅ | | Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ | -| QCOW2 with a backing file, VMDK snapshots, DMG with LZFSE or a password | 🔜 | +| QCOW2 with a backing file, VMDK snapshots (by name or VirtualBox UUID), DMG with LZFSE | ✅ | +| Password-protected DMG, parent identity check for VHD, VHDX and QCOW2 | 🔜 | | Mobile device image (see item 7) | 🔭 | ### 4. Assembling loose disks diff --git a/ROADMAP.pt-BR.md b/ROADMAP.pt-BR.md index 88e6fa7..7899fba 100644 --- a/ROADMAP.pt-BR.md +++ b/ROADMAP.pt-BR.md @@ -68,7 +68,8 @@ Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recupera | E01 e Ex01: leitura, gravação e `guara verify` | ✅ | | AFF4: leitura e gravação | ✅ | | Contêineres de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG (leitura) | ✅ | -| QCOW2 com arquivo de apoio, snapshots de VMDK, DMG com LZFSE ou com senha | 🔜 | +| QCOW2 com arquivo de apoio, snapshots de VMDK (por nome ou UUID do VirtualBox), DMG com LZFSE | ✅ | +| DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2 | 🔜 | | Imagem de dispositivo móvel (ver item 7) | 🔭 | ### 4. Montagem de discos soltos diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml index 5aa5bc1..e3b6b12 100644 --- a/crates/guara-core/Cargo.toml +++ b/crates/guara-core/Cargo.toml @@ -29,6 +29,7 @@ hmac = "0.13" pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] } ripemd = "0.2" argon2 = "0.6" +lzfse_rust = "0.2.1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/guara-core/src/vdisk/dmg.rs b/crates/guara-core/src/vdisk/dmg.rs index baad42d..664f9c0 100644 --- a/crates/guara-core/src/vdisk/dmg.rs +++ b/crates/guara-core/src/vdisk/dmg.rs @@ -1,6 +1,6 @@ //! 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. +//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem +//! clara. use super::{bad, be32, be64, read_exact, read_vec, Backend}; use crate::source::{FileSource, Source}; @@ -187,7 +187,8 @@ impl Dmg { bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?; } 0x8000_0007 => { - return Err(bad("chunk LZFSE (formato ULFO) ainda não é suportado")); + lzfse_rust::decode_bytes(&raw, &mut out) + .map_err(|e| bad(format!("chunk LZFSE inválido: {e}")))?; } t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))), } diff --git a/crates/guara-core/src/vdisk/mod.rs b/crates/guara-core/src/vdisk/mod.rs index 000d253..eed5bc9 100644 --- a/crates/guara-core/src/vdisk/mod.rs +++ b/crates/guara-core/src/vdisk/mod.rs @@ -2,9 +2,9 @@ //! //! 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. +//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a +//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi +//! alterado depois do filho, os dados lidos refletem o pai atual. mod dmg; mod qcow2; @@ -130,8 +130,8 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result> { }; 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))?), + "QCOW2" => Box::new(qcow2::Qcow2::open(file, path, depth).map_err(|e| io_err(e, &name))?), + "VMDK" => Box::new(vmdk::Vmdk::open(file, path, depth).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))?), @@ -139,17 +139,23 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result> { Ok(Some(VDisk { inner, format })) } -/// Abre o disco pai de um VHD/VHDX diferencial. +/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o +/// arquivo é tratado como disco bruto sem tentar reconhecer o formato. pub(crate) fn open_parent( candidates: &[std::path::PathBuf], depth: u32, + raw: bool, ) -> 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)); + if !raw { + if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? { + return Ok(Box::new(v)); + } + } else if depth > MAX_DEPTH { + return Err(bad("cadeia de discos pai longa demais ou circular")); } let f = FileSource::open(p)?; return Ok(Box::new(f)); diff --git a/crates/guara-core/src/vdisk/qcow2.rs b/crates/guara-core/src/vdisk/qcow2.rs index b84b31a..5e446f1 100644 --- a/crates/guara-core/src/vdisk/qcow2.rs +++ b/crates/guara-core/src/vdisk/qcow2.rs @@ -1,10 +1,15 @@ -//! 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. +//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd), +//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai. +//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um +//! arquivo local (`json:`, `nbd:`, URLs) são recusadas. -use super::{bad, be32, be64, check_table, read_exact, read_vec, Backend}; +use super::{ + bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec, + Backend, +}; use crate::source::{FileSource, Source}; use std::io::{self, Read}; +use std::path::Path; use std::sync::Mutex; const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00; @@ -16,12 +21,13 @@ pub(super) struct Qcow2 { cluster_bits: u32, l1: Vec, zstd: bool, + parent: Option>, /// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados). cache: Mutex)>>, } impl Qcow2 { - pub(super) fn open(file: FileSource) -> io::Result { + pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> 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")); @@ -30,11 +36,6 @@ impl Qcow2 { 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!( @@ -63,6 +64,11 @@ impl Qcow2 { } } } + let parent = if be64(&h, 8) != 0 { + Some(Self::open_backing(&file, &h, version, path, depth)?) + } else { + None + }; let l1_size = be32(&h, 36) as u64; let l1_off = be64(&h, 40); let n = check_table(l1_size, 8, "tabela L1")?; @@ -74,10 +80,65 @@ impl Qcow2 { cluster_bits, l1, zstd, + parent, cache: Mutex::new(None), }) } + /// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw` + /// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz. + fn open_backing( + file: &FileSource, + h: &[u8], + version: u32, + path: &Path, + depth: u32, + ) -> io::Result> { + let (off, len) = (be64(h, 8), be32(h, 16) as usize); + if len == 0 || len > 1023 { + return Err(bad("nome do arquivo de apoio QCOW2 inválido")); + } + let name = String::from_utf8_lossy(&read_vec(file, off, len)?).into_owned(); + if name.starts_with("json:") + || name.contains("://") + || name.split(':').next().is_some_and(|p| { + matches!(p, "nbd" | "ssh" | "rbd" | "sheepdog" | "gluster" | "iscsi") + }) + { + return Err(bad(format!( + "o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados" + ))); + } + // extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes + let hlen = if version == 3 && h.len() >= 104 { + be32(h, 100) as u64 + } else { + 72 + }; + let mut raw = false; + let mut pos = hlen.div_ceil(8) * 8; + for _ in 0..64 { + let mut eh = [0u8; 8]; + if read_exact(file, pos, &mut eh).is_err() { + break; + } + let (kind, elen) = (be32(&eh, 0), be32(&eh, 4) as u64); + if kind == 0 || elen > 1 << 20 { + break; + } + if kind == 0xE279_2ACA { + let fmt = read_vec(file, pos + 8, elen as usize)?; + raw = fmt == b"raw"; + } + pos += 8 + elen.div_ceil(8) * 8; + } + if version == 2 { + raw = false; + } + let dir = path.parent().unwrap_or(Path::new(".")); + open_parent(&candidate_paths(dir, &[name]), depth, raw) + } + fn cluster_size(&self) -> u64 { 1 << self.cluster_bits } @@ -139,8 +200,10 @@ impl Backend for Qcow2 { return Ok(n); } let off = entry & OFFSET_MASK; - if off == 0 || entry & 1 != 0 { - buf[..n].fill(0); + if entry & 1 != 0 { + buf[..n].fill(0); // zerado de propósito: não consulta o pai + } else if off == 0 { + read_parent(&self.parent, voff, &mut buf[..n])?; } else { read_exact(&self.file, off + within, &mut buf[..n])?; } diff --git a/crates/guara-core/src/vdisk/vhd.rs b/crates/guara-core/src/vdisk/vhd.rs index 204cb43..c25cb0a 100644 --- a/crates/guara-core/src/vdisk/vhd.rs +++ b/crates/guara-core/src/vdisk/vhd.rs @@ -112,7 +112,7 @@ impl Vhd { } } names.push(utf16(&dh[64..64 + 512], true)); - Some(open_parent(&candidate_paths(dir, &names), depth)?) + Some(open_parent(&candidate_paths(dir, &names), depth, false)?) } else { None }; diff --git a/crates/guara-core/src/vdisk/vhdx.rs b/crates/guara-core/src/vdisk/vhdx.rs index ff8063b..75b801c 100644 --- a/crates/guara-core/src/vdisk/vhdx.rs +++ b/crates/guara-core/src/vdisk/vhdx.rs @@ -130,7 +130,7 @@ impl Vhdx { } } let dir = path.parent().unwrap_or(Path::new(".")); - Some(open_parent(&candidate_paths(dir, &names), depth)?) + Some(open_parent(&candidate_paths(dir, &names), depth, false)?) } else { None }; diff --git a/crates/guara-core/src/vdisk/vmdk.rs b/crates/guara-core/src/vdisk/vmdk.rs index c6baf22..b7078c8 100644 --- a/crates/guara-core/src/vdisk/vmdk.rs +++ b/crates/guara-core/src/vdisk/vmdk.rs @@ -1,9 +1,12 @@ //! 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. +//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos +//! que o filho não alocou; o formato SEsparse é recusado. -use super::{bad, check_table, le16, le32, le64, read_exact, read_vec, Backend}; +use super::{ + bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent, + read_vec, Backend, +}; use crate::source::{FileSource, Source}; use std::io::{self, Read}; use std::path::Path; @@ -36,20 +39,73 @@ struct Extent { pub(super) struct Vmdk { extents: Vec, size: u64, + parent: Option>, } -fn parent_in(descriptor: &str) -> bool { - descriptor.lines().any(|l| { - let l = l.trim(); - l.strip_prefix("parentCID") +/// Valor de uma chave `chave=valor` do descritor, sem aspas. +fn desc_value(descriptor: &str, key: &str) -> Option { + descriptor.lines().find_map(|l| { + l.trim() + .strip_prefix(key) .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") - }) + .map(|v| v.trim().trim_matches('"').to_string()) }) } +/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido +/// logo depois do cabeçalho de uma extensão esparsa. +fn descriptor_of(path: &Path) -> Option { + let f = FileSource::open(path).ok()?; + let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?; + if head.starts_with(b"KDMV") { + let off = (le64(&head, 28) * SECTOR) as usize; + let len = (le64(&head, 36) * SECTOR) as usize; + let d = head.get(off..(off + len).min(head.len()))?; + let end = d.iter().position(|&b| b == 0).unwrap_or(d.len()); + return Some(String::from_utf8_lossy(&d[..end]).into_owned()); + } + head.starts_with(b"# Disk Desc") + .then(|| String::from_utf8_lossy(&head).into_owned()) +} + +/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só +/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter. +struct ParentRef { + name: Option, + uuid: Option, + cid: Option, +} + +fn parent_ref(descriptor: &str) -> Option { + let cid = desc_value(descriptor, "parentCID") + .filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("ffffffff")); + let uuid = desc_value(descriptor, "ddb.uuid.parent") + .filter(|u| !u.is_empty() && u.chars().any(|c| c != '0' && c != '-')); + let name = desc_value(descriptor, "parentFileNameHint").filter(|n| !n.is_empty()); + (cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid }) +} + +/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`. +fn find_by_uuid(dir: &Path, uuid: &str) -> Option { + let mut d = Some(dir); + for _ in 0..3 { + let cur = d?; + for e in std::fs::read_dir(cur).ok()?.flatten() { + let p = e.path(); + if p.extension() + .is_some_and(|x| x.eq_ignore_ascii_case("vmdk")) + && descriptor_of(&p) + .and_then(|ds| desc_value(&ds, "ddb.uuid.image")) + .is_some_and(|u| u.eq_ignore_ascii_case(uuid)) + { + return Some(p); + } + } + d = cur.parent(); + } + None +} + impl Sparse { fn open(file: FileSource) -> io::Result<(Self, u64, Option)> { let mut h = read_vec(&file, 0, SECTOR as usize)?; @@ -95,11 +151,6 @@ impl Sparse { } 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)?; @@ -118,7 +169,9 @@ impl Sparse { )) } - fn read(&self, off: u64, buf: &mut [u8]) -> io::Result { + /// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo + /// (o chamador completa com o pai); grão zerado de propósito devolve zeros. + fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> { let g = off / self.grain; let within = off % self.grain; let n = buf.len().min((self.grain - within) as usize); @@ -129,13 +182,16 @@ impl Sparse { read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?; sector = u32::from_le_bytes(b) as u64; } - if sector <= 1 { + if sector == 0 { + return Ok((n, false)); + } + if sector == 1 { buf[..n].fill(0); - return Ok(n); + return Ok((n, true)); } if !self.compressed { read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?; - return Ok(n); + return Ok((n, true)); } let mut cache = self.cache.lock().unwrap(); if cache.as_ref().map(|c| c.0) != Some(g) { @@ -153,17 +209,12 @@ impl Sparse { } let data = &cache.as_ref().unwrap().1; buf[..n].copy_from_slice(&data[within as usize..within as usize + n]); - Ok(n) + Ok((n, true)) } } /// 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() { @@ -216,27 +267,79 @@ fn parse_descriptor(text: &str, dir: &Path) -> io::Result> { } impl Vmdk { - pub(super) fn open(file: FileSource, path: &Path) -> io::Result { + fn open_parent_of(r: &ParentRef, dir: &Path, depth: u32) -> io::Result> { + let mut found = r + .name + .as_ref() + .map(|n| candidate_paths(dir, std::slice::from_ref(n))) + .unwrap_or_default(); + let by_name = found.iter().any(|p| p.is_file()); + if !by_name { + if let Some(p) = r.uuid.as_deref().and_then(|u| find_by_uuid(dir, u)) { + found = vec![p]; + } + } + if r.name.is_none() && r.uuid.is_none() { + return Err(bad( + "VMDK com pai, mas o descritor não diz qual (sem parentFileNameHint nem ddb.uuid.parent)", + )); + } + if r.name.is_none() && found.is_empty() { + return Err(bad( + "VMDK com pai identificado só por UUID (VirtualBox) e o pai não está na pasta do filho nem nas duas acima", + )); + } + if let Some(cid) = &r.cid { + // o pai precisa ser o mesmo de quando o filho foi criado + if let Some(p) = found.iter().find(|p| p.is_file()) { + let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID")); + if have + .as_deref() + .is_some_and(|h| !h.eq_ignore_ascii_case(cid)) + { + return Err(bad(format!( + "o pai {} foi alterado depois do filho (CID {} em vez de {cid}); os dados do filho não valem mais", + p.display(), + have.unwrap_or_default() + ))); + } + } + } + open_parent(&found, depth, false) + } + + pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> 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), - }] + let dir = path.parent().unwrap_or(Path::new(".")); + let (extents, descriptor) = if &head == b"KDMV" { + let (sp, size, descriptor) = Sparse::open(file)?; + ( + vec![Extent { + start: 0, + len: size, + kind: Kind::Sparse(sp), + }], + descriptor, + ) } 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 text = String::from_utf8_lossy(&raw).into_owned(); + (parse_descriptor(&text, dir)?, Some(text)) + }; + let parent = match descriptor.as_deref().and_then(parent_ref) { + Some(r) => Some(Self::open_parent_of(&r, dir, depth)?), + None => None, }; let size = extents.iter().map(|e| e.len).sum(); - Ok(Self { extents, size }) + Ok(Self { + extents, + size, + parent, + }) } } @@ -261,7 +364,13 @@ impl Backend for Vmdk { read_exact(file, offset + rel, &mut buf[..n])?; Ok(n) } - Kind::Sparse(s) => s.read(rel, &mut buf[..n]), + Kind::Sparse(s) => { + let (got, allocated) = s.read(rel, &mut buf[..n])?; + if !allocated { + read_parent(&self.parent, voff, &mut buf[..got])?; + } + Ok(got) + } } } } diff --git a/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin b/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin new file mode 100644 index 0000000..bcd1dfa Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin differ diff --git a/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin.lzfse b/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin.lzfse new file mode 100644 index 0000000..2a774e2 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin.lzfse differ diff --git a/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin b/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin new file mode 100644 index 0000000..ed605f9 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin differ diff --git a/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin.lzfse b/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin.lzfse new file mode 100644 index 0000000..a505bf3 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin.lzfse differ diff --git a/crates/guara-core/tests/fixtures/vdisk/vbox-snapshot.vmdk b/crates/guara-core/tests/fixtures/vdisk/vbox-snapshot.vmdk new file mode 100644 index 0000000..c8f2278 Binary files /dev/null and b/crates/guara-core/tests/fixtures/vdisk/vbox-snapshot.vmdk differ diff --git a/crates/guara-core/tests/vdisk.rs b/crates/guara-core/tests/vdisk.rs index 211254b..7aebafc 100644 --- a/crates/guara-core/tests/vdisk.rs +++ b/crates/guara-core/tests/vdisk.rs @@ -281,22 +281,82 @@ fn qcow2_with_zero_raw_and_compressed_clusters() { } #[test] -fn qcow2_with_backing_file_or_encryption_is_refused() { +fn qcow2_with_encryption_or_a_remote_backing_file_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}"); + + let mut img = build_qcow2(&raw()); + let name = b"nbd://servidor/disco"; + img[512..512 + name.len()].copy_from_slice(name); + be(&mut img, 8, 512, 8); + be(&mut img, 16, name.len() as u64, 4); + let p = dir.path().join("r.qcow2"); + std::fs::write(&p, &img).unwrap(); + let e = open_path(&p).err().expect("deveria recusar").to_string(); + assert!(e.contains("não é um arquivo local"), "{e}"); +} + +/// Filho QCOW2 que só aloca o cluster 1 (dados), marca o 2 como zerado e deixa o resto para o pai. +fn build_qcow2_child(backing: &str, fmt: Option<&str>, own: &[u8]) -> Vec { + const CS: usize = 1 << 16; + let mut img = vec![0u8; 3 * CS]; + img[..4].copy_from_slice(b"QFI\xfb"); + be(&mut img, 4, 3, 4); + be(&mut img, 16, backing.len() as u64, 4); + be(&mut img, 8, 1024, 8); + img[1024..1024 + backing.len()].copy_from_slice(backing.as_bytes()); + be(&mut img, 20, 16, 4); + be(&mut img, 24, 16 * CS as u64, 8); + be(&mut img, 36, 1, 4); + be(&mut img, 40, CS as u64, 8); + be(&mut img, 100, 112, 4); + if let Some(f) = fmt { + // extensão 0xE2792ACA com o nome do formato do apoio, depois o fim das extensões + be(&mut img, 112, 0xE279_2ACA, 4); + be(&mut img, 116, f.len() as u64, 4); + img[120..120 + f.len()].copy_from_slice(f.as_bytes()); + } + be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8); + let host = img.len() as u64; + img.extend_from_slice(own); + img.resize(img.len().div_ceil(CS) * CS, 0); + be(&mut img, 2 * CS + 8, host | (1 << 63), 8); // cluster 1 + be(&mut img, 2 * CS + 16, 1, 8); // cluster 2: zerado de propósito + img +} + +#[test] +fn qcow2_backing_file_supplies_what_the_child_did_not_allocate() { + const CS: usize = 1 << 16; + let base = raw(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("base.raw"), &base).unwrap(); + let own = vec![0x42u8; CS]; + let mut expect = base.clone(); + expect[CS..2 * CS].copy_from_slice(&own); + expect[2 * CS..3 * CS].fill(0); + for (name, fmt) in [("a.qcow2", Some("raw")), ("b.qcow2", None)] { + let p = dir.path().join(name); + std::fs::write(&p, build_qcow2_child("base.raw", fmt, &own)).unwrap(); + check_content(&p, &expect); + } + // com o pai em outro formato: o filho aponta para um QCOW2 + std::fs::write(dir.path().join("base.qcow2"), build_qcow2(&base)).unwrap(); + let p = dir.path().join("c.qcow2"); + std::fs::write(&p, build_qcow2_child("base.qcow2", Some("qcow2"), &own)).unwrap(); + check_content(&p, &expect); + // sem o pai, a abertura diz o que falta + std::fs::remove_file(dir.path().join("base.raw")).unwrap(); + let e = open_path(&dir.path().join("a.qcow2")) + .err() + .unwrap() + .to_string(); + assert!(e.contains("disco pai não encontrado"), "{e}"); } // ---------- VMDK ---------- @@ -324,8 +384,114 @@ fn vmdk_descriptor_with_flat_and_zero_extents() { check_content(&p, &expect); } +/// VMDK esparso feito à mão: 16 grãos de 64 KiB; `grains[i]` = `None` (não alocado, vem do pai), +/// `Some(None)` (zerado de propósito) ou `Some(Some(dados))`. +fn build_vmdk_sparse(descriptor: &str, grains: &[Option>>]) -> Vec { + const GRAIN: usize = 128; // setores + let mut img = vec![0u8; 26 * 512]; + img[..4].copy_from_slice(b"KDMV"); + le(&mut img, 4, 1, 4); + le(&mut img, 8, 3, 4); + le(&mut img, 12, (grains.len() * GRAIN) as u64, 8); + le(&mut img, 20, GRAIN as u64, 8); + le(&mut img, 28, 1, 8); + le(&mut img, 36, 20, 8); + le(&mut img, 44, 512, 4); + le(&mut img, 56, 21, 8); + le(&mut img, 64, 26, 8); + img[512..512 + descriptor.len()].copy_from_slice(descriptor.as_bytes()); + le(&mut img, 21 * 512, 22, 4); // diretório de grãos -> tabela no setor 22 + for (i, g) in grains.iter().enumerate() { + let entry = match g { + None => 0, + Some(None) => 1, + Some(Some(d)) => { + let sector = (img.len() / 512) as u64; + let mut d = d.clone(); + d.resize(GRAIN * 512, 0); + img.extend_from_slice(&d); + sector + } + }; + le(&mut img, 22 * 512 + i * 4, entry, 4); + } + img +} + #[test] -fn vmdk_with_a_parent_is_refused() { +fn vmdk_child_reads_the_parent_for_unallocated_grains() { + let base = raw(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("base.vmdk"), build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=11111111\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n", + &(0..16) + .map(|i| Some(Some(base[i * 65536..(i + 1) * 65536].to_vec()))) + .collect::>(), + )) + .unwrap(); + let own = vec![0x37u8; 65536]; + let mut grains: Vec>>> = vec![None; 16]; + grains[3] = Some(Some(own.clone())); + grains[5] = Some(None); // zerado de propósito: não consulta o pai + let child = build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=22222222\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n", + &grains, + ); + let p = dir.path().join("child.vmdk"); + std::fs::write(&p, child).unwrap(); + let mut expect = base.clone(); + expect[3 * 65536..4 * 65536].copy_from_slice(&own); + expect[5 * 65536..6 * 65536].fill(0); + check_content(&p, &expect); +} + +/// Snapshot real do VirtualBox: o filho só diz o UUID do pai (`ddb.uuid.parent`), sem nome. +#[test] +fn virtualbox_snapshot_vmdk_finds_its_parent_by_uuid() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("Snapshots"); + std::fs::create_dir(&sub).unwrap(); + // o pai fica na pasta de cima, como o VirtualBox organiza a máquina + std::fs::copy(fx("disk.vmdk"), dir.path().join("disco-base.vmdk")).unwrap(); + std::fs::copy(fx("vbox-snapshot.vmdk"), sub.join("filho.vmdk")).unwrap(); + check_content(&sub.join("filho.vmdk"), &raw()); + // sem o pai por perto, a abertura explica + std::fs::remove_file(dir.path().join("disco-base.vmdk")).unwrap(); + let e = open_path(&sub.join("filho.vmdk")) + .err() + .unwrap() + .to_string(); + assert!(e.contains("identificado só por UUID"), "{e}"); +} + +#[test] +fn vmdk_child_is_refused_when_the_parent_changed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("base.vmdk"), + build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=aaaaaaaa\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n", + &vec![None; 16], + ), + ) + .unwrap(); + std::fs::write( + dir.path().join("filho.vmdk"), + build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=bbbbbbbb\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n", + &vec![None; 16], + ), + ) + .unwrap(); + let e = open_path(&dir.path().join("filho.vmdk")) + .err() + .unwrap() + .to_string(); + assert!(e.contains("foi alterado depois do filho"), "{e}"); +} + +#[test] +fn vmdk_with_a_parent_but_no_name_is_refused() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("c.vmdk"); std::fs::write( @@ -334,7 +500,7 @@ fn vmdk_with_a_parent_is_refused() { ) .unwrap(); let e = open_path(&p).err().expect("deveria recusar").to_string(); - assert!(e.contains("disco pai"), "{e}"); + assert!(e.contains("não diz qual"), "{e}"); } // ---------- VHD ---------- @@ -619,8 +785,36 @@ fn dmg_with_every_chunk_type() { check_content(&p, &expect); } +/// Chunks `ULFO` com dados compactados pelo compressor de referência da Apple (`lzfse`): +/// um bloco `bvx2` (FSE, 64 KiB) e um pequeno (`bvxn`/LZVN). #[test] -fn dmg_lzfse_and_encrypted_are_refused_with_a_reason() { +fn dmg_lzfse_chunks_from_the_reference_compressor() { + let big = std::fs::read(fx("lzfse-64k.bin")).unwrap(); + let small = std::fs::read(fx("lzfse-small.bin")).unwrap(); + let mut small_padded = small.clone(); + small_padded.resize(small.len().div_ceil(512) * 512, 0); + let chunks = [ + DmgChunk { + ty: 0x8000_0007, + sectors: (big.len() / 512) as u64, + payload: std::fs::read(fx("lzfse-64k.bin.lzfse")).unwrap(), + }, + DmgChunk { + ty: 0x8000_0007, + sectors: (small_padded.len() / 512) as u64, + payload: std::fs::read(fx("lzfse-small.bin.lzfse")).unwrap(), + }, + ]; + let mut expect = big.clone(); + expect.extend_from_slice(&small_padded); + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("l.dmg"); + std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap(); + check_content(&p, &expect); +} + +#[test] +fn dmg_with_a_corrupt_lzfse_chunk_and_encrypted_are_refused_with_a_reason() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("l.dmg"); let chunks = [DmgChunk { diff --git a/docs/pt-BR/suporte.md b/docs/pt-BR/suporte.md index b2f9a2d..6b048e5 100644 --- a/docs/pt-BR/suporte.md +++ b/docs/pt-BR/suporte.md @@ -55,12 +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 | ❌ | +| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) e arquivo de apoio | ❌ | +| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized`, descritores com extensões flat/zero e snapshots (pai por nome ou, no VirtualBox, por UUID) | ❌ | | 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 | ❌ | +| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib, bzip2 e LZFSE | ❌ | -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). +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 cifrado, QCOW2 cujo apoio não é um arquivo local, VMDK em SEsparse e DMG protegido por senha. Discos diferenciais (VHD, VHDX, QCOW2 com apoio, snapshot de VMDK) leem o pai no que o filho não gravou; o pai é procurado ao lado do filho (VMDK: por nome ou UUID) e, no VMDK, o CID dele precisa bater, senão a imagem é recusada porque o pai mudou. A identidade dos pais de VHD, VHDX e QCOW2 não é conferida. 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 5b61db7..d7e7459 100644 --- a/docs/support.md +++ b/docs/support.md @@ -55,12 +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 | ❌ | +| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) and backing files | ❌ | +| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized`, descriptors with flat/zero extents and snapshots (parent by name or, for VirtualBox, by UUID) | ❌ | | VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ | -| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib and bzip2 chunks | ❌ | +| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib, bzip2 and LZFSE 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). +Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: encrypted QCOW2, QCOW2 whose backing file is not a local file, VMDK in SEsparse, and password-protected DMG. Differencing disks (VHD, VHDX, QCOW2 backing file, VMDK snapshot) read the parent for what the child did not write; the parent is found next to the child (VMDK: by name or UUID) and, for VMDK, its CID must match, otherwise the image is refused because the parent changed. The identity of VHD, VHDX and QCOW2 parents is not checked. 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.