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

+114
View File
@@ -0,0 +1,114 @@
//! Carving por assinatura/estrutura sobre intervalos de bytes.
use crate::signatures::{category_for_ext, detect, might_start, Hit};
use crate::source::Source;
use crate::types::Category;
pub const SECTOR: u64 = 512;
const CHUNK: usize = 8 * 1024 * 1024;
const LOOKAHEAD: usize = 4096;
const MIN_LEN: u64 = 256;
pub struct CarveCfg<'a> {
pub max_size: u64,
pub categories: &'a [Category],
}
/// Varre [start, end) a partir de `*next`. Chama `on_hit(offset, hit)` e retorna
/// `true` se terminou o intervalo (`false` se cancelado).
/// `advance(done_bytes)` informa o progresso dentro do intervalo.
pub fn carve_range(
src: &dyn Source,
start: u64,
end: u64,
next: &mut u64,
cfg: &CarveCfg,
cancelled: &(dyn Fn() -> bool + Sync),
ev: &mut dyn CarveEvents,
) -> bool {
let end = end.min(src.size());
let mut pos = (*next).max(start);
// Alinha ao setor.
pos = pos.div_ceil(SECTOR) * SECTOR;
let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
let mut skip_until = 0u64;
while pos < end {
if cancelled() {
*next = pos;
return false;
}
let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD);
let got = match read_full(src, pos, &mut buf[..want]) {
Ok(n) => n,
Err(_) => {
// Setor ruim: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
pos += CHUNK as u64;
ev.advance(CHUNK as u64);
*next = pos;
continue;
}
};
if got == 0 {
break;
}
let scan_len = got.min(CHUNK).min((end - pos) as usize);
let mut i = 0usize;
while i < scan_len {
let abs = pos + i as u64;
if abs < skip_until {
i += SECTOR as usize;
continue;
}
let b0 = buf[i];
if might_start(b0) {
let hend = (i + 128).min(got);
let head = &buf[i..hend];
if let Some(hit) = detect(src, abs, head, cfg.max_size) {
let allowed = cfg.categories.is_empty()
|| cfg.categories.contains(&category_for_ext(hit.ext));
if hit.len >= MIN_LEN && allowed {
let action = ev.hit(abs, &hit);
if matches!(action, HitAction::Skip)
&& hit.confidence != crate::types::Confidence::Low
{
skip_until = abs + hit.len;
}
}
}
}
i += SECTOR as usize;
}
let step = scan_len as u64;
pos += step.div_ceil(SECTOR) * SECTOR;
ev.advance(step);
*next = pos;
}
*next = end;
true
}
/// Receptor de eventos do carving.
pub trait CarveEvents {
fn advance(&mut self, bytes: u64);
fn hit(&mut self, offset: u64, hit: &Hit) -> HitAction;
}
/// O que fazer depois de um acerto.
pub enum HitAction {
/// Pula o conteúdo do arquivo reconhecido (comportamento padrão).
Skip,
/// Segue setor a setor (acerto descartado).
Continue,
}
fn read_full(src: &dyn Source, off: u64, buf: &mut [u8]) -> std::io::Result<usize> {
let mut got = 0;
while got < buf.len() {
let n = src.read_at(off + got as u64, &mut buf[got..])?;
if n == 0 {
break;
}
got += n;
}
Ok(got)
}
+249
View File
@@ -0,0 +1,249 @@
//! Enumeração de dispositivos de armazenamento (melhor esforço por plataforma).
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
pub path: String,
pub name: String,
pub size: u64,
/// "HD", "SSD", "NVMe", "USB", "Disco" ou "Imagem".
pub kind: String,
pub removable: bool,
pub model: Option<String>,
pub mounted_at: Vec<String>,
/// O processo atual consegue abrir o dispositivo para leitura bruta.
pub readable: bool,
/// SSD com descarte (TRIM) ativo: arquivos apagados tendem a sumir de verdade.
pub trim: Option<bool>,
}
/// (dispositivo, ponto de montagem)
pub fn mount_table() -> Vec<(String, String)> {
#[cfg(target_os = "linux")]
{
let Ok(s) = std::fs::read_to_string("/proc/mounts") else {
return vec![];
};
s.lines()
.filter_map(|l| {
let mut it = l.split_whitespace();
let d = it.next()?;
let m = it.next()?;
d.starts_with("/dev/")
.then(|| (d.to_string(), m.replace("\\040", " ")))
})
.collect()
}
#[cfg(target_os = "macos")]
{
let Ok(o) = std::process::Command::new("mount").output() else {
return vec![];
};
let mut v = vec![];
for l in String::from_utf8_lossy(&o.stdout).lines() {
if let Some((d, rest)) = l.split_once(" on ") {
if d.starts_with("/dev/") {
let m = rest.split(" (").next().unwrap_or("").to_string();
v.push((d.to_string(), m.clone()));
v.push((d.replace("/dev/disk", "/dev/rdisk"), m));
}
}
}
v
}
#[cfg(windows)]
{
vec![]
}
}
pub fn list_devices() -> Vec<DeviceInfo> {
#[cfg(target_os = "linux")]
return linux();
#[cfg(target_os = "macos")]
return macos();
#[cfg(windows)]
return windows();
}
#[cfg(target_os = "linux")]
fn linux() -> Vec<DeviceInfo> {
use std::fs;
let mounts = mount_table();
let mut out = vec![];
let Ok(rd) = fs::read_dir("/sys/block") else {
return out;
};
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if ["loop", "ram", "zram", "dm-", "md", "sr", "fd", "nbd"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
let base = e.path();
let rd_str = |p: &str| {
fs::read_to_string(base.join(p))
.ok()
.map(|s| s.trim().to_string())
};
let sectors: u64 = rd_str("size").and_then(|s| s.parse().ok()).unwrap_or(0);
if sectors == 0 {
continue;
}
let removable = rd_str("removable").as_deref() == Some("1");
let rotational = rd_str("queue/rotational").as_deref() == Some("1");
let trim = rd_str("queue/discard_max_bytes")
.and_then(|s| s.parse::<u64>().ok())
.map(|v| v > 0);
let path = format!("/dev/{name}");
let kind = if removable {
"USB"
} else if name.starts_with("nvme") {
"NVMe"
} else if rotational {
"HD"
} else {
"SSD"
};
let model = rd_str("device/model").or_else(|| rd_str("device/name"));
let mounted_at = mounts
.iter()
.filter(|(d, _)| {
d.starts_with(&path)
&& d[path.len()..]
.chars()
.next()
.map(|c| c.is_ascii_digit() || c == 'p')
.unwrap_or(true)
})
.map(|(_, m)| m.clone())
.collect();
let readable = fs::File::open(&path).is_ok();
out.push(DeviceInfo {
path,
name: model.clone().unwrap_or_else(|| name.clone()),
size: sectors * 512,
kind: kind.into(),
removable,
model,
mounted_at,
readable,
trim: if rotational { None } else { trim },
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
#[cfg(target_os = "macos")]
fn macos() -> Vec<DeviceInfo> {
let mut out = vec![];
let Ok(rd) = std::fs::read_dir("/dev") else {
return out;
};
let mounts = mount_table();
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().to_string();
let Some(num) = n.strip_prefix("disk") else {
continue;
};
if num.is_empty() || !num.chars().all(|c| c.is_ascii_digit()) {
continue;
}
let info = std::process::Command::new("diskutil")
.args(["info", &n])
.output()
.ok();
let text = info
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let field = |k: &str| {
text.lines().find_map(|l| {
l.trim()
.strip_prefix(k)
.map(|v| v.trim().trim_start_matches(':').trim().to_string())
})
};
let size = field("Disk Size:")
.and_then(|v| {
v.split('(')
.nth(1)
.and_then(|s| s.split_whitespace().next().map(String::from))
})
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let ssd = field("Solid State:").map(|v| v == "Yes").unwrap_or(false);
let removable = field("Removable Media:")
.map(|v| v != "Fixed" && v != "No")
.unwrap_or(false)
|| field("Protocol:").map(|v| v == "USB").unwrap_or(false);
let model = field("Device / Media Name:");
let path = format!("/dev/r{n}");
let readable = std::fs::File::open(&path).is_ok();
let mounted_at = mounts
.iter()
.filter(|(d, _)| d.starts_with(&format!("/dev/{n}")))
.map(|(_, m)| m.clone())
.collect();
out.push(DeviceInfo {
path,
name: model.clone().unwrap_or(n.clone()),
size,
kind: if removable {
"USB"
} else if ssd {
"SSD"
} else {
"HD"
}
.into(),
removable,
model,
mounted_at,
readable,
trim: ssd.then_some(true),
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
#[cfg(windows)]
fn windows() -> Vec<DeviceInfo> {
let mut out = vec![];
for i in 0..32 {
let path = format!(r"\\.\PhysicalDrive{i}");
match crate::source::open_windows_device(std::path::Path::new(&path)) {
Ok(s) => {
use crate::source::Source;
out.push(DeviceInfo {
path,
name: format!("Disco {i}"),
size: s.size(),
kind: "Disco".into(),
removable: false,
model: None,
mounted_at: vec![],
readable: true,
trim: None,
});
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => out.push(DeviceInfo {
path,
name: format!("Disco {i}"),
size: 0,
kind: "Disco".into(),
removable: false,
model: None,
mounted_at: vec![],
readable: false,
trim: None,
}),
Err(_) => {}
}
}
out
}
+19
View File
@@ -0,0 +1,19 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("erro de E/S: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Msg(String),
#[error("JSON inválido: {0}")]
Json(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
pub fn msg(s: impl Into<String>) -> Self {
Error::Msg(s.into())
}
}
+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)
}
+87
View File
@@ -0,0 +1,87 @@
//! Imagem forense de um disco: leitura tolerante a setores ruins, com mapa de
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
use crate::error::{Error, Result};
use crate::source::Source;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::Path;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ImageReport {
pub bytes: u64,
pub sha256: String,
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
pub bad_ranges: Vec<(u64, u64)>,
pub cancelled: bool,
}
const BLOCK: usize = 1 << 20;
const SECTOR: usize = 512;
pub fn create_image(
src: &dyn Source,
dest: &Path,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(u64, u64),
) -> Result<ImageReport> {
let mut out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dest)
.map_err(|e| {
Error::msg(format!(
"não foi possível criar {}: {e} (o arquivo não pode existir)",
dest.display()
))
})?;
let size = src.size();
let mut rep = ImageReport::default();
let mut hasher = Sha256::new();
let mut buf = vec![0u8; BLOCK];
let mut pos = 0u64;
while pos < size {
if cancelled() {
rep.cancelled = true;
break;
}
let want = (size - pos).min(BLOCK as u64) as usize;
match read_full(src, pos, &mut buf[..want]) {
Ok(()) => {}
Err(_) => {
// Refaz setor a setor para isolar os trechos ruins.
let mut o = 0;
while o < want {
let n = SECTOR.min(want - o);
if read_full(src, pos + o as u64, &mut buf[o..o + n]).is_err() {
buf[o..o + n].fill(0);
match rep.bad_ranges.last_mut() {
Some(l) if l.0 + l.1 == pos + o as u64 => l.1 += n as u64,
_ => rep.bad_ranges.push((pos + o as u64, n as u64)),
}
}
o += n;
}
}
}
out.write_all(&buf[..want])?;
hasher.update(&buf[..want]);
pos += want as u64;
progress(pos, size);
}
out.flush()?;
rep.bytes = pos;
rep.sha256 = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let map = dest.with_extension("map.json");
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
Ok(rep)
}
fn read_full(src: &dyn Source, off: u64, buf: &mut [u8]) -> std::io::Result<()> {
crate::source::read_exact_at(src, off, buf)
}
+22
View File
@@ -0,0 +1,22 @@
//! Guará — núcleo de recuperação de arquivos.
//!
//! Princípio central: a origem é aberta **somente para leitura**; nada neste
//! crate escreve no dispositivo que está sendo recuperado.
pub mod carve;
pub mod device;
pub mod error;
pub mod fs;
pub mod image;
pub mod partition;
pub mod recover;
pub mod scan;
pub mod service;
pub mod signatures;
pub mod source;
pub mod types;
pub use error::{Error, Result};
pub use types::*;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+472
View File
@@ -0,0 +1,472 @@
//! Tabelas de partição (MBR/GPT), detecção de sistemas de arquivos,
//! volumes cifrados e busca de partições perdidas.
use crate::source::{read_vec, Source};
use crate::types::{DiskLayout, FsKind, Gap, PartInfo};
const SECTOR: u64 = 512;
const MIN_GAP: u64 = 1024 * 1024;
/// Identifica o sistema de arquivos que começa em `start` e (se possível) seu tamanho.
pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)> {
let b = read_vec(src, start, 4096).ok()?;
if b.len() < 1100 {
return None;
}
detect_fs_in(&b)
}
/// Mesma detecção sobre um buffer que começa no setor candidato (>= 1100 bytes).
pub fn detect_fs_in(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
if b.len() < 1100 {
return None;
}
if &b[3..11] == b"NTFS " {
let bps = u16::from_le_bytes([b[11], b[12]]) as u64;
let total = u64::from_le_bytes(b[40..48].try_into().unwrap());
if bps.is_power_of_two() && (512..=4096).contains(&bps) && b[13] != 0 {
return Some((FsKind::Ntfs, Some((total + 1) * bps)));
}
}
if &b[3..11] == b"EXFAT " {
let shift = b[108] as u32;
if (9..=12).contains(&shift) {
let vol = u64::from_le_bytes(b[72..80].try_into().unwrap());
return Some((FsKind::Exfat, Some(vol << shift)));
}
}
if &b[3..11] == b"-FVE-FS-" {
return Some((FsKind::Bitlocker, None));
}
if b.starts_with(b"LUKS\xBA\xBE") {
return Some((FsKind::Luks, None));
}
if &b[32..36] == b"NXSB" {
let bs = u32::from_le_bytes(b[36..40].try_into().unwrap()) as u64;
let cnt = u64::from_le_bytes(b[40..48].try_into().unwrap());
return Some((FsKind::Apfs, Some(bs * cnt)));
}
if b[1024..1026] == *b"H+" || b[1024..1026] == *b"HX" {
return Some((FsKind::HfsPlus, None));
}
if b[1080] == 0x53 && b[1081] == 0xEF {
let blocks = u32::from_le_bytes(b[1028..1032].try_into().unwrap()) as u64;
let log = u32::from_le_bytes(b[1048..1052].try_into().unwrap());
if log <= 6 {
return Some((FsKind::Ext, Some(blocks << (10 + log))));
}
}
if let Some(r) = detect_fat(b) {
return Some(r);
}
None
}
fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
if b[510] != 0x55 || b[511] != 0xAA {
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;
if !bps.is_power_of_two()
|| !(512..=4096).contains(&bps)
|| spc == 0
|| !spc.is_power_of_two()
|| reserved == 0
|| !(1..=2).contains(&nfats)
{
return None;
}
let total = if ts16 != 0 { ts16 } else { ts32 };
let fatsz = if fat16 != 0 { fat16 } else { fat32 };
if total == 0 || fatsz == 0 {
return None;
}
let root_secs = (root_entries * 32).div_ceil(bps);
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
let clusters = data / spc;
let kind = if clusters < 4085 {
FsKind::Fat12
} else if clusters < 65525 {
FsKind::Fat16
} else {
FsKind::Fat32
};
// Coerência: FAT32 tem root_entries == 0 e fat16 == 0.
if (kind == FsKind::Fat32) != (root_entries == 0 && fat16 == 0) {
return None;
}
Some((kind, Some(total * bps)))
}
/// Lê o rótulo (label) do volume quando disponível de forma simples.
fn fat_label(b: &[u8], kind: FsKind) -> Option<String> {
let off = if kind == FsKind::Fat32 { 71 } else { 43 };
let s = String::from_utf8_lossy(b.get(off..off + 11)?)
.trim()
.to_string();
(!s.is_empty() && s != "NO NAME").then_some(s)
}
// ----------------------------------------------------------------- tabelas
/// Analisa o disco. `search_lost` varre lacunas atrás de volumes perdidos (lento em discos grandes).
pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
let size = src.size();
let mut layout = DiskLayout {
size,
table: "none".into(),
..Default::default()
};
let sector0 = read_vec(src, 0, 512).unwrap_or_default();
let has_mbr = sector0.len() == 512 && sector0[510] == 0x55 && sector0[511] == 0xAA;
let gpt = read_gpt(src, size);
let mut parts: Vec<PartInfo> = Vec::new();
if let Some(g) = gpt {
layout.table = "gpt".into();
parts = g;
} else if has_mbr {
// Pode ser MBR de verdade ou um setor de boot de volume sem tabela (superfloppy).
let looks_boot = detect_fs_in(&read_vec(src, 0, 4096).unwrap_or_default()).is_some();
if !looks_boot {
let mp = read_mbr(src, &sector0, size);
if !mp.is_empty() {
layout.table = "mbr".into();
parts = mp;
}
}
}
for p in &mut parts {
if let Some((fs, _)) = detect_fs(src, p.start) {
p.fs = fs;
p.encrypted = fs.encrypted().map(String::from);
if matches!(fs, FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32) {
if let Ok(b) = read_vec(src, p.start, 512) {
p.label = fat_label(&b, fs);
}
}
}
}
// Volume sem tabela de partições (pendrive "superfloppy" ou partição crua).
if parts.is_empty() {
if let Some((fs, len)) = detect_fs(src, 0) {
parts.push(PartInfo {
index: 0,
start: 0,
len: len.unwrap_or(size).min(size),
fs,
label: None,
lost: false,
encrypted: fs.encrypted().map(String::from),
table_type: Some("volume sem tabela de partições".into()),
});
layout.table = "none".into();
}
}
parts.sort_by_key(|p| p.start);
layout.gaps = compute_gaps(&parts, size);
// Partições perdidas: procurar assinaturas de volumes nas lacunas.
let mut lost = Vec::new();
if search_lost {
lost = find_lost(src, &layout.gaps, &parts);
if parts.is_empty() && layout.table == "none" {
// Disco sem tabela nem volume no início: procurar no disco todo.
lost = find_lost(
src,
&[Gap {
start: 0,
len: size,
}],
&[],
);
}
}
for mut l in lost {
l.index = parts.len();
parts.push(l);
}
parts.sort_by_key(|p| p.start);
for (i, p) in parts.iter_mut().enumerate() {
p.index = i;
}
layout.gaps = compute_gaps(
&parts
.iter()
.filter(|p| !p.lost)
.cloned()
.collect::<Vec<_>>(),
size,
);
layout.partitions = parts;
for p in &layout.partitions {
if let Some(e) = &p.encrypted {
layout.warnings.push(format!(
"Partição {} está cifrada com {}: o Guará só detecta cifragem na v1 e não varre esse volume sem a chave.",
p.index + 1,
e
));
}
if p.fs == FsKind::Apfs {
layout.warnings.push(format!(
"Partição {} é APFS: leitura de metadados ainda não suportada; usando apenas varredura por assinatura. Se estiver cifrada (FileVault), nada será encontrado.",
p.index + 1
));
}
}
layout
}
fn compute_gaps(parts: &[PartInfo], size: u64) -> Vec<Gap> {
let mut gaps = vec![];
let mut cur = 0u64;
let mut sorted: Vec<&PartInfo> = parts.iter().collect();
sorted.sort_by_key(|p| p.start);
for p in sorted {
if p.start > cur && p.start - cur > MIN_GAP {
gaps.push(Gap {
start: cur,
len: p.start - cur,
});
}
cur = cur.max(p.start + p.len);
}
if size > cur && size - cur > MIN_GAP {
gaps.push(Gap {
start: cur,
len: size - cur,
});
}
gaps
}
fn read_mbr(src: &dyn Source, s0: &[u8], size: u64) -> Vec<PartInfo> {
let mut out = vec![];
let mut ext_base = None;
for i in 0..4 {
let e = &s0[446 + i * 16..462 + i * 16];
let ty = e[4];
let lba = u32::from_le_bytes(e[8..12].try_into().unwrap()) as u64;
let cnt = u32::from_le_bytes(e[12..16].try_into().unwrap()) as u64;
if ty == 0 || cnt == 0 {
continue;
}
if ty == 0xEE {
continue; // MBR protetor de GPT
}
if matches!(ty, 0x05 | 0x0F | 0x85) {
ext_base = Some(lba * SECTOR);
continue;
}
if lba * SECTOR >= size {
continue;
}
out.push(part(
out.len(),
lba * SECTOR,
cnt * SECTOR,
Some(format!("MBR 0x{ty:02X}")),
));
}
if let Some(base) = ext_base {
let mut cur = base;
for _ in 0..128 {
let Ok(ebr) = read_vec(src, cur, 512) else {
break;
};
if ebr.len() < 512 || ebr[510] != 0x55 || ebr[511] != 0xAA {
break;
}
let e1 = &ebr[446..462];
let lba = u32::from_le_bytes(e1[8..12].try_into().unwrap()) as u64;
let cnt = u32::from_le_bytes(e1[12..16].try_into().unwrap()) as u64;
if e1[4] != 0 && cnt != 0 {
out.push(part(
out.len(),
cur + lba * SECTOR,
cnt * SECTOR,
Some(format!("MBR lógica 0x{:02X}", e1[4])),
));
}
let e2 = &ebr[462..478];
let next = u32::from_le_bytes(e2[8..12].try_into().unwrap()) as u64;
if next == 0 || !matches!(e2[4], 0x05 | 0x0F | 0x85) {
break;
}
cur = base + next * SECTOR;
}
}
out
}
fn part(index: usize, start: u64, len: u64, table_type: Option<String>) -> PartInfo {
PartInfo {
index,
start,
len,
fs: FsKind::Unknown,
label: None,
lost: false,
encrypted: None,
table_type,
}
}
fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
// Tenta o cabeçalho primário (LBA 1) e depois o de backup (último setor).
let hdr = read_vec(src, SECTOR, 512)
.ok()
.filter(|h| h.len() == 512 && &h[0..8] == b"EFI PART")
.or_else(|| {
let last = size.checked_sub(SECTOR)?;
read_vec(src, last, 512)
.ok()
.filter(|h| h.len() == 512 && &h[0..8] == b"EFI PART")
})?;
let table_lba = u64::from_le_bytes(hdr[72..80].try_into().unwrap());
let n = u32::from_le_bytes(hdr[80..84].try_into().unwrap()) as usize;
let esz = u32::from_le_bytes(hdr[84..88].try_into().unwrap()) as usize;
if n == 0 || n > 1024 || !(128..=4096).contains(&esz) {
return None;
}
let mut tbl = read_vec(src, table_lba * SECTOR, n * esz).ok()?;
if tbl.len() < n * esz {
// Cabeçalho de backup aponta para a tabela de backup; se falhar, desiste.
tbl.resize(n * esz, 0);
}
let mut out = vec![];
for i in 0..n {
let e = &tbl[i * esz..(i + 1) * esz];
if e[0..16].iter().all(|&b| b == 0) {
continue;
}
let first = u64::from_le_bytes(e[32..40].try_into().unwrap());
let last = u64::from_le_bytes(e[40..48].try_into().unwrap());
if last < first || last * SECTOR >= size + SECTOR {
continue;
}
let name: Vec<u16> = e[56..128]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.take_while(|&c| c != 0)
.collect();
let mut p = part(
out.len(),
first * SECTOR,
(last - first + 1) * SECTOR,
Some(gpt_type_name(&e[0..16]).into()),
);
let label = String::from_utf16_lossy(&name);
if !label.is_empty() {
p.label = Some(label);
}
out.push(p);
}
Some(out)
}
fn gpt_type_name(guid: &[u8]) -> &'static str {
// GUIDs em formato de disco (mixed-endian).
match guid {
[0x28, 0x73, 0x2A, 0xC1, 0x1F, 0xF8, 0xD2, 0x11, 0xBA, 0x4B, 0x00, 0xA0, 0xC9, 0x3E, 0xC9, 0x3B] => {
"EFI System"
}
[0xA2, 0xA0, 0xD0, 0xEB, 0xE5, 0xB9, 0x33, 0x44, 0x87, 0xC0, 0x68, 0xB6, 0xB7, 0x26, 0x99, 0xC7] => {
"Microsoft basic data"
}
[0x16, 0xE3, 0xC9, 0xE3, 0x5C, 0x0B, 0xB8, 0x4D, 0x81, 0x7D, 0xF9, 0x2D, 0xF0, 0x02, 0x15, 0xAE] => {
"Microsoft reserved"
}
[0xAF, 0x3D, 0xC6, 0x0F, 0x83, 0x84, 0x72, 0x47, 0x8E, 0x79, 0x3D, 0x69, 0xD8, 0x47, 0x7D, 0xE4] => {
"Linux filesystem"
}
[0x6D, 0xFD, 0x57, 0x06, 0xAB, 0xA4, 0xC4, 0x43, 0x84, 0xE5, 0x09, 0x33, 0xC8, 0x4B, 0x4F, 0x4F] => {
"Linux swap"
}
[0x00, 0x53, 0x46, 0x48, 0x00, 0x00, 0xAA, 0xAA, 0x11, 0xAA, 0x00, 0x30, 0x65, 0x43, 0xEC, 0xAC] => {
"Apple HFS+"
}
[0xEF, 0x57, 0x34, 0x7C, 0x00, 0x00, 0xAA, 0xAA, 0x11, 0xAA, 0x00, 0x30, 0x65, 0x43, 0xEC, 0xAC] => {
"Apple APFS"
}
_ => "GPT",
}
}
/// Procura volumes (por assinatura) dentro de lacunas, em limites de setor.
fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo> {
const CHUNK: u64 = 4 * 1024 * 1024;
let mut found: Vec<PartInfo> = vec![];
for g in gaps {
let mut pos = g.start;
let end = g.start + g.len;
let mut skip_until = 0u64;
while pos < end {
let want = CHUNK.min(end - pos) + 4096;
let Ok(buf) = read_vec(src, pos, want as usize) else {
break;
};
if buf.len() < 1100 {
break;
}
let limit = CHUNK.min(end - pos).min(buf.len() as u64 - 1100);
let mut off = 0u64;
while off <= limit {
let abs = pos + off;
if abs >= skip_until {
let s = &buf[off as usize..];
if s.len() >= 1100 {
// Filtro rápido: só chama a detecção completa se algum magic casa.
let quick = &s[3..11] == b"NTFS "
|| &s[3..11] == b"EXFAT "
|| &s[3..11] == b"-FVE-FS-"
|| s.starts_with(b"LUKS\xBA\xBE")
|| (s[510] == 0x55 && s[511] == 0xAA && s[0] != 0)
|| (s[1080] == 0x53 && s[1081] == 0xEF);
if quick {
if let Some((fs, len)) = detect_fs_in(s) {
// ext: o superbloco está a 1024 bytes do início do volume,
// por isso o candidato ext é o setor atual (início do volume).
let len = len.unwrap_or(0);
let ok = len >= 64 * 1024
&& abs + len <= src.size() + 1024 * 1024
&& !overlaps(known, abs, len);
// Evita falso positivo de FAT em setor de boot MBR genérico.
if ok && (fs != FsKind::Fat12 || len >= 512 * 1024) {
let mut p =
part(0, abs, len, Some("encontrada por varredura".into()));
p.fs = fs;
p.lost = true;
p.encrypted = fs.encrypted().map(String::from);
skip_until = abs + len;
found.push(p);
}
}
}
}
}
off += SECTOR;
}
pos += CHUNK;
}
}
found
}
fn overlaps(known: &[PartInfo], start: u64, len: u64) -> bool {
known
.iter()
.any(|p| start < p.start + p.len && p.start < start + len)
}
+212
View File
@@ -0,0 +1,212 @@
//! Gravação dos arquivos recuperados (sempre em outro destino) com SHA-256
//! e manifesto da sessão.
use crate::error::{Error, Result};
use crate::fs::sanitize_name;
use crate::source::{read_vec, Source};
use crate::types::*;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveredFile {
pub id: u64,
pub path: String,
pub size: u64,
pub sha256: String,
pub confidence: Confidence,
pub origin: Origin,
pub partial: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecoverReport {
pub destination: String,
pub recovered: Vec<RecoveredFile>,
pub failed: Vec<(u64, String)>,
pub total_bytes: u64,
pub cancelled: bool,
}
/// Recusa destinos que ficam no mesmo dispositivo da origem.
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest)?;
let dest = dest.canonicalize()?;
let Some(sp) = source_path else { return Ok(()) };
let sp_str = sp.to_string_lossy().to_string();
for (dev, mnt) in crate::device::mount_table() {
let same = dev == sp_str
|| (dev.starts_with(&sp_str)
&& dev[sp_str.len()..]
.chars()
.next()
.map(|c| c.is_ascii_digit() || c == 'p' || c == 's')
.unwrap_or(false));
if same {
let m = Path::new(&mnt);
if dest.starts_with(m) && m != Path::new("/") {
return Err(Error::msg(format!(
"O destino {} fica em {} (montado a partir da origem {}). Escolha outro disco para não sobrescrever dados recuperáveis.",
dest.display(),
mnt,
sp_str
)));
}
if m == Path::new("/") {
return Err(Error::msg(format!("A origem {sp_str} contém o sistema (/). Recupere para um disco externo ou use uma imagem.")));
}
}
}
if let Ok(meta) = std::fs::metadata(sp) {
if meta.is_file() {
// Imagem: evita gravar ao lado só se for o mesmo arquivo (não faz sentido), mas aceita.
}
}
Ok(())
}
pub fn recover(
src: &dyn Source,
files: &[&Found],
dest: &Path,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> {
std::fs::create_dir_all(dest)?;
let mut report = RecoverReport {
destination: dest.display().to_string(),
..Default::default()
};
let mut used: HashSet<PathBuf> = HashSet::new();
for (i, f) in files.iter().enumerate() {
if cancelled() {
report.cancelled = true;
break;
}
progress(i, files.len(), report.total_bytes);
let rel = target_rel(f);
let mut path = dest.join(&rel);
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
report.failed.push((f.id, e.to_string()));
continue;
}
}
let mut n = 1;
while path.exists() || used.contains(&path) {
let stem = Path::new(&f.name)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let name = if f.ext.is_empty() {
format!("{stem} ({n})")
} else {
format!("{stem} ({n}).{}", f.ext)
};
path = path.with_file_name(sanitize_name(&name));
n += 1;
}
used.insert(path.clone());
match write_file(src, f, &path) {
Ok(hash) => {
report.total_bytes += f.size;
report.recovered.push(RecoveredFile {
id: f.id,
path: path
.strip_prefix(dest)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/"),
size: f.size,
sha256: hash,
confidence: f.confidence,
origin: f.origin,
partial: f.confidence == Confidence::Low,
});
}
Err(e) => report.failed.push((f.id, e.to_string())),
}
}
progress(files.len(), files.len(), report.total_bytes);
let manifest = serde_json::json!({
"ferramenta": "Guará",
"versao": env!("CARGO_PKG_VERSION"),
"gerado_em": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
"algoritmo_hash": "SHA-256",
"relatorio": &report,
});
std::fs::write(
dest.join("guara-manifesto.json"),
serde_json::to_vec_pretty(&manifest)?,
)?;
Ok(report)
}
fn target_rel(f: &Found) -> PathBuf {
let mut p = PathBuf::new();
match &f.dir {
Some(d) if !d.is_empty() => {
p.push("Recuperados");
for c in d.split('/') {
if !c.is_empty() {
p.push(sanitize_name(c));
}
}
}
_ => {
p.push(f.category.label_pt());
}
}
p.push(sanitize_name(&f.name));
p
}
/// Escreve o conteúdo e devolve o SHA-256.
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
let mut out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
let mut hasher = Sha256::new();
match &f.data {
DataLoc::Resident { bytes } => {
out.write_all(bytes)?;
hasher.update(bytes);
}
DataLoc::Extents { extents } => {
let mut left = f.size;
let mut buf = vec![0u8; 1 << 20];
for e in extents {
let mut done = 0u64;
while done < e.len && left > 0 {
let n = (e.len - done).min(left).min(buf.len() as u64) as usize;
if e.sparse {
buf[..n].fill(0);
} else {
let got = read_vec(src, e.offset + done, n)?;
if got.len() < n {
return Err(Error::msg("leitura além do fim da origem"));
}
buf[..n].copy_from_slice(&got);
}
out.write_all(&buf[..n])?;
hasher.update(&buf[..n]);
done += n as u64;
left -= n as u64;
}
}
}
}
if let Some(m) = f.modified.filter(|m| *m > 0) {
let _ = out.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(m as u64));
}
out.flush()?;
Ok(hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect())
}
+343
View File
@@ -0,0 +1,343 @@
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos →
//! carving no espaço livre/desconhecido → fusão sem duplicatas.
use crate::carve::{carve_range, CarveCfg, CarveEvents, HitAction};
use crate::fs::{self, merge_extents, overlaps, Ctx};
use crate::partition;
use crate::signatures::category_for_ext;
use crate::source::Source;
use crate::types::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Progress {
pub phase: String,
pub done: u64,
pub total: u64,
}
/// Receptor dos resultados (a interface/CLI implementa).
pub trait Sink {
fn found(&mut self, f: Found);
/// Um achado de carving confirmou um arquivo já listado por metadados.
fn confirm(&mut self, id: u64, confidence: Confidence);
fn progress(&mut self, p: Progress);
fn warning(&mut self, w: String);
fn layout(&mut self, l: &DiskLayout);
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RangeState {
pub start: u64,
pub end: u64,
pub next: u64,
}
/// Estado serializável para pausar/retomar.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Checkpoint {
pub fs_done: bool,
pub ranges: Vec<RangeState>,
pub next_id: u64,
pub layout: Option<DiskLayout>,
}
pub enum Outcome {
Completed,
Interrupted(Checkpoint),
}
pub fn run(
src: &dyn Source,
opts: &ScanOptions,
resume: Option<Checkpoint>,
existing: &[Found],
cancelled: &(dyn Fn() -> bool + Sync),
sink: &mut dyn Sink,
) -> Outcome {
let mut cp = resume.unwrap_or_default();
let next_id = AtomicU64::new(
cp.next_id
.max(existing.iter().map(|f| f.id + 1).max().unwrap_or(1))
.max(1),
);
sink.progress(Progress {
phase: "Lendo tabela de partições".into(),
done: 0,
total: 0,
});
let layout = match cp.layout.clone() {
Some(l) => l,
None => {
let l = partition::analyze(src, opts.mode == ScanMode::Deep);
sink.layout(&l);
for w in &l.warnings {
sink.warning(w.clone());
}
l
}
};
cp.layout = Some(layout.clone());
// Índices para fusão: primeiro offset -> (id, confiança), e extents de achados por metadados.
let mut starts: HashMap<u64, (u64, Confidence)> = HashMap::new();
let mut fs_extents: Vec<Extent> = vec![];
for f in existing {
index_found(f, &mut starts, &mut fs_extents);
}
if !cp.fs_done {
let mut live_all: Vec<Extent> = vec![];
let mut free_all: Vec<Extent> = vec![];
let mut have_free_for: Vec<(u64, u64)> = vec![];
for p in &layout.partitions {
if p.encrypted.is_some() {
continue;
}
let phase = format!("Lendo metadados {} (partição {})", p.fs.name(), p.index + 1);
sink.progress(Progress {
phase: phase.clone(),
done: 0,
total: 0,
});
let mut emit_buf: Vec<Found> = vec![];
let res = {
let progress_phase = phase.clone();
let prog_cell = std::sync::Mutex::new((0u64, 0u64));
let progress = |d: u64, t: u64| {
*prog_cell.lock().unwrap() = (d, t);
};
let mut emit = |f: Found| emit_buf.push(f);
let mut ctx = Ctx {
src,
next_id: &next_id,
cancelled,
progress: &progress,
emit: &mut emit,
};
let r = match p.fs {
FsKind::Ntfs => Some(fs::ntfs::scan(&mut ctx, p.start)),
FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 => {
Some(fs::fat::scan(&mut ctx, p.start, p.fs))
}
FsKind::Exfat => Some(fs::exfat::scan(&mut ctx, p.start)),
FsKind::Ext => Some(fs::ext::scan(&mut ctx, p.start, p.len)),
_ => None,
};
let _ = progress_phase;
r
};
for f in emit_buf {
index_found(&f, &mut starts, &mut fs_extents);
sink.found(f);
}
if let Some(r) = res {
for w in r.warnings {
sink.warning(w);
}
live_all.extend(r.live);
if let Some(fr) = r.free {
free_all.extend(fr);
have_free_for.push((p.start, p.start + p.len));
}
}
if cancelled() {
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
return Outcome::Interrupted(cp);
}
}
cp.fs_done = true;
cp.ranges = if opts.mode == ScanMode::Deep {
build_ranges(
&layout,
&live_all,
&free_all,
&have_free_for,
opts,
src.size(),
)
} else {
vec![]
};
}
fs_extents = merge_extents(fs_extents);
let total: u64 = cp.ranges.iter().map(|r| r.end - r.start).sum();
let done: u64 = cp
.ranges
.iter()
.map(|r| r.next.saturating_sub(r.start).min(r.end - r.start))
.sum();
let cfg = CarveCfg {
max_size: opts.max_carve_size,
categories: &opts.categories,
};
let mut handler = Handler {
sink,
starts,
fs_extents,
next_id: &next_id,
done,
total,
};
for ri in 0..cp.ranges.len() {
let (start, end) = (cp.ranges[ri].start, cp.ranges[ri].end);
let mut next = cp.ranges[ri].next;
if next >= end {
continue;
}
let finished = carve_range(src, start, end, &mut next, &cfg, cancelled, &mut handler);
cp.ranges[ri].next = next;
if !finished {
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
return Outcome::Interrupted(cp);
}
}
Outcome::Completed
}
struct Handler<'a> {
sink: &'a mut dyn Sink,
starts: HashMap<u64, (u64, Confidence)>,
fs_extents: Vec<Extent>,
next_id: &'a AtomicU64,
done: u64,
total: u64,
}
impl CarveEvents for Handler<'_> {
fn advance(&mut self, n: u64) {
self.done += n;
self.sink.progress(Progress {
phase: "Varredura profunda por assinatura".into(),
done: self.done.min(self.total),
total: self.total,
});
}
fn hit(&mut self, off: u64, hit: &crate::signatures::Hit) -> HitAction {
if let Some(&(id, conf)) = self.starts.get(&off) {
if hit.confidence > conf {
self.sink.confirm(id, hit.confidence);
self.starts.insert(off, (id, hit.confidence));
}
return HitAction::Skip;
}
if overlaps(&self.fs_extents, off, 1) {
return HitAction::Continue; // dentro de arquivo já listado (ex.: miniatura embutida)
}
let id = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let f = Found {
id,
name: format!("arquivo_{id:06}.{}", hit.ext),
dir: None,
ext: hit.ext.to_string(),
category: category_for_ext(hit.ext),
size: hit.len,
origin: Origin::Carved,
confidence: hit.confidence,
data: DataLoc::Extents {
extents: vec![Extent::new(off, hit.len)],
},
modified: None,
note: if hit.confidence == Confidence::Low {
Some("sem rodapé/estrutura completa: pode estar truncado".into())
} else {
None
},
};
self.starts.insert(off, (id, hit.confidence));
self.sink.found(f);
HitAction::Skip
}
}
fn index_found(f: &Found, starts: &mut HashMap<u64, (u64, Confidence)>, ex: &mut Vec<Extent>) {
if let DataLoc::Extents { extents } = &f.data {
if let Some(o) = f.first_offset() {
starts.insert(o, (f.id, f.confidence));
}
ex.extend(extents.iter().filter(|e| !e.sparse).copied());
}
}
fn build_ranges(
layout: &DiskLayout,
live: &[Extent],
free: &[Extent],
have_free_for: &[(u64, u64)],
opts: &ScanOptions,
disk_size: u64,
) -> Vec<RangeState> {
let live = merge_extents(live.to_vec());
let free = merge_extents(free.to_vec());
let mut out: Vec<Extent> = vec![];
for p in &layout.partitions {
if p.encrypted.is_some() {
continue;
}
let whole = Extent::new(p.start, p.len.min(disk_size.saturating_sub(p.start)));
if opts.include_allocated {
out.push(whole);
} else if have_free_for
.iter()
.any(|&(s, e)| s == p.start && e == p.start + p.len)
{
out.extend(clip(&free, whole));
} else if p.fs.has_metadata_scanner() {
out.extend(subtract(whole, &live));
} else {
out.push(whole);
}
}
for g in &layout.gaps {
out.push(Extent::new(g.start, g.len));
}
if layout.partitions.is_empty() && layout.gaps.is_empty() {
out.push(Extent::new(0, disk_size));
}
merge_extents(out)
.into_iter()
.filter(|e| e.len >= 4096)
.map(|e| RangeState {
start: e.offset,
end: e.end(),
next: e.offset,
})
.collect()
}
fn clip(sorted: &[Extent], window: Extent) -> Vec<Extent> {
sorted
.iter()
.filter_map(|e| {
let s = e.offset.max(window.offset);
let t = e.end().min(window.end());
(t > s).then(|| Extent::new(s, t - s))
})
.collect()
}
fn subtract(window: Extent, sorted_live: &[Extent]) -> Vec<Extent> {
let mut out = vec![];
let mut cur = window.offset;
let i = sorted_live.partition_point(|e| e.end() <= window.offset);
for e in &sorted_live[i..] {
if e.offset >= window.end() {
break;
}
if e.offset > cur {
out.push(Extent::new(cur, e.offset - cur));
}
cur = cur.max(e.end());
}
if cur < window.end() {
out.push(Extent::new(cur, window.end() - cur));
}
out
}
+699
View File
@@ -0,0 +1,699 @@
//! Camada de serviço usada pela interface gráfica (Tauri e modo `guara ui`):
//! jobs em segundo plano (varredura, recuperação, imagem) e despachante RPC.
use crate::device;
use crate::error::{Error, Result};
use crate::image;
use crate::partition;
use crate::recover;
use crate::scan::{self, Checkpoint, Outcome, Progress, Sink};
use crate::source::{open_path, Source};
use crate::types::*;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Running,
Paused,
Done,
Stopped,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum JobKind {
Scan,
Recover,
Image,
}
struct State {
status: Status,
progress: Progress,
found: Vec<Found>,
index: HashMap<u64, usize>,
warnings: Vec<String>,
layout: Option<DiskLayout>,
checkpoint: Option<Checkpoint>,
error: Option<String>,
report: Option<Value>,
started: Instant,
finished_in: Option<f64>,
}
pub struct Job {
id: String,
kind: JobKind,
target: String,
opts: ScanOptions,
cancel: AtomicBool,
stop_requested: AtomicBool,
src: Option<Arc<dyn Source>>,
st: Mutex<State>,
}
impl Job {
fn new(
id: String,
kind: JobKind,
target: String,
opts: ScanOptions,
src: Option<Arc<dyn Source>>,
) -> Arc<Job> {
Arc::new(Job {
id,
kind,
target,
opts,
cancel: AtomicBool::new(false),
stop_requested: AtomicBool::new(false),
src,
st: Mutex::new(State {
status: Status::Running,
progress: Progress::default(),
found: vec![],
index: HashMap::new(),
warnings: vec![],
layout: None,
checkpoint: None,
error: None,
report: None,
started: Instant::now(),
finished_in: None,
}),
})
}
fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
}
struct JobSink(Arc<Job>);
impl Sink for JobSink {
fn found(&mut self, f: Found) {
let mut s = self.0.st.lock().unwrap();
let i = s.found.len();
s.index.insert(f.id, i);
s.found.push(f);
}
fn confirm(&mut self, id: u64, c: Confidence) {
let mut s = self.0.st.lock().unwrap();
if let Some(&i) = s.index.get(&id) {
s.found[i].confidence = s.found[i].confidence.max(c);
}
}
fn progress(&mut self, p: Progress) {
self.0.st.lock().unwrap().progress = p;
}
fn warning(&mut self, w: String) {
let mut s = self.0.st.lock().unwrap();
if !s.warnings.contains(&w) {
s.warnings.push(w);
}
}
fn layout(&mut self, l: &DiskLayout) {
self.0.st.lock().unwrap().layout = Some(l.clone());
}
}
pub struct Engine {
jobs: Mutex<HashMap<String, Arc<Job>>>,
counter: AtomicU64,
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
impl Engine {
pub fn new() -> Self {
Self {
jobs: Mutex::new(HashMap::new()),
counter: AtomicU64::new(1),
}
}
fn job(&self, id: &str) -> Result<Arc<Job>> {
self.jobs
.lock()
.unwrap()
.get(id)
.cloned()
.ok_or_else(|| Error::msg(format!("job {id} não existe")))
}
fn register(
&self,
kind: JobKind,
target: String,
opts: ScanOptions,
src: Option<Arc<dyn Source>>,
) -> Arc<Job> {
let n = self.counter.fetch_add(1, Ordering::Relaxed);
let id = format!("j{n}");
let job = Job::new(id.clone(), kind, target, opts, src);
self.jobs.lock().unwrap().insert(id, job.clone());
job
}
// ---------------------------------------------------------------- varredura
pub fn start_scan(&self, path: &str, opts: ScanOptions) -> Result<String> {
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
let job = self.register(JobKind::Scan, path.to_string(), opts, Some(src));
spawn_scan(job.clone(), None);
Ok(job.id.clone())
}
pub fn pause(&self, id: &str) -> Result<()> {
let j = self.job(id)?;
j.cancel.store(true, Ordering::Relaxed);
Ok(())
}
pub fn stop(&self, id: &str) -> Result<()> {
let j = self.job(id)?;
j.stop_requested.store(true, Ordering::Relaxed);
j.cancel.store(true, Ordering::Relaxed);
Ok(())
}
pub fn resume(&self, id: &str) -> Result<()> {
let j = self.job(id)?;
let cp = {
let mut s = j.st.lock().unwrap();
if s.status != Status::Paused {
return Err(Error::msg("a varredura não está pausada"));
}
s.status = Status::Running;
s.checkpoint.take()
};
j.cancel.store(false, Ordering::Relaxed);
j.stop_requested.store(false, Ordering::Relaxed);
spawn_scan(j, cp);
Ok(())
}
pub fn status(&self, id: &str) -> Result<Value> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
let mut counts: HashMap<&'static str, usize> = HashMap::new();
let mut by_conf = [0usize; 3];
for f in &s.found {
*counts.entry(cat_key(f.category)).or_default() += 1;
by_conf[f.confidence as usize] += 1;
}
let elapsed = s
.finished_in
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
Ok(json!({
"id": j.id,
"kind": j.kind,
"target": j.target,
"status": s.status,
"progress": s.progress,
"found": s.found.len(),
"categories": counts,
"confidence": { "low": by_conf[0], "medium": by_conf[1], "high": by_conf[2] },
"warnings": s.warnings,
"layout": s.layout,
"error": s.error,
"report": s.report,
"elapsed": elapsed,
}))
}
pub fn results(&self, id: &str, q: &Query) -> Result<Value> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
let needle = q
.query
.as_deref()
.map(|x| x.to_lowercase())
.filter(|x| !x.is_empty());
let mut items: Vec<&Found> = s
.found
.iter()
.filter(|f| q.category.map(|c| f.category == c).unwrap_or(true))
.filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true))
.filter(|f| {
needle
.as_ref()
.map(|n| f.full_path().to_lowercase().contains(n.as_str()))
.unwrap_or(true)
})
.collect();
match q.sort.as_deref() {
Some("name") => items.sort_by_key(|f| f.name.to_lowercase()),
Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)),
Some("confidence") => {
items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size)))
}
_ => {}
}
let total = items.len();
let page: Vec<Value> = items
.into_iter()
.skip(q.offset)
.take(q.limit.clamp(1, 2000))
.map(|f| {
json!({
"id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category,
"size": f.size, "origin": f.origin, "confidence": f.confidence,
"modified": f.modified, "note": f.note,
})
})
.collect();
Ok(json!({ "total": total, "items": page }))
}
pub fn preview(&self, id: &str, file: u64) -> Result<Value> {
let j = self.job(id)?;
let (f, src) = {
let s = j.st.lock().unwrap();
let i = *s
.index
.get(&file)
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
(
s.found[i].clone(),
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
)
};
let mime = match f.ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
_ => return Ok(json!({ "mime": null })),
};
if f.size > 8 * 1024 * 1024 {
return Ok(json!({ "mime": null, "reason": "grande demais para pré-visualizar" }));
}
let tmp =
std::env::temp_dir().join(format!("guara-preview-{}-{}", std::process::id(), f.id));
let r = recover::write_file(src.as_ref(), &f, &tmp);
let bytes = std::fs::read(&tmp);
let _ = std::fs::remove_file(&tmp);
r?;
Ok(json!({ "mime": mime, "base64": base64(&bytes?) }))
}
// ---------------------------------------------------------------- recuperação
pub fn start_recover(
&self,
scan_id: &str,
ids: Option<Vec<u64>>,
filter: Option<Query>,
dest: &str,
) -> Result<String> {
let sj = self.job(scan_id)?;
let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?;
let files: Vec<Found> = {
let s = sj.st.lock().unwrap();
match (&ids, &filter) {
(Some(ids), _) => ids
.iter()
.filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone()))
.collect(),
(None, Some(q)) => {
let needle = q
.query
.as_deref()
.map(|x| x.to_lowercase())
.filter(|x| !x.is_empty());
s.found
.iter()
.filter(|f| q.category.map(|c| f.category == c).unwrap_or(true))
.filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true))
.filter(|f| {
needle
.as_ref()
.map(|n| f.full_path().to_lowercase().contains(n.as_str()))
.unwrap_or(true)
})
.cloned()
.collect()
}
(None, None) => s.found.clone(),
}
};
if files.is_empty() {
return Err(Error::msg("nenhum arquivo selecionado"));
}
let dest_path = PathBuf::from(dest);
recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?;
let job = self.register(
JobKind::Recover,
dest.to_string(),
ScanOptions::default(),
None,
);
let j2 = job.clone();
std::thread::spawn(move || {
let cancelled = || j2.cancelled();
let mut prog = |done: usize, total: usize, bytes: u64| {
let mut s = j2.st.lock().unwrap();
s.progress = Progress {
phase: format!("Recuperando ({bytes} bytes)"),
done: done as u64,
total: total as u64,
};
};
let refs: Vec<&Found> = files.iter().collect();
let res = recover::recover(src.as_ref(), &refs, &dest_path, &cancelled, &mut prog);
let mut s = j2.st.lock().unwrap();
match res {
Ok(rep) => {
s.report = serde_json::to_value(&rep).ok();
s.status = if rep.cancelled {
Status::Stopped
} else {
Status::Done
};
}
Err(e) => {
s.error = Some(e.to_string());
s.status = Status::Error;
}
}
s.finished_in = Some(s.started.elapsed().as_secs_f64());
});
Ok(job.id.clone())
}
// ---------------------------------------------------------------- imagem
pub fn start_image(&self, path: &str, dest: &str) -> Result<String> {
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
recover::check_destination(
Some(Path::new(path)),
Path::new(dest).parent().unwrap_or(Path::new(".")),
)?;
let job = self.register(
JobKind::Image,
dest.to_string(),
ScanOptions::default(),
None,
);
let j2 = job.clone();
let dest = PathBuf::from(dest);
std::thread::spawn(move || {
let cancelled = || j2.cancelled();
let mut prog = |d: u64, t: u64| {
j2.st.lock().unwrap().progress = Progress {
phase: "Criando imagem do disco".into(),
done: d,
total: t,
};
};
let res = image::create_image(src.as_ref(), &dest, &cancelled, &mut prog);
let mut s = j2.st.lock().unwrap();
match res {
Ok(rep) => {
s.status = if rep.cancelled {
Status::Stopped
} else {
Status::Done
};
s.report = serde_json::to_value(&rep).ok();
}
Err(e) => {
s.error = Some(e.to_string());
s.status = Status::Error;
}
}
s.finished_in = Some(s.started.elapsed().as_secs_f64());
});
Ok(job.id.clone())
}
// ---------------------------------------------------------------- sessões
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
let data = SessionFile {
version: 1,
target: j.target.clone(),
options: j.opts.clone(),
found: s.found.clone(),
checkpoint: s.checkpoint.clone(),
layout: s.layout.clone(),
warnings: s.warnings.clone(),
done: s.status == Status::Done,
};
std::fs::write(path, serde_json::to_vec(&data)?)?;
Ok(())
}
pub fn load_session(&self, path: &str) -> Result<String> {
let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?;
let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?;
let job = self.register(
JobKind::Scan,
data.target.clone(),
data.options.clone(),
Some(src),
);
{
let mut s = job.st.lock().unwrap();
for (i, f) in data.found.iter().enumerate() {
s.index.insert(f.id, i);
}
s.found = data.found;
s.layout = data.layout;
s.warnings = data.warnings;
s.checkpoint = data.checkpoint;
s.status = if data.done {
Status::Done
} else {
Status::Paused
};
}
Ok(job.id.clone())
}
// ---------------------------------------------------------------- RPC
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
let s = |k: &str| -> Result<String> {
p.get(k)
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| Error::msg(format!("parâmetro '{k}' ausente")))
};
Ok(match method {
"devices" => json!(device::list_devices()),
"inspect" => {
let path = s("path")?;
let src = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?;
json!(partition::analyze(src.as_ref(), false))
}
"scan_start" => {
let opts: ScanOptions = p
.get("options")
.cloned()
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default();
json!({ "job": self.start_scan(&s("path")?, opts)? })
}
"scan_pause" => {
self.pause(&s("job")?)?;
json!({})
}
"scan_resume" => {
self.resume(&s("job")?)?;
json!({})
}
"scan_stop" | "job_stop" => {
self.stop(&s("job")?)?;
json!({})
}
"status" => self.status(&s("job")?)?,
"results" => {
let q: Query = p
.get("query")
.cloned()
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default();
self.results(&s("job")?, &q)?
}
"preview" => self.preview(
&s("job")?,
p.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| Error::msg("id ausente"))?,
)?,
"recover_start" => {
let ids: Option<Vec<u64>> = p
.get("ids")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?;
let filter: Option<Query> = p
.get("filter")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?;
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?)? })
}
"image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }),
"session_save" => {
self.save_session(&s("job")?, &s("path")?)?;
json!({})
}
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
"defaults" => {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() })
}
"version" => {
json!({ "version": env!("CARGO_PKG_VERSION"), "os": std::env::consts::OS })
}
other => return Err(Error::msg(format!("método desconhecido: {other}"))),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Query {
#[serde(default)]
pub category: Option<Category>,
#[serde(default)]
pub min_confidence: Option<Confidence>,
#[serde(default)]
pub query: Option<String>,
#[serde(default)]
pub sort: Option<String>,
#[serde(default)]
pub offset: usize,
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_limit() -> usize {
200
}
impl Default for Query {
fn default() -> Self {
Self {
category: None,
min_confidence: None,
query: None,
sort: None,
offset: 0,
limit: 200,
}
}
}
#[derive(Serialize, Deserialize)]
struct SessionFile {
version: u32,
target: String,
options: ScanOptions,
found: Vec<Found>,
checkpoint: Option<Checkpoint>,
layout: Option<DiskLayout>,
warnings: Vec<String>,
done: bool,
}
fn cat_key(c: Category) -> &'static str {
match c {
Category::Image => "image",
Category::Document => "document",
Category::Video => "video",
Category::Audio => "audio",
Category::Archive => "archive",
Category::Database => "database",
Category::Other => "other",
}
}
fn open_error(path: &str, e: std::io::Error) -> Error {
if e.kind() == std::io::ErrorKind::PermissionDenied {
Error::msg(format!(
"Sem permissão para ler {path}. Discos exigem privilégios de administrador: no Linux/macOS rode com sudo; no Windows, execute como administrador."
))
} else {
Error::msg(format!("Não foi possível abrir {path}: {e}"))
}
}
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
std::thread::spawn(move || {
let src = job.src.clone().expect("origem");
let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
let cancelled = || job.cancelled();
let mut sink = JobSink(job.clone());
let outcome = scan::run(
src.as_ref(),
&job.opts,
cp,
&existing,
&cancelled,
&mut sink,
);
let mut s = job.st.lock().unwrap();
match outcome {
Outcome::Completed => {
s.status = Status::Done;
s.checkpoint = None;
}
Outcome::Interrupted(cp) => {
if job.stop_requested.load(Ordering::Relaxed) {
s.status = Status::Stopped;
} else {
s.status = Status::Paused;
s.checkpoint = Some(cp);
}
}
}
s.finished_in = Some(s.started.elapsed().as_secs_f64());
});
}
pub fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (c[0] as u32) << 16
| (*c.get(1).unwrap_or(&0) as u32) << 8
| *c.get(2).unwrap_or(&0) as u32;
out.push(T[(n >> 18) as usize & 63] as char);
out.push(T[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 {
T[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if c.len() > 2 {
T[n as usize & 63] as char
} else {
'='
});
}
out
}
+833
View File
@@ -0,0 +1,833 @@
//! Assinaturas de formatos e validação estrutural para o carving.
//!
//! Cada detector lê a estrutura real do arquivo (cabeçalhos, blocos, rodapés)
//! para determinar o tamanho exato e o nível de confiança, em vez de apenas
//! procurar bytes mágicos.
use crate::source::{read_vec, Source};
use crate::types::{Category, Confidence};
/// Resultado de um detector: arquivo reconhecido a partir de `offset`.
#[derive(Debug, Clone)]
pub struct Hit {
pub len: u64,
pub ext: &'static str,
pub confidence: Confidence,
}
pub fn category_for_ext(ext: &str) -> Category {
match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "heic" | "avif" | "tif" | "tiff"
| "psd" | "svg" | "ico" | "cr2" | "nef" | "dng" => Category::Image,
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "odt" | "ods" | "odp"
| "txt" | "rtf" | "epub" | "md" | "csv" | "html" | "xml" | "json" => Category::Document,
"mp4" | "mov" | "m4v" | "avi" | "mkv" | "webm" | "wmv" | "flv" | "mpg" | "mpeg" | "3gp" => {
Category::Video
}
"mp3" | "wav" | "flac" | "ogg" | "m4a" | "aac" | "wma" | "opus" => Category::Audio,
"zip" | "rar" | "7z" | "gz" | "tar" | "bz2" | "xz" | "jar" | "apk" => Category::Archive,
"sqlite" | "db" | "sqlite3" | "mdb" | "accdb" => Category::Database,
_ => Category::Other,
}
}
/// Verifica se o início do conteúdo é coerente com a extensão.
/// `None` = extensão sem assinatura conhecida (não dá para julgar).
pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
let ext = ext.to_ascii_lowercase();
let starts = |m: &[u8]| head.starts_with(m);
let r = match ext.as_str() {
"jpg" | "jpeg" => starts(&[0xFF, 0xD8, 0xFF]),
"png" => starts(&[0x89, b'P', b'N', b'G']),
"gif" => starts(b"GIF8"),
"bmp" => starts(b"BM"),
"pdf" => starts(b"%PDF"),
"zip" | "docx" | "xlsx" | "pptx" | "odt" | "ods" | "odp" | "jar" | "epub" | "apk" => {
starts(b"PK\x03\x04") || starts(b"PK\x05\x06")
}
"doc" | "xls" | "ppt" => starts(&[0xD0, 0xCF, 0x11, 0xE0]),
"7z" => starts(&[b'7', b'z', 0xBC, 0xAF, 0x27, 0x1C]),
"rar" => starts(b"Rar!"),
"gz" => starts(&[0x1F, 0x8B]),
"mp4" | "mov" | "m4v" | "m4a" | "3gp" | "heic" | "avif" => {
head.len() >= 8 && &head[4..8] == b"ftyp"
|| (head.len() >= 8 && matches!(&head[4..8], b"moov" | b"mdat" | b"free" | b"wide"))
}
"avi" | "wav" | "webp" => starts(b"RIFF"),
"mp3" => starts(b"ID3") || (head.len() >= 2 && head[0] == 0xFF && head[1] & 0xE0 == 0xE0),
"flac" => starts(b"fLaC"),
"ogg" | "opus" => starts(b"OggS"),
"mkv" | "webm" => starts(&[0x1A, 0x45, 0xDF, 0xA3]),
"sqlite" | "sqlite3" | "db" => starts(b"SQLite format 3\0"),
"exe" | "dll" => starts(b"MZ"),
_ => return None,
};
Some(r)
}
/// Tabela de primeiros bytes -> detectores candidatos.
/// O carving consulta `candidates(first_byte)` e chama `detect`.
pub fn might_start(b0: u8) -> bool {
matches!(
b0,
0xFF | 0x89
| b'G'
| b'B'
| b'%'
| b'P'
| 0xD0
| b'7'
| b'S'
| b'R'
| b'I'
| b'M'
| 0x00
| b'f'
| 0x1F
)
}
const MAX_HEADER: usize = 64 * 1024;
/// Tenta reconhecer um arquivo que começa em `off`.
/// `head` já contém os primeiros bytes lidos em `off` (pelo menos 16, se existirem).
pub fn detect(src: &dyn Source, off: u64, head: &[u8], max_size: u64) -> Option<Hit> {
if head.len() < 12 {
return None;
}
let h = head;
if h.starts_with(&[0xFF, 0xD8, 0xFF]) {
return jpeg(src, off, max_size);
}
if h.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return png(src, off, max_size);
}
if h.starts_with(b"GIF87a") || h.starts_with(b"GIF89a") {
return gif(src, off, max_size);
}
if h.starts_with(b"%PDF-") {
return pdf(src, off, max_size);
}
if h.starts_with(b"PK\x03\x04") {
return zip(src, off, max_size);
}
if &h[4..8] == b"ftyp" {
return isobmff(src, off, max_size);
}
if h.starts_with(b"RIFF") {
return riff(h, max_size);
}
if h.starts_with(b"BM") {
return bmp(h, max_size);
}
if h.starts_with(b"SQLite format 3\0") {
return sqlite(h, max_size);
}
if h.starts_with(&[b'7', b'z', 0xBC, 0xAF, 0x27, 0x1C]) {
return sevenz(h, max_size);
}
if h.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) {
return ole(src, off, max_size);
}
if h.starts_with(b"ID3") {
return mp3(src, off, max_size);
}
if h.starts_with(b"MZ") {
return pe(src, off, max_size);
}
None
}
// ---------------------------------------------------------------- JPEG
fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let head = read_vec(src, off, MAX_HEADER).ok()?;
let mut p = 2usize;
let mut sos_end = None;
let mut saw_sof = false;
while p + 4 <= head.len() {
if head[p] != 0xFF {
return None;
}
let m = head[p + 1];
if m == 0xFF {
p += 1;
continue;
}
if m == 0xD9 {
return Some(Hit {
len: p as u64 + 2,
ext: "jpg",
confidence: Confidence::Medium,
});
}
if (0xD0..=0xD8).contains(&m) || m == 0x01 {
p += 2;
continue;
}
let seg = u16::from_be_bytes([head[p + 2], head[p + 3]]) as usize;
if seg < 2 {
return None;
}
if matches!(m, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
saw_sof = true;
}
p += 2 + seg;
if m == 0xDA {
sos_end = Some(p);
break;
}
}
let start = sos_end?;
if !saw_sof {
return None;
}
// Dados entropy-coded: procurar EOI (FF D9) ignorando FF00 e RSTn.
let mut pos = off + start as u64;
let limit = off + max.min(512 * 1024 * 1024);
let mut buf = vec![0u8; 1 << 20];
let mut prev_ff = false;
while pos < limit {
let n = src.read_at(pos, &mut buf).ok()?;
if n == 0 {
break;
}
for (i, &b) in buf[..n].iter().enumerate() {
if prev_ff {
if b == 0xD9 {
return Some(Hit {
len: pos + i as u64 + 1 - off,
ext: "jpg",
confidence: Confidence::High,
});
}
prev_ff = b == 0xFF;
} else {
prev_ff = b == 0xFF;
}
}
pos += n as u64;
}
// Sem EOI: imagem truncada/sobrescrita; ainda útil, mas incerta.
Some(Hit {
len: (pos - off).min(4 * 1024 * 1024),
ext: "jpg",
confidence: Confidence::Low,
})
}
// ---------------------------------------------------------------- PNG
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let mut p = 8u64;
let mut first = true;
for _ in 0..200_000 {
let c = read_vec(src, off + p, 8).ok()?;
if c.len() < 8 {
break;
}
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
let ty = &c[4..8];
if !ty.iter().all(|b| b.is_ascii_alphabetic()) || len > 0x7FFF_FFFF {
break;
}
if first && ty != b"IHDR" {
return None;
}
first = false;
p += 12 + len;
if p > max {
break;
}
if ty == b"IEND" {
return Some(Hit {
len: p,
ext: "png",
confidence: Confidence::High,
});
}
}
if first {
return None;
}
Some(Hit {
len: p.min(max).min(src.size().saturating_sub(off)),
ext: "png",
confidence: Confidence::Low,
})
}
// ---------------------------------------------------------------- GIF
fn gif(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let d = read_vec(src, off, (16usize * 1024 * 1024).min(max as usize)).ok()?;
if d.len() < 13 {
return None;
}
let flags = d[10];
let mut p = 13usize;
if flags & 0x80 != 0 {
p += 3 * (1usize << ((flags & 7) + 1));
}
while p < d.len() {
match d[p] {
0x3B => {
return Some(Hit {
len: p as u64 + 1,
ext: "gif",
confidence: Confidence::High,
})
}
0x21 => {
p += 2;
p = skip_subblocks(&d, p)?;
}
0x2C => {
if p + 10 > d.len() {
break;
}
let f = d[p + 9];
p += 10;
if f & 0x80 != 0 {
p += 3 * (1usize << ((f & 7) + 1));
}
p += 1;
p = skip_subblocks(&d, p)?;
}
_ => return None,
}
}
Some(Hit {
len: d.len() as u64,
ext: "gif",
confidence: Confidence::Low,
})
}
fn skip_subblocks(d: &[u8], mut p: usize) -> Option<usize> {
loop {
let n = *d.get(p)? as usize;
p += 1 + n;
if n == 0 {
return Some(p);
}
}
}
// ---------------------------------------------------------------- BMP
fn bmp(h: &[u8], max: u64) -> Option<Hit> {
if h.len() < 18 {
return None;
}
let size = u32::from_le_bytes([h[2], h[3], h[4], h[5]]) as u64;
let reserved = u32::from_le_bytes([h[6], h[7], h[8], h[9]]);
let dib = u32::from_le_bytes([h[14], h[15], h[16], h[17]]);
if reserved != 0
|| !matches!(dib, 12 | 40 | 52 | 56 | 64 | 108 | 124)
|| size < 26
|| size > max
{
return None;
}
Some(Hit {
len: size,
ext: "bmp",
confidence: Confidence::High,
})
}
// ---------------------------------------------------------------- PDF
fn pdf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let limit = max.min(1 << 30);
let mut pos = off;
let mut buf = vec![0u8; 1 << 20 | 16];
let mut last_eof: Option<u64> = None;
let mut carry: Vec<u8> = Vec::new();
while pos - off < limit {
let n = src.read_at(pos, &mut buf[..(1 << 20)]).ok()?;
if n == 0 {
break;
}
let mut hay = std::mem::take(&mut carry);
let base = pos - hay.len() as u64;
hay.extend_from_slice(&buf[..n]);
let mut i = 0;
while let Some(f) = find(&hay[i..], b"%%EOF") {
let end = base + (i + f) as u64 + 5;
// Continua se logo após o %%EOF vier uma atualização incremental.
let tail = read_vec(src, end, 32).ok()?;
let rest: Vec<u8> = tail
.iter()
.copied()
.skip_while(|b| b.is_ascii_whitespace())
.collect();
let skipped = tail.len() - rest.len();
last_eof = Some(end + skipped.min(2) as u64 - off);
let more = rest.first().map(|b| b.is_ascii_digit()).unwrap_or(false)
|| rest.starts_with(b"%PDF");
if !more {
let conf = if last_eof.is_some() {
Confidence::High
} else {
Confidence::Low
};
return Some(Hit {
len: last_eof.unwrap(),
ext: "pdf",
confidence: conf,
});
}
i += f + 5;
}
let keep = hay.len().min(8);
carry = hay[hay.len() - keep..].to_vec();
pos += n as u64;
}
last_eof.map(|l| Hit {
len: l,
ext: "pdf",
confidence: Confidence::Medium,
})
}
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
// ---------------------------------------------------------------- ZIP & derivados
fn zip(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let limit = max.min(4 << 30);
let mut pos = off;
let mut buf = vec![0u8; 1 << 20];
let mut carry: Vec<u8> = Vec::new();
while pos - off < limit {
let n = src.read_at(pos, &mut buf).ok()?;
if n == 0 {
break;
}
let mut hay = std::mem::take(&mut carry);
let base = pos - hay.len() as u64;
hay.extend_from_slice(&buf[..n]);
let mut i = 0;
while let Some(f) = find(&hay[i..], b"PK\x05\x06") {
let at = base + (i + f) as u64;
if let Some(hit) = check_eocd(src, off, at) {
return Some(hit);
}
i += f + 4;
}
let keep = hay.len().min(3);
carry = hay[hay.len() - keep..].to_vec();
pos += n as u64;
}
None
}
fn check_eocd(src: &dyn Source, start: u64, at: u64) -> Option<Hit> {
let e = read_vec(src, at, 22).ok()?;
if e.len() < 22 {
return None;
}
let entries = u16::from_le_bytes([e[10], e[11]]) as u64;
let cd_size = u32::from_le_bytes([e[12], e[13], e[14], e[15]]) as u64;
let cd_off = u32::from_le_bytes([e[16], e[17], e[18], e[19]]) as u64;
let comment = u16::from_le_bytes([e[20], e[21]]) as u64;
if entries == 0 || cd_off + cd_size != at - start {
return None;
}
let cd = read_vec(src, start + cd_off, cd_size.min(4 << 20) as usize).ok()?;
let names = zip_names(&cd);
let ext = classify_zip(src, start, &names);
Some(Hit {
len: at - start + 22 + comment,
ext,
confidence: Confidence::High,
})
}
fn zip_names(cd: &[u8]) -> Vec<String> {
let mut out = vec![];
let mut p = 0;
while p + 46 <= cd.len() && &cd[p..p + 4] == b"PK\x01\x02" {
let n = u16::from_le_bytes([cd[p + 28], cd[p + 29]]) as usize;
let m = u16::from_le_bytes([cd[p + 30], cd[p + 31]]) as usize;
let k = u16::from_le_bytes([cd[p + 32], cd[p + 33]]) as usize;
if p + 46 + n > cd.len() {
break;
}
out.push(String::from_utf8_lossy(&cd[p + 46..p + 46 + n]).into_owned());
p += 46 + n + m + k;
if out.len() > 20_000 {
break;
}
}
out
}
fn classify_zip(src: &dyn Source, start: u64, names: &[String]) -> &'static str {
let has = |pre: &str| names.iter().any(|n| n.starts_with(pre));
if has("word/") {
"docx"
} else if has("xl/") {
"xlsx"
} else if has("ppt/") {
"pptx"
} else if names.first().map(|n| n == "mimetype").unwrap_or(false) {
// O primeiro item (sem compressão) traz o tipo MIME logo após o cabeçalho.
let head = read_vec(src, start, 256).unwrap_or_default();
let s = String::from_utf8_lossy(&head);
if s.contains("opendocument.text") {
"odt"
} else if s.contains("opendocument.spreadsheet") {
"ods"
} else if s.contains("opendocument.presentation") {
"odp"
} else if s.contains("epub+zip") {
"epub"
} else {
"zip"
}
} else if names.iter().any(|n| n == "AndroidManifest.xml") {
"apk"
} else if has("META-INF/") {
"jar"
} else {
"zip"
}
}
// ---------------------------------------------------------------- MP4 / MOV / HEIC (ISO BMFF)
fn isobmff(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let head = read_vec(src, off, 32).ok()?;
let brand = head.get(8..12)?.to_vec();
let mut p = 0u64;
let mut saw_moov = false;
let mut saw_mdat = false;
let total = src.size().saturating_sub(off);
for _ in 0..100_000 {
let b = read_vec(src, off + p, 16).ok()?;
if b.len() < 8 {
break;
}
let mut size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as u64;
let ty = [b[4], b[5], b[6], b[7]];
let known = matches!(
&ty,
b"ftyp"
| b"moov"
| b"mdat"
| b"free"
| b"skip"
| b"wide"
| b"uuid"
| b"meta"
| b"moof"
| b"mfra"
| b"sidx"
| b"pnot"
| b"styp"
| b"pdin"
| b"junk"
);
if !known {
break;
}
if size == 1 {
if b.len() < 16 {
break;
}
size = u64::from_be_bytes(b[8..16].try_into().unwrap());
} else if size == 0 {
// Até o fim do arquivo: tamanho desconhecido.
size = total - p;
}
if size < 8 || p + size > max {
break;
}
match &ty {
b"moov" | b"moof" => saw_moov = true,
b"mdat" => saw_mdat = true,
_ => {}
}
p += size;
if p >= total {
break;
}
}
if p < 16 || !(saw_moov || saw_mdat) {
return None;
}
let ext = match &brand[..] {
b"qt " => "mov",
b"heic" | b"heix" | b"mif1" | b"msf1" | b"heim" | b"hevc" => "heic",
b"avif" | b"avis" => "avif",
b"M4A " | b"M4B " => "m4a",
b"M4V " => "m4v",
b"3gp4" | b"3gp5" | b"3gp6" | b"3g2a" => "3gp",
_ => "mp4",
};
let conf = if saw_moov && p <= total {
Confidence::High
} else {
Confidence::Low
};
Some(Hit {
len: p.min(total),
ext,
confidence: conf,
})
}
// ---------------------------------------------------------------- RIFF (AVI / WAV / WEBP)
fn riff(h: &[u8], max: u64) -> Option<Hit> {
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as u64 + 8;
let ext = match &h[8..12] {
b"AVI " => "avi",
b"WAVE" => "wav",
b"WEBP" => "webp",
_ => return None,
};
if size < 16 || size > max {
return None;
}
Some(Hit {
len: size + (size & 1),
ext,
confidence: Confidence::High,
})
}
// ---------------------------------------------------------------- SQLite
fn sqlite(h: &[u8], max: u64) -> Option<Hit> {
if h.len() < 100 {
return None;
}
let ps = u16::from_be_bytes([h[16], h[17]]) as u64;
let ps = if ps == 1 { 65536 } else { ps };
if !(512..=65536).contains(&ps) || !ps.is_power_of_two() {
return None;
}
let pages = u32::from_be_bytes([h[28], h[29], h[30], h[31]]) as u64;
let change = &h[24..28];
let valid_for = &h[92..96];
if pages == 0 {
return None;
}
let len = pages * ps;
if len > max {
return None;
}
let conf = if change == valid_for {
Confidence::High
} else {
Confidence::Medium
};
Some(Hit {
len,
ext: "sqlite",
confidence: conf,
})
}
// ---------------------------------------------------------------- 7z
fn sevenz(h: &[u8], max: u64) -> Option<Hit> {
if h.len() < 32 {
return None;
}
let crc = u32::from_le_bytes([h[8], h[9], h[10], h[11]]);
if crc32(&h[12..32]) != crc {
return None;
}
let next_off = u64::from_le_bytes(h[12..20].try_into().unwrap());
let next_len = u64::from_le_bytes(h[20..28].try_into().unwrap());
let len = 32u64.checked_add(next_off)?.checked_add(next_len)?;
if len > max {
return None;
}
Some(Hit {
len,
ext: "7z",
confidence: Confidence::High,
})
}
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xEDB8_8320
} else {
crc >> 1
};
}
}
!crc
}
// ---------------------------------------------------------------- OLE2 (doc/xls/ppt legado)
fn ole(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let hdr = read_vec(src, off, 512).ok()?;
if hdr.len() < 512 {
return None;
}
let shift = u16::from_le_bytes([hdr[30], hdr[31]]) as u32;
if !(shift == 9 || shift == 12) {
return None;
}
let ssz = 1u64 << shift;
let n_fat = u32::from_le_bytes(hdr[44..48].try_into().unwrap()) as usize;
if n_fat == 0 || n_fat > 109 {
return None;
}
let mut last_used = 0u64;
for i in 0..n_fat {
let sec = u32::from_le_bytes(hdr[76 + i * 4..80 + i * 4].try_into().unwrap()) as u64;
let fat = read_vec(src, off + (sec + 1) * ssz, ssz as usize).ok()?;
for (j, e) in fat.chunks_exact(4).enumerate() {
let v = u32::from_le_bytes(e.try_into().unwrap());
if v != 0xFFFF_FFFF {
last_used = last_used.max(i as u64 * (ssz / 4) + j as u64);
}
}
}
let len = (last_used + 2) * ssz;
if len > max {
return None;
}
let data = read_vec(src, off, len.min(8 << 20) as usize).ok()?;
let u16s = |s: &str| -> Vec<u8> { s.encode_utf16().flat_map(|c| c.to_le_bytes()).collect() };
let ext = if find(&data, &u16s("WordDocument")).is_some() {
"doc"
} else if find(&data, &u16s("Workbook")).is_some() || find(&data, &u16s("Book")).is_some() {
"xls"
} else if find(&data, &u16s("PowerPoint Document")).is_some() {
"ppt"
} else {
"ole"
};
Some(Hit {
len,
ext,
confidence: Confidence::High,
})
}
// ---------------------------------------------------------------- MP3
fn mp3(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let h = read_vec(src, off, 10).ok()?;
if h.len() < 10 || h[3] == 0xFF || h[4] == 0xFF {
return None;
}
let tag = ((h[6] as u64 & 0x7F) << 21)
| ((h[7] as u64 & 0x7F) << 14)
| ((h[8] as u64 & 0x7F) << 7)
| (h[9] as u64 & 0x7F);
let mut p = 10 + tag;
let mut frames = 0u32;
loop {
let f = read_vec(src, off + p, 4).ok()?;
if f.len() < 4 {
break;
}
let Some(len) = mpeg_frame_len(&f) else { break };
p += len;
frames += 1;
if p > max {
break;
}
}
if frames < 20 {
return None;
}
// Possível tag ID3v1 de 128 bytes ao final.
if let Ok(t) = read_vec(src, off + p, 3) {
if t == b"TAG" {
p += 128;
}
}
Some(Hit {
len: p,
ext: "mp3",
confidence: Confidence::Medium,
})
}
fn mpeg_frame_len(h: &[u8]) -> Option<u64> {
if h[0] != 0xFF || h[1] & 0xE0 != 0xE0 {
return None;
}
let ver = (h[1] >> 3) & 3; // 3 = MPEG1, 2 = MPEG2, 0 = 2.5
let layer = (h[1] >> 1) & 3; // 1 = layer III
if ver == 1 || layer != 1 {
return None;
}
let br_idx = (h[2] >> 4) as usize;
let sr_idx = ((h[2] >> 2) & 3) as usize;
if br_idx == 0 || br_idx == 15 || sr_idx == 3 {
return None;
}
const BR1: [u32; 16] = [
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
];
const BR2: [u32; 16] = [
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0,
];
const SR1: [u32; 3] = [44100, 48000, 32000];
let (br, sr, coef) = if ver == 3 {
(BR1[br_idx], SR1[sr_idx], 144)
} else {
(BR2[br_idx], SR1[sr_idx] / if ver == 2 { 2 } else { 4 }, 72)
};
let pad = ((h[2] >> 1) & 1) as u32;
Some((coef * br * 1000 / sr + pad) as u64)
}
// ---------------------------------------------------------------- PE (exe/dll)
fn pe(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let h = read_vec(src, off, 4096).ok()?;
if h.len() < 0x40 {
return None;
}
let lfanew = u32::from_le_bytes(h[0x3C..0x40].try_into().unwrap()) as usize;
if lfanew + 24 > h.len() || &h[lfanew..lfanew + 4] != b"PE\0\0" {
return None;
}
let nsec = u16::from_le_bytes([h[lfanew + 6], h[lfanew + 7]]) as usize;
let opt = u16::from_le_bytes([h[lfanew + 20], h[lfanew + 21]]) as usize;
let mut s = lfanew + 24 + opt;
let mut end = 0u64;
for _ in 0..nsec.min(96) {
if s + 40 > h.len() {
return None;
}
let raw_size = u32::from_le_bytes(h[s + 16..s + 20].try_into().unwrap()) as u64;
let raw_ptr = u32::from_le_bytes(h[s + 20..s + 24].try_into().unwrap()) as u64;
end = end.max(raw_ptr + raw_size);
s += 40;
}
if end < 512 || end > max {
return None;
}
let ext = if u16::from_le_bytes([h[lfanew + 22], h[lfanew + 23]]) & 0x2000 != 0 {
"dll"
} else {
"exe"
};
Some(Hit {
len: end,
ext,
confidence: Confidence::Medium,
})
}
+276
View File
@@ -0,0 +1,276 @@
//! Acesso somente leitura a discos, partições e imagens.
//!
//! Nenhum tipo deste módulo expõe escrita: a origem nunca é modificada.
use std::fs::File;
use std::io;
use std::path::Path;
use std::sync::Arc;
/// Origem de bytes legível por posição absoluta.
pub trait Source: Send + Sync {
fn size(&self) -> u64;
/// Lê até `buf.len()` bytes a partir de `offset`. Retorna 0 no fim.
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize>;
}
impl<T: Source + ?Sized> Source for Arc<T> {
fn size(&self) -> u64 {
(**self).size()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
(**self).read_at(offset, buf)
}
}
/// Lê exatamente `buf.len()` bytes, ou falha com `UnexpectedEof`.
pub fn read_exact_at(src: &dyn Source, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
while !buf.is_empty() {
let n = src.read_at(offset, buf)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"fim da origem",
));
}
offset += n as u64;
buf = &mut buf[n..];
}
Ok(())
}
/// Lê o máximo possível (pode devolver menos que `len` no fim da origem).
pub fn read_vec(src: &dyn Source, offset: u64, len: usize) -> io::Result<Vec<u8>> {
let mut v = vec![0u8; len];
let mut got = 0;
while got < len {
let n = src.read_at(offset + got as u64, &mut v[got..])?;
if n == 0 {
break;
}
got += n;
}
v.truncate(got);
Ok(v)
}
/// Arquivo de imagem ou dispositivo de blocos aberto somente para leitura.
pub struct FileSource {
file: File,
size: u64,
/// Dispositivos brutos (Windows/macOS) exigem leituras alinhadas ao setor.
align: u64,
}
impl FileSource {
pub fn open(path: &Path) -> io::Result<Self> {
let meta = std::fs::metadata(path)?;
let is_device = is_device(&meta);
let file = open_readonly(path, is_device)?;
let size = if is_device {
device_size(&file, path)?
} else {
meta.len()
};
let align = if is_device && cfg!(any(windows, target_os = "macos")) {
4096
} else {
1
};
Ok(Self { file, size, align })
}
fn raw_read(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
#[cfg(unix)]
{
std::os::unix::fs::FileExt::read_at(&self.file, buf, offset)
}
#[cfg(windows)]
{
std::os::windows::fs::FileExt::seek_read(&self.file, buf, offset)
}
}
}
impl Source for FileSource {
fn size(&self) -> u64 {
self.size
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.size || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((self.size - offset) as usize);
if self.align <= 1 {
return self.raw_read(offset, &mut buf[..want]);
}
// Leitura alinhada para dispositivos brutos.
let a = self.align;
let start = offset / a * a;
let end = ((offset + want as u64).div_ceil(a) * a).min(self.size.div_ceil(a) * a);
let mut tmp = vec![0u8; (end - start) as usize];
let mut got = 0;
while got < tmp.len() {
let n = self.raw_read(start + got as u64, &mut tmp[got..])?;
if n == 0 {
break;
}
got += n;
}
let skip = (offset - start) as usize;
if got <= skip {
return Ok(0);
}
let n = (got - skip).min(want);
buf[..n].copy_from_slice(&tmp[skip..skip + n]);
Ok(n)
}
}
/// Janela [start, start+len) de outra origem (uma partição, por exemplo).
pub struct SubSource {
inner: Arc<dyn Source>,
start: u64,
len: u64,
}
impl SubSource {
pub fn new(inner: Arc<dyn Source>, start: u64, len: u64) -> Self {
let len = len.min(inner.size().saturating_sub(start));
Self { inner, start, len }
}
}
impl Source for SubSource {
fn size(&self) -> u64 {
self.len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.len {
return Ok(0);
}
let want = buf.len().min((self.len - offset) as usize);
self.inner.read_at(self.start + offset, &mut buf[..want])
}
}
/// Origem em memória (testes e dados pequenos).
pub struct MemSource(pub Vec<u8>);
impl Source for MemSource {
fn size(&self) -> u64 {
self.0.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
let off = offset as usize;
if off >= self.0.len() {
return Ok(0);
}
let n = buf.len().min(self.0.len() - off);
buf[..n].copy_from_slice(&self.0[off..off + n]);
Ok(n)
}
}
fn is_device(meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
let ft = meta.file_type();
ft.is_block_device() || ft.is_char_device()
}
#[cfg(windows)]
{
let _ = meta;
false
}
}
#[cfg(unix)]
fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
std::fs::OpenOptions::new().read(true).open(path)
}
#[cfg(windows)]
fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
// FILE_SHARE_READ | FILE_SHARE_WRITE: não bloqueia outros processos.
std::fs::OpenOptions::new()
.read(true)
.share_mode(0x1 | 0x2)
.open(path)
}
#[cfg(unix)]
fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
#[cfg(target_os = "macos")]
{
use std::os::unix::io::AsRawFd;
const DKIOCGETBLOCKSIZE: libc::c_ulong = 0x4004_6418;
const DKIOCGETBLOCKCOUNT: libc::c_ulong = 0x4008_6419;
let fd = file.as_raw_fd();
let (mut bs, mut bc) = (0u32, 0u64);
// SAFETY: ioctls de leitura com ponteiros para variáveis locais válidas.
let ok = unsafe {
libc::ioctl(fd, DKIOCGETBLOCKSIZE, &mut bs) == 0
&& libc::ioctl(fd, DKIOCGETBLOCKCOUNT, &mut bc) == 0
};
if ok {
return Ok(bs as u64 * bc);
}
}
use std::io::{Seek, SeekFrom};
let mut f = file.try_clone()?;
f.seek(SeekFrom::End(0))
}
/// Abre um dispositivo Windows (`\\.\PhysicalDriveN`) e obtém o tamanho.
#[cfg(windows)]
pub fn open_windows_device(path: &Path) -> io::Result<FileSource> {
let file = open_readonly(path, true)?;
let size = device_size(&file, path)?;
Ok(FileSource {
file,
size,
align: 4096,
})
}
#[cfg(windows)]
fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Ioctl::{GET_LENGTH_INFORMATION, IOCTL_DISK_GET_LENGTH_INFO};
use windows_sys::Win32::System::IO::DeviceIoControl;
let mut info = GET_LENGTH_INFORMATION { Length: 0 };
let mut ret = 0u32;
// SAFETY: buffer de saída válido com o tamanho exato da estrutura.
let ok = unsafe {
DeviceIoControl(
file.as_raw_handle() as _,
IOCTL_DISK_GET_LENGTH_INFO,
std::ptr::null(),
0,
&mut info as *mut _ as *mut _,
std::mem::size_of::<GET_LENGTH_INFORMATION>() as u32,
&mut ret,
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(info.Length as u64)
}
/// Abre um caminho (imagem ou dispositivo) como origem somente leitura.
pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
#[cfg(windows)]
{
let s = path.to_string_lossy();
if s.starts_with(r"\\.\") {
return Ok(Arc::new(open_windows_device(path)?));
}
}
Ok(Arc::new(FileSource::open(path)?))
}
+272
View File
@@ -0,0 +1,272 @@
//! Tipos compartilhados entre núcleo, CLI e interface gráfica.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
Low,
Medium,
High,
}
impl Confidence {
pub fn label_pt(self) -> &'static str {
match self {
Confidence::High => "alta",
Confidence::Medium => "média",
Confidence::Low => "baixa",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Category {
Image,
Document,
Video,
Audio,
Archive,
Database,
Other,
}
impl Category {
pub const ALL: [Category; 7] = [
Category::Image,
Category::Document,
Category::Video,
Category::Audio,
Category::Archive,
Category::Database,
Category::Other,
];
pub fn label_pt(self) -> &'static str {
match self {
Category::Image => "Fotos",
Category::Document => "Documentos",
Category::Video => "Vídeos",
Category::Audio => "Áudio",
Category::Archive => "Compactados",
Category::Database => "Bancos de dados",
Category::Other => "Outros",
}
}
pub fn parse(s: &str) -> Option<Category> {
Some(match s.to_ascii_lowercase().as_str() {
"image" | "imagem" | "fotos" => Category::Image,
"document" | "documento" | "documentos" => Category::Document,
"video" | "vídeo" | "videos" | "vídeos" => Category::Video,
"audio" | "áudio" => Category::Audio,
"archive" | "compactados" => Category::Archive,
"database" | "banco" => Category::Database,
"other" | "outros" => Category::Other,
_ => return None,
})
}
}
/// Trecho contíguo de bytes na origem (offset absoluto).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Extent {
pub offset: u64,
pub len: u64,
/// Trecho esparso: lido como zeros.
#[serde(default, skip_serializing_if = "is_false")]
pub sparse: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl Extent {
pub fn new(offset: u64, len: u64) -> Self {
Self {
offset,
len,
sparse: false,
}
}
pub fn end(&self) -> u64 {
self.offset + self.len
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum DataLoc {
/// Dados dentro do próprio registro (NTFS residente).
Resident {
bytes: Vec<u8>,
},
Extents {
extents: Vec<Extent>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Origin {
Ntfs,
Fat,
Exfat,
Carved,
}
/// Um arquivo encontrado (apagado, perdido ou reconstruído por assinatura).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Found {
pub id: u64,
pub name: String,
/// Pasta de origem, quando o sistema de arquivos a preserva.
pub dir: Option<String>,
pub ext: String,
pub category: Category,
pub size: u64,
pub origin: Origin,
pub confidence: Confidence,
pub data: DataLoc,
/// Segundos desde 1970-01-01 UTC.
pub modified: Option<i64>,
pub note: Option<String>,
}
impl Found {
pub fn first_offset(&self) -> Option<u64> {
match &self.data {
DataLoc::Extents { extents } => extents.iter().find(|e| !e.sparse).map(|e| e.offset),
DataLoc::Resident { .. } => None,
}
}
pub fn full_path(&self) -> String {
match &self.dir {
Some(d) if !d.is_empty() => format!("{}/{}", d.trim_end_matches('/'), self.name),
_ => self.name.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FsKind {
Ntfs,
Fat12,
Fat16,
Fat32,
Exfat,
Ext,
Apfs,
HfsPlus,
Bitlocker,
Luks,
Unknown,
}
impl FsKind {
pub fn name(self) -> &'static str {
match self {
FsKind::Ntfs => "NTFS",
FsKind::Fat12 => "FAT12",
FsKind::Fat16 => "FAT16",
FsKind::Fat32 => "FAT32",
FsKind::Exfat => "exFAT",
FsKind::Ext => "ext2/3/4",
FsKind::Apfs => "APFS",
FsKind::HfsPlus => "HFS+",
FsKind::Bitlocker => "BitLocker",
FsKind::Luks => "LUKS",
FsKind::Unknown => "desconhecido",
}
}
/// Volume cifrado: só detectamos (v1), sem desbloquear.
pub fn encrypted(self) -> Option<&'static str> {
match self {
FsKind::Bitlocker => Some("BitLocker"),
FsKind::Luks => Some("LUKS"),
_ => None,
}
}
/// Há leitor de metadados (arquivos apagados com nome) para este sistema.
pub fn has_metadata_scanner(self) -> bool {
matches!(
self,
FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartInfo {
pub index: usize,
pub start: u64,
pub len: u64,
pub fs: FsKind,
pub label: Option<String>,
/// Encontrada por varredura, não consta da tabela de partições.
pub lost: bool,
pub encrypted: Option<String>,
/// Descrição do tipo na tabela (ex.: "Microsoft basic data").
pub table_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Gap {
pub start: u64,
pub len: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiskLayout {
pub size: u64,
/// "gpt", "mbr" ou "none".
pub table: String,
pub partitions: Vec<PartInfo>,
/// Espaço não particionado relevante (> 1 MiB).
pub gaps: Vec<Gap>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScanMode {
/// Só metadados dos sistemas de arquivos (segundos).
Quick,
/// Metadados + carving por assinatura em todo espaço livre/desconhecido.
Deep,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions {
pub mode: ScanMode,
/// Inclui áreas ainda alocadas no carving (mais lento, acha cópias antigas).
#[serde(default)]
pub include_allocated: bool,
/// Limita o carving a estas categorias (vazio = todas).
#[serde(default)]
pub categories: Vec<Category>,
/// Tamanho máximo aceito para arquivos carved (bytes).
#[serde(default = "default_max_carve")]
pub max_carve_size: u64,
}
fn default_max_carve() -> u64 {
8 * 1024 * 1024 * 1024
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
mode: ScanMode::Deep,
include_allocated: false,
categories: vec![],
max_carve_size: default_max_carve(),
}
}
}