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
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:
1 parent
1457caa453
commit
45d535bdb7
34 files changed
+4433
-365
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// "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)
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & 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("<script>alert(1)</script>.txt"));
|
||||
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
||||
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
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user