feat: explorador de arquivos, miniaturas, metadados e recursos periciais (0.3.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s

- Explorador com grade/lista virtualizada, navegação por tipo, data EXIF, pasta e
  câmera, seleção estilo gerenciador de arquivos e visualização rápida.
- Miniaturas no núcleo, metadados (EXIF, PDF, Office, MP4), datas MACB do NTFS/FAT/exFAT.
- Hashes MD5/SHA-1/SHA-256, busca de texto no disco, exportação CSV, relatório HTML,
  detecção de duplicadas, tema, correções de interface e textos sem travessões.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 16:16:20 -03:00
1 parent 1457caa453
commit 45d535bdb7
34 files changed
+4433 -365

No files matched your search

+6
View File
@@ -11,6 +11,12 @@ rust-version.workspace = true
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sha1 = "0.10"
md-5 = "0.10"
jpeg-decoder = { version = "0.3", default-features = false }
jpeg-encoder = "0.6"
image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
thiserror = "2"
[target.'cfg(unix)'.dependencies]
+368
View File
@@ -0,0 +1,368 @@
//! Navegação estilo gerenciador de arquivos: filtros, ordenação, facetas
//! (tipos, datas, pastas, câmeras) e cache de miniaturas.
use crate::types::*;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::{Arc, Condvar, Mutex};
#[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,
/// Pasta (prefixo), "@carved" (achados por assinatura) ou "@raiz".
#[serde(default)]
pub folder: Option<String>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub month: Option<u32>,
/// Só arquivos sem data conhecida.
#[serde(default)]
pub undated: Option<bool>,
#[serde(default)]
pub camera: Option<String>,
#[serde(default)]
pub origin: Option<Origin>,
#[serde(default)]
pub min_size: Option<u64>,
#[serde(default)]
pub max_size: Option<u64>,
/// Megapixels mínimos.
#[serde(default)]
pub min_mp: Option<f64>,
#[serde(default)]
pub has_gps: Option<bool>,
/// Mostra só a primeira ocorrência de cada conteúdo repetido.
#[serde(default)]
pub dedupe: Option<bool>,
}
fn default_limit() -> usize {
200
}
impl Default for Query {
fn default() -> Self {
serde_json::from_str("{}").unwrap()
}
}
impl Query {
pub fn needle(&self) -> Option<String> {
self.query
.as_deref()
.map(|x| x.to_lowercase())
.filter(|x| !x.is_empty())
}
/// Itens que atendem ao filtro (na ordem de achado), já sem duplicadas se pedido.
pub fn select<'a>(&self, found: &'a [Found]) -> Vec<&'a Found> {
let needle = self.needle();
let mut v: Vec<&Found> = found.iter().filter(|f| self.matches(f, &needle)).collect();
if self.dedupe == Some(true) {
let mut seen = std::collections::HashSet::new();
v.retain(|f| f.sig.map(|g| seen.insert((g, f.size))).unwrap_or(true));
}
v
}
pub fn matches(&self, f: &Found, needle: &Option<String>) -> bool {
if self.category.is_some_and(|c| f.category != c) {
return false;
}
if self.min_confidence.is_some_and(|c| f.confidence < c) {
return false;
}
if self.origin.is_some_and(|o| f.origin != o) {
return false;
}
if self.min_size.is_some_and(|m| f.size < m) || self.max_size.is_some_and(|m| f.size > m) {
return false;
}
if let Some(n) = needle {
let hay =
format!("{} {}", f.full_path(), f.camera.as_deref().unwrap_or("")).to_lowercase();
if !hay.contains(n.as_str()) {
return false;
}
}
if let Some(folder) = &self.folder {
let ok = match folder.as_str() {
"@carved" => f.origin == Origin::Carved,
"@raiz" => {
f.origin != Origin::Carved
&& f.dir.as_deref().map(str::is_empty).unwrap_or(true)
}
p => f
.dir
.as_deref()
.is_some_and(|d| d == p || d.starts_with(&format!("{p}/"))),
};
if !ok {
return false;
}
}
if self.year.is_some() || self.month.is_some() {
let Some(t) = f.when() else { return false };
let (y, m, _) = crate::meta::ymd(t);
if self.year.is_some_and(|yy| yy != y) || self.month.is_some_and(|mm| mm != m) {
return false;
}
}
if self.undated == Some(true) && f.when().is_some() {
return false;
}
if let Some(c) = &self.camera {
if f.camera.as_deref() != Some(c.as_str()) {
return false;
}
}
if let Some(mp) = self.min_mp {
match f.px {
Some((w, h)) if (w as f64 * h as f64) / 1e6 >= mp => {}
_ => return false,
}
}
if self.has_gps == Some(true) && !f.gps {
return false;
}
true
}
}
pub fn sort_items(items: &mut [&Found], sort: Option<&str>) {
match sort {
Some("name") => items.sort_by_key(|f| f.name.to_lowercase()),
Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)),
Some("date") => items.sort_by(|a, b| b.when().cmp(&a.when()).then(a.id.cmp(&b.id))),
Some("date_asc") => items.sort_by(|a, b| match (a.when(), b.when()) {
(Some(x), Some(y)) => x.cmp(&y).then(a.id.cmp(&b.id)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
_ => a.id.cmp(&b.id),
}),
Some("confidence") => {
items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size)))
}
_ => {}
}
}
pub fn item_json(f: &Found) -> Value {
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, "created": f.created, "accessed": f.accessed,
"taken": f.taken, "when": f.when(), "camera": f.camera, "px": f.px, "gps": f.gps,
"note": f.note,
})
}
/// Contagens para a navegação lateral.
pub fn facets(found: &[Found]) -> Value {
let mut cats: HashMap<&'static str, usize> = HashMap::new();
let mut years: BTreeMap<i64, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
let mut cams: BTreeMap<String, usize> = BTreeMap::new();
let mut folders: BTreeMap<String, usize> = BTreeMap::new();
let (mut undated, mut carved, mut root, mut gps) = (0usize, 0usize, 0usize, 0usize);
let mut conf = [0usize; 3];
let mut seen_sig = std::collections::HashSet::new();
let mut dups = 0usize;
for f in found {
if let Some(g) = f.sig {
if !seen_sig.insert((g, f.size)) {
dups += 1;
}
}
*cats.entry(cat_key(f.category)).or_default() += 1;
conf[f.confidence as usize] += 1;
match f.when() {
Some(t) => {
let (y, m, _) = crate::meta::ymd(t);
let e = years.entry(y).or_default();
e.0 += 1;
*e.1.entry(m).or_default() += 1;
}
None => undated += 1,
}
if let Some(c) = &f.camera {
*cams.entry(c.clone()).or_default() += 1;
}
if f.gps {
gps += 1;
}
if f.origin == Origin::Carved {
carved += 1;
} else if let Some(d) = f.dir.as_deref().filter(|d| !d.is_empty()) {
let mut acc = String::new();
for part in d.split('/') {
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(part);
*folders.entry(acc.clone()).or_default() += 1;
}
} else {
root += 1;
}
}
let cams_v: Vec<Value> = {
let mut v: Vec<_> = cams.into_iter().collect();
v.sort_by_key(|x| std::cmp::Reverse(x.1));
v.into_iter()
.map(|(n, c)| json!({ "name": n, "count": c }))
.collect()
};
json!({
"total": found.len(),
"categories": cats,
"confidence": { "low": conf[0], "medium": conf[1], "high": conf[2] },
"years": years.iter().rev().map(|(y, (n, ms))| json!({
"year": y, "count": n,
"months": ms.iter().rev().map(|(m, c)| json!({"month": m, "count": c})).collect::<Vec<_>>()
})).collect::<Vec<_>>(),
"undated": undated,
"cameras": cams_v,
"folders": folders.iter().map(|(p, c)| json!({
"path": p, "name": p.rsplit('/').next().unwrap_or(p), "depth": p.matches('/').count(), "count": c
})).collect::<Vec<_>>(),
"carved": carved,
"root": root,
"gps": gps,
"duplicates": dups,
})
}
pub 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",
}
}
// ------------------------------------------------------------------ cache de miniaturas
type Key = (String, u64, u32);
pub struct ThumbCache {
map: HashMap<Key, Arc<Vec<u8>>>,
order: VecDeque<Key>,
bytes: usize,
}
impl ThumbCache {
const CAP: usize = 128 * 1024 * 1024;
pub fn new() -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
bytes: 0,
}
}
pub fn get(&self, k: &Key) -> Option<Arc<Vec<u8>>> {
self.map.get(k).cloned()
}
pub fn put(&mut self, k: Key, v: Arc<Vec<u8>>) {
self.bytes += v.len();
if self.map.insert(k.clone(), v).is_none() {
self.order.push_back(k);
}
while self.bytes > Self::CAP {
let Some(old) = self.order.pop_front() else {
break;
};
if let Some(v) = self.map.remove(&old) {
self.bytes -= v.len();
}
}
}
}
impl Default for ThumbCache {
fn default() -> Self {
Self::new()
}
}
/// Limita quantas miniaturas são decodificadas ao mesmo tempo (memória/CPU).
pub struct Gate {
n: Mutex<usize>,
cv: Condvar,
max: usize,
}
pub struct Permit<'a>(&'a Gate);
impl Gate {
pub fn new(max: usize) -> Self {
Self {
n: Mutex::new(0),
cv: Condvar::new(),
max: max.max(1),
}
}
pub fn acquire(&self) -> Permit<'_> {
let mut n = self.n.lock().unwrap();
while *n >= self.max {
n = self.cv.wait(n).unwrap();
}
*n += 1;
Permit(self)
}
}
impl Drop for Permit<'_> {
fn drop(&mut self) {
*self.0.n.lock().unwrap() -= 1;
self.0.cv.notify_one();
}
}
/// Assinatura rápida: tamanho + FNV-1a do início (32 KiB) e do fim (32 KiB).
pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
use crate::recover::read_found_at;
if f.size == 0 {
return None;
}
let mut h: u64 = 0xcbf29ce484222325 ^ f.size;
let mut feed = |off: u64| {
let mut buf = vec![0u8; 32 * 1024];
let mut got = 0;
while got < buf.len() {
match read_found_at(src, f, off + got as u64, &mut buf[got..]) {
Ok(0) | Err(_) => break,
Ok(n) => got += n,
}
}
for &b in &buf[..got] {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
got
};
let a = feed(0);
if a == 0 {
return None;
}
if f.size > 64 * 1024 {
feed(f.size - 32 * 1024);
}
Some(h)
}
+10
View File
@@ -174,6 +174,14 @@ fn walk(
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 cre_unix = super::dos_to_unix(
u16::from_le_bytes([e[10], e[11]]),
u16::from_le_bytes([e[8], e[9]]),
);
let acc_unix = super::dos_to_unix(
u16::from_le_bytes([e[18], e[19]]),
u16::from_le_bytes([e[16], e[17]]),
);
let mut stream: Option<&[u8]> = None;
let mut name_u16: Vec<u8> = vec![];
for s in 1..=secondary.min(19) {
@@ -259,6 +267,8 @@ fn walk(
origin: Origin::Exfat,
data: DataLoc::Extents { extents: ex },
modified: mod_unix,
created: cre_unix,
accessed: acc_unix,
base: conf,
note,
},
+17 -3
View File
@@ -237,6 +237,11 @@ impl Walk<'_, '_, '_, '_> {
u16::from_le_bytes([e[24], e[25]]),
u16::from_le_bytes([e[22], e[23]]),
);
let ctime = super::dos_to_unix(
u16::from_le_bytes([e[16], e[17]]),
u16::from_le_bytes([e[14], e[15]]),
);
let atime = super::dos_to_unix(u16::from_le_bytes([e[18], e[19]]), 0);
self.count += 1;
if self.count % 512 == 0 {
(self.ctx.progress)(self.count, 0);
@@ -277,7 +282,7 @@ impl Walk<'_, '_, '_, '_> {
if size == 0 || !self.fat.valid(start) {
continue;
}
self.deleted_file(&name, path, start, size, mtime);
self.deleted_file(&name, path, start, size, (ctime, mtime, atime));
}
}
@@ -291,7 +296,14 @@ impl Walk<'_, '_, '_, '_> {
v
}
fn deleted_file(&mut self, name: &str, dir: &str, start: u32, size: u64, mtime: Option<i64>) {
fn deleted_file(
&mut self,
name: &str,
dir: &str,
start: u32,
size: u64,
times: (Option<i64>, Option<i64>, Option<i64>),
) {
let fat = self.fat;
let cb = fat.cluster_bytes();
let need = size.div_ceil(cb);
@@ -336,7 +348,9 @@ impl Walk<'_, '_, '_, '_> {
data: DataLoc::Extents {
extents: trim_extents(extents, size),
},
modified: mtime,
modified: times.1,
created: times.0,
accessed: times.2,
base: conf,
note,
};
+9
View File
@@ -47,6 +47,8 @@ pub struct NewFound<'a> {
pub origin: Origin,
pub data: DataLoc,
pub modified: Option<i64>,
pub created: Option<i64>,
pub accessed: Option<i64>,
pub base: Confidence,
pub note: Option<&'a str>,
}
@@ -98,6 +100,13 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
confidence: conf,
data: n.data,
modified: n.modified,
created: n.created,
accessed: n.accessed,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note,
}
}
+10
View File
@@ -22,6 +22,8 @@ struct Rec {
parent: u64,
name: Option<(String, u8)>,
mtime: Option<i64>,
ctime: Option<i64>,
atime: Option<i64>,
data: Option<Data>,
has_attr_list: bool,
}
@@ -199,6 +201,8 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
origin: Origin::Ntfs,
data,
modified: rec.mtime,
created: rec.ctime,
accessed: rec.atime,
base: conf,
note: if rec.has_attr_list {
Some("registro com lista de atributos: pode estar incompleto")
@@ -320,6 +324,8 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
parent: 0,
name: None,
mtime: None,
ctime: None,
atime: None,
data: None,
has_attr_list: false,
};
@@ -341,7 +347,11 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
0x10 if !nonres => {
let vo = le16(r, p + 20) as usize;
if p + vo + 16 <= r.len() {
rec.ctime = filetime_to_unix(le64(r, p + vo));
rec.mtime = filetime_to_unix(le64(r, p + vo + 8));
if p + vo + 32 <= r.len() {
rec.atime = filetime_to_unix(le64(r, p + vo + 24));
}
}
}
0x20 => rec.has_attr_list = true,
+45
View File
@@ -0,0 +1,45 @@
//! Hashes de integridade usados em perícia: MD5, SHA-1 e SHA-256 numa única passada.
use md5::Md5;
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hashes {
pub md5: String,
pub sha1: String,
pub sha256: String,
}
#[derive(Default)]
pub struct MultiHasher {
md5: Md5,
sha1: Sha1,
sha256: Sha256,
}
impl MultiHasher {
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, data: &[u8]) {
self.md5.update(data);
self.sha1.update(data);
self.sha256.update(data);
}
pub fn finalize(self) -> Hashes {
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
Hashes {
md5: hex(&self.md5.finalize()),
sha1: hex(&self.sha1.finalize()),
sha256: hex(&self.sha256.finalize()),
}
}
}
pub fn hash_bytes(data: &[u8]) -> Hashes {
let mut h = MultiHasher::new();
h.update(data);
h.finalize()
}
+10 -7
View File
@@ -2,9 +2,9 @@
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
use crate::error::{Error, Result};
use crate::hashing::MultiHasher;
use crate::source::Source;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::Path;
@@ -12,6 +12,10 @@ use std::path::Path;
pub struct ImageReport {
pub bytes: u64,
pub sha256: String,
#[serde(default)]
pub md5: String,
#[serde(default)]
pub sha1: String,
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
pub bad_ranges: Vec<(u64, u64)>,
pub cancelled: bool,
@@ -38,7 +42,7 @@ pub fn create_image(
})?;
let size = src.size();
let mut rep = ImageReport::default();
let mut hasher = Sha256::new();
let mut hasher = MultiHasher::new();
let mut buf = vec![0u8; BLOCK];
let mut pos = 0u64;
while pos < size {
@@ -72,11 +76,10 @@ pub fn create_image(
}
out.flush()?;
rep.bytes = pos;
rep.sha256 = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let h = hasher.finalize();
rep.sha256 = h.sha256;
rep.md5 = h.md5;
rep.sha1 = h.sha1;
let map = dest.with_extension("map.json");
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
Ok(rep)
+7 -1
View File
@@ -1,4 +1,4 @@
//! Guará — núcleo de recuperação de arquivos.
//! 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.
@@ -6,15 +6,21 @@
pub mod carve;
pub mod device;
pub mod error;
pub mod explorer;
pub mod fs;
pub mod hashing;
pub mod image;
pub mod meta;
pub mod partition;
pub mod recover;
pub mod report;
pub mod scan;
pub mod search;
pub mod service;
pub mod settings;
pub mod signatures;
pub mod source;
pub mod thumb;
pub mod types;
pub use error::{Error, Result};
+952
View File
@@ -0,0 +1,952 @@
//! Extração de metadados de arquivos recuperados: EXIF (câmera, datas, GPS),
//! informações de PDF, propriedades de documentos Office/OpenDocument e
//! dados de vídeo MP4/MOV. Tudo é leitura posicional, sem gravar em disco.
use crate::recover::read_found_at;
use crate::source::Source;
use crate::types::Found;
use serde::{Deserialize, Serialize};
use std::io::Read;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Meta {
pub kind: String,
pub fields: Vec<(String, String)>,
/// (latitude, longitude) em graus decimais.
pub gps: Option<(f64, f64)>,
/// Data em que a foto foi tirada (EXIF), s desde 1970 (hora da câmera lida como UTC).
#[serde(default)]
pub taken: Option<i64>,
#[serde(default)]
pub camera: Option<String>,
#[serde(default)]
pub px: Option<(u32, u32)>,
/// Orientação EXIF (1..8).
#[serde(default)]
pub orientation: u16,
}
impl Meta {
fn add(&mut self, k: &str, v: impl Into<String>) {
let v = v.into();
let v = v.trim().to_string();
if !v.is_empty() {
self.fields.push((k.to_string(), v));
}
}
}
struct Fr<'a> {
src: &'a dyn Source,
f: &'a Found,
}
impl Fr<'_> {
fn read(&self, off: u64, len: usize) -> Vec<u8> {
let mut out = vec![0u8; len.min(self.f.size.saturating_sub(off) as usize)];
let mut got = 0;
while got < out.len() {
match read_found_at(self.src, self.f, off + got as u64, &mut out[got..]) {
Ok(0) | Err(_) => break,
Ok(n) => got += n,
}
}
out.truncate(got);
out
}
}
pub fn extract(src: &dyn Source, f: &Found) -> Meta {
let fr = Fr { src, f };
let mut m = Meta::default();
match f.ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => {
m.kind = "Imagem JPEG".into();
jpeg(&fr, &mut m);
}
"png" => {
m.kind = "Imagem PNG".into();
let h = fr.read(0, 32);
if h.len() >= 24 && &h[12..16] == b"IHDR" {
m.px = Some((be32(&h[16..]), be32(&h[20..])));
m.add(
"Dimensões",
format!("{} × {} px", be32(&h[16..]), be32(&h[20..])),
);
}
}
"gif" => {
m.kind = "Imagem GIF".into();
let h = fr.read(0, 16);
if h.len() >= 10 {
m.px = Some((le16(&h[6..]) as u32, le16(&h[8..]) as u32));
m.add(
"Dimensões",
format!("{} × {} px", le16(&h[6..]), le16(&h[8..])),
);
}
}
"bmp" => {
m.kind = "Imagem BMP".into();
let h = fr.read(0, 32);
if h.len() >= 26 {
m.px = Some((le32(&h[18..]), (le32(&h[22..]) as i32).unsigned_abs()));
m.add(
"Dimensões",
format!(
"{} × {} px",
le32(&h[18..]),
(le32(&h[22..]) as i32).unsigned_abs()
),
);
}
}
"pdf" => {
m.kind = "Documento PDF".into();
pdf(&fr, &mut m);
}
"docx" | "xlsx" | "pptx" => {
m.kind = "Documento Office".into();
ooxml(&fr, &mut m);
}
"odt" | "ods" | "odp" => {
m.kind = "Documento OpenDocument".into();
odf(&fr, &mut m);
}
"mp4" | "mov" | "m4v" | "m4a" | "3gp" => {
m.kind = "Mídia MP4/MOV".into();
mp4(&fr, &mut m);
}
_ => {}
}
m
}
fn be16(b: &[u8]) -> u16 {
u16::from_be_bytes([b[0], b[1]])
}
fn be32(b: &[u8]) -> u32 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
}
fn le16(b: &[u8]) -> u16 {
u16::from_le_bytes([b[0], b[1]])
}
fn le32(b: &[u8]) -> u32 {
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
}
// ------------------------------------------------------------------ EXIF
fn jpeg(fr: &Fr, m: &mut Meta) {
let d = fr.read(0, 256 * 1024);
jpeg_bytes(&d, m);
}
/// Lê os segmentos de cabeçalho de um JPEG (já em memória): dimensões e EXIF.
pub fn jpeg_bytes(d: &[u8], m: &mut Meta) {
let mut p = 2usize;
while p + 4 <= d.len() && d[p] == 0xFF {
let mk = d[p + 1];
if mk == 0xDA || mk == 0xD9 {
break;
}
let len = be16(&d[p + 2..]) as usize;
if len < 2 || p + 2 + len > d.len() {
break;
}
if mk == 0xE1 && d[p + 4..].starts_with(b"Exif\0\0") {
exif(&d[p + 10..p + 2 + len], m);
}
if (mk == 0xC0 || mk == 0xC2) && p + 9 <= d.len() {
m.px = Some((be16(&d[p + 7..]) as u32, be16(&d[p + 5..]) as u32));
m.add(
"Dimensões",
format!("{} × {} px", be16(&d[p + 7..]), be16(&d[p + 5..])),
);
}
p += 2 + len;
}
}
/// Miniatura JPEG embutida no EXIF (IFD1), útil quando a imagem principal está danificada.
pub fn exif_thumbnail(d: &[u8]) -> Option<Vec<u8>> {
let mut p = 2usize;
while p + 4 <= d.len() && d[p] == 0xFF {
let mk = d[p + 1];
if mk == 0xDA || mk == 0xD9 {
return None;
}
let len = be16(&d[p + 2..]) as usize;
if len < 2 || p + 2 + len > d.len() {
return None;
}
if mk == 0xE1 && d[p + 4..].starts_with(b"Exif\0\0") {
let tiff = &d[p + 10..p + 2 + len];
if tiff.len() < 8 {
return None;
}
let le = match &tiff[0..2] {
b"II" => true,
b"MM" => false,
_ => return None,
};
let t = Tiff { d: tiff, le };
let ifd0 = t.u32(4)? as usize;
let n = t.u16(ifd0)? as usize;
let ifd1 = t.u32(ifd0 + 2 + n * 12)? as usize;
if ifd1 == 0 {
return None;
}
let (mut off, mut length) = (None, None);
for e in t.ifd(ifd1) {
match e.tag {
0x0201 => off = t.u32(e.pos),
0x0202 => length = t.u32(e.pos),
_ => {}
}
}
let (o, l) = (off? as usize, length? as usize);
return tiff.get(o..o + l).map(|b| b.to_vec());
}
p += 2 + len;
}
None
}
struct Tiff<'a> {
d: &'a [u8],
le: bool,
}
struct Entry {
tag: u16,
typ: u16,
count: u32,
pos: usize, // onde estão os dados do valor dentro do TIFF
}
impl Tiff<'_> {
fn u16(&self, o: usize) -> Option<u16> {
let b = self.d.get(o..o + 2)?;
Some(if self.le {
u16::from_le_bytes([b[0], b[1]])
} else {
u16::from_be_bytes([b[0], b[1]])
})
}
fn u32(&self, o: usize) -> Option<u32> {
let b = self.d.get(o..o + 4)?;
Some(if self.le {
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
} else {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
})
}
fn ifd(&self, off: usize) -> Vec<Entry> {
let mut v = vec![];
let Some(n) = self.u16(off) else { return v };
for i in 0..(n as usize).min(200) {
let e = off + 2 + i * 12;
let (Some(tag), Some(typ), Some(count)) =
(self.u16(e), self.u16(e + 2), self.u32(e + 4))
else {
break;
};
let size = match typ {
1 | 2 | 6 | 7 => 1,
3 | 8 => 2,
4 | 9 | 11 => 4,
5 | 10 | 12 => 8,
_ => 1,
} * count as usize;
let pos = if size <= 4 {
e + 8
} else {
self.u32(e + 8).unwrap_or(0) as usize
};
v.push(Entry {
tag,
typ,
count,
pos,
});
}
v
}
fn ascii(&self, e: &Entry) -> String {
let end = (e.pos + e.count as usize).min(self.d.len());
let b = self.d.get(e.pos..end).unwrap_or(&[]);
String::from_utf8_lossy(b)
.trim_end_matches('\0')
.trim()
.to_string()
}
fn rational(&self, e: &Entry, i: usize) -> Option<f64> {
let o = e.pos + i * 8;
let (n, d) = (self.u32(o)?, self.u32(o + 4)?);
(d != 0).then(|| n as f64 / d as f64)
}
fn num(&self, e: &Entry) -> Option<u32> {
match e.typ {
3 => self.u16(e.pos).map(u32::from),
4 => self.u32(e.pos),
_ => None,
}
}
}
fn exif(d: &[u8], m: &mut Meta) {
if d.len() < 8 {
return;
}
let le = match &d[0..2] {
b"II" => true,
b"MM" => false,
_ => return,
};
let t = Tiff { d, le };
let Some(ifd0) = t.u32(4) else { return };
let mut exif_ptr = None;
let mut gps_ptr = None;
let (mut make, mut model) = (String::new(), String::new());
for e in t.ifd(ifd0 as usize) {
match e.tag {
0x010F => {
make = t.ascii(&e);
m.add("Fabricante da câmera", make.clone());
}
0x0110 => {
model = t.ascii(&e);
m.add("Modelo da câmera", model.clone());
}
0x0112 => m.orientation = t.num(&e).unwrap_or(1) as u16,
0x0131 => m.add("Software", t.ascii(&e)),
0x0132 => m.add("Data (EXIF)", exif_date(&t.ascii(&e))),
0x013B => m.add("Autor", t.ascii(&e)),
0x8298 => m.add("Copyright", t.ascii(&e)),
0x8769 => exif_ptr = t.u32(e.pos),
0x8825 => gps_ptr = t.u32(e.pos),
_ => {}
}
}
let first = make.split_whitespace().next().unwrap_or("").to_lowercase();
let cam = if model.is_empty() {
make.clone()
} else if make.is_empty() || (!first.is_empty() && model.to_lowercase().starts_with(&first)) {
model.clone()
} else {
format!("{make} {model}")
};
if !cam.trim().is_empty() {
m.camera = Some(cam.trim().to_string());
}
if let Some(o) = exif_ptr {
let (mut w, mut h) = (None, None);
for e in t.ifd(o as usize) {
match e.tag {
0x9003 => {
m.taken = exif_epoch(&t.ascii(&e));
m.add("Foto tirada em", exif_date(&t.ascii(&e)));
}
0x9004 => m.add("Digitalizada em", exif_date(&t.ascii(&e))),
0xA434 => m.add("Lente", t.ascii(&e)),
0xA431 => m.add("Nº de série do corpo", t.ascii(&e)),
0xA430 => m.add("Proprietário da câmera", t.ascii(&e)),
0xA002 => w = t.num(&e),
0xA003 => h = t.num(&e),
0x829A => {
if let Some(v) = t.rational(&e, 0) {
m.add(
"Exposição",
if v > 0.0 && v < 1.0 {
format!("1/{:.0} s", 1.0 / v)
} else {
format!("{v:.1} s")
},
);
}
}
0x829D => {
if let Some(v) = t.rational(&e, 0) {
m.add("Abertura", format!("f/{v:.1}"));
}
}
0x8827 => {
if let Some(v) = t.num(&e) {
m.add("ISO", v.to_string());
}
}
0x920A => {
if let Some(v) = t.rational(&e, 0) {
m.add("Distância focal", format!("{v:.0} mm"));
}
}
_ => {}
}
}
if let (Some(w), Some(h)) = (w, h) {
if m.px.is_none() {
m.px = Some((w, h));
}
if !m.fields.iter().any(|(k, _)| k == "Dimensões") {
m.add("Dimensões", format!("{w} × {h} px"));
}
}
}
if let Some(o) = gps_ptr {
let (mut lat, mut lon, mut lat_ref, mut lon_ref, mut alt) = (None, None, 'N', 'E', None);
for e in t.ifd(o as usize) {
let dms = |e: &Entry| -> Option<f64> {
Some(t.rational(e, 0)? + t.rational(e, 1)? / 60.0 + t.rational(e, 2)? / 3600.0)
};
match e.tag {
0x0001 => lat_ref = t.ascii(&e).chars().next().unwrap_or('N'),
0x0002 => lat = dms(&e),
0x0003 => lon_ref = t.ascii(&e).chars().next().unwrap_or('E'),
0x0004 => lon = dms(&e),
0x0006 => alt = t.rational(&e, 0),
0x001D => m.add("Data do GPS", t.ascii(&e).replace(':', "-")),
_ => {}
}
}
if let (Some(a), Some(b)) = (lat, lon) {
let a = if lat_ref == 'S' { -a } else { a };
let b = if lon_ref == 'W' { -b } else { b };
if a.abs() <= 90.0 && b.abs() <= 180.0 && !(a == 0.0 && b == 0.0) {
m.gps = Some((a, b));
m.add("Localização (GPS)", format!("{a:.6}, {b:.6}"));
if let Some(h) = alt {
m.add("Altitude", format!("{h:.0} m"));
}
}
}
}
}
/// "2023:07:14 18:22:05" -> segundos desde 1970 (hora da câmera lida como UTC)
fn exif_epoch(s: &str) -> Option<i64> {
let b = s.as_bytes();
if b.len() < 19 || b[4] != b':' || b[7] != b':' {
return None;
}
let n = |a: usize, c: usize| s.get(a..c)?.parse::<i64>().ok();
let (y, mo, d, h, mi, se) = (
n(0, 4)?,
n(5, 7)?,
n(8, 10)?,
n(11, 13)?,
n(14, 16)?,
n(17, 19)?,
);
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || y < 1970 {
return None;
}
Some(crate::fs::days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se)
}
/// "2023:07:14 18:22:05" -> "14/07/2023 18:22:05"
fn exif_date(s: &str) -> String {
let b = s.as_bytes();
if b.len() >= 19 && b[4] == b':' && b[7] == b':' {
format!("{}/{}/{} {}", &s[8..10], &s[5..7], &s[0..4], &s[11..19])
} else {
s.to_string()
}
}
// ------------------------------------------------------------------ PDF
fn pdf(fr: &Fr, m: &mut Meta) {
let size = fr.f.size;
let head = fr.read(0, 96 * 1024);
let tail = if size > 192 * 1024 {
fr.read(size - 96 * 1024, 96 * 1024)
} else {
Vec::new()
};
let mut all = head.clone();
all.extend_from_slice(&tail);
if head.starts_with(b"%PDF-") {
m.add(
"Versão do PDF",
String::from_utf8_lossy(&head[5..8.min(head.len())]).to_string(),
);
}
for (key, label) in [
("/Title", "Título"),
("/Author", "Autor"),
("/Subject", "Assunto"),
("/Creator", "Criado com"),
("/Producer", "Gerado por"),
] {
if let Some(v) = pdf_string(&all, key) {
m.add(label, v);
}
}
for (key, label) in [
("/CreationDate", "Criado em"),
("/ModDate", "Modificado em"),
] {
if let Some(v) = pdf_string(&all, key) {
m.add(label, pdf_date(&v));
}
}
let pages = find_all(&all, b"/Count ").into_iter().filter_map(|i| {
let s: String = all[i + 7..]
.iter()
.take(8)
.map(|&c| c as char)
.take_while(|c| c.is_ascii_digit())
.collect();
s.parse::<u32>().ok()
});
if let Some(n) = pages.max() {
m.add("Páginas (aprox.)", n.to_string());
}
if !find_all(&all, b"/Encrypt").is_empty() {
m.add("Criptografado", "sim");
}
}
fn find_all(h: &[u8], n: &[u8]) -> Vec<usize> {
let mut v = vec![];
let mut i = 0;
while i + n.len() <= h.len() {
if &h[i..i + n.len()] == n {
v.push(i);
i += n.len();
} else {
i += 1;
}
}
v
}
fn pdf_string(d: &[u8], key: &str) -> Option<String> {
let k = key.as_bytes();
for at in find_all(d, k) {
let mut p = at + k.len();
// evita /Creator casar com /CreatorXyz
if d.get(p).map(|c| c.is_ascii_alphanumeric()).unwrap_or(false) {
continue;
}
while d.get(p).map(|c| c.is_ascii_whitespace()).unwrap_or(false) {
p += 1;
}
match d.get(p)? {
b'(' => {
let mut depth = 0;
let mut out = vec![];
let mut i = p;
while i < d.len() && out.len() < 400 {
let c = d[i];
match c {
b'\\' if i + 1 < d.len() => {
i += 1;
if d[i].is_ascii_digit() && d[i] < b'8' {
// escape octal \ddd (1 a 3 dígitos)
let mut v = 0u32;
let mut n = 0;
while n < 3 && i < d.len() && d[i] >= b'0' && d[i] < b'8' {
v = v * 8 + (d[i] - b'0') as u32;
i += 1;
n += 1;
}
out.push((v & 0xFF) as u8);
continue;
}
out.push(match d[i] {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
o => o,
});
}
b'(' => {
depth += 1;
if depth > 1 {
out.push(c);
}
}
b')' => {
depth -= 1;
if depth == 0 {
break;
}
out.push(c);
}
_ => out.push(c),
}
i += 1;
}
return Some(pdf_text(&out));
}
b'<' if d.get(p + 1) != Some(&b'<') => {
let hex: Vec<u8> = d[p + 1..]
.iter()
.take_while(|&&c| c != b'>')
.copied()
.filter(|c| c.is_ascii_hexdigit())
.collect();
let bytes: Vec<u8> = hex
.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap(), 16).unwrap_or(0))
.collect();
return Some(pdf_text(&bytes));
}
_ => {}
}
}
None
}
fn pdf_text(b: &[u8]) -> String {
if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
let u: Vec<u16> = b[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
return String::from_utf16_lossy(&u);
}
// PDFDocEncoding ~ Latin-1 na prática
b.iter().map(|&c| c as char).collect()
}
/// "D:20230714182205-03'00'" -> "14/07/2023 18:22:05"
fn pdf_date(s: &str) -> String {
let t = s.trim_start_matches("D:");
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.len() >= 8 {
let g = |a: usize, b: usize| digits.get(a..b).unwrap_or("00");
return format!(
"{}/{}/{} {}:{}:{}",
g(6, 8),
g(4, 6),
g(0, 4),
g(8, 10),
g(10, 12),
g(12, 14)
);
}
s.to_string()
}
// ------------------------------------------------------------------ ZIP (Office/ODF)
struct ZipEnt {
name: String,
method: u16,
csize: u64,
usize: u64,
off: u64,
}
fn zip_dir(fr: &Fr) -> Vec<ZipEnt> {
let size = fr.f.size;
let tail_len = size.min(66_000);
let tail = fr.read(size - tail_len, tail_len as usize);
let Some(e) = (0..tail.len().saturating_sub(21))
.rev()
.find(|&i| tail[i..].starts_with(b"PK\x05\x06"))
else {
return vec![];
};
let n = le16(&tail[e + 10..]) as usize;
let cd_size = le32(&tail[e + 12..]) as usize;
let cd_off = le32(&tail[e + 16..]) as u64;
if cd_size > 8 << 20 || cd_off + cd_size as u64 > size {
return vec![];
}
let cd = fr.read(cd_off, cd_size);
let mut out = vec![];
let mut p = 0;
while out.len() < n.min(5000) && p + 46 <= cd.len() && &cd[p..p + 4] == b"PK\x01\x02" {
let nl = le16(&cd[p + 28..]) as usize;
let xl = le16(&cd[p + 30..]) as usize;
let cl = le16(&cd[p + 32..]) as usize;
if p + 46 + nl > cd.len() {
break;
}
out.push(ZipEnt {
name: String::from_utf8_lossy(&cd[p + 46..p + 46 + nl]).to_string(),
method: le16(&cd[p + 10..]),
csize: le32(&cd[p + 20..]) as u64,
usize: le32(&cd[p + 24..]) as u64,
off: le32(&cd[p + 42..]) as u64,
});
p += 46 + nl + xl + cl;
}
out
}
fn zip_read(fr: &Fr, e: &ZipEnt) -> Option<Vec<u8>> {
if e.usize > 4 << 20 {
return None;
}
let lh = fr.read(e.off, 30);
if lh.len() < 30 || &lh[0..4] != b"PK\x03\x04" {
return None;
}
let start = e.off + 30 + le16(&lh[26..]) as u64 + le16(&lh[28..]) as u64;
let data = fr.read(start, e.csize as usize);
match e.method {
0 => Some(data),
8 => {
let mut out = Vec::new();
flate2::read::DeflateDecoder::new(&data[..])
.take(4 << 20)
.read_to_end(&mut out)
.ok()?;
Some(out)
}
_ => None,
}
}
/// Texto do primeiro elemento `<tag ...>texto</tag>`.
fn xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}");
let mut from = 0;
while let Some(i) = xml[from..].find(&open) {
let s = from + i + open.len();
match xml[s..].chars().next() {
Some('>') | Some(' ') | Some('/') => {
let gt = xml[s..].find('>')? + s;
if xml[..gt].ends_with('/') {
return None;
}
let end = xml[gt + 1..].find(&format!("</{tag}>"))? + gt + 1;
return Some(unescape(&xml[gt + 1..end]));
}
_ => from = s,
}
}
None
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
/// "2023-07-14T18:22:05Z" -> "14/07/2023 18:22:05 (UTC)"
fn iso_date(s: &str) -> String {
let b = s.as_bytes();
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' {
let utc = if s.ends_with('Z') { " (UTC)" } else { "" };
return format!(
"{}/{}/{} {}{}",
&s[8..10],
&s[5..7],
&s[0..4],
&s[11..19],
utc
);
}
s.to_string()
}
fn ooxml(fr: &Fr, m: &mut Meta) {
let ents = zip_dir(fr);
let get = |n: &str| {
ents.iter()
.find(|e| e.name == n)
.and_then(|e| zip_read(fr, e))
.map(|b| String::from_utf8_lossy(&b).to_string())
};
if let Some(x) = get("docProps/core.xml") {
for (tag, label) in [
("dc:title", "Título"),
("dc:subject", "Assunto"),
("dc:creator", "Autor"),
("cp:lastModifiedBy", "Última modificação por"),
("cp:keywords", "Palavras-chave"),
] {
if let Some(v) = xml_tag(&x, tag) {
m.add(label, v);
}
}
for (tag, label) in [
("dcterms:created", "Criado em"),
("dcterms:modified", "Modificado em"),
] {
if let Some(v) = xml_tag(&x, tag) {
m.add(label, iso_date(&v));
}
}
}
if let Some(x) = get("docProps/app.xml") {
for (tag, label) in [
("Application", "Aplicativo"),
("Company", "Empresa"),
("Pages", "Páginas"),
("Words", "Palavras"),
] {
if let Some(v) = xml_tag(&x, tag) {
m.add(label, v);
}
}
}
}
fn odf(fr: &Fr, m: &mut Meta) {
let ents = zip_dir(fr);
let Some(x) = ents
.iter()
.find(|e| e.name == "meta.xml")
.and_then(|e| zip_read(fr, e))
.map(|b| String::from_utf8_lossy(&b).to_string())
else {
return;
};
for (tag, label) in [
("dc:title", "Título"),
("meta:initial-creator", "Criado por"),
("dc:creator", "Última modificação por"),
("meta:generator", "Aplicativo"),
] {
if let Some(v) = xml_tag(&x, tag) {
m.add(label, v);
}
}
for (tag, label) in [
("meta:creation-date", "Criado em"),
("dc:date", "Modificado em"),
] {
if let Some(v) = xml_tag(&x, tag) {
m.add(label, iso_date(&v));
}
}
}
// ------------------------------------------------------------------ MP4 / MOV
fn mp4(fr: &Fr, m: &mut Meta) {
let size = fr.f.size;
let mut p = 0u64;
for _ in 0..64 {
let h = fr.read(p, 16);
if h.len() < 8 {
return;
}
let mut sz = be32(&h) as u64;
if sz == 1 && h.len() >= 16 {
sz = u64::from_be_bytes(h[8..16].try_into().unwrap());
} else if sz == 0 {
sz = size - p;
}
if sz < 8 {
return;
}
if &h[4..8] == b"moov" {
let body = fr.read(p + 8, (sz - 8).min(4 << 20) as usize);
moov(&body, m);
return;
}
p += sz;
if p >= size {
return;
}
}
}
fn boxes(d: &[u8]) -> Vec<(&[u8], &[u8])> {
let mut v = vec![];
let mut p = 0;
while p + 8 <= d.len() {
let sz = be32(&d[p..]) as usize;
if sz < 8 || p + sz > d.len() {
break;
}
v.push((&d[p + 4..p + 8], &d[p + 8..p + sz]));
p += sz;
}
v
}
fn moov(d: &[u8], m: &mut Meta) {
const EPOCH_1904: u64 = 2_082_844_800;
for (ty, body) in boxes(d) {
match ty {
b"mvhd" if body.len() >= 24 => {
let v = body[0];
let (created, ts, dur) = if v == 1 && body.len() >= 32 {
(
u64::from_be_bytes(body[4..12].try_into().unwrap()),
be32(&body[20..]) as u64,
u64::from_be_bytes(body[24..32].try_into().unwrap()),
)
} else {
(
be32(&body[4..]) as u64,
be32(&body[12..]) as u64,
be32(&body[16..]) as u64,
)
};
if created > EPOCH_1904 {
m.add(
"Gravado em (metadado)",
fmt_unix((created - EPOCH_1904) as i64),
);
}
if ts > 0 && dur > 0 {
let s = dur / ts;
m.add(
"Duração",
format!("{}:{:02}:{:02}", s / 3600, s % 3600 / 60, s % 60),
);
}
}
b"trak" => {
for (t2, b2) in boxes(body) {
if t2 == b"tkhd" && b2.len() >= 84 {
let off = if b2[0] == 1 { 88 } else { 76 };
if b2.len() >= off + 8 {
let (w, h) = (be32(&b2[off..]) >> 16, be32(&b2[off + 4..]) >> 16);
if w > 0 && h > 0 && !m.fields.iter().any(|(k, _)| k == "Dimensões") {
m.add("Dimensões", format!("{w} × {h} px"));
}
}
}
}
}
_ => {}
}
}
}
pub fn fmt_unix(t: i64) -> String {
let days = t.div_euclid(86400);
let rem = t.rem_euclid(86400);
let (y, mo, d) = civil_from_days(days);
format!(
"{d:02}/{mo:02}/{y} {:02}:{:02}:{:02} (UTC)",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
/// (ano, mês, dia) de um instante em segundos desde 1970.
pub fn ymd(t: i64) -> (i64, u32, u32) {
let (y, m, d) = civil_from_days(t.div_euclid(86400));
(y, m as u32, d as u32)
}
/// Inverso de `days_from_civil` (Howard Hinnant).
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
+13 -11
View File
@@ -3,10 +3,10 @@
use crate::error::{Error, Result};
use crate::fs::sanitize_name;
use crate::hashing::{Hashes, MultiHasher};
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};
@@ -17,6 +17,10 @@ pub struct RecoveredFile {
pub path: String,
pub size: u64,
pub sha256: String,
#[serde(default)]
pub md5: String,
#[serde(default)]
pub sha1: String,
pub confidence: Confidence,
pub origin: Origin,
pub partial: bool,
@@ -111,7 +115,7 @@ pub fn recover(
}
used.insert(path.clone());
match write_file(src, f, &path) {
Ok(hash) => {
Ok(h) => {
report.total_bytes += f.size;
report.recovered.push(RecoveredFile {
id: f.id,
@@ -121,7 +125,9 @@ pub fn recover(
.to_string_lossy()
.replace('\\', "/"),
size: f.size,
sha256: hash,
sha256: h.sha256,
md5: h.md5,
sha1: h.sha1,
confidence: f.confidence,
origin: f.origin,
partial: f.confidence == Confidence::Low,
@@ -135,7 +141,7 @@ pub fn recover(
"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",
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
"relatorio": &report,
});
std::fs::write(
@@ -165,12 +171,12 @@ fn target_rel(f: &Found) -> PathBuf {
}
/// Escreve o conteúdo e devolve o SHA-256.
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
let mut out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
let mut hasher = Sha256::new();
let mut hasher = MultiHasher::new();
match &f.data {
DataLoc::Resident { bytes } => {
out.write_all(bytes)?;
@@ -204,11 +210,7 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
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())
Ok(hasher.finalize())
}
/// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset`
+306
View File
@@ -0,0 +1,306 @@
//! Exportação para análise e relatório de recuperação (cadeia de custódia):
//! CSV dos achados e um relatório HTML autocontido, pronto para imprimir.
use crate::hashing::Hashes;
use crate::meta::fmt_unix;
use crate::recover::RecoverReport;
use crate::types::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CaseInfo {
#[serde(default)]
pub case_id: String,
#[serde(default)]
pub examiner: String,
#[serde(default)]
pub organization: String,
#[serde(default)]
pub notes: String,
}
pub struct ReportInput<'a> {
pub case: &'a CaseInfo,
pub target: &'a str,
pub options: &'a ScanOptions,
pub layout: Option<&'a DiskLayout>,
pub found: &'a [Found],
pub warnings: &'a [String],
pub started_unix: Option<i64>,
pub elapsed: f64,
pub recoveries: &'a [RecoverReport],
pub source_hashes: Option<&'a Hashes>,
}
fn cell(s: &str) -> String {
// aspas e escapa; neutraliza "injeção de fórmula" em planilhas
let mut t = s.replace('"', "\"\"");
if t.starts_with(['=', '+', '-', '@', '\t', '\r']) {
t.insert(0, '\'');
}
format!("\"{t}\"")
}
fn iso(t: Option<i64>) -> String {
match t {
Some(t) => {
let (y, m, d) = crate::meta::ymd(t);
let r = t.rem_euclid(86400);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}",
r / 3600,
r % 3600 / 60,
r % 60
)
}
None => String::new(),
}
}
/// CSV com `;` (abre direto no Excel em português) e BOM UTF-8.
pub fn csv(found: &[&Found]) -> String {
let mut out = String::from("\u{feff}id;nome;pasta;extensao;categoria;tamanho_bytes;origem;confianca;modificado_utc;criado_utc;acessado_utc;foto_tirada;camera;largura;altura;gps;deslocamento_no_disco;observacao\r\n");
for f in found {
let (w, h) =
f.px.map(|(w, h)| (w.to_string(), h.to_string()))
.unwrap_or_default();
let off = f.first_offset().map(|o| o.to_string()).unwrap_or_default();
let cat = crate::explorer::cat_key(f.category);
let row = [
f.id.to_string(),
cell(&f.name),
cell(f.dir.as_deref().unwrap_or("")),
cell(&f.ext),
cat.to_string(),
f.size.to_string(),
format!("{:?}", f.origin).to_lowercase(),
format!("{:?}", f.confidence).to_lowercase(),
iso(f.modified),
iso(f.created),
iso(f.accessed),
iso(f.taken),
cell(f.camera.as_deref().unwrap_or("")),
w,
h,
if f.gps { "sim".into() } else { String::new() },
off,
cell(f.note.as_deref().unwrap_or("")),
];
out.push_str(&row.join(";"));
out.push_str("\r\n");
}
out
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn human(n: u64) -> String {
const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = n as f64;
let mut i = 0;
while v >= 1024.0 && i < 4 {
v /= 1024.0;
i += 1;
}
if i == 0 {
format!("{n} B")
} else {
format!("{v:.1} {}", U[i])
}
}
const MAX_ROWS: usize = 20_000;
pub fn html(r: &ReportInput) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut o = String::new();
o.push_str(
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
td.n{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;word-break:break-all}
.kv td:first-child{width:230px;color:#5b6f6d}.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;font-weight:600}
.alta{background:#d7f5e0;color:#146c2e}.media{background:#fff0cf;color:#8a5a00}.baixa{background:#fde0e0;color:#a32121}
.note{background:#fff7e0;border-left:4px solid #e0a100;padding:10px 14px;border-radius:6px;margin:8px 0}
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
</style></head><body><main>"#,
);
o.push_str("<header><h1>Relatório de recuperação de arquivos</h1><div class=\"mut\">Gerado pelo Guará ");
o.push_str(env!("CARGO_PKG_VERSION"));
o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
o.push_str("<h2>Identificação do caso</h2><table class=\"kv\">");
for (k, v) in [
("Número do caso", &r.case.case_id),
("Responsável pelo exame", &r.case.examiner),
("Organização", &r.case.organization),
] {
o.push_str(&format!(
"<tr><td>{k}</td><td>{}</td></tr>",
esc(if v.is_empty() { "não informado" } else { v })
));
}
if !r.case.notes.is_empty() {
o.push_str(&format!(
"<tr><td>Observações</td><td>{}</td></tr>",
esc(&r.case.notes).replace('\n', "<br>")
));
}
o.push_str("</table>");
o.push_str("<h2>Origem examinada</h2><table class=\"kv\">");
o.push_str(&format!(
"<tr><td>Dispositivo / imagem</td><td class=\"mono\">{}</td></tr>",
esc(r.target)
));
if let Some(l) = r.layout {
o.push_str(&format!("<tr><td>Tamanho</td><td>{} ({} bytes)</td></tr><tr><td>Tabela de partições</td><td>{}</td></tr>", human(l.size), l.size, esc(&l.table.to_uppercase())));
}
if let Some(h) = r.source_hashes {
o.push_str(&format!(
"<tr><td>Hash da origem</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
esc(&h.md5),
esc(&h.sha1),
esc(&h.sha256)
));
} else {
o.push_str("<tr><td>Hash da origem</td><td class=\"mut\">não calculado nesta sessão (use <span class=\"mono\">guara hash</span> ou <span class=\"mono\">guara image</span>)</td></tr>");
}
if let Some(t) = r.started_unix {
o.push_str(&format!(
"<tr><td>Início da varredura</td><td>{}</td></tr>",
fmt_unix(t)
));
}
o.push_str(&format!(
"<tr><td>Duração da varredura</td><td>{:.1} s</td></tr><tr><td>Modo</td><td>{}{}</td></tr><tr><td>Garantia de leitura</td><td>A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.</td></tr></table>",
r.elapsed,
if r.options.mode == ScanMode::Deep { "profunda (metadados + assinatura)" } else { "rápida (metadados)" },
if r.options.include_allocated { ", incluindo áreas em uso" } else { "" }
));
if let Some(l) = r.layout {
o.push_str("<h2>Partições</h2><table><tr><th>#</th><th>Sistema</th><th>Início (bytes)</th><th>Tamanho</th><th>Rótulo</th><th>Observação</th></tr>");
for p in &l.partitions {
let obs = [
p.lost.then_some("encontrada por varredura".to_string()),
p.encrypted
.as_ref()
.map(|e| format!("cifrada ({e}), não examinada")),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("; ");
o.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td></tr>",
p.index + 1,
esc(p.fs.name()),
p.start,
human(p.len),
esc(p.label.as_deref().unwrap_or("")),
esc(&obs)
));
}
for g in &l.gaps {
o.push_str(&format!("<tr><td>-</td><td>espaço não particionado</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td></td><td></td></tr>", g.start, human(g.len)));
}
o.push_str("</table>");
}
// Resumo
let mut by_cat = std::collections::BTreeMap::new();
let mut conf = [0usize; 3];
let mut bytes = 0u64;
for f in r.found {
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
conf[f.confidence as usize] += 1;
bytes += f.size;
}
o.push_str(&format!("<h2>Resumo dos achados</h2><table class=\"kv\"><tr><td>Arquivos encontrados</td><td><b>{}</b> ({})</td></tr>", r.found.len(), human(bytes)));
o.push_str(&format!(
"<tr><td>Confiança</td><td><span class=\"pill alta\">alta {}</span> <span class=\"pill media\">média {}</span> <span class=\"pill baixa\">baixa {}</span></td></tr>",
conf[2], conf[1], conf[0]
));
o.push_str(&format!(
"<tr><td>Por tipo</td><td>{}</td></tr></table>",
by_cat
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join(" · ")
));
for w in r.warnings {
o.push_str(&format!("<div class=\"note\">{}</div>", esc(w)));
}
if !r.recoveries.is_empty() {
o.push_str("<h2>Arquivos recuperados (integridade)</h2>");
for rec in r.recoveries {
o.push_str(&format!(
"<p>Destino: <span class=\"mono\">{}</span>, {} arquivo(s), {}{}</p>",
esc(&rec.destination),
rec.recovered.len(),
human(rec.total_bytes),
if rec.failed.is_empty() {
String::new()
} else {
format!(", {} falha(s)", rec.failed.len())
}
));
o.push_str("<table><tr><th>Arquivo</th><th>Tamanho</th><th>Hashes</th></tr>");
for f in rec.recovered.iter().take(MAX_ROWS) {
o.push_str(&format!(
"<tr><td class=\"mono\">{}</td><td class=\"n\">{}</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
esc(&f.path),
human(f.size),
f.md5,
f.sha1,
f.sha256
));
}
o.push_str("</table>");
}
}
o.push_str("<h2>Relação de arquivos encontrados</h2>");
if r.found.len() > MAX_ROWS {
o.push_str(&format!("<div class=\"note\">Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.</div>", r.found.len()));
}
o.push_str("<table><tr><th>Nome</th><th>Pasta</th><th>Tamanho</th><th>Modificado (UTC)</th><th>Criado (UTC)</th><th>Origem</th><th>Confiança</th><th>Posição no disco</th></tr>");
for f in r.found.iter().take(MAX_ROWS) {
let (cl, cc) = match f.confidence {
Confidence::High => ("alta", "alta"),
Confidence::Medium => ("média", "media"),
Confidence::Low => ("baixa", "baixa"),
};
o.push_str(&format!(
"<tr><td>{}</td><td class=\"mono\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td><td>{}</td><td><span class=\"pill {cc}\">{cl}</span></td><td class=\"n mono\">{}</td></tr>",
esc(&f.name),
esc(f.dir.as_deref().unwrap_or("")),
human(f.size),
iso(f.modified),
iso(f.created),
match f.origin {
Origin::Carved => "assinatura",
Origin::Ntfs => "NTFS",
Origin::Fat => "FAT",
Origin::Exfat => "exFAT",
},
f.first_offset().map(|o| o.to_string()).unwrap_or_default()
));
}
o.push_str("</table><footer>Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.</footer></main></body></html>");
o
}
+7
View File
@@ -245,6 +245,13 @@ impl CarveEvents for Handler<'_> {
extents: vec![Extent::new(off, hit.len)],
},
modified: None,
created: None,
accessed: None,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note: if hit.confidence == Confidence::Low {
Some("sem rodapé/estrutura completa: pode estar truncado".into())
} else {
+194
View File
@@ -0,0 +1,194 @@
//! Busca de texto bruta em todo o disco (inclusive espaço livre e não particionado),
//! em UTF-8 e UTF-16LE, útil quando não há estrutura de arquivo para recuperar.
use crate::source::Source;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchOpts {
pub terms: Vec<String>,
/// Ignora maiúsculas/minúsculas (letras ASCII).
#[serde(default = "yes")]
pub ignore_case: bool,
/// Procura também a codificação UTF-16 (Windows, Office, bancos de dados).
#[serde(default = "yes")]
pub utf16: bool,
#[serde(default = "default_max")]
pub max_hits: usize,
}
fn yes() -> bool {
true
}
fn default_max() -> usize {
20_000
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hit {
pub offset: u64,
pub term: String,
pub encoding: String,
pub context: String,
}
const CHUNK: usize = 8 * 1024 * 1024;
const CTX: usize = 48;
struct Pat {
term: String,
enc: &'static str,
bytes: Vec<u8>,
skip: [usize; 256],
}
fn make_pats(o: &SearchOpts) -> Vec<Pat> {
let mut v = vec![];
for t in o.terms.iter().filter(|t| !t.is_empty()) {
let folded = if o.ignore_case {
t.to_lowercase()
} else {
t.clone()
};
let mut add = |enc: &'static str, bytes: Vec<u8>| {
if bytes.is_empty() {
return;
}
let mut skip = [bytes.len(); 256];
for (i, &b) in bytes[..bytes.len() - 1].iter().enumerate() {
skip[b as usize] = bytes.len() - 1 - i;
}
v.push(Pat {
term: t.clone(),
enc,
bytes,
skip,
});
};
add("UTF-8", folded.as_bytes().to_vec());
if o.utf16 {
add(
"UTF-16",
folded
.encode_utf16()
.flat_map(|c| c.to_le_bytes())
.collect(),
);
}
}
v
}
fn find_all(hay: &[u8], p: &Pat, mut f: impl FnMut(usize) -> bool) {
let n = p.bytes.len();
if hay.len() < n {
return;
}
let mut i = 0;
while i + n <= hay.len() {
let last = hay[i + n - 1];
if last == p.bytes[n - 1] && hay[i..i + n] == p.bytes[..] {
if !f(i) {
return;
}
i += 1;
} else {
i += p.skip[last as usize];
}
}
}
fn snippet(buf: &[u8], at: usize, len: usize, utf16: bool) -> String {
let a = at.saturating_sub(CTX);
let b = (at + len + CTX).min(buf.len());
let s: String = if utf16 {
let a = a & !1;
let u: Vec<u16> = buf[a..b]
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&u)
} else {
String::from_utf8_lossy(&buf[a..b]).into_owned()
};
s.chars()
.map(|c| if c.is_control() { '·' } else { c })
.collect()
}
/// Varre `src` inteira. Retorna o total de ocorrências. `on_hit` pode devolver false para parar.
pub fn search(
src: &dyn Source,
o: &SearchOpts,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(u64, u64),
on_hit: &mut dyn FnMut(Hit) -> bool,
) -> usize {
let pats = make_pats(o);
if pats.is_empty() {
return 0;
}
let overlap = pats.iter().map(|p| p.bytes.len()).max().unwrap_or(1) + 2 * CTX;
let size = src.size();
let mut pos = 0u64;
let mut hits = 0usize;
let mut buf = vec![0u8; CHUNK + overlap];
'outer: while pos < size {
if cancelled() {
break;
}
let want = ((size - pos) as usize).min(CHUNK + overlap);
let mut got = 0;
while got < want {
match src.read_at(pos + got as u64, &mut buf[got..want]) {
Ok(0) => break,
Ok(n) => got += n,
Err(_) => {
// setor ruim: pula este trecho
for b in &mut buf[got..want] {
*b = 0;
}
got = want;
}
}
}
if got == 0 {
break;
}
let fresh = got.min(CHUNK); // início de ocorrências aceitas neste bloco
let folded: Vec<u8>;
let hay: &[u8] = if o.ignore_case {
folded = buf[..got].to_ascii_lowercase();
&folded
} else {
&buf[..got]
};
let mut chunk_hits: Vec<(usize, usize)> = vec![]; // (offset no bloco, índice do padrão)
for (pi, p) in pats.iter().enumerate() {
find_all(hay, p, |i| {
if i < fresh {
chunk_hits.push((i, pi));
}
true
});
}
chunk_hits.sort();
for (i, pi) in chunk_hits {
let p = &pats[pi];
hits += 1;
let h = Hit {
offset: pos + i as u64,
term: p.term.clone(),
encoding: p.enc.to_string(),
context: snippet(&buf[..got], i, p.bytes.len(), p.enc == "UTF-16"),
};
if !on_hit(h) || hits >= o.max_hits {
break 'outer;
}
}
pos += CHUNK as u64;
progress(pos.min(size), size);
}
progress(size, size);
hits
}
+324 -95
View File
@@ -33,6 +33,8 @@ pub enum JobKind {
Scan,
Recover,
Image,
Search,
Hash,
}
struct State {
@@ -47,6 +49,11 @@ struct State {
report: Option<Value>,
started: Instant,
finished_in: Option<f64>,
/// Leitura de datas EXIF/dimensões em andamento: (feitos, total).
enrich: Option<(u64, u64)>,
recoveries: Vec<recover::RecoverReport>,
started_unix: Option<i64>,
hits: Vec<Value>,
}
pub struct Job {
@@ -88,6 +95,13 @@ impl Job {
report: None,
started: Instant::now(),
finished_in: None,
enrich: None,
recoveries: vec![],
started_unix: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64),
hits: vec![],
}),
})
}
@@ -128,6 +142,8 @@ impl Sink for JobSink {
pub struct Engine {
jobs: Mutex<HashMap<String, Arc<Job>>>,
counter: AtomicU64,
thumbs: Mutex<crate::explorer::ThumbCache>,
gate: crate::explorer::Gate,
}
impl Default for Engine {
@@ -141,6 +157,13 @@ impl Engine {
Self {
jobs: Mutex::new(HashMap::new()),
counter: AtomicU64::new(1),
thumbs: Mutex::new(crate::explorer::ThumbCache::new()),
gate: crate::explorer::Gate::new(
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2)
.min(6),
),
}
}
@@ -231,53 +254,63 @@ impl Engine {
"error": s.error,
"report": s.report,
"elapsed": elapsed,
"hits": s.hits.len(),
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
}))
}
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 mut items: Vec<&Found> = q.select(&s.found);
crate::explorer::sort_items(&mut items, q.sort.as_deref());
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,
})
})
.take(q.limit.clamp(1, 5000))
.map(crate::explorer::item_json)
.collect();
Ok(json!({ "total": total, "items": page }))
}
/// Contagens para a navegação lateral (tipos, datas, pastas, câmeras).
pub fn facets(&self, id: &str) -> Result<Value> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
Ok(crate::explorer::facets(&s.found))
}
/// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo.
pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> {
let (f, src) = self.open_file(id, file)?;
Ok(crate::meta::extract(src.as_ref(), &f))
}
/// Miniatura JPEG (cache em memória; decodificações limitadas em paralelo).
pub fn thumb(&self, job: &str, id: u64, size: u32) -> std::result::Result<Arc<Vec<u8>>, u16> {
let size = size.clamp(48, 640);
let key = (job.to_string(), id, size);
if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
return if v.is_empty() { Err(422) } else { Ok(v) };
}
let (f, src) = self.open_file(job, id).map_err(|_| 404u16)?;
if f.category != Category::Image {
return Err(415);
}
let _permit = self.gate.acquire();
if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
return if v.is_empty() { Err(422) } else { Ok(v) };
}
let out = Arc::new(crate::thumb::thumbnail(src.as_ref(), &f, size).unwrap_or_default());
self.thumbs.lock().unwrap().put(key, out.clone());
if out.is_empty() {
Err(422)
} else {
Ok(out)
}
}
/// Achado + origem para leitura posicional (streaming de pré-visualização).
pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc<dyn Source>)> {
let j = self.job(id)?;
@@ -310,25 +343,7 @@ impl Engine {
.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, Some(q)) => q.select(&s.found).into_iter().cloned().collect(),
(None, None) => s.found.clone(),
}
};
@@ -344,6 +359,7 @@ impl Engine {
None,
);
let j2 = job.clone();
let parent = sj.clone();
std::thread::spawn(move || {
let cancelled = || j2.cancelled();
let mut prog = |done: usize, total: usize, bytes: u64| {
@@ -360,6 +376,7 @@ impl Engine {
match res {
Ok(rep) => {
s.report = serde_json::to_value(&rep).ok();
parent.st.lock().unwrap().recoveries.push(rep.clone());
s.status = if rep.cancelled {
Status::Stopped
} else {
@@ -422,6 +439,156 @@ impl Engine {
Ok(job.id.clone())
}
// ---------------------------------------------------------------- exportação e relatório
/// CSV dos achados que atendem ao filtro (ou todos). Devolve quantas linhas gravou.
pub fn export_csv(&self, id: &str, path: &str, q: Option<&Query>) -> Result<usize> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
let items: Vec<&Found> = match q {
Some(q) => q.select(&s.found),
None => s.found.iter().collect(),
};
std::fs::write(path, crate::report::csv(&items))?;
Ok(items.len())
}
/// Relatório HTML de recuperação (identificação do caso, origem, achados, hashes).
pub fn export_report(
&self,
id: &str,
path: &str,
case: crate::report::CaseInfo,
hashes: Option<crate::hashing::Hashes>,
) -> Result<()> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
let elapsed = s
.finished_in
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
let html = crate::report::html(&crate::report::ReportInput {
case: &case,
target: &j.target,
options: &j.opts,
layout: s.layout.as_ref(),
found: &s.found,
warnings: &s.warnings,
started_unix: s.started_unix,
elapsed,
recoveries: &s.recoveries,
source_hashes: hashes.as_ref(),
});
std::fs::write(path, html)?;
Ok(())
}
// ---------------------------------------------------------------- busca de texto e hash da origem
pub fn start_search(&self, path: &str, opts: crate::search::SearchOpts) -> Result<String> {
if opts.terms.iter().all(|t| t.trim().is_empty()) {
return Err(Error::msg("informe pelo menos um termo"));
}
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
let job = self.register(
JobKind::Search,
path.to_string(),
ScanOptions::default(),
Some(src.clone()),
);
let j2 = job.clone();
std::thread::spawn(move || {
let layout = crate::partition::analyze(src.as_ref(), false);
let cancelled = || j2.cancelled();
let mut prog = |d: u64, t: u64| {
j2.st.lock().unwrap().progress = Progress {
phase: "Procurando texto no disco".into(),
done: d,
total: t,
};
};
let mut on_hit = |h: crate::search::Hit| {
let part = layout
.partitions
.iter()
.find(|p| h.offset >= p.start && h.offset < p.start + p.len)
.map(|p| p.index + 1);
let mut s = j2.st.lock().unwrap();
s.hits.push(json!({
"offset": h.offset, "term": h.term, "encoding": h.encoding,
"context": h.context, "partition": part,
}));
true
};
crate::search::search(src.as_ref(), &opts, &cancelled, &mut prog, &mut on_hit);
let mut s = j2.st.lock().unwrap();
s.status = if j2.stop_requested.load(Ordering::Relaxed) {
Status::Stopped
} else {
Status::Done
};
s.finished_in = Some(s.started.elapsed().as_secs_f64());
});
Ok(job.id.clone())
}
pub fn search_results(&self, id: &str, offset: usize, limit: usize) -> Result<Value> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
Ok(json!({
"total": s.hits.len(),
"items": s.hits.iter().skip(offset).take(limit.clamp(1, 2000)).cloned().collect::<Vec<_>>(),
}))
}
/// Calcula MD5/SHA-1/SHA-256 da origem inteira (verificação de integridade).
pub fn start_hash(&self, path: &str) -> Result<String> {
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
let job = self.register(
JobKind::Hash,
path.to_string(),
ScanOptions::default(),
None,
);
let j2 = job.clone();
std::thread::spawn(move || {
let size = src.size();
let mut h = crate::hashing::MultiHasher::new();
let mut buf = vec![0u8; 4 << 20];
let mut pos = 0u64;
let mut err = None;
while pos < size && !j2.cancelled() {
let want = ((size - pos) as usize).min(buf.len());
match crate::source::read_exact_at(src.as_ref(), pos, &mut buf[..want]) {
Ok(()) => {
h.update(&buf[..want]);
pos += want as u64;
}
Err(e) => {
err = Some(format!("falha de leitura em {pos}: {e}. Para discos com defeito use a imagem forense."));
break;
}
}
j2.st.lock().unwrap().progress = Progress {
phase: "Calculando hashes da origem".into(),
done: pos,
total: size,
};
}
let mut s = j2.st.lock().unwrap();
if let Some(e) = err {
s.error = Some(e);
s.status = Status::Error;
} else if pos < size {
s.status = Status::Stopped;
} else {
s.report = serde_json::to_value(h.finalize()).ok();
s.status = Status::Done;
}
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<()> {
@@ -436,6 +603,8 @@ impl Engine {
layout: s.layout.clone(),
warnings: s.warnings.clone(),
done: s.status == Status::Done,
recoveries: s.recoveries.clone(),
started_unix: s.started_unix,
};
std::fs::write(path, serde_json::to_vec(&data)?)?;
Ok(())
@@ -458,6 +627,8 @@ impl Engine {
s.found = data.found;
s.layout = data.layout;
s.warnings = data.warnings;
s.recoveries = data.recoveries;
s.started_unix = data.started_unix.or(s.started_unix);
s.checkpoint = data.checkpoint;
s.status = if data.done {
Status::Done
@@ -465,6 +636,9 @@ impl Engine {
Status::Paused
};
}
if data.done {
spawn_enrich(job.clone());
}
Ok(job.id.clone())
}
@@ -506,6 +680,49 @@ impl Engine {
json!({})
}
"status" => self.status(&s("job")?)?,
"facets" => self.facets(&s("job")?)?,
"export_csv" => {
let q: Option<Query> = p
.get("query")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?;
json!({ "rows": self.export_csv(&s("job")?, &s("path")?, q.as_ref())? })
}
"export_report" => {
let case: crate::report::CaseInfo = p
.get("case")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default();
let hashes: Option<crate::hashing::Hashes> = p
.get("hashes")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?;
self.export_report(&s("job")?, &s("path")?, case, hashes)?;
json!({})
}
"search_start" => {
let opts: crate::search::SearchOpts = serde_json::from_value(p.clone())?;
json!({ "job": self.start_search(&s("path")?, opts)? })
}
"search_results" => self.search_results(
&s("job")?,
p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
p.get("limit").and_then(|v| v.as_u64()).unwrap_or(200) as usize,
)?,
"hash_start" => json!({ "job": self.start_hash(&s("path")?)? }),
"metadata" => json!(self.metadata(
&s("job")?,
p.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| Error::msg("id ausente"))?
)?),
"results" => {
let q: Query = p
.get("query")
@@ -556,38 +773,7 @@ impl Engine {
}
}
#[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,
}
}
}
pub use crate::explorer::Query;
#[derive(Serialize, Deserialize)]
struct SessionFile {
@@ -599,19 +785,13 @@ struct SessionFile {
layout: Option<DiskLayout>,
warnings: Vec<String>,
done: bool,
#[serde(default)]
recoveries: Vec<recover::RecoverReport>,
#[serde(default)]
started_unix: Option<i64>,
}
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",
}
}
use crate::explorer::cat_key;
fn open_error(path: &str, e: std::io::Error) -> Error {
if e.kind() == std::io::ErrorKind::PermissionDenied {
@@ -653,6 +833,55 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
}
}
s.finished_in = Some(s.started.elapsed().as_secs_f64());
let done = s.status == Status::Done;
drop(s);
if done {
spawn_enrich(job);
}
});
}
/// Lê EXIF/dimensões das imagens achadas (em segundo plano) para permitir
/// navegar por data, câmera e resolução.
fn spawn_enrich(job: Arc<Job>) {
std::thread::spawn(move || {
let Some(src) = job.src.clone() else { return };
let todo: Vec<(u64, Found)> = {
let s = job.st.lock().unwrap();
s.found
.iter()
.filter(|f| {
f.sig.is_none()
|| (f.category == Category::Image && f.px.is_none() && f.taken.is_none())
})
.map(|f| (f.id, f.clone()))
.collect()
};
if todo.is_empty() {
return;
}
let total = todo.len() as u64;
for (i, (id, f)) in todo.into_iter().enumerate() {
if job.cancelled() && job.stop_requested.load(Ordering::Relaxed) {
break;
}
let sig = crate::explorer::quick_sig(src.as_ref(), &f);
let m = (f.category == Category::Image && f.px.is_none() && f.taken.is_none())
.then(|| crate::meta::extract(src.as_ref(), &f));
let mut s = job.st.lock().unwrap();
if let Some(&k) = s.index.get(&id) {
let t = &mut s.found[k];
t.sig = sig;
if let Some(m) = m {
t.taken = m.taken;
t.camera = m.camera.clone();
t.px = m.px.or(t.px);
t.gps = m.gps.is_some();
}
}
s.enrich = Some((i as u64 + 1, total));
}
job.st.lock().unwrap().enrich = None;
});
}
+126
View File
@@ -0,0 +1,126 @@
//! Miniaturas para a grade de arquivos. Decodifica com redução na própria
//! decodificação (JPEG) para ser rápido mesmo com milhares de fotos e, se a
//! imagem estiver danificada, tenta a miniatura embutida no EXIF.
use crate::recover::read_found_at;
use crate::source::Source;
use crate::types::Found;
use image::{imageops, ImageBuffer, Rgb, RgbImage};
const MAX_READ: u64 = 64 * 1024 * 1024;
fn read_all(src: &dyn Source, f: &Found) -> Option<Vec<u8>> {
if f.size == 0 || f.size > MAX_READ {
return None;
}
let mut v = vec![0u8; f.size as usize];
let mut got = 0;
while got < v.len() {
match read_found_at(src, f, got as u64, &mut v[got..]) {
Ok(0) | Err(_) => break,
Ok(n) => got += n,
}
}
v.truncate(got);
(!v.is_empty()).then_some(v)
}
/// Miniatura JPEG com o maior lado = `max` (None se não for imagem ou não decodificar).
pub fn thumbnail(src: &dyn Source, f: &Found, max: u32) -> Option<Vec<u8>> {
let data = read_all(src, f)?;
let img = match f.ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => decode_jpeg(&data, max).or_else(|| {
let t = crate::meta::exif_thumbnail(&data)?;
decode_jpeg(&t, max)
})?,
"png" | "gif" | "bmp" | "webp" => decode_other(&data, max)?,
_ => return None,
};
encode(&img)
}
fn decode_jpeg(data: &[u8], max: u32) -> Option<RgbImage> {
let mut m = crate::meta::Meta::default();
crate::meta::jpeg_bytes(data, &mut m);
let orientation = m.orientation;
let mut dec = jpeg_decoder::Decoder::new(std::io::Cursor::new(data));
dec.read_info().ok()?;
let _ = dec.scale(max.min(65535) as u16, max.min(65535) as u16);
let px = dec.decode().ok()?;
let info = dec.info()?;
let (w, h) = (info.width as u32, info.height as u32);
let rgb: Vec<u8> = match info.pixel_format {
jpeg_decoder::PixelFormat::RGB24 => px,
jpeg_decoder::PixelFormat::L8 => px.iter().flat_map(|&g| [g, g, g]).collect(),
jpeg_decoder::PixelFormat::L16 => px
.chunks_exact(2)
.flat_map(|c| [c[0], c[0], c[0]])
.collect(),
jpeg_decoder::PixelFormat::CMYK32 => px
.chunks_exact(4)
.flat_map(|c| {
// Adobe CMYK vem invertido
let k = c[3] as u32;
[
(c[0] as u32 * k / 255) as u8,
(c[1] as u32 * k / 255) as u8,
(c[2] as u32 * k / 255) as u8,
]
})
.collect(),
};
let img: RgbImage = ImageBuffer::from_raw(w, h, rgb)?;
Some(orient(fit(img, max), orientation))
}
fn decode_other(data: &[u8], max: u32) -> Option<RgbImage> {
let dynimg = image::load_from_memory(data).ok()?;
let rgba = dynimg.to_rgba8();
// Achata a transparência sobre cinza claro.
let mut rgb = RgbImage::new(rgba.width(), rgba.height());
for (x, y, p) in rgba.enumerate_pixels() {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 240 * (255 - a)) / 255) as u8;
rgb.put_pixel(x, y, Rgb([mix(p[0]), mix(p[1]), mix(p[2])]));
}
Some(fit(rgb, max))
}
fn fit(img: RgbImage, max: u32) -> RgbImage {
let (w, h) = (img.width(), img.height());
if w.max(h) <= max {
return img;
}
let (nw, nh) = if w >= h {
(max, (h as u64 * max as u64 / w as u64).max(1) as u32)
} else {
((w as u64 * max as u64 / h as u64).max(1) as u32, max)
};
imageops::resize(&img, nw, nh, imageops::FilterType::Triangle)
}
fn orient(img: RgbImage, o: u16) -> RgbImage {
match o {
2 => imageops::flip_horizontal(&img),
3 => imageops::rotate180(&img),
4 => imageops::flip_vertical(&img),
5 => imageops::flip_horizontal(&imageops::rotate90(&img)),
6 => imageops::rotate90(&img),
7 => imageops::flip_horizontal(&imageops::rotate270(&img)),
8 => imageops::rotate270(&img),
_ => img,
}
}
fn encode(img: &RgbImage) -> Option<Vec<u8>> {
let mut out = Vec::new();
let enc = jpeg_encoder::Encoder::new(&mut out, 80);
enc.encode(
img.as_raw(),
img.width() as u16,
img.height() as u16,
jpeg_encoder::ColorType::Rgb,
)
.ok()?;
Some(out)
}
+25
View File
@@ -132,9 +132,34 @@ pub struct Found {
pub data: DataLoc,
/// Segundos desde 1970-01-01 UTC.
pub modified: Option<i64>,
#[serde(default)]
pub created: Option<i64>,
#[serde(default)]
pub accessed: Option<i64>,
/// Data em que a foto foi tirada (EXIF; hora da câmera lida como UTC), s desde 1970.
#[serde(default)]
pub taken: Option<i64>,
#[serde(default)]
pub camera: Option<String>,
/// Largura × altura em pixels, quando conhecidas.
#[serde(default)]
pub px: Option<(u32, u32)>,
/// Há coordenadas GPS nos metadados.
#[serde(default)]
pub gps: bool,
/// Assinatura rápida do conteúdo (tamanho + início + fim) para achar duplicadas.
#[serde(default)]
pub sig: Option<u64>,
pub note: Option<String>,
}
impl Found {
/// Data mais representativa: quando foi tirada > modificada > criada.
pub fn when(&self) -> Option<i64> {
self.taken.or(self.modified).or(self.created)
}
}
impl Found {
pub fn first_offset(&self) -> Option<u64> {
match &self.data {
+507
View File
@@ -0,0 +1,507 @@
//! Recursos periciais: hashes, metadados (EXIF/PDF/Office/MP4), miniaturas,
//! busca de texto, exportações e navegação (facetas/duplicadas).
use guara_core::explorer::{self, Query};
use guara_core::hashing::hash_bytes;
use guara_core::source::MemSource;
use guara_core::*;
use std::io::Write;
fn resident(name: &str, bytes: Vec<u8>) -> Found {
let ext = name.rsplit('.').next().unwrap_or("").to_string();
Found {
id: 1,
name: name.into(),
dir: None,
category: signatures::category_for_ext(&ext),
ext,
size: bytes.len() as u64,
origin: Origin::Carved,
confidence: Confidence::High,
data: DataLoc::Resident { bytes },
modified: None,
created: None,
accessed: None,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note: None,
}
}
// ------------------------------------------------------------------ hashes
#[test]
fn hashes_conferem_com_vetores_conhecidos() {
let h = hash_bytes(b"abc");
assert_eq!(h.md5, "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(h.sha1, "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(
h.sha256,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
// ------------------------------------------------------------------ EXIF
fn ifd(entries: &[(u16, u16, u32, Vec<u8>)], start: usize) -> Vec<u8> {
let n = entries.len();
let mut table = (n as u16).to_le_bytes().to_vec();
let mut data: Vec<u8> = vec![];
let data_off = start + 2 + 12 * n + 4;
for (tag, typ, cnt, val) in entries {
table.extend(tag.to_le_bytes());
table.extend(typ.to_le_bytes());
table.extend(cnt.to_le_bytes());
if val.len() <= 4 {
let mut v = val.clone();
v.resize(4, 0);
table.extend(v);
} else {
if data.len() % 2 == 1 {
data.push(0);
}
table.extend(((data_off + data.len()) as u32).to_le_bytes());
data.extend(val);
}
}
table.extend([0, 0, 0, 0]);
table.extend(data);
table
}
fn rat(p: &[(u32, u32)]) -> Vec<u8> {
p.iter()
.flat_map(|(a, b)| a.to_le_bytes().into_iter().chain(b.to_le_bytes()))
.collect()
}
fn jpeg_with_exif(orientation: u16, w: usize, h: usize) -> Vec<u8> {
// pixels: gradiente
let mut px = Vec::with_capacity(w * h * 3);
for y in 0..h {
for x in 0..w {
px.extend([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]);
}
}
let mut jpg = vec![];
jpeg_encoder::Encoder::new(&mut jpg, 85)
.encode(&px, w as u16, h as u16, jpeg_encoder::ColorType::Rgb)
.unwrap();
let asc = |s: &str| {
let mut v = s.as_bytes().to_vec();
v.push(0);
v
};
let make = asc("Canon");
let model = asc("Canon EOS 80D");
let dt = asc("2023:07:14 18:22:05");
let e0 = |exif: u32, gps: u32| {
ifd(
&[
(0x010F, 2, make.len() as u32, make.clone()),
(0x0110, 2, model.len() as u32, model.clone()),
(0x0112, 3, 1, orientation.to_le_bytes().to_vec()),
(0x8769, 4, 1, exif.to_le_bytes().to_vec()),
(0x8825, 4, 1, gps.to_le_bytes().to_vec()),
],
8,
)
};
let first = e0(0, 0);
let exif_off = 8 + first.len();
let exif_ifd = ifd(&[(0x9003, 2, dt.len() as u32, dt.clone())], exif_off);
let gps_off = exif_off + exif_ifd.len();
let gps_ifd = ifd(
&[
(1, 2, 2, b"S\0".to_vec()),
(2, 5, 3, rat(&[(23, 1), (33, 1), (1800, 100)])),
(3, 2, 2, b"W\0".to_vec()),
(4, 5, 3, rat(&[(46, 1), (38, 1), (0, 1)])),
],
gps_off,
);
let mut tiff = b"II*\0".to_vec();
tiff.extend(8u32.to_le_bytes());
tiff.extend(e0(exif_off as u32, gps_off as u32));
tiff.extend(exif_ifd);
tiff.extend(gps_ifd);
let mut app1 = b"Exif\0\0".to_vec();
app1.extend(tiff);
let mut out = vec![0xFF, 0xD8, 0xFF, 0xE1];
out.extend(((app1.len() + 2) as u16).to_be_bytes());
out.extend(app1);
out.extend(&jpg[2..]);
out
}
#[test]
fn exif_camera_data_e_gps() {
let f = resident("foto.jpg", jpeg_with_exif(1, 64, 48));
let m = meta::extract(&MemSource(vec![]), &f);
assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D"));
assert_eq!(m.px, Some((64, 48)));
// 2023-07-14 18:22:05 lido como UTC
assert_eq!(m.taken, Some(1_689_358_925));
let (lat, lon) = m.gps.unwrap();
assert!((lat + 23.5550).abs() < 0.001, "{lat}");
assert!((lon + 46.6333).abs() < 0.001, "{lon}");
assert!(m
.fields
.iter()
.any(|(k, v)| k == "Foto tirada em" && v == "14/07/2023 18:22:05"));
}
#[test]
fn miniatura_respeita_orientacao_e_tamanho() {
let src = MemSource(vec![]);
// 64x48 com orientação 6 (girar 90 graus) => retrato 48x64, reduzido para caber em 32
let f = resident("foto.jpg", jpeg_with_exif(6, 64, 48));
let t = thumb::thumbnail(&src, &f, 32).expect("miniatura");
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
dec.decode().unwrap();
let i = dec.info().unwrap();
assert_eq!((i.width, i.height), (24, 32));
// PNG também
let mut img = ::image::RgbImage::new(100, 50);
for p in img.pixels_mut() {
*p = ::image::Rgb([200, 30, 30]);
}
let mut png = std::io::Cursor::new(vec![]);
img.write_to(&mut png, ::image::ImageFormat::Png).unwrap();
let f = resident("x.png", png.into_inner());
let t = thumb::thumbnail(&src, &f, 40).expect("miniatura png");
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
dec.decode().unwrap();
assert_eq!(
(dec.info().unwrap().width, dec.info().unwrap().height),
(40, 20)
);
// lixo não vira miniatura
assert!(thumb::thumbnail(&src, &resident("x.jpg", vec![1, 2, 3, 4, 5]), 32).is_none());
}
// ------------------------------------------------------------------ PDF, Office, MP4
#[test]
fn metadados_de_pdf() {
let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count 12 /Kids [] >>\nendobj\n3 0 obj\n<< /Title (Relat\\363rio Final) /Author (Maria \\(Perita\\) Silva) /Creator (LibreOffice Writer) /Producer <FEFF00500044004600310032> /CreationDate (D:20230714182205-03'00') >>\nendobj\ntrailer\n%%EOF\n".to_vec();
let m = meta::extract(&MemSource(vec![]), &resident("a.pdf", pdf));
let get = |k: &str| {
m.fields
.iter()
.find(|(a, _)| a == k)
.map(|(_, v)| v.clone())
};
assert_eq!(get("Título").as_deref(), Some("Relatório Final"));
assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva"));
assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer"));
assert_eq!(get("Gerado por").as_deref(), Some("PDF12"));
assert_eq!(get("Criado em").as_deref(), Some("14/07/2023 18:22:05"));
assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12"));
}
fn zip_store(entries: &[(&str, &str)], deflate: bool) -> Vec<u8> {
let mut out = vec![];
let mut cd = vec![];
for (name, body) in entries {
let (method, data): (u16, Vec<u8>) = if deflate {
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
e.write_all(body.as_bytes()).unwrap();
(8, e.finish().unwrap())
} else {
(0, body.as_bytes().to_vec())
};
let off = out.len() as u32;
let crc = signatures::crc32(body.as_bytes());
let mut lh = b"PK\x03\x04".to_vec();
lh.extend([20, 0, 0, 0]);
lh.extend(method.to_le_bytes());
lh.extend([0, 0, 0, 0]);
lh.extend(crc.to_le_bytes());
lh.extend((data.len() as u32).to_le_bytes());
lh.extend((body.len() as u32).to_le_bytes());
lh.extend((name.len() as u16).to_le_bytes());
lh.extend([0, 0]);
lh.extend(name.as_bytes());
out.extend(&lh);
out.extend(&data);
let mut c = b"PK\x01\x02".to_vec();
c.extend([20, 0, 20, 0, 0, 0]);
c.extend(method.to_le_bytes());
c.extend([0, 0, 0, 0]);
c.extend(crc.to_le_bytes());
c.extend((data.len() as u32).to_le_bytes());
c.extend((body.len() as u32).to_le_bytes());
c.extend((name.len() as u16).to_le_bytes());
c.extend([0; 12]);
c.extend(off.to_le_bytes());
c.extend(name.as_bytes());
cd.extend(c);
}
let cd_off = out.len() as u32;
out.extend(&cd);
out.extend(b"PK\x05\x06\0\0\0\0");
out.extend((entries.len() as u16).to_le_bytes());
out.extend((entries.len() as u16).to_le_bytes());
out.extend((cd.len() as u32).to_le_bytes());
out.extend(cd_off.to_le_bytes());
out.extend([0, 0]);
out
}
#[test]
fn metadados_de_documentos_office() {
let core = r#"<?xml version="1.0"?><cp:coreProperties xmlns:cp="x" xmlns:dc="y" xmlns:dcterms="z"><dc:title>Tese &amp; Defesa</dc:title><dc:creator>Ana Souza</dc:creator><cp:lastModifiedBy>Bruno Lima</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">2023-07-14T18:22:05Z</dcterms:created></cp:coreProperties>"#;
let app = "<Properties><Application>Microsoft Office Word</Application><Company>ACME</Company></Properties>";
for deflate in [false, true] {
let z = zip_store(
&[
("[Content_Types].xml", "<Types/>"),
("docProps/core.xml", core),
("docProps/app.xml", app),
],
deflate,
);
let m = meta::extract(&MemSource(vec![]), &resident("t.docx", z));
let get = |k: &str| {
m.fields
.iter()
.find(|(a, _)| a == k)
.map(|(_, v)| v.clone())
};
assert_eq!(
get("Título").as_deref(),
Some("Tese & Defesa"),
"deflate={deflate}"
);
assert_eq!(get("Autor").as_deref(), Some("Ana Souza"));
assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima"));
assert_eq!(
get("Criado em").as_deref(),
Some("14/07/2023 18:22:05 (UTC)")
);
assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word"));
assert_eq!(get("Empresa").as_deref(), Some("ACME"));
}
}
#[test]
fn metadados_de_mp4() {
fn bx(ty: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();
v.extend(ty);
v.extend(body);
v
}
let created_1904: u32 = 1_689_358_925 + 2_082_844_800;
let mut mvhd = vec![0, 0, 0, 0];
mvhd.extend(created_1904.to_be_bytes());
mvhd.extend(created_1904.to_be_bytes());
mvhd.extend(1000u32.to_be_bytes()); // timescale
mvhd.extend(125_000u32.to_be_bytes()); // 125 s
mvhd.extend(vec![0u8; 80]);
let mut tkhd = vec![0u8; 84];
tkhd[76..80].copy_from_slice(&(1920u32 << 16).to_be_bytes());
tkhd[80..84].copy_from_slice(&(1080u32 << 16).to_be_bytes());
let moov = bx(
b"moov",
&[bx(b"mvhd", &mvhd), bx(b"trak", &bx(b"tkhd", &tkhd))].concat(),
);
let mut file = bx(b"ftyp", b"isom\0\0\0\0isom");
file.extend(moov);
file.extend(bx(b"mdat", &[0u8; 32]));
let m = meta::extract(&MemSource(vec![]), &resident("v.mp4", file));
let get = |k: &str| {
m.fields
.iter()
.find(|(a, _)| a == k)
.map(|(_, v)| v.clone())
};
assert_eq!(get("Duração").as_deref(), Some("0:02:05"));
assert_eq!(
get("Gravado em (metadado)").as_deref(),
Some("14/07/2023 18:22:05 (UTC)")
);
assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
}
// ------------------------------------------------------------------ busca de texto
#[test]
fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
let mb = 1024 * 1024;
let mut disk = vec![0u8; 20 * mb];
let put = |d: &mut Vec<u8>, at: usize, b: &[u8]| d[at..at + b.len()].copy_from_slice(b);
put(&mut disk, 1000, b"... Segredo-123 ...");
let u16s: Vec<u8> = "segredo-123"
.encode_utf16()
.flat_map(|c| c.to_le_bytes())
.collect();
put(&mut disk, 3 * mb + 6, &u16s);
// exatamente cruzando o limite do primeiro bloco de 8 MiB
put(&mut disk, 8 * mb - 4, b"SEGREDO-123");
let opts = search::SearchOpts {
terms: vec!["segredo-123".into()],
ignore_case: true,
utf16: true,
max_hits: 100,
};
let mut hits = vec![];
let n = search::search(
&MemSource(disk.clone()),
&opts,
&|| false,
&mut |_, _| {},
&mut |h| {
hits.push(h);
true
},
);
assert_eq!(n, 3, "{hits:?}");
assert_eq!(hits[0].offset, 1004);
assert_eq!(hits[0].encoding, "UTF-8");
assert!(hits[0].context.contains("Segredo-123"));
assert_eq!(hits[1].offset, 3 * mb as u64 + 6);
assert_eq!(hits[1].encoding, "UTF-16");
assert_eq!(hits[2].offset, 8 * mb as u64 - 4);
// sem ignorar maiúsculas só acha a grafia exata
let opts = search::SearchOpts {
terms: vec!["Segredo-123".into()],
ignore_case: false,
utf16: false,
max_hits: 100,
};
let n = search::search(
&MemSource(disk),
&opts,
&|| false,
&mut |_, _| {},
&mut |_| true,
);
assert_eq!(n, 1);
}
// ------------------------------------------------------------------ exportações
#[test]
fn csv_escapa_e_neutraliza_formulas() {
let mut f = resident("=SOMA(A1).xlsx", vec![1]);
f.note = Some("diz \"oi\"; ok".into());
f.camera = Some("Cam;X".into());
let csv = report::csv(&[&f]);
assert!(csv.starts_with('\u{feff}'));
assert!(csv.contains("\"'=SOMA(A1).xlsx\""), "{csv}");
assert!(csv.contains("\"diz \"\"oi\"\"; ok\""));
assert_eq!(csv.lines().count(), 2);
}
#[test]
fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
let f = resident("<script>alert(1)</script>.txt", vec![1, 2, 3]);
let case = report::CaseInfo {
case_id: "2026-001".into(),
examiner: "Perita <Ana>".into(),
organization: "Lab".into(),
notes: "obs".into(),
};
let opts = ScanOptions::default();
let hashes = hash_bytes(b"disco");
let html = report::html(&report::ReportInput {
case: &case,
target: "/dev/sdb",
options: &opts,
layout: None,
found: &[f],
warnings: &["aviso <b>".to_string()],
started_unix: Some(1_689_358_925),
elapsed: 1.5,
recoveries: &[],
source_hashes: Some(&hashes),
});
assert!(!html.contains("<script>alert"), "HTML não escapado");
assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;.txt"));
assert!(html.contains("2026-001") && html.contains("Perita &lt;Ana&gt;"));
assert!(html.contains(&hashes.sha256));
assert!(html.contains("somente para leitura"));
}
// ------------------------------------------------------------------ navegação
#[test]
fn facetas_filtros_e_duplicadas() {
let mk = |id: u64,
name: &str,
dir: Option<&str>,
taken: Option<i64>,
cam: Option<&str>,
sig: Option<u64>| {
let mut f = resident(name, vec![0; 10]);
f.id = id;
f.dir = dir.map(String::from);
f.origin = if dir.is_some() {
Origin::Fat
} else {
Origin::Carved
};
f.taken = taken;
f.camera = cam.map(String::from);
f.sig = sig;
f
};
let jul23 = 1_689_358_925; // 2023-07-14
let jan24 = 1_705_000_000; // 2024-01-11
let found = vec![
mk(
1,
"a.jpg",
Some("DCIM/100"),
Some(jul23),
Some("Canon"),
Some(7),
),
mk(
2,
"b.jpg",
Some("DCIM/100"),
Some(jan24),
Some("Canon"),
Some(8),
),
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
mk(4, "d.jpg", None, None, None, None),
];
let f = explorer::facets(&found);
assert_eq!(f["total"], 4);
assert_eq!(f["years"][0]["year"], 2024);
assert_eq!(f["years"][1]["year"], 2023);
assert_eq!(f["years"][1]["count"], 2);
assert_eq!(f["undated"], 1);
assert_eq!(f["carved"], 1);
assert_eq!(f["duplicates"], 1);
assert_eq!(f["cameras"][0]["name"], "Canon");
let dcim = f["folders"]
.as_array()
.unwrap()
.iter()
.find(|d| d["path"] == "DCIM")
.unwrap();
assert_eq!(dcim["count"], 2);
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
assert_eq!(q(r#"{"folder":"DCIM"}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"folder":"@carved"}"#).select(&found).len(), 1);
assert_eq!(q(r#"{"camera":"Canon"}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"undated":true}"#).select(&found).len(), 1);
assert_eq!(q(r#"{"dedupe":true}"#).select(&found).len(), 3);
assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1);
let mut items = q("{}").select(&found);
explorer::sort_items(&mut items, Some("date"));
assert_eq!(items[0].id, 2); // mais recente primeiro
assert_eq!(items.last().unwrap().id, 4); // sem data por último
}
+9
View File
@@ -32,6 +32,15 @@ fn check(fs: &str, only_carvable: bool) {
assert!(seen.insert(o), "[{fs}] achado duplicado no offset {o}");
}
}
if fs != "ext4" {
for f in res.found.iter().filter(|f| f.origin != Origin::Carved) {
assert!(
f.modified.is_some() && f.created.is_some(),
"[{fs}] {} sem datas de modificação/criação",
f.name
);
}
}
assert_eq!(before, sha256_file(&img), "a origem foi modificada!");
}