From 0ced7b725a07af80257edef2fa559f844bc994c0 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Mon, 19 Oct 2026 00:19:47 -0300 Subject: [PATCH] =?UTF-8?q?Btrfs:=20=C3=A1rvores=20atuais,=20mapa=20livre?= =?UTF-8?q?=20e=20arquivos=20apagados=20em=20vers=C3=B5es=20antigas=20de?= =?UTF-8?q?=20n=C3=B3s;=20extens=C3=B5es=20comprimidas=20(zlib,=20zstd,=20?= =?UTF-8?q?lzo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/guara-core/Cargo.toml | 1 + crates/guara-core/src/fs/btrfs.rs | 822 ++++++++++++++++++++++++++++++ crates/guara-core/src/fs/mod.rs | 1 + crates/guara-core/src/recover.rs | 24 + crates/guara-core/src/scan.rs | 1 + crates/guara-core/src/types.rs | 59 ++- crates/guara-core/tests/btrfs.rs | 280 ++++++++++ 7 files changed, 1186 insertions(+), 2 deletions(-) create mode 100644 crates/guara-core/src/fs/btrfs.rs create mode 100644 crates/guara-core/tests/btrfs.rs diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml index 1210799..178f1bc 100644 --- a/crates/guara-core/Cargo.toml +++ b/crates/guara-core/Cargo.toml @@ -22,6 +22,7 @@ tiny_http = "0.12" bzip2 = "0.6" zip = { version = "2", default-features = false, features = ["deflate"] } snap = "1" +ruzstd = "0.7" lz4_flex = { version = "0.11", default-features = false, features = ["safe-decode", "safe-encode"] } aes = "0.8" hmac = "0.12" diff --git a/crates/guara-core/src/fs/btrfs.rs b/crates/guara-core/src/fs/btrfs.rs new file mode 100644 index 0000000..dcc1d2b --- /dev/null +++ b/crates/guara-core/src/fs/btrfs.rs @@ -0,0 +1,822 @@ +//! Btrfs (um dispositivo; perfis single, DUP e RAID 1): árvores atuais para nomes e mapa de +//! espaço livre, e arquivos apagados achados em nós antigos das árvores. Como o Btrfs nunca +//! sobrescreve metadados no lugar (copy-on-write), as versões anteriores dos nós continuam no +//! disco até o espaço ser reaproveitado: varremos os blocos de metadata em busca delas. +//! Extensões comprimidas (zlib, zstd, lzo) são descompactadas na recuperação. + +use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan}; +use crate::source::{read_vec, Source}; +use crate::types::{Confidence, DataLoc, Extent, Origin, Packed, PackedCodec, Part}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +const MAGIC: &[u8; 8] = b"_BHRfS_M"; +const SB_OFF: u64 = 0x10000; + +// chaves +const INODE_ITEM: u8 = 1; +const INODE_REF: u8 = 12; +const EXTENT_DATA: u8 = 108; +const ROOT_ITEM: u8 = 132; +const EXTENT_ITEM: u8 = 168; +const METADATA_ITEM: u8 = 169; +const CHUNK_ITEM: u8 = 228; + +fn le16(b: &[u8], o: usize) -> u16 { + b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).unwrap_or(0) +} +fn le32(b: &[u8], o: usize) -> u32 { + b.get(o..o + 4).map(|s| u32::from_le_bytes(s.try_into().unwrap())).unwrap_or(0) +} +fn le64(b: &[u8], o: usize) -> u64 { + b.get(o..o + 8).map(|s| u64::from_le_bytes(s.try_into().unwrap())).unwrap_or(0) +} + +pub fn crc32c(data: &[u8]) -> u32 { + static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); + let t = TABLE.get_or_init(|| { + let mut t = [0u32; 256]; + for i in 0..256u32 { + let mut c = i; + for _ in 0..8 { + c = if c & 1 != 0 { (c >> 1) ^ 0x82F6_3B78 } else { c >> 1 }; + } + t[i as usize] = c; + } + t + }); + let mut c = !0u32; + for &b in data { + c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8); + } + !c +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Key { + pub objectid: u64, + pub ty: u8, + pub offset: u64, +} + +fn key_at(b: &[u8], o: usize) -> Key { + Key { objectid: le64(b, o), ty: b.get(o + 8).copied().unwrap_or(0), offset: le64(b, o + 9) } +} + +#[derive(Clone, Debug)] +struct Chunk { + logical: u64, + len: u64, + ty: u64, + /// (offset físico do primeiro stripe) por cópia + phys: Vec, +} + +#[derive(Clone, Debug)] +enum NodeKind { + Leaf(Vec<(Key, Vec)>), + Internal(Vec<(Key, u64)>), +} + +#[derive(Clone, Debug)] +struct Node { + bytenr: u64, + generation: u64, + owner: u64, + kind: NodeKind, +} + +struct Fs<'a> { + src: &'a dyn Source, + base: u64, + nodesize: u64, + sectorsize: u64, + fsid: [u8; 16], + chunks: Vec, + root: u64, + chunk_root: u64, +} + +impl<'a> Fs<'a> { + fn open(src: &'a dyn Source, base: u64) -> Option> { + let sb = read_vec(src, base + SB_OFF, 4096).ok()?; + if sb.len() < 4096 || &sb[64..72] != MAGIC { + return None; + } + let csum_type = le16(&sb, 196); + if csum_type == 0 && crc32c(&sb[32..4096]) != le32(&sb, 0) { + return None; + } + let nodesize = le32(&sb, 148) as u64; + let sectorsize = le32(&sb, 144) as u64; + if !nodesize.is_power_of_two() || !(4096..=65536).contains(&nodesize) || !sectorsize.is_power_of_two() { + return None; + } + let mut fsid = [0u8; 16]; + fsid.copy_from_slice(&sb[32..48]); + let mut fs = Fs { + src, + base, + nodesize, + sectorsize, + fsid, + chunks: vec![], + root: le64(&sb, 80), + chunk_root: le64(&sb, 88), + }; + // sys_chunk_array + let n = le32(&sb, 160) as usize; + let arr = sb.get(0x32B..0x32B + n.min(2048))?; + let mut p = 0; + while p + 17 + 48 <= arr.len() { + let k = key_at(arr, p); + if k.ty != CHUNK_ITEM { + break; + } + let (ch, used) = parse_chunk(&arr[p + 17..], k.offset)?; + fs.chunks.push(ch); + p += 17 + used; + } + // chunk tree completa + let mut more = vec![]; + let cr = fs.chunk_root; + fs.walk(cr, &mut |k, d| { + if k.ty == CHUNK_ITEM { + if let Some((ch, _)) = parse_chunk(d, k.offset) { + more.push(ch); + } + } + }); + for c in more { + if !fs.chunks.iter().any(|x| x.logical == c.logical) { + fs.chunks.push(c); + } + } + fs.chunks.sort_by_key(|c| c.logical); + Some(fs) + } + + fn phys(&self, logical: u64) -> Option { + let i = self.chunks.partition_point(|c| c.logical + c.len <= logical); + let c = self.chunks.get(i)?; + (c.logical <= logical).then(|| c.phys.first().map(|p| self.base + p + (logical - c.logical))).flatten() + } + + fn read_node(&self, logical: u64) -> Option { + let p = self.phys(logical)?; + let raw = read_vec(self.src, p, self.nodesize as usize).ok()?; + self.parse_node(&raw, Some(logical)) + } + + fn parse_node(&self, raw: &[u8], expect: Option) -> Option { + if raw.len() as u64 != self.nodesize || raw[32..48] != self.fsid { + return None; + } + let bytenr = le64(raw, 48); + if expect.map_or(false, |e| e != bytenr) { + return None; + } + if crc32c(&raw[32..]) != le32(raw, 0) { + return None; + } + let nritems = le32(raw, 96) as usize; + let level = raw[100]; + if nritems > (self.nodesize as usize) / 25 { + return None; + } + let hdr = 101usize; + let kind = if level == 0 { + let mut items = Vec::with_capacity(nritems); + for i in 0..nritems { + let o = hdr + i * 25; + let k = key_at(raw, o); + let (off, size) = (le32(raw, o + 17) as usize, le32(raw, o + 21) as usize); + let s = hdr + off; + if s + size > raw.len() { + return None; + } + items.push((k, raw[s..s + size].to_vec())); + } + NodeKind::Leaf(items) + } else { + let mut ptrs = Vec::with_capacity(nritems); + for i in 0..nritems { + let o = hdr + i * 33; + if o + 33 > raw.len() { + return None; + } + ptrs.push((key_at(raw, o), le64(raw, o + 17))); + } + NodeKind::Internal(ptrs) + }; + Some(Node { bytenr, generation: le64(raw, 80), owner: le64(raw, 88), kind }) + } + + /// Percorre uma árvore em ordem, chamando `f` para cada item de folha. + fn walk(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8])) { + let mut seen = HashSet::new(); + self.walk_inner(root, f, &mut seen, &mut |_| {}, 0); + } + + fn walk_nodes(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8]), nodes: &mut dyn FnMut(&Node)) { + let mut seen = HashSet::new(); + self.walk_inner(root, f, &mut seen, nodes, 0); + } + + fn walk_inner(&self, at: u64, f: &mut dyn FnMut(&Key, &[u8]), seen: &mut HashSet, nodes: &mut dyn FnMut(&Node), depth: u32) { + if depth > 10 || !seen.insert(at) { + return; + } + let Some(n) = self.read_node(at) else { return }; + nodes(&n); + match &n.kind { + NodeKind::Leaf(items) => { + for (k, d) in items { + f(k, d); + } + } + NodeKind::Internal(ptrs) => { + for (_, child) in ptrs { + self.walk_inner(*child, f, seen, nodes, depth + 1); + } + } + } + } +} + +fn parse_chunk(d: &[u8], logical: u64) -> Option<(Chunk, usize)> { + if d.len() < 48 { + return None; + } + let len = le64(d, 0); + let ty = le64(d, 24); + let nstripes = le16(d, 44) as usize; + if nstripes == 0 || nstripes > 32 || d.len() < 48 + nstripes * 32 { + return None; + } + let phys = (0..nstripes).map(|i| le64(d, 48 + i * 32 + 8)).collect(); + Some((Chunk { logical, len, ty, phys }, 48 + nstripes * 32)) +} + +// ---------------------------------------------------------------- inodes + +#[derive(Clone, Debug, Default)] +struct InodeData { + gen: u64, + size: u64, + mode: u32, + mtime: i64, + ctime: i64, + atime: i64, + has_inode: bool, + refs: HashMap, + /// offset no arquivo -> (geração do nó, item) + extents: BTreeMap)>, +} + +type Files = HashMap<(u64, u64), InodeData>; + +fn add_item(files: &mut Files, owner: u64, gen: u64, k: &Key, d: &[u8]) { + match k.ty { + INODE_ITEM if d.len() >= 160 => { + let e = files.entry((owner, k.objectid)).or_default(); + let g = le64(d, 0); + if !e.has_inode || g >= e.gen { + e.has_inode = true; + e.gen = g; + e.size = le64(d, 16); + e.mode = le32(d, 52); + e.atime = le64(d, 112) as i64; + e.ctime = le64(d, 124) as i64; + e.mtime = le64(d, 136) as i64; + } + } + INODE_REF if d.len() >= 10 => { + let nlen = le16(d, 8) as usize; + if let Some(n) = d.get(10..10 + nlen) { + files.entry((owner, k.objectid)).or_default().refs.insert(k.offset, String::from_utf8_lossy(n).to_string()); + } + } + EXTENT_DATA if d.len() >= 21 => { + let e = files.entry((owner, k.objectid)).or_default(); + match e.extents.get(&k.offset) { + Some((g, _)) if *g > gen => {} + _ => { + e.extents.insert(k.offset, (gen, d.to_vec())); + } + } + } + _ => {} + } +} + +fn is_fs_tree(id: u64) -> bool { + id == 5 || (256..(1u64 << 48)).contains(&id) +} + +pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan { + let _ = part_len; + let mut res = VolumeScan::default(); + let Some(fs) = Fs::open(ctx.src, base) else { + return res; + }; + if fs.chunks.iter().any(|c| c.ty & 0x1 << 3 != 0 || c.ty & 0x10 != 0 || c.ty & 0x100 != 0 || c.ty & 0x200 != 0) { + res.warnings.push("Btrfs em RAID 0/10/5/6: a leitura de metadados ainda não suporta esses perfis; usando só assinaturas.".into()); + return res; + } + + // 1) Árvores atuais: raízes das subvolumes e extents alocados. + let mut roots: Vec<(u64, u64)> = vec![]; // (id da árvore, bytenr) + let mut extent_root = 0u64; + let rr = fs.root; + fs.walk(rr, &mut |k, d| { + if k.ty == ROOT_ITEM && d.len() >= 239 { + let bytenr = le64(d, 176); + if k.objectid == 2 { + extent_root = bytenr; + } else if is_fs_tree(k.objectid) { + roots.push((k.objectid, bytenr)); + } + } + }); + let mut live_nodes: HashSet = HashSet::new(); + let mut live: Files = HashMap::new(); + for &(id, bytenr) in &roots { + let mut tmp: Vec<(Key, Vec)> = vec![]; + let mut gens: HashMap = HashMap::new(); + let mut seen_nodes: Vec<(u64, u64)> = vec![]; + { + let mut items = |k: &Key, d: &[u8]| tmp.push((*k, d.to_vec())); + let mut nodes = |n: &Node| { + seen_nodes.push((n.bytenr, n.generation)); + }; + fs.walk_nodes(bytenr, &mut items, &mut nodes); + } + for (b, g) in seen_nodes { + live_nodes.insert(b); + gens.insert(b, g); + } + for (k, d) in &tmp { + add_item(&mut live, id, u64::MAX, k, d); + } + if (ctx.cancelled)() { + return res; + } + } + + // Extents alocados (árvore de extents) e mapa livre dos blocos de dados. + let mut allocated: Vec = vec![]; + if extent_root != 0 { + let nodesize = fs.nodesize; + fs.walk(extent_root, &mut |k, _| { + if k.ty == EXTENT_ITEM { + allocated.push(Extent::new(k.objectid, k.offset)); + } else if k.ty == METADATA_ITEM { + allocated.push(Extent::new(k.objectid, nodesize)); + } + }); + } + let allocated = merge_extents(allocated); + let mut free: Vec = vec![]; + for c in fs.chunks.iter().filter(|c| c.ty & 0x1 != 0) { + // blocos de dados: livres = chunk - alocado + let mut cur = c.logical; + let i = allocated.partition_point(|e| e.end() <= c.logical); + for e in &allocated[i..] { + if e.offset >= c.logical + c.len { + break; + } + if e.offset > cur { + if let Some(p) = fs.phys(cur) { + free.push(Extent::new(p, e.offset - cur)); + } + } + cur = cur.max(e.end()); + } + if cur < c.logical + c.len { + if let Some(p) = fs.phys(cur) { + free.push(Extent::new(p, c.logical + c.len - cur)); + } + } + } + res.free = Some(merge_extents(free)); + + // 2) Varre os blocos de metadata atrás de versões antigas de folhas de árvores de arquivos. + let mut stale: Files = HashMap::new(); + let ns = fs.nodesize as usize; + let step = fs.sectorsize as usize; + let md_chunks: Vec = fs.chunks.iter().filter(|c| c.ty & 0x4 != 0).cloned().collect(); + let total_md: u64 = md_chunks.iter().map(|c| c.len).sum(); + let mut done_md = 0u64; + for c in &md_chunks { + let Some(p0) = c.phys.first().copied() else { continue }; + let mut off = 0u64; + let block = 8usize << 20; + while off < c.len { + if (ctx.cancelled)() { + return res; + } + let n = ((c.len - off) as usize).min(block + ns); + let Ok(buf) = read_vec(ctx.src, fs.base + p0 + off, n) else { break }; + let mut i = 0usize; + while i + ns <= buf.len() && (off + i as u64) < c.len { + let logical = c.logical + off + i as u64; + // cabeçalho barato antes do CRC + if buf[i + 32..i + 48] == fs.fsid && le64(&buf[i..], 48) == logical && buf[i + 100] == 0 { + if let Some(node) = fs.parse_node(&buf[i..i + ns], Some(logical)) { + if is_fs_tree(node.owner) && !live_nodes.contains(&logical) { + if let NodeKind::Leaf(items) = &node.kind { + for (k, d) in items { + add_item(&mut stale, node.owner, node.generation, k, d); + } + } + } + } + } + i += step; + } + off += block as u64; + done_md += block as u64; + (ctx.progress)(done_md.min(total_md), total_md.max(1)); + } + } + + // 3) Arquivos que só existem nas folhas antigas. + let path_of = |owner: u64, mut ino: u64| -> String { + let mut parts: Vec = vec![]; + for _ in 0..64 { + if ino == 256 { + break; + } + let e = stale.get(&(owner, ino)).or_else(|| live.get(&(owner, ino))); + match e.and_then(|e| e.refs.iter().next()) { + Some((parent, name)) => { + parts.push(name.clone()); + ino = *parent; + } + None => { + parts.push("(pasta perdida)".into()); + break; + } + } + } + parts.reverse(); + parts.join("/") + }; + let mut keys: Vec<&(u64, u64)> = stale.keys().collect(); + keys.sort(); + for key in keys { + let (owner, ino) = *key; + if live.contains_key(&(owner, ino)) { + continue; + } + let e = &stale[&(owner, ino)]; + if !e.has_inode || e.mode & 0o170000 != 0o100000 || e.size == 0 || e.extents.is_empty() { + continue; + } + let Some((name, parent)) = e.refs.iter().next().map(|(p, n)| (n.clone(), *p)) else { continue }; + let dir = { + let p = path_of(owner, parent); + Some(if p.is_empty() { String::new() } else { format!("/{p}") }) + }; + let Some((data, any_alloc, all_ok)) = build_data(&fs, e, &allocated) else { continue }; + let (conf, note) = if !all_ok { + (Confidence::Low, "faltam trechos do arquivo na versão antiga da árvore".to_string()) + } else if any_alloc { + (Confidence::Medium, "versão antiga do Btrfs; alguns blocos ainda constam como alocados (podem ser de outro arquivo)".to_string()) + } else { + (Confidence::High, "versão antiga da árvore do Btrfs; blocos de dados livres".to_string()) + }; + let ts = |t: i64| (t > 0).then_some(t); + let nf = NewFound { + name, + dir, + size: e.size, + origin: Origin::Btrfs, + data, + modified: ts(e.mtime), + created: ts(e.ctime), + accessed: ts(e.atime), + base: conf, + note: Some(¬e), + }; + let f = make_found(ctx, nf); + (ctx.emit)(f); + } + res +} + +/// Converte os itens EXTENT_DATA em extents físicos ou partes (inline/comprimidas). +fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc, bool, bool)> { + let mut parts: Vec = vec![]; + let mut pos = 0u64; + let mut any_alloc = false; + let mut all_ok = true; + let mut plain_only = true; + let mut hole = |parts: &mut Vec, len: u64| { + if len > 0 { + parts.push(Part::Run { offset: 0, len }); + // marcador: offset 0 + sparse tratado abaixo + } + }; + let _ = &mut hole; + let mut sparse_marks: Vec = vec![]; + for (&foff, (_, d)) in &e.extents { + if foff >= e.size { + break; + } + if foff > pos { + sparse_marks.push(parts.len()); + parts.push(Part::Run { offset: 0, len: foff - pos }); + plain_only = false; + pos = foff; + } + let ty = d[20]; + let comp = d[16]; + if ty == 0 { + // inline + let raw = &d[21..]; + let ram = le64(d, 8) as usize; + let bytes = match comp { + 0 => raw.to_vec(), + c => decode(c, raw, ram)?, + }; + let len = (bytes.len() as u64).min(e.size - foff); + parts.push(Part::Bytes { bytes: bytes[..len as usize].to_vec() }); + plain_only = false; + pos = foff + len; + continue; + } + if d.len() < 53 { + all_ok = false; + break; + } + let (disk, disk_len, offset, num) = (le64(d, 21), le64(d, 29), le64(d, 37), le64(d, 45)); + let num = num.min(e.size.saturating_sub(foff)); + if disk == 0 || ty == 2 { + sparse_marks.push(parts.len()); + parts.push(Part::Run { offset: 0, len: num }); + plain_only = false; + } else { + let Some(phys) = fs.phys(disk) else { + all_ok = false; + break; + }; + let overlaps = { + let i = allocated.partition_point(|x| x.end() <= disk); + allocated.get(i).map_or(false, |x| x.offset < disk + disk_len) + }; + any_alloc |= overlaps; + if comp == 0 { + parts.push(Part::Run { offset: phys + offset, len: num }); + } else { + let codec = match comp { + 1 => PackedCodec::Zlib, + 2 => PackedCodec::Zstd, + 3 | 4 | 5 | 6 => PackedCodec::Lzo, + _ => { + all_ok = false; + break; + } + }; + parts.push(Part::Packed(Packed { offset: phys, disk_len, ram_len: le64(d, 8), skip: offset, len: num, codec })); + plain_only = false; + } + } + pos = foff + num; + } + if pos < e.size { + all_ok = false; + } + if plain_only { + let ex: Vec = parts.iter().filter_map(|p| if let Part::Run { offset, len } = p { Some(Extent::new(*offset, *len)) } else { None }).collect(); + return Some((DataLoc::Extents { extents: ex }, any_alloc, all_ok)); + } + // Buracos viram trechos esparsos: Part::Run com offset 0 não serve; usa Bytes de zeros pequenos. + let mut out: Vec = vec![]; + for (i, p) in parts.into_iter().enumerate() { + if sparse_marks.contains(&i) { + if let Part::Run { len, .. } = p { + out.push(Part::Zeros { len }); + continue; + } + } + out.push(p); + } + Some((DataLoc::Parts { parts: out }, any_alloc, all_ok)) +} + +fn decode(codec: u8, raw: &[u8], ram: usize) -> Option> { + match codec { + 1 => { + use std::io::Read; + let mut v = Vec::with_capacity(ram); + flate2::read::ZlibDecoder::new(raw).read_to_end(&mut v).ok()?; + Some(v) + } + 2 => { + use std::io::Read; + let mut dec = ruzstd::StreamingDecoder::new(raw).ok()?; + let mut v = Vec::with_capacity(ram); + dec.read_to_end(&mut v).ok()?; + Some(v) + } + _ => lzo_btrfs(raw, ram), + } +} + +/// Formato de extensão LZO do Btrfs: tamanho total (u32) + segmentos [tamanho (u32)][dados LZO1X] +/// de até um setor de 4 KiB descomprimido cada, sem cruzar fronteiras de setor. +pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option> { + if raw.len() < 4 { + return None; + } + let total = le32(raw, 0) as usize; + let mut out = Vec::with_capacity(ram); + let mut p = 4usize; + while p + 4 <= raw.len().min(total) && out.len() < ram { + // pula o preenchimento até o próximo setor se sobrar menos de 4 bytes + let in_sector = 4096 - (p % 4096); + if in_sector < 4 { + p += in_sector; + continue; + } + let seg = le32(raw, p) as usize; + p += 4; + let chunk = raw.get(p..p + seg)?; + let want = (ram - out.len()).min(4096); + out.extend(lzo1x_decompress(chunk, want)?); + p += seg; + } + Some(out) +} + +/// LZO1X (descompressão segura, no mesmo esquema de estados do kernel Linux). +pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option> { + let mut out: Vec = Vec::with_capacity(max_out); + let mut ip = 0usize; + let byte = |i: usize| src.get(i).copied().map(|b| b as usize); + let lits = |out: &mut Vec, ip: &mut usize, n: usize| -> Option<()> { + out.extend_from_slice(src.get(*ip..*ip + n)?); + *ip += n; + Some(()) + }; + let ext = |ip: &mut usize, base: usize| -> Option { + let mut extra = 0usize; + while byte(*ip)? == 0 { + extra += 255; + *ip += 1; + } + let v = base + extra + byte(*ip)?; + *ip += 1; + Some(v) + }; + // estado: literais ao fim da instrução anterior (0..3) ou 4 depois de uma corrida de literais + let mut state; + if byte(0)? > 17 { + let t = byte(0)? - 17; + ip = 1; + lits(&mut out, &mut ip, t)?; + state = if t < 4 { t } else { 4 }; + } else { + state = 0; + } + loop { + if out.len() > max_out + 4096 { + return None; + } + let t = byte(ip)?; + ip += 1; + if t < 16 { + if state == 0 { + let n = if t == 0 { ext(&mut ip, 15)? } else { t } + 3; + lits(&mut out, &mut ip, n)?; + state = 4; + continue; + } + let h = byte(ip)?; + ip += 1; + let (dist, len) = if state == 4 { ((t >> 2) + (h << 2) + 2049, 3) } else { ((t >> 2) + (h << 2) + 1, 2) }; + copy_match(&mut out, dist, len)?; + let next = t & 3; + lits(&mut out, &mut ip, next)?; + state = next; + } else if t >= 64 { + let h = byte(ip)?; + ip += 1; + copy_match(&mut out, 1 + ((t >> 2) & 7) + (h << 3), (t >> 5) + 1)?; + let next = t & 3; + lits(&mut out, &mut ip, next)?; + state = next; + } else if t >= 32 { + let len = if t & 31 == 0 { ext(&mut ip, 31)? } else { t & 31 } + 2; + let b = (byte(ip)?) | (byte(ip + 1)? << 8); + ip += 2; + copy_match(&mut out, 1 + (b >> 2), len)?; + let next = b & 3; + lits(&mut out, &mut ip, next)?; + state = next; + } else { + let len = if t & 7 == 0 { ext(&mut ip, 7)? } else { t & 7 } + 2; + let b = (byte(ip)?) | (byte(ip + 1)? << 8); + ip += 2; + let d = ((t & 8) << 11) + (b >> 2); + if d == 0 { + return Some(out); + } + copy_match(&mut out, d + 0x4000, len)?; + let next = b & 3; + lits(&mut out, &mut ip, next)?; + state = next; + } + } +} + +fn copy_match(out: &mut Vec, dist: usize, len: usize) -> Option<()> { + if dist == 0 || dist > out.len() { + return None; + } + let start = out.len() - dist; + for i in 0..len { + let b = out[start + i]; + out.push(b); + } + Some(()) +} + +/// Arquivos e pastas atuais com caminho completo e tamanho (usa só as árvores vivas). +pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> { + let Some(fs) = Fs::open(src, base) else { return vec![] }; + let mut roots: Vec<(u64, u64)> = vec![]; + fs.walk(fs.root, &mut |k, d| { + if k.ty == ROOT_ITEM && d.len() >= 239 && is_fs_tree(k.objectid) { + roots.push((k.objectid, le64(d, 176))); + } + }); + let mut files: Files = HashMap::new(); + for (id, b) in roots { + fs.walk(b, &mut |k, d| add_item(&mut files, id, u64::MAX, k, d)); + } + let mut out = vec![]; + for (&(owner, ino), e) in &files { + if !e.has_inode || ino == 256 { + continue; + } + let mut parts = vec![]; + let mut cur = ino; + for _ in 0..64 { + if cur == 256 { + break; + } + match files.get(&(owner, cur)).and_then(|x| x.refs.iter().next()) { + Some((p, n)) => { + parts.push(n.clone()); + cur = *p; + } + None => break, + } + } + parts.reverse(); + out.push((parts.join("/"), e.size, e.mode & 0o170000 == 0o040000)); + } + out.sort(); + out +} + +/// Posições das árvores principais, para ferramentas e testes que precisam editar a imagem. +pub struct TreeLocations { + pub nodesize: u64, + /// Endereço físico (absoluto na origem) do primeiro nó de cada árvore. + pub root_tree: u64, + pub fs_tree: u64, + pub extent_tree: u64, + /// Endereços lógicos correspondentes. + pub root_tree_logical: u64, + pub fs_tree_logical: u64, + pub extent_tree_logical: u64, + /// Converte endereço lógico em físico. + pub chunks: Vec<(u64, u64, u64)>, +} + +pub fn locate_trees(src: &dyn Source, base: u64) -> Option { + let fs = Fs::open(src, base)?; + let (mut fs_t, mut ext_t) = (0u64, 0u64); + fs.walk(fs.root, &mut |k, d| { + if k.ty == ROOT_ITEM && d.len() >= 239 { + if k.objectid == 5 { + fs_t = le64(d, 176); + } else if k.objectid == 2 { + ext_t = le64(d, 176); + } + } + }); + Some(TreeLocations { + nodesize: fs.nodesize, + root_tree: fs.phys(fs.root)?, + fs_tree: fs.phys(fs_t)?, + extent_tree: fs.phys(ext_t)?, + root_tree_logical: fs.root, + fs_tree_logical: fs_t, + extent_tree_logical: ext_t, + chunks: fs.chunks.iter().filter_map(|c| c.phys.first().map(|p| (c.logical, c.len, fs.base + p))).collect(), + }) +} diff --git a/crates/guara-core/src/fs/mod.rs b/crates/guara-core/src/fs/mod.rs index 888b54f..871f57c 100644 --- a/crates/guara-core/src/fs/mod.rs +++ b/crates/guara-core/src/fs/mod.rs @@ -4,6 +4,7 @@ //! devolve os extents ainda alocados ("live") para o carving pular e, quando //! possível, os trechos livres. +pub mod btrfs; pub mod exfat; pub mod ext; pub mod fat; diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs index 32373a9..701e080 100644 --- a/crates/guara-core/src/recover.rs +++ b/crates/guara-core/src/recover.rs @@ -382,6 +382,21 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result { out.write_all(bytes)?; hasher.update(bytes); } + Part::Zeros { len } => { + let z = vec![0u8; (1 << 20).min(*len as usize).max(1)]; + let mut left = *len; + while left > 0 { + let n = left.min(z.len() as u64) as usize; + out.write_all(&z[..n])?; + hasher.update(&z[..n]); + left -= n as u64; + } + } + Part::Packed(pk) => { + let v = pk.read(src)?; + out.write_all(&v)?; + hasher.update(&v); + } Part::Run { offset, len } => { let mut done = 0u64; while done < *len { @@ -461,6 +476,15 @@ pub fn read_found_at( Ok(n) } Part::Run { offset: o, .. } => src.read_at(o + within, &mut buf[..n]), + Part::Zeros { .. } => { + buf[..n].fill(0); + Ok(n) + } + Part::Packed(pk) => { + let v = pk.read(src)?; + buf[..n].copy_from_slice(&v[within as usize..within as usize + n]); + Ok(n) + } }; } pos += plen; diff --git a/crates/guara-core/src/scan.rs b/crates/guara-core/src/scan.rs index 342f4d1..89bf5db 100644 --- a/crates/guara-core/src/scan.rs +++ b/crates/guara-core/src/scan.rs @@ -147,6 +147,7 @@ pub fn run( FsKind::Exfat => Some(fs::exfat::scan(&mut ctx, p.start)), FsKind::Ext => Some(fs::ext::scan(&mut ctx, p.start, p.len)), FsKind::HfsPlus => Some(fs::hfsplus::scan(&mut ctx, p.start, p.len)), + FsKind::Btrfs => Some(fs::btrfs::scan(&mut ctx, p.start, p.len)), _ => None, }; let _ = progress_phase; diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs index 14437ea..baaa6f9 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -121,13 +121,68 @@ pub enum Part { }, /// Trecho contíguo da origem. Run { offset: u64, len: u64 }, + /// Zeros (buracos de arquivos esparsos). + Zeros { len: u64 }, + /// Extensão comprimida na origem (Btrfs): descompactada ao ler. + Packed(Packed), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PackedCodec { + Zlib, + Zstd, + Lzo, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Packed { + /// Onde estão os bytes comprimidos na origem. + pub offset: u64, + pub disk_len: u64, + /// Tamanho da extensão depois de descompactada. + pub ram_len: u64, + /// Bytes a pular no começo da extensão descompactada. + pub skip: u64, + /// Bytes úteis a partir de `skip`. + pub len: u64, + pub codec: PackedCodec, +} + +impl Packed { + /// Descompacta a extensão e devolve os `len` bytes úteis. + pub fn read(&self, src: &dyn crate::source::Source) -> std::io::Result> { + use std::io::Read; + let raw = crate::source::read_vec(src, self.offset, self.disk_len as usize)?; + let bad = |m: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("extensão comprimida ilegível ({m})")); + let mut data = match self.codec { + PackedCodec::Zlib => { + let mut v = Vec::with_capacity(self.ram_len as usize); + flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut v).map_err(|_| bad("zlib"))?; + v + } + PackedCodec::Zstd => { + let mut v = Vec::with_capacity(self.ram_len as usize); + ruzstd::StreamingDecoder::new(&raw[..]).map_err(|_| bad("zstd"))?.read_to_end(&mut v).map_err(|_| bad("zstd"))?; + v + } + PackedCodec::Lzo => crate::fs::btrfs::lzo_btrfs(&raw, self.ram_len as usize).ok_or_else(|| bad("lzo"))?, + }; + data.resize(self.ram_len.max(data.len() as u64) as usize, 0); + let s = (self.skip as usize).min(data.len()); + let e = (s + self.len as usize).min(data.len()); + let mut v = data[s..e].to_vec(); + v.resize(self.len as usize, 0); + Ok(v) + } } impl Part { pub fn len(&self) -> u64 { match self { Part::Bytes { bytes } => bytes.len() as u64, - Part::Run { len, .. } => *len, + Part::Run { len, .. } | Part::Zeros { len } => *len, + Part::Packed(p) => p.len, } } pub fn is_empty(&self) -> bool { @@ -331,7 +386,7 @@ impl FsKind { pub fn has_metadata_scanner(self) -> bool { matches!( self, - FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat | FsKind::Ext | FsKind::HfsPlus + FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat | FsKind::Ext | FsKind::HfsPlus | FsKind::Btrfs ) } } diff --git a/crates/guara-core/tests/btrfs.rs b/crates/guara-core/tests/btrfs.rs new file mode 100644 index 0000000..5690447 --- /dev/null +++ b/crates/guara-core/tests/btrfs.rs @@ -0,0 +1,280 @@ +//! Btrfs: imagens criadas com mkfs.btrfs --rootdir; exclusão simulada como o COW faz +//! (nova versão da folha em outro bloco, a antiga continua no disco). + +mod common; +use common::*; +use guara_core::fs::btrfs::{crc32c, live_paths, lzo1x_decompress}; +use guara_core::source::{FileSource, Source}; +use guara_core::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn have(t: &str) -> bool { + Command::new("sh").args(["-c", &format!("command -v {t}")]).output().map(|o| o.status.success()).unwrap_or(false) +} + +fn data(seed: u64, n: usize) -> Vec { + let mut x = seed | 1; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as u8 + }) + .collect() +} + +struct Img { + _dir: tempfile::TempDir, + path: PathBuf, + files: Vec<(&'static str, Vec)>, +} + +fn build() -> Option { + if !have("mkfs.btrfs") { + eprintln!("SKIP: mkfs.btrfs ausente"); + return None; + } + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root"); + std::fs::create_dir_all(root.join("docs/sub")).unwrap(); + std::fs::create_dir_all(root.join("fotos")).unwrap(); + let files = vec![ + ("docs/relatorio.dat", data(1, 150_000)), + ("docs/sub/contrato.dat", data(2, 9_000)), + ("fotos/viagem.dat", data(3, 700_000)), + ("fotos/fica.dat", data(4, 30_000)), + ("pequeno.txt", b"conteudo pequeno inline".to_vec()), + ]; + for (p, c) in &files { + std::fs::write(root.join(p), c).unwrap(); + } + let path = dir.path().join("b.img"); + std::fs::File::create(&path).unwrap().set_len(128 << 20).unwrap(); + let o = Command::new("mkfs.btrfs").args(["-q", "-f", "--rootdir"]).arg(&root).arg(&path).output().unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + Some(Img { _dir: dir, path, files }) +} + +#[test] +fn lists_the_live_tree_with_paths_and_sizes() { + let Some(i) = build() else { return }; + let src = FileSource::open(&i.path).unwrap(); + let listed = live_paths(&src, 0); + for (p, c) in &i.files { + let f = listed.iter().find(|x| x.0 == *p).unwrap_or_else(|| panic!("{p} ausente: {listed:?}")); + assert_eq!(f.1, c.len() as u64, "{p}"); + assert!(!f.2); + } + assert!(listed.iter().any(|x| x.0 == "docs/sub" && x.2)); +} + +// --- manipulação bruta de folhas (independente do módulo) --- + +struct Leaf { + raw: Vec, +} + +impl Leaf { + fn nritems(&self) -> usize { + u32::from_le_bytes(self.raw[96..100].try_into().unwrap()) as usize + } + fn item(&self, i: usize) -> (u64, u8, u64, usize, usize) { + let o = 101 + i * 25; + let r = &self.raw; + ( + u64::from_le_bytes(r[o..o + 8].try_into().unwrap()), + r[o + 8], + u64::from_le_bytes(r[o + 9..o + 17].try_into().unwrap()), + u32::from_le_bytes(r[o + 17..o + 21].try_into().unwrap()) as usize, + u32::from_le_bytes(r[o + 21..o + 25].try_into().unwrap()) as usize, + ) + } + fn data(&self, i: usize) -> &[u8] { + let (_, _, _, off, size) = self.item(i); + &self.raw[101 + off..101 + off + size] + } + fn seal(&mut self) { + let c = crc32c(&self.raw[32..]); + self.raw[0..4].copy_from_slice(&c.to_le_bytes()); + } + /// Nova versão da folha sem os itens que `drop` rejeita, num novo endereço lógico. + fn without(&self, drop: impl Fn(u64, u8) -> bool, new_bytenr: u64) -> Leaf { + let mut kept: Vec<(usize, Vec)> = vec![]; + for i in 0..self.nritems() { + let (obj, ty, _, _, _) = self.item(i); + if !drop(obj, ty) { + kept.push((i, self.data(i).to_vec())); + } + } + let mut raw = vec![0u8; self.raw.len()]; + raw[..101].copy_from_slice(&self.raw[..101]); + raw[48..56].copy_from_slice(&new_bytenr.to_le_bytes()); + let gen = u64::from_le_bytes(raw[80..88].try_into().unwrap()) + 1; + raw[80..88].copy_from_slice(&gen.to_le_bytes()); + raw[96..100].copy_from_slice(&(kept.len() as u32).to_le_bytes()); + let mut end = raw.len() - 101; + for (n, (i, d)) in kept.iter().enumerate() { + let o = 101 + n * 25; + raw[o..o + 17].copy_from_slice(&self.raw[101 + i * 25..101 + i * 25 + 17]); + end -= d.len(); + raw[o + 17..o + 21].copy_from_slice(&(end as u32).to_le_bytes()); + raw[o + 21..o + 25].copy_from_slice(&(d.len() as u32).to_le_bytes()); + raw[101 + end..101 + end + d.len()].copy_from_slice(d); + } + let mut l = Leaf { raw }; + l.seal(); + l + } +} + +fn superblock(img: &[u8]) -> &[u8] { + &img[0x10000..0x11000] +} + +/// Mapa lógico -> físico dos chunks de sistema (suficiente para imagens pequenas de um dispositivo). +fn chunk_map(img: &[u8]) -> Vec<(u64, u64, u64)> { + let sb = superblock(img); + let n = u32::from_le_bytes(sb[160..164].try_into().unwrap()) as usize; + let arr = &sb[0x32B..0x32B + n]; + let mut p = 0; + let mut out = vec![]; + while p + 17 + 80 <= arr.len() { + let logical = u64::from_le_bytes(arr[p + 9..p + 17].try_into().unwrap()); + let len = u64::from_le_bytes(arr[p + 17..p + 25].try_into().unwrap()); + let phys = u64::from_le_bytes(arr[p + 17 + 48 + 8..p + 17 + 48 + 16].try_into().unwrap()); + out.push((logical, len, phys)); + let ns = u16::from_le_bytes(arr[p + 17 + 44..p + 17 + 46].try_into().unwrap()) as usize; + p += 17 + 48 + ns * 32; + } + out +} + +#[test] +fn deleted_file_survives_in_the_previous_version_of_the_leaf() { + let Some(i) = build() else { return }; + let mut img = std::fs::read(&i.path).unwrap(); + let sb = superblock(&img).to_vec(); + let nodesize = u32::from_le_bytes(sb[148..152].try_into().unwrap()) as usize; + let src = FileSource::open(&i.path).unwrap(); + let t = guara_core::fs::btrfs::locate_trees(&src, 0).expect("árvores"); + assert_eq!(t.nodesize as usize, nodesize); + let fs_leaf = (t.fs_tree as usize, t.fs_tree_logical); + let root_leaf = (t.root_tree as usize, t.root_tree_logical); + let ext_leaf = (t.extent_tree as usize, t.extent_tree_logical); + let to_logical = |phys: usize| -> u64 { + let c = t.chunks.iter().find(|c| (phys as u64) >= c.2 && (phys as u64) < c.2 + c.1).expect("chunk"); + c.0 + (phys as u64 - c.2) + }; + let fl = Leaf { raw: img[fs_leaf.0..fs_leaf.0 + nodesize].to_vec() }; + // inode do arquivo apagado + let victim = "relatorio.dat"; + let mut ino = 0; + for k in 0..fl.nritems() { + let (obj, ty, _, _, _) = fl.item(k); + if ty == 12 { + let d = fl.data(k); + let nl = u16::from_le_bytes([d[8], d[9]]) as usize; + if &d[10..10 + nl] == victim.as_bytes() { + ino = obj; + } + } + } + assert!(ino > 256); + // extent de dados do arquivo (para liberar no extent tree) + let mut data_bytenr = 0; + for k in 0..fl.nritems() { + let (obj, ty, _, _, _) = fl.item(k); + if obj == ino && ty == 108 { + data_bytenr = u64::from_le_bytes(fl.data(k)[21..29].try_into().unwrap()); + } + } + assert!(data_bytenr > 0); + // Novo bloco livre para a nova versão: primeiro bloco do metadata sem folha válida e todo zero. + let free_slot = (0..img.len() / nodesize) + .map(|n| n * nodesize) + .find(|&o| o > fs_leaf.0 && img[o..o + nodesize].iter().all(|&x| x == 0) && t.chunks.iter().any(|c| (o as u64) >= c.2 && (o as u64) < c.2 + c.1) && o < fs_leaf.0 + (8 << 20)) + .expect("bloco livre"); + // logical = bytenr da folha antiga + (offset físico - offset físico antigo): mesmo chunk + let new_bytenr = to_logical(free_slot); + let newleaf = fl.without(|o, _| o == ino, new_bytenr); + img[free_slot..free_slot + nodesize].copy_from_slice(&newleaf.raw); + // Aponta o ROOT_ITEM da árvore 5 para a nova versão (e sobe a geração), na folha da árvore raiz. + let mut rl = Leaf { raw: img[root_leaf.0..root_leaf.0 + nodesize].to_vec() }; + for k in 0..rl.nritems() { + let (obj, ty, _, off, _) = rl.item(k); + if obj == 5 && ty == 132 { + let at = 101 + off + 176; + rl.raw[at..at + 8].copy_from_slice(&new_bytenr.to_le_bytes()); + } + } + rl.seal(); + img[root_leaf.0..root_leaf.0 + nodesize].copy_from_slice(&rl.raw); + // Libera o extent de dados no extent tree (em outro endereço da mesma folha). + let el = Leaf { raw: img[ext_leaf.0..ext_leaf.0 + nodesize].to_vec() }; + let newext = el.without(|o, ty| o == data_bytenr && ty == 168, ext_leaf.1); + let _ = to_logical; + img[ext_leaf.0..ext_leaf.0 + nodesize].copy_from_slice(&newext.raw); + let _ = chunk_map(&img); + + let p2 = i._dir.path().join("deleted.img"); + std::fs::write(&p2, &img).unwrap(); + init(); + let c = scan_path(&p2, &ScanOptions { mode: ScanMode::Deep, ..Default::default() }); + let f = c + .found + .iter() + .find(|f| f.name == victim && f.origin == Origin::Btrfs) + .unwrap_or_else(|| panic!("não achou: {:?}", c.found.iter().map(|f| (&f.name, f.origin)).collect::>())); + assert_eq!(f.size, 150_000); + assert_eq!(f.dir.as_deref(), Some("/docs")); + assert!(f.confidence >= Confidence::Medium && f.note.as_deref().unwrap_or("").contains("livres"), "{:?}", f.note); + let out = i._dir.path().join("out"); + let rep = recover_all(&p2, std::slice::from_ref(f), &out); + assert!(rep.failed.is_empty(), "{:?}", rep.failed); + let rp = PathBuf::from(&rep.recovered[0].path); + let got = std::fs::read(if rp.is_absolute() { rp } else { out.join(rp) }).unwrap(); + assert!(got == i.files[0].1); + // Os arquivos que continuam existindo não aparecem como apagados. + assert!(!c.found.iter().any(|f| f.name == "fica.dat" && f.origin == Origin::Btrfs)); + let _ = Path::new(""); +} + +#[test] +fn lzo_literals_and_a_match() { + // 20 literais + fim + let mut s = vec![17 + 20]; + s.extend((0..20u8).map(|i| b'a' + i)); + s.extend_from_slice(&[0x11, 0, 0]); + assert_eq!(lzo1x_decompress(&s, 4096).unwrap(), (0..20u8).map(|i| b'a' + i).collect::>()); + // "abcd" + M2 (dist 4, len 4) + fim + let s = vec![17 + 4, b'a', b'b', b'c', b'd', 0x6C, 0x00, 0x11, 0, 0]; + assert_eq!(lzo1x_decompress(&s, 4096).unwrap(), b"abcdabcd"); +} + +#[test] +fn packed_extents_decode_zlib_and_zstd() { + use guara_core::source::MemSource; + use guara_core::{Packed, PackedCodec}; + use std::io::Write; + let plain: Vec = (0..100_000u32).map(|i| (i % 251) as u8).collect(); + // zlib + let mut e = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default()); + e.write_all(&plain).unwrap(); + let z = e.finish().unwrap(); + let mut disk = vec![0u8; 4096]; + disk.extend_from_slice(&z); + let p = Packed { offset: 4096, disk_len: z.len() as u64, ram_len: plain.len() as u64, skip: 1000, len: 5000, codec: PackedCodec::Zlib }; + assert_eq!(p.read(&MemSource(disk)).unwrap(), &plain[1000..6000]); + // zstd, se o utilitário existir + if have("zstd") { + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("p.bin"); + std::fs::write(&f, &plain).unwrap(); + assert!(Command::new("zstd").args(["-q", "-f"]).arg(&f).status().unwrap().success()); + let z = std::fs::read(dir.path().join("p.bin.zst")).unwrap(); + let p = Packed { offset: 0, disk_len: z.len() as u64, ram_len: plain.len() as u64, skip: 0, len: plain.len() as u64, codec: PackedCodec::Zstd }; + assert!(p.read(&MemSource(z)).unwrap() == plain); + } +}