feat: Guará 0.1.0 — recuperação de arquivos (núcleo, CLI e interface gráfica)

Núcleo em Rust somente leitura: NTFS, FAT, exFAT, partições MBR/GPT e perdidas,
carving por estrutura, fusão sem duplicatas, confiança por arquivo, imagem
forense, pausar/retomar. CLI `guara`, UI web/Tauri, CI e releases por tag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 15:02:19 -03:00
commit 765cb0d4a2
57 files changed
+8621

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
//! exFAT: conjuntos de entradas apagados (0x05/0x40/0x41) e bitmap de alocação.
use super::{
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::HashSet;
struct Vol<'a> {
src: &'a dyn Source,
base: u64,
fat_off: u64,
heap_off: u64,
cbytes: u64,
clusters: u32,
root: u32,
bitmap: Vec<u8>,
}
impl Vol<'_> {
fn coff(&self, c: u32) -> u64 {
self.heap_off + (c as u64 - 2) * self.cbytes
}
fn valid(&self, c: u32) -> bool {
c >= 2 && (c as u64) < self.clusters as u64 + 2
}
fn allocated(&self, c: u32) -> bool {
let i = (c - 2) as usize;
self.bitmap
.get(i / 8)
.map(|b| b >> (i % 8) & 1 == 1)
.unwrap_or(false)
}
fn fat(&self, c: u32) -> Option<u32> {
let v = read_vec(self.src, self.fat_off + c as u64 * 4, 4).ok()?;
(v.len() == 4).then(|| u32::from_le_bytes(v.try_into().unwrap()))
}
fn chain(&self, start: u32) -> Vec<u32> {
let mut out = vec![];
let mut c = start;
while self.valid(c) && (out.len() as u64) <= self.clusters as u64 {
out.push(c);
match self.fat(c) {
Some(n) if self.valid(n) => c = n,
_ => break,
}
}
out
}
fn read(&self, cl: &[u32], limit: usize) -> Vec<u8> {
let mut out = vec![];
for &c in cl {
if let Ok(v) = read_vec(self.src, self.coff(c), self.cbytes as usize) {
out.extend_from_slice(&v);
}
if out.len() >= limit {
break;
}
}
out
}
fn extents(&self, cl: &[u32]) -> Vec<Extent> {
merge_extents(
cl.iter()
.map(|&c| Extent::new(self.coff(c), self.cbytes))
.collect(),
)
}
}
pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
let mut res = VolumeScan::default();
let Some(b) = read_vec(ctx.src, base, 512).ok().filter(|b| b.len() == 512) else {
res.warnings.push("exFAT: setor de boot ilegível.".into());
return res;
};
let ss = 1u64 << b[108];
let cbytes = ss << b[109];
let fat_off = base + u32::from_le_bytes(b[80..84].try_into().unwrap()) as u64 * ss;
let heap_off = base + u32::from_le_bytes(b[88..92].try_into().unwrap()) as u64 * ss;
let clusters = u32::from_le_bytes(b[92..96].try_into().unwrap());
let root = u32::from_le_bytes(b[96..100].try_into().unwrap());
let mut vol = Vol {
src: ctx.src,
base,
fat_off,
heap_off,
cbytes,
clusters,
root,
bitmap: vec![],
};
// Diretório raiz: procura a entrada do bitmap (0x81).
let root_chain = vol.chain(vol.root);
let root_data = vol.read(&root_chain, 64 << 20);
let mut bm: Option<(u32, u64)> = None;
for e in root_data.chunks_exact(32) {
if e[0] == 0x81 {
bm = Some((
u32::from_le_bytes(e[20..24].try_into().unwrap()),
u64::from_le_bytes(e[24..32].try_into().unwrap()),
));
break;
}
}
if let Some((c, len)) = bm {
if vol.valid(c) {
vol.bitmap =
read_vec(ctx.src, vol.coff(c), len.min(256 << 20) as usize).unwrap_or_default();
}
} else {
res.warnings
.push("exFAT: bitmap de alocação não encontrado; confiança será menor.".into());
}
// Extents vivos: metadados (antes do heap) + clusters alocados no bitmap.
let mut live = vec![Extent::new(base, heap_off - base)];
let mut run_start: Option<u32> = None;
for c in 2..clusters + 2 {
let a = vol.allocated(c);
match (a, run_start) {
(true, None) => run_start = Some(c),
(false, Some(s)) => {
live.push(Extent::new(vol.coff(s), (c - s) as u64 * cbytes));
run_start = None;
}
_ => {}
}
}
if let Some(s) = run_start {
live.push(Extent::new(vol.coff(s), (clusters + 2 - s) as u64 * cbytes));
}
let live = merge_extents(live);
let mut visited = HashSet::new();
visited.insert(vol.root);
walk(ctx, &vol, &root_data, "", false, 0, &live, &mut visited);
res.live = live;
res
}
#[allow(clippy::too_many_arguments)]
fn walk(
ctx: &mut Ctx,
vol: &Vol,
data: &[u8],
path: &str,
in_deleted: bool,
depth: u32,
live: &[Extent],
visited: &mut HashSet<u32>,
) {
if depth > 48 || (ctx.cancelled)() {
return;
}
let ents: Vec<&[u8]> = data.chunks_exact(32).collect();
let mut i = 0;
while i < ents.len() {
let e = ents[i];
if e[0] == 0x00 {
break;
}
let ty = e[0];
// 0x85 = arquivo/pasta vivo; 0x05 = apagado.
if ty != 0x85 && ty != 0x05 {
i += 1;
continue;
}
let deleted = ty == 0x05;
let secondary = e[1] as usize;
let attr = u16::from_le_bytes([e[4], e[5]]);
let mdate = u16::from_le_bytes([e[14], e[15]]);
let mtime = u16::from_le_bytes([e[12], e[13]]);
let mod_unix = super::dos_to_unix(mdate, mtime);
let mut stream: Option<&[u8]> = None;
let mut name_u16: Vec<u8> = vec![];
for s in 1..=secondary.min(19) {
let Some(se) = ents.get(i + s) else { break };
match se[0] & 0x7F {
0x40 => stream = Some(se),
0x41 => name_u16.extend_from_slice(&se[2..32]),
_ => {}
}
}
i += 1 + secondary;
let Some(st) = stream else { continue };
let nlen = st[3] as usize;
let name = {
let n = utf16le(&name_u16);
n.chars().take(nlen).collect::<String>()
};
let flags = st[1];
let first = u32::from_le_bytes(st[20..24].try_into().unwrap());
let size = u64::from_le_bytes(st[24..32].try_into().unwrap());
let no_chain = flags & 2 != 0;
let is_dir = attr & 0x10 != 0;
let child_path = if path.is_empty() {
name.clone()
} else {
format!("{path}/{name}")
};
if !vol.valid(first) {
continue;
}
let need = size.div_ceil(vol.cbytes) as u32;
let cl: Vec<u32> = if no_chain || deleted {
// Apagados: a cadeia na FAT pode ter sido preservada; senão presume contíguo.
let chain = if !no_chain { vol.chain(first) } else { vec![] };
if chain.len() as u32 >= need.max(1) {
chain
} else {
(first..first.saturating_add(need.max(1)))
.filter(|c| vol.valid(*c))
.collect()
}
} else {
vol.chain(first)
};
if is_dir {
if !visited.insert(first) {
continue;
}
let d = vol.read(&cl, 64 << 20);
walk(
ctx,
vol,
&d,
&child_path,
deleted || in_deleted,
depth + 1,
live,
visited,
);
continue;
}
if !(deleted || in_deleted) || size == 0 || (cl.len() as u32) < need {
continue;
}
let ex = super::fat::trim_extents(vol.extents(&cl[..need as usize]), size);
let reused =
!vol.bitmap.is_empty() && cl[..need as usize].iter().any(|&c| vol.allocated(c));
let (conf, note) = if reused {
(
Confidence::Low,
Some("clusters já reutilizados por outros arquivos"),
)
} else {
(Confidence::High, None)
};
let _ = overlaps(live, 0, 0);
let f = make_found(
ctx,
NewFound {
name: sanitize_name(&name),
dir: (!path.is_empty()).then(|| path.to_string()),
size,
origin: Origin::Exfat,
data: DataLoc::Extents { extents: ex },
modified: mod_unix,
base: conf,
note,
},
);
(ctx.emit)(f);
}
let _ = vol.base;
}
+90
View File
@@ -0,0 +1,90 @@
//! ext2/3/4: lê apenas o mapa de blocos livres, para o carving varrer só o
//! que está livre. (Inodes apagados de ext4 têm a árvore de extents zerada,
//! então a recuperação é por assinatura.)
use super::{merge_extents, Ctx, VolumeScan};
use crate::source::read_vec;
use crate::types::Extent;
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let mut res = VolumeScan::default();
let Some(sb) = read_vec(ctx.src, base + 1024, 1024)
.ok()
.filter(|s| s.len() == 1024)
else {
return res;
};
let le32 = |o: usize| u32::from_le_bytes(sb[o..o + 4].try_into().unwrap()) as u64;
let le16 = |o: usize| u16::from_le_bytes(sb[o..o + 2].try_into().unwrap()) as usize;
if le16(56) != 0xEF53 {
return res;
}
let bs = 1024u64 << le32(24).min(6);
let incompat = le32(96);
let is64 = incompat & 0x80 != 0;
let mut blocks = le32(4);
if is64 {
blocks |= le32(336) << 32;
}
let first_data = le32(20);
let bpg = le32(32).max(1);
let desc_size = if is64 { le16(254).max(64) } else { 32 };
let groups = blocks.saturating_sub(first_data).div_ceil(bpg);
let gdt_block = first_data + 1;
let gdt_len = groups * desc_size as u64;
let Some(gdt) = read_vec(ctx.src, base + gdt_block * bs, gdt_len as usize)
.ok()
.filter(|g| g.len() as u64 == gdt_len)
else {
res.warnings
.push("ext: tabela de grupos ilegível; varrendo a partição inteira.".into());
return res;
};
let mut free: Vec<Extent> = vec![];
for g in 0..groups {
if (ctx.cancelled)() {
break;
}
let d = &gdt[(g * desc_size as u64) as usize..];
let mut bb = u32::from_le_bytes(d[0..4].try_into().unwrap()) as u64;
if is64 && desc_size >= 64 {
bb |= (u32::from_le_bytes(d[0x20..0x24].try_into().unwrap()) as u64) << 32;
}
let flags = u16::from_le_bytes(d[0x12..0x14].try_into().unwrap());
let gstart = first_data + g * bpg;
let gblocks = bpg.min(blocks.saturating_sub(gstart));
if flags & 0x2 != 0 {
// BLOCK_UNINIT: grupo sem bitmap válido; trata como livre.
free.push(Extent::new(base + gstart * bs, gblocks * bs));
continue;
}
let Some(bm) = read_vec(ctx.src, base + bb * bs, (gblocks.div_ceil(8)) as usize).ok()
else {
continue;
};
let mut run: Option<u64> = None;
for i in 0..gblocks {
let used = bm
.get((i / 8) as usize)
.map(|b| b >> (i % 8) & 1 == 1)
.unwrap_or(true);
match (used, run) {
(false, None) => run = Some(i),
(true, Some(s)) => {
free.push(Extent::new(base + (gstart + s) * bs, (i - s) * bs));
run = None;
}
_ => {}
}
}
if let Some(s) = run {
free.push(Extent::new(base + (gstart + s) * bs, (gblocks - s) * bs));
}
if g % 64 == 0 {
(ctx.progress)(g, groups);
}
}
let _ = part_len;
res.free = Some(merge_extents(free));
res
}
+442
View File
@@ -0,0 +1,442 @@
//! FAT12/16/32: entradas de diretório apagadas (inclusive nomes longos) e
//! reconstrução dos clusters pela regra "próximos clusters livres".
use super::{make_found, merge_extents, sanitize_name, utf16le, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, FsKind, Origin};
use std::cell::RefCell;
use std::collections::HashSet;
struct Fat<'a> {
src: &'a dyn Source,
kind: FsKind,
bps: u64,
spc: u64,
fat_off: u64,
root_off: u64,
root_len: u64,
root_cluster: u32,
data_off: u64,
clusters: u32,
cache: RefCell<(u64, Vec<u8>)>,
}
const PAGE: u64 = 256 * 1024;
impl<'a> Fat<'a> {
fn open(src: &'a dyn Source, base: u64, kind: FsKind) -> Option<Self> {
let b = read_vec(src, base, 512).ok()?;
if b.len() < 512 {
return None;
}
let bps = u16::from_le_bytes([b[11], b[12]]) as u64;
let spc = b[13] as u64;
let reserved = u16::from_le_bytes([b[14], b[15]]) as u64;
let nfats = b[16] as u64;
let root_entries = u16::from_le_bytes([b[17], b[18]]) as u64;
let ts16 = u16::from_le_bytes([b[19], b[20]]) as u64;
let fat16 = u16::from_le_bytes([b[22], b[23]]) as u64;
let ts32 = u32::from_le_bytes(b[32..36].try_into().unwrap()) as u64;
let fat32 = u32::from_le_bytes(b[36..40].try_into().unwrap()) as u64;
let root_cluster = u32::from_le_bytes(b[44..48].try_into().unwrap());
let total = if ts16 != 0 { ts16 } else { ts32 };
let fatsz = if fat16 != 0 { fat16 } else { fat32 };
if bps == 0 || spc == 0 || fatsz == 0 {
return None;
}
let root_len = root_entries * 32;
let root_secs = root_len.div_ceil(bps);
let data_sec = reserved + nfats * fatsz + root_secs;
let clusters = (total.saturating_sub(data_sec) / spc) as u32;
Some(Self {
src,
kind,
bps,
spc,
fat_off: base + reserved * bps,
root_off: base + (reserved + nfats * fatsz) * bps,
root_len,
root_cluster,
data_off: base + data_sec * bps,
clusters,
cache: RefCell::new((u64::MAX, Vec::new())),
})
}
fn cluster_bytes(&self) -> u64 {
self.bps * self.spc
}
fn cluster_off(&self, c: u32) -> u64 {
self.data_off + (c as u64 - 2) * self.cluster_bytes()
}
fn valid(&self, c: u32) -> bool {
c >= 2 && (c as u64) < self.clusters as u64 + 2
}
fn fat_bytes(&self, off: u64, n: usize) -> Option<Vec<u8>> {
let page = off / PAGE;
let mut cache = self.cache.borrow_mut();
if cache.0 != page {
let v = read_vec(self.src, self.fat_off + page * PAGE, PAGE as usize).ok()?;
*cache = (page, v);
}
let s = (off - page * PAGE) as usize;
if s + n <= cache.1.len() {
return Some(cache.1[s..s + n].to_vec());
}
drop(cache);
read_vec(self.src, self.fat_off + off, n)
.ok()
.filter(|v| v.len() == n)
}
fn entry(&self, c: u32) -> Option<u32> {
match self.kind {
FsKind::Fat32 => {
let v = self.fat_bytes(c as u64 * 4, 4)?;
Some(u32::from_le_bytes(v.try_into().unwrap()) & 0x0FFF_FFFF)
}
FsKind::Fat16 => {
let v = self.fat_bytes(c as u64 * 2, 2)?;
Some(u16::from_le_bytes(v.try_into().unwrap()) as u32)
}
_ => {
let v = self.fat_bytes(c as u64 * 3 / 2, 2)?;
let raw = u16::from_le_bytes(v.try_into().unwrap());
Some(if c % 2 == 0 { raw & 0x0FFF } else { raw >> 4 } as u32)
}
}
}
fn is_eoc(&self, v: u32) -> bool {
match self.kind {
FsKind::Fat32 => v >= 0x0FFF_FFF8,
FsKind::Fat16 => v >= 0xFFF8,
_ => v >= 0xFF8,
}
}
/// Cadeia de clusters viva a partir de `start` (com limite contra ciclos).
fn chain(&self, start: u32) -> Vec<u32> {
let mut out = vec![];
let mut c = start;
let mut seen = 0u64;
while self.valid(c) && seen <= self.clusters as u64 {
out.push(c);
seen += 1;
match self.entry(c) {
Some(n) if !self.is_eoc(n) && n != 0 => c = n,
_ => break,
}
}
out
}
fn clusters_to_extents(&self, cl: &[u32]) -> Vec<Extent> {
let cb = self.cluster_bytes();
merge_extents(
cl.iter()
.map(|&c| Extent::new(self.cluster_off(c), cb))
.collect(),
)
}
fn read_clusters(&self, cl: &[u32], limit: usize) -> Vec<u8> {
let mut out = Vec::new();
for &c in cl {
if let Ok(v) = read_vec(self.src, self.cluster_off(c), self.cluster_bytes() as usize) {
out.extend_from_slice(&v);
}
if out.len() >= limit {
break;
}
}
out
}
}
struct Walk<'f, 'a, 'c, 'x> {
fat: &'f Fat<'a>,
ctx: &'c mut Ctx<'x>,
live: Vec<Extent>,
visited: HashSet<u32>,
count: u64,
}
pub fn scan(ctx: &mut Ctx, base: u64, kind: FsKind) -> VolumeScan {
let mut res = VolumeScan::default();
let Some(fat) = Fat::open(ctx.src, base, kind) else {
res.warnings
.push("FAT: setor de boot ilegível; usando apenas assinatura.".into());
return res;
};
// Estruturas do sistema (reservado + FATs + raiz fixa) são "vivas".
let mut live = vec![Extent::new(base, fat.data_off - base)];
let mut w = Walk {
fat: &fat,
ctx,
live: vec![],
visited: HashSet::new(),
count: 0,
};
if kind == FsKind::Fat32 {
let root = fat.chain(fat.root_cluster);
w.live.extend(fat.clusters_to_extents(&root));
let data = fat.read_clusters(&root, 64 << 20);
w.dir(&data, "", false, 0);
} else {
let data = read_vec(fat.src, fat.root_off, fat.root_len as usize).unwrap_or_default();
w.dir(&data, "", false, 0);
}
live.extend(w.live);
res.live = merge_extents(live);
res
}
impl Walk<'_, '_, '_, '_> {
fn dir(&mut self, data: &[u8], path: &str, in_deleted: bool, depth: u32) {
if depth > 48 || (self.ctx.cancelled)() {
return;
}
let mut lfn: Vec<(u8, [u16; 13])> = vec![];
for e in data.chunks_exact(32) {
if e[0] == 0x00 {
break;
}
let attr = e[11];
if attr & 0x3F == 0x0F {
let mut ch = [0u16; 13];
let idx = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
for (k, &o) in idx.iter().enumerate() {
ch[k] = u16::from_le_bytes([e[o], e[o + 1]]);
}
lfn.push((e[13], ch));
continue;
}
let parts = std::mem::take(&mut lfn);
if attr & 0x08 != 0 || e[0] == b'.' {
continue;
}
let deleted = e[0] == 0xE5;
let is_dir = attr & 0x10 != 0;
let mut short = [0u8; 11];
short.copy_from_slice(&e[0..11]);
let name =
long_name(&parts, &short).unwrap_or_else(|| short_name(&short, e[12], deleted));
let hi = u16::from_le_bytes([e[20], e[21]]) as u32;
let lo = u16::from_le_bytes([e[26], e[27]]) as u32;
let start = if self.fat.kind == FsKind::Fat32 {
(hi << 16) | lo
} else {
lo
};
let size = u32::from_le_bytes(e[28..32].try_into().unwrap()) as u64;
let mtime = super::dos_to_unix(
u16::from_le_bytes([e[24], e[25]]),
u16::from_le_bytes([e[22], e[23]]),
);
self.count += 1;
if self.count % 512 == 0 {
(self.ctx.progress)(self.count, 0);
}
let child_path = if path.is_empty() {
name.clone()
} else {
format!("{path}/{name}")
};
if is_dir {
if !self.fat.valid(start) || !self.visited.insert(start) {
continue;
}
if deleted || in_deleted {
// Diretório apagado: lê clusters contíguos livres enquanto parecerem entradas.
let cl = self.contiguous_free(start, 16);
let data = self
.fat
.read_clusters(&cl, 16 * self.fat.cluster_bytes() as usize);
self.dir(&data, &child_path, true, depth + 1);
} else {
let cl = self.fat.chain(start);
self.live.extend(self.fat.clusters_to_extents(&cl));
let data = self.fat.read_clusters(&cl, 64 << 20);
self.dir(&data, &child_path, false, depth + 1);
}
continue;
}
if !deleted && !in_deleted {
if self.fat.valid(start) && size > 0 {
let cl = self.fat.chain(start);
self.live.extend(self.fat.clusters_to_extents(&cl));
}
continue;
}
if size == 0 || !self.fat.valid(start) {
continue;
}
self.deleted_file(&name, path, start, size, mtime);
}
}
fn contiguous_free(&self, start: u32, max: usize) -> Vec<u32> {
let mut v = vec![];
let mut c = start;
while v.len() < max && self.fat.valid(c) && (v.is_empty() || self.fat.entry(c) == Some(0)) {
v.push(c);
c += 1;
}
v
}
fn deleted_file(&mut self, name: &str, dir: &str, start: u32, size: u64, mtime: Option<i64>) {
let fat = self.fat;
let cb = fat.cluster_bytes();
let need = size.div_ceil(cb);
if need > fat.clusters as u64 {
return;
}
let start_reused = fat.entry(start).map(|v| v != 0).unwrap_or(true);
let mut cl = Vec::with_capacity(need as usize);
let mut c = start;
let mut skipped = false;
while (cl.len() as u64) < need && fat.valid(c) {
let free = fat.entry(c) == Some(0);
if free || (cl.is_empty() && start_reused) {
cl.push(c);
} else {
skipped = true;
}
c += 1;
}
if (cl.len() as u64) < need {
return;
}
let (conf, note) = if start_reused {
(
Confidence::Low,
Some("cluster inicial já reutilizado por outro arquivo"),
)
} else if skipped {
(
Confidence::Medium,
Some("arquivo possivelmente fragmentado: ordem dos clusters presumida"),
)
} else {
(Confidence::High, None)
};
let extents = fat.clusters_to_extents(&cl);
let nf = NewFound {
name: sanitize_name(name),
dir: (!dir.is_empty()).then(|| dir.to_string()),
size,
origin: Origin::Fat,
data: DataLoc::Extents {
extents: trim_extents(extents, size),
},
modified: mtime,
base: conf,
note,
};
let f = make_found(self.ctx, nf);
(self.ctx.emit)(f);
}
}
/// Corta a lista de extents para exatamente `size` bytes.
pub fn trim_extents(mut ex: Vec<Extent>, size: u64) -> Vec<Extent> {
let mut left = size;
let mut out = vec![];
for e in ex.drain(..) {
if left == 0 {
break;
}
let take = e.len.min(left);
out.push(Extent {
offset: e.offset,
len: take,
sparse: e.sparse,
});
left -= take;
}
out
}
fn short_name(raw: &[u8; 11], case: u8, deleted: bool) -> String {
let mut base: Vec<u8> = raw[0..8].to_vec();
if deleted {
base[0] = b'_';
} else if base[0] == 0x05 {
base[0] = 0xE5;
}
let ext = &raw[8..11];
let f = |b: &[u8], lower: bool| -> String {
let s: String = b
.iter()
.map(|&c| c as char)
.collect::<String>()
.trim_end()
.to_string();
if lower {
s.to_lowercase()
} else {
s
}
};
let b = f(&base, case & 0x08 != 0);
let e = f(ext, case & 0x10 != 0);
if e.is_empty() {
b
} else {
format!("{b}.{e}")
}
}
fn long_name(parts: &[(u8, [u16; 13])], short: &[u8; 11]) -> Option<String> {
if parts.is_empty() {
return None;
}
let mut sum = 0u8;
for &b in short {
sum = (sum >> 1).wrapping_add((sum & 1) << 7).wrapping_add(b);
}
// Entradas LFN de um arquivo apagado preservam o checksum do nome curto original,
// porém o primeiro byte do nome curto virou 0xE5; tentamos as duas variantes.
let ok = parts.iter().all(|p| p.0 == parts[0].0);
if !ok {
return None;
}
let target = parts[0].0;
if sum != target {
// Nome curto apagado perdeu o 1º caractere: aceita se o resto bater por tentativa.
let mut hit = false;
for c in 0u8..=255 {
let mut s2 = *short;
s2[0] = c;
let mut s = 0u8;
for &b in &s2 {
s = (s >> 1).wrapping_add((s & 1) << 7).wrapping_add(b);
}
if s == target {
hit = true;
break;
}
}
if !hit {
return None;
}
}
let mut u: Vec<u16> = vec![];
for (_, ch) in parts.iter().rev() {
u.extend_from_slice(ch);
}
let end = u
.iter()
.position(|&c| c == 0 || c == 0xFFFF)
.unwrap_or(u.len());
let raw: Vec<u8> = u[..end].iter().flat_map(|c| c.to_le_bytes()).collect();
let s = utf16le(&raw);
(!s.is_empty()).then_some(s)
}
+183
View File
@@ -0,0 +1,183 @@
//! Leitores de metadados de sistemas de arquivos (somente leitura).
//!
//! Cada leitor percorre estruturas do volume atrás de arquivos apagados,
//! devolve os extents ainda alocados ("live") para o carving pular e, quando
//! possível, os trechos livres.
pub mod exfat;
pub mod ext;
pub mod fat;
pub mod ntfs;
use crate::signatures::{category_for_ext, matches_ext};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Found, Origin};
use std::sync::atomic::{AtomicU64, Ordering};
/// Contexto compartilhado por todos os leitores.
pub struct Ctx<'a> {
pub src: &'a dyn Source,
pub next_id: &'a AtomicU64,
pub cancelled: &'a (dyn Fn() -> bool + Sync),
/// (feito, total) dentro da fase atual do leitor.
pub progress: &'a (dyn Fn(u64, u64) + Sync),
pub emit: &'a mut dyn FnMut(Found),
}
impl Ctx<'_> {
pub fn id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
}
/// Resultado de varrer um volume.
#[derive(Default)]
pub struct VolumeScan {
/// Extents (absolutos) ocupados por arquivos existentes e metadados.
pub live: Vec<Extent>,
/// Se o leitor conhece o mapa de espaço livre (ext), os trechos livres (absolutos).
pub free: Option<Vec<Extent>>,
pub warnings: Vec<String>,
}
pub struct NewFound<'a> {
pub name: String,
pub dir: Option<String>,
pub size: u64,
pub origin: Origin,
pub data: DataLoc,
pub modified: Option<i64>,
pub base: Confidence,
pub note: Option<&'a str>,
}
/// Monta um `Found`, ajustando a confiança pelo conteúdo real no disco.
pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
let ext = n
.name
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.filter(|e| e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()))
.unwrap_or_default();
let mut conf = n.base;
let mut note = n.note.map(String::from);
let head: Vec<u8> = match &n.data {
DataLoc::Resident { bytes } => bytes.iter().take(32).copied().collect(),
DataLoc::Extents { extents } => extents
.iter()
.find(|e| !e.sparse)
.and_then(|e| read_vec(ctx.src, e.offset, 32.min(e.len as usize)).ok())
.unwrap_or_default(),
};
if n.size > 0
&& !head.is_empty()
&& head.iter().all(|&b| b == 0)
&& !matches!(n.data, DataLoc::Resident { .. })
{
conf = Confidence::Low;
note = Some("início do arquivo zerado (apagado/TRIM ou sobrescrito)".into());
} else {
match matches_ext(&ext, &head) {
Some(false) => {
conf = Confidence::Low;
note =
Some("conteúdo não confere com a extensão: provavelmente sobrescrito".into());
}
Some(true) => {}
None => conf = conf.min(Confidence::Medium),
}
}
Found {
id: ctx.id(),
category: category_for_ext(&ext),
ext,
name: n.name,
dir: n.dir,
size: n.size,
origin: n.origin,
confidence: conf,
data: n.data,
modified: n.modified,
note,
}
}
/// Ordena e funde extents adjacentes/sobrepostos.
pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
v.retain(|e| e.len > 0);
v.sort_by_key(|e| e.offset);
let mut out: Vec<Extent> = Vec::with_capacity(v.len());
for e in v {
if let Some(last) = out.last_mut() {
if e.offset <= last.end() {
if e.end() > last.end() {
last.len = e.end() - last.offset;
}
continue;
}
}
out.push(e);
}
out
}
/// Há sobreposição entre [off, off+len) e algum extent de `sorted` (já mesclado)?
pub fn overlaps(sorted: &[Extent], off: u64, len: u64) -> bool {
let i = sorted.partition_point(|e| e.end() <= off);
sorted.get(i).map(|e| e.offset < off + len).unwrap_or(false)
}
pub fn dos_to_unix(date: u16, time: u16) -> Option<i64> {
if date == 0 {
return None;
}
let year = 1980 + (date >> 9) as i64;
let month = ((date >> 5) & 0xF) as i64;
let day = (date & 0x1F) as i64;
if !(1..=12).contains(&month) || day == 0 {
return None;
}
let (h, m, s) = (
(time >> 11) as i64,
((time >> 5) & 0x3F) as i64,
((time & 0x1F) * 2) as i64,
);
Some(days_from_civil(year, month, day) * 86400 + h * 3600 + m * 60 + s)
}
/// Dias desde 1970-01-01 (algoritmo de Howard Hinnant).
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
pub fn utf16le(bytes: &[u8]) -> String {
let u: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&u)
}
pub fn sanitize_name(s: &str) -> String {
let t: String = s
.chars()
.map(|c| {
if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
'_'
} else {
c
}
})
.collect();
let t = t.trim().trim_matches('.').to_string();
if t.is_empty() {
"arquivo".into()
} else {
t
}
}
+445
View File
@@ -0,0 +1,445 @@
//! NTFS: percorre a MFT atrás de registros apagados, resolve caminhos e
//! reconstrói os data runs. Coleta também os extents vivos.
use super::{
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::HashMap;
struct Run {
/// Cluster lógico inicial no arquivo (VCN).
vcn: u64,
len: u64,
/// None = esparso.
lcn: Option<u64>,
}
struct Rec {
in_use: bool,
is_dir: bool,
parent: u64,
name: Option<(String, u8)>,
mtime: Option<i64>,
data: Option<Data>,
has_attr_list: bool,
}
enum Data {
Resident(Vec<u8>),
NonResident {
runs: Vec<Run>,
real: u64,
unsupported: bool,
},
}
pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
let mut res = VolumeScan::default();
let Some(boot) = read_vec(ctx.src, base, 512).ok().filter(|b| b.len() == 512) else {
res.warnings.push("NTFS: setor de boot ilegível.".into());
return res;
};
let bps = u16::from_le_bytes([boot[11], boot[12]]) as u64;
let spc = boot[13] as u64;
let cl = bps * spc;
let total_secs = u64::from_le_bytes(boot[40..48].try_into().unwrap());
let mft_lcn = u64::from_le_bytes(boot[48..56].try_into().unwrap());
let mirr_lcn = u64::from_le_bytes(boot[56..64].try_into().unwrap());
let rs = boot[64] as i8;
let rec_size = if rs > 0 {
rs as u64 * cl
} else {
1u64 << (-(rs as i32))
};
if cl == 0 || !(256..=65536).contains(&rec_size) {
res.warnings
.push("NTFS: parâmetros de boot inválidos.".into());
return res;
}
// Registro 0 ($MFT) define onde está toda a MFT.
let mut mft_runs: Option<(Vec<Run>, u64)> = None;
for lcn in [mft_lcn, mirr_lcn] {
if let Some(mut r) = read_vec(ctx.src, base + lcn * cl, rec_size as usize)
.ok()
.filter(|r| r.len() as u64 == rec_size)
{
if let Some(rec) = parse_record(&mut r, bps, base, cl) {
if let Some(Data::NonResident { runs, real, .. }) = rec.data {
mft_runs = Some((runs, real));
break;
}
}
}
res.warnings.push(
"NTFS: $MFT principal corrompida, tentando o espelho (só cobre registros iniciais)."
.into(),
);
}
let Some((runs, mft_len)) = mft_runs else {
res.warnings
.push("NTFS: não foi possível ler a MFT; só varredura por assinatura.".into());
return res;
};
let mut live: Vec<Extent> = vec![Extent::new(base, 0)];
for r in &runs {
if let Some(l) = r.lcn {
live.push(Extent::new(base + l * cl, r.len * cl));
}
}
let n_records = mft_len / rec_size;
let mut dirs: HashMap<u64, (u64, String)> = HashMap::new();
let mut deleted: Vec<(u64, Rec)> = vec![];
// Lê a MFT em blocos grandes seguindo os runs.
let chunk_recs = (1 << 20) / rec_size.max(1);
let mut idx = 0u64;
let mut buf: Vec<u8>;
while idx < n_records {
if (ctx.cancelled)() {
break;
}
let nrec = chunk_recs.min(n_records - idx);
buf = read_mft_range(ctx.src, base, cl, &runs, idx * rec_size, nrec * rec_size);
for k in 0..nrec {
let s = (k * rec_size) as usize;
let e = s + rec_size as usize;
if e > buf.len() {
break;
}
let rn = idx + k;
let rec_buf = &mut buf[s..e];
let Some(rec) = parse_record(rec_buf, bps, base, cl) else {
continue;
};
if rec.in_use {
if rec.is_dir {
if let Some((n, _)) = &rec.name {
dirs.insert(rn, (rec.parent, n.clone()));
}
} else if let Some(Data::NonResident { runs, .. }) = &rec.data {
for r in runs {
if let Some(l) = r.lcn {
live.push(Extent::new(base + l * cl, r.len * cl));
}
}
}
} else if rec.is_dir {
if let Some((n, _)) = &rec.name {
dirs.entry(rn).or_insert((rec.parent, n.clone()));
}
} else if rec.name.is_some() && rec.data.is_some() {
deleted.push((rn, rec));
}
}
idx += nrec;
(ctx.progress)(idx, n_records);
}
let live = merge_extents(live);
let _ = total_secs;
for (rn, rec) in deleted {
if (ctx.cancelled)() {
break;
}
let name = rec.name.as_ref().map(|n| n.0.clone()).unwrap_or_default();
let dir = resolve_path(&dirs, rec.parent, rn);
let (data, size, conf, note): (DataLoc, u64, Confidence, Option<&str>) =
match rec.data.unwrap() {
Data::Resident(b) => {
let n = b.len() as u64;
(DataLoc::Resident { bytes: b }, n, Confidence::High, None)
}
Data::NonResident {
runs,
real,
unsupported,
} => {
if unsupported {
continue; // comprimido/cifrado (EFS): fora do escopo da v1
}
let mut ex = vec![];
for r in &runs {
match r.lcn {
Some(l) => ex.push(Extent::new(base + l * cl, r.len * cl)),
None => ex.push(Extent {
offset: 0,
len: r.len * cl,
sparse: true,
}),
}
}
let ex = super::fat::trim_extents(ex, real);
let reused = ex
.iter()
.any(|e| !e.sparse && overlaps(&live, e.offset, e.len));
let (c, n) = if reused {
(
Confidence::Low,
Some("clusters já reutilizados por outros arquivos"),
)
} else {
(Confidence::High, None)
};
(DataLoc::Extents { extents: ex }, real, c, n)
}
};
if size == 0 {
continue;
}
let f = make_found(
ctx,
NewFound {
name: sanitize_name(&name),
dir,
size,
origin: Origin::Ntfs,
data,
modified: rec.mtime,
base: conf,
note: if rec.has_attr_list {
Some("registro com lista de atributos: pode estar incompleto")
} else {
note
},
},
);
let mut f = f;
if rec.has_attr_list {
f.confidence = f.confidence.min(Confidence::Medium);
}
(ctx.emit)(f);
}
res.live = live;
res
}
fn resolve_path(dirs: &HashMap<u64, (u64, String)>, mut parent: u64, own: u64) -> Option<String> {
let mut parts = vec![];
let mut guard = 0;
while parent != 5 && guard < 64 {
match dirs.get(&parent) {
Some((p, n)) => {
parts.push(n.clone());
if *p == parent {
break;
}
parent = *p;
}
None => {
parts.push(format!("[pasta perdida {parent}]"));
break;
}
}
guard += 1;
}
let _ = own;
parts.reverse();
let s = parts.join("/");
(!s.is_empty()).then_some(s)
}
fn read_mft_range(
src: &dyn Source,
base: u64,
cl: u64,
runs: &[Run],
mut off: u64,
mut len: u64,
) -> Vec<u8> {
let mut out = Vec::with_capacity(len as usize);
for r in runs {
if len == 0 {
break;
}
let rs = r.vcn * cl;
let re = rs + r.len * cl;
if off >= re {
continue;
}
let within = off - rs.min(off);
let take = (re - off).min(len);
match r.lcn {
Some(l) => {
let v = read_vec(src, base + l * cl + within, take as usize).unwrap_or_default();
let got = v.len();
out.extend_from_slice(&v);
out.resize(out.len() + (take as usize - got), 0);
}
None => out.resize(out.len() + take as usize, 0),
}
off += take;
len -= take;
}
out
}
fn le16(b: &[u8], o: usize) -> u16 {
u16::from_le_bytes([b[o], b[o + 1]])
}
fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
/// Interpreta um registro de arquivo da MFT (aplica fixups no próprio buffer).
fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
if r.len() < 48 || &r[0..4] != b"FILE" {
return None;
}
let usa_off = le16(r, 4) as usize;
let usa_n = le16(r, 6) as usize;
if usa_n < 2 || usa_off + usa_n * 2 > r.len() {
return None;
}
let usn = [r[usa_off], r[usa_off + 1]];
for i in 1..usa_n {
let end = i * bps as usize;
if end > r.len() {
break;
}
if r[end - 2..end] != usn {
return None; // registro rasgado
}
r[end - 2] = r[usa_off + i * 2];
r[end - 1] = r[usa_off + i * 2 + 1];
}
let flags = le16(r, 22);
let base_ref = le64(r, 32) & 0xFFFF_FFFF_FFFF;
if base_ref != 0 {
return None; // registro de extensão: tratado só via lista de atributos (não suportado)
}
let mut rec = Rec {
in_use: flags & 1 != 0,
is_dir: flags & 2 != 0,
parent: 0,
name: None,
mtime: None,
data: None,
has_attr_list: false,
};
let mut p = le16(r, 20) as usize;
let used = (le32(r, 24) as usize).min(r.len());
while p + 16 <= used {
let ty = le32(r, p);
if ty == 0xFFFF_FFFF {
break;
}
let alen = le32(r, p + 4) as usize;
if alen < 16 || p + alen > r.len() {
break;
}
let nonres = r[p + 8] != 0;
let name_len = r[p + 9];
let aflags = le16(r, p + 12);
match ty {
0x10 if !nonres => {
let vo = le16(r, p + 20) as usize;
if p + vo + 16 <= r.len() {
rec.mtime = filetime_to_unix(le64(r, p + vo + 8));
}
}
0x20 => rec.has_attr_list = true,
0x30 if !nonres => {
let vo = p + le16(r, p + 20) as usize;
if vo + 0x42 <= r.len() {
let ns = r[vo + 0x41];
let nl = r[vo + 0x40] as usize;
if vo + 0x42 + nl * 2 <= r.len() && ns != 2 {
let name = utf16le(&r[vo + 0x42..vo + 0x42 + nl * 2]);
let better = rec
.name
.as_ref()
.map(|(_, old)| ns == 1 || ns == 3 || *old == 2)
.unwrap_or(true);
if better {
rec.parent = le64(r, vo) & 0xFFFF_FFFF_FFFF;
rec.name = Some((name, ns));
}
}
}
}
0x80 if name_len == 0 => {
if !nonres {
let vl = le32(r, p + 16) as usize;
let vo = p + le16(r, p + 20) as usize;
if vo + vl <= r.len() {
rec.data = Some(Data::Resident(r[vo..vo + vl].to_vec()));
}
} else if p + 64 <= r.len() {
let start_vcn = le64(r, p + 16);
let ro = p + le16(r, p + 32) as usize;
let real = le64(r, p + 48);
if start_vcn == 0 && ro < p + alen {
let runs = parse_runs(&r[ro..p + alen]);
rec.data = Some(Data::NonResident {
runs,
real,
unsupported: aflags & 0x4001 != 0,
});
}
}
}
_ => {}
}
p += alen;
}
Some(rec)
}
fn parse_runs(mut d: &[u8]) -> Vec<Run> {
let mut out = vec![];
let mut vcn = 0u64;
let mut lcn = 0i64;
while let Some(&h) = d.first() {
if h == 0 {
break;
}
let ll = (h & 0xF) as usize;
let lo = (h >> 4) as usize;
if ll == 0 || ll > 8 || lo > 8 || d.len() < 1 + ll + lo {
break;
}
let mut len = 0u64;
for i in 0..ll {
len |= (d[1 + i] as u64) << (8 * i);
}
let lcn_opt = if lo == 0 {
None
} else {
let mut v = 0i64;
for i in 0..lo {
v |= (d[1 + ll + i] as i64) << (8 * i);
}
if d[ll + lo] & 0x80 != 0 && lo < 8 {
v |= -1i64 << (8 * lo);
}
lcn += v;
if lcn < 0 {
break;
}
Some(lcn as u64)
};
out.push(Run {
vcn,
len,
lcn: lcn_opt,
});
vcn += len;
d = &d[1 + ll + lo..];
}
out
}
fn filetime_to_unix(ft: u64) -> Option<i64> {
if ft == 0 {
return None;
}
let secs = (ft / 10_000_000) as i64 - 11_644_473_600;
(secs > 0).then_some(secs)
}