Relatório em PDF (A4) com gerador próprio

guara report relatorio.pdf (e o botão de relatório da interface, pela extensão) gera
as mesmas seções do HTML em PDF com fontes padrão, sem dependência nova. O relatório,
em PDF e HTML, passa a trazer a situação da trilha de auditoria e o hash da última
entrada. Conferido com pdfinfo e pdftotext.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 16:09:46 -03:00
1 parent 26e5ebb592
commit 58940965bb
15 files changed
+1082 -17

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ pub mod mp4rebuild;
pub mod notify;
pub mod owner;
pub mod partition;
pub mod pdf;
pub mod raid;
pub mod raid_detect;
pub mod reconnect;
+484
View File
@@ -0,0 +1,484 @@
//! Gerador mínimo de PDF (A4, texto e tabelas) para o relatório de recuperação.
//!
//! Usa só as fontes padrão do PDF (Helvetica, Helvetica-Bold e Courier) com codificação
//! WinAnsi, então o arquivo é autocontido e não depende de fontes instaladas. Caracteres fora
//! do Latin-1/WinAnsi viram `?`.
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
const W: f64 = 595.28;
const H: f64 = 841.89;
const MARGIN: f64 = 40.0;
#[derive(Clone, Copy, PartialEq)]
pub enum Font {
Regular,
Bold,
Mono,
}
impl Font {
fn res(self) -> &'static str {
match self {
Font::Regular => "F1",
Font::Bold => "F2",
Font::Mono => "F3",
}
}
}
// Larguras AFM (milésimos de em) dos caracteres ASCII 32..=126.
const HELV: [u16; 95] = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667,
611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500,
222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
];
const HELV_B: [u16; 95] = [
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667,
611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556,
278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
];
/// Letra base de um caractere acentuado do Latin-1 (a largura é a da letra sem acento).
fn base_letter(c: char) -> char {
match c {
'À'..='Å' => 'A',
'Ç' => 'C',
'È'..='Ë' => 'E',
'Ì'..='Ï' => 'I',
'Ñ' => 'N',
'Ò'..='Ö' | 'Ø' => 'O',
'Ù'..='Ü' => 'U',
'Ý' => 'Y',
'à'..='å' => 'a',
'ç' => 'c',
'è'..='ë' => 'e',
'ì'..='ï' => 'i',
'ñ' => 'n',
'ò'..='ö' | 'ø' => 'o',
'ù'..='ü' => 'u',
'ý' | 'ÿ' => 'y',
_ => '?',
}
}
fn char_width(c: char, f: Font) -> f64 {
if f == Font::Mono {
return 0.6;
}
let t = if f == Font::Bold { &HELV_B } else { &HELV };
let c = if (' '..='~').contains(&c) {
c
} else {
base_letter(c)
};
match c {
' '..='~' => t[c as usize - 32] as f64 / 1000.0,
_ => 0.556,
}
}
pub fn text_width(s: &str, size: f64, f: Font) -> f64 {
s.chars().map(|c| char_width(c, f)).sum::<f64>() * size
}
/// Byte WinAnsi de um caractere (ou `?`).
fn win_ansi(c: char) -> u8 {
let n = c as u32;
match c {
'€' => 0x80,
'‘' => 0x91,
'’' => 0x92,
'“' => 0x93,
'”' => 0x94,
'•' => 0x95,
'–' => 0x96,
'—' => 0x97,
'…' => 0x85,
_ if (0x20..=0x7E).contains(&n) || (0xA0..=0xFF).contains(&n) => n as u8,
_ => b'?',
}
}
fn escape(s: &str) -> String {
let mut o = String::new();
for c in s.chars() {
match win_ansi(c) {
b'(' => o.push_str("\\("),
b')' => o.push_str("\\)"),
b'\\' => o.push_str("\\\\"),
b if (0x20..0x7F).contains(&b) => o.push(b as char),
b => o.push_str(&format!("\\{b:03o}")),
}
}
o
}
/// Quebra `s` em linhas de no máximo `width` pontos. Sem espaço onde quebrar (caminhos,
/// hashes), corta no meio da palavra.
pub fn wrap(s: &str, size: f64, f: Font, width: f64) -> Vec<String> {
let mut lines = vec![];
for para in s.split('\n') {
let mut cur = String::new();
let mut cur_w = 0.0;
for word in para.split(' ') {
let ww = text_width(word, size, f);
let sp = if cur.is_empty() {
0.0
} else {
char_width(' ', f) * size
};
if cur_w + sp + ww <= width {
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
cur_w += sp + ww;
continue;
}
if !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
cur_w = 0.0;
}
if ww <= width {
cur = word.to_string();
cur_w = ww;
} else {
for c in word.chars() {
let cw = char_width(c, f) * size;
if cur_w + cw > width && !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
cur_w = 0.0;
}
cur.push(c);
cur_w += cw;
}
}
}
lines.push(cur);
}
lines
}
pub struct Col {
pub title: &'static str,
pub width: f64,
pub font: Font,
pub right: bool,
}
pub struct Pdf {
title: String,
footer: String,
pages: Vec<String>,
cur: String,
y: f64,
}
type Rgb = (f64, f64, f64);
pub const TEAL: Rgb = (0.059, 0.463, 0.431);
pub const GRAY: Rgb = (0.36, 0.43, 0.42);
pub const BLACK: Rgb = (0.06, 0.12, 0.12);
impl Pdf {
pub fn new(title: &str, footer: &str) -> Self {
Self {
title: title.into(),
footer: footer.into(),
pages: vec![],
cur: String::new(),
y: H - MARGIN,
}
}
pub fn content_width() -> f64 {
W - 2.0 * MARGIN
}
fn new_page(&mut self) {
if !self.cur.is_empty() || self.y != H - MARGIN {
self.pages.push(std::mem::take(&mut self.cur));
}
self.y = H - MARGIN;
}
fn ensure(&mut self, h: f64) {
if self.y - h < MARGIN + 14.0 {
self.new_page();
}
}
fn put(&mut self, x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb) {
if s.is_empty() {
return;
}
self.cur.push_str(&format!(
"BT /{} {size:.1} Tf {:.3} {:.3} {:.3} rg {x:.2} {y:.2} Td ({}) Tj ET\n",
f.res(),
c.0,
c.1,
c.2,
escape(s)
));
}
fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Rgb) {
self.cur.push_str(&format!(
"{:.3} {:.3} {:.3} rg {x:.2} {y:.2} {w:.2} {h:.2} re f\n",
c.0, c.1, c.2
));
}
fn line(&mut self, x1: f64, x2: f64, y: f64, c: Rgb, w: f64) {
self.cur.push_str(&format!(
"{:.3} {:.3} {:.3} RG {w} w {x1:.2} {y:.2} m {x2:.2} {y:.2} l S\n",
c.0, c.1, c.2
));
}
pub fn space(&mut self, h: f64) {
self.y -= h;
}
pub fn title(&mut self, s: &str, sub: &str) {
self.ensure(60.0);
self.put(MARGIN, self.y - 20.0, s, 20.0, Font::Bold, BLACK);
self.put(MARGIN, self.y - 36.0, sub, 9.0, Font::Regular, GRAY);
self.line(MARGIN, W - MARGIN, self.y - 44.0, TEAL, 2.0);
self.y -= 58.0;
}
pub fn heading(&mut self, s: &str) {
self.ensure(50.0);
self.y -= 14.0;
self.put(MARGIN, self.y - 11.0, s, 12.0, Font::Bold, TEAL);
self.y -= 20.0;
}
pub fn paragraph(&mut self, s: &str, size: f64, f: Font, c: Rgb) {
for l in wrap(s, size, f, Self::content_width()) {
self.ensure(size * 1.4);
self.put(MARGIN, self.y - size, &l, size, f, c);
self.y -= size * 1.4;
}
self.y -= 3.0;
}
/// Aviso destacado com barra lateral.
pub fn note(&mut self, s: &str) {
let size = 9.0;
let lines = wrap(s, size, Font::Regular, Self::content_width() - 16.0);
let h = lines.len() as f64 * size * 1.4 + 8.0;
self.ensure(h + 4.0);
let top = self.y;
self.rect(MARGIN, top - h, Self::content_width(), h, (1.0, 0.97, 0.88));
self.rect(MARGIN, top - h, 3.0, h, (0.88, 0.63, 0.0));
for (i, l) in lines.iter().enumerate() {
self.put(
MARGIN + 10.0,
top - 4.0 - size * 1.4 * (i as f64 + 0.8),
l,
size,
Font::Regular,
BLACK,
);
}
self.y -= h + 6.0;
}
/// Linhas rótulo/valor; o valor pode ser monoespaçado.
pub fn kv(&mut self, rows: &[(&str, &str, Font)]) {
let lw = 140.0;
let size = 9.5;
for (k, v, f) in rows {
let lines = wrap(v, size, *f, Self::content_width() - lw - 8.0);
let h = lines.len() as f64 * size * 1.4 + 6.0;
self.ensure(h);
let top = self.y;
self.put(MARGIN + 4.0, top - size - 3.0, k, size, Font::Regular, GRAY);
for (i, l) in lines.iter().enumerate() {
self.put(
MARGIN + lw,
top - size - 3.0 - i as f64 * size * 1.4,
l,
size,
*f,
BLACK,
);
}
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
self.y -= h;
}
self.y -= 4.0;
}
/// Tabela com cabeçalho repetido a cada página e linhas que quebram texto.
pub fn table(&mut self, cols: &[Col], rows: &[Vec<String>]) {
let size = 8.0;
let pad = 3.0;
let header = |p: &mut Pdf| {
let h = size * 1.4 + 2.0 * pad;
p.ensure(h + 2.0 * size * 1.4);
let top = p.y;
p.rect(
MARGIN,
top - h,
Self::content_width(),
h,
(0.906, 0.953, 0.949),
);
let mut x = MARGIN;
for c in cols {
let tx = if c.right {
x + c.width - pad - text_width(c.title, size, Font::Bold)
} else {
x + pad
};
p.put(
tx,
top - pad - size,
c.title,
size,
Font::Bold,
(0.043, 0.31, 0.29),
);
x += c.width;
}
p.y -= h;
};
header(self);
for row in rows {
let cells: Vec<Vec<String>> = cols
.iter()
.zip(row)
.map(|(c, t)| wrap(t, size, c.font, c.width - 2.0 * pad))
.collect();
let n = cells.iter().map(|c| c.len()).max().unwrap_or(1).max(1);
let h = n as f64 * size * 1.35 + 2.0 * pad;
if self.y - h < MARGIN + 14.0 {
self.new_page();
header(self);
}
let top = self.y;
let mut x = MARGIN;
for (c, lines) in cols.iter().zip(&cells) {
for (i, l) in lines.iter().enumerate() {
let tx = if c.right {
x + c.width - pad - text_width(l, size, c.font)
} else {
x + pad
};
self.put(
tx,
top - pad - size - i as f64 * size * 1.35,
l,
size,
c.font,
BLACK,
);
}
x += c.width;
}
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
self.y -= h;
}
self.y -= 6.0;
}
/// Fecha o documento. `date` é a data de criação em UTC (`AAAAMMDDHHMMSS`).
pub fn finish(mut self, date: &str) -> Vec<u8> {
if !self.cur.is_empty() || self.pages.is_empty() {
self.pages.push(std::mem::take(&mut self.cur));
}
let total = self.pages.len();
for (i, p) in self.pages.iter_mut().enumerate() {
let label = format!("{} · página {} de {total}", self.footer, i + 1);
let x = W - MARGIN - text_width(&label, 8.0, Font::Regular);
p.push_str(&format!(
"BT /F1 8 Tf {:.3} {:.3} {:.3} rg {x:.2} 24 Td ({}) Tj ET\n",
GRAY.0,
GRAY.1,
GRAY.2,
escape(&label)
));
}
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
let mut offsets: Vec<usize> = vec![];
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
// 1 catálogo, 2 páginas, 3-5 fontes, 6 info, depois (página, conteúdo) por página
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
let kids: String = (0..total)
.map(|i| format!("{} 0 R", 7 + 2 * i))
.collect::<Vec<_>>()
.join(" ");
obj(
&mut out,
format!("<< /Type /Pages /Kids [{kids}] /Count {total} >>").as_bytes(),
);
for name in ["Helvetica", "Helvetica-Bold", "Courier"] {
obj(
&mut out,
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /{name} /Encoding /WinAnsiEncoding >>"
)
.as_bytes(),
);
}
let title: String = format!("\u{feff}{}", self.title)
.encode_utf16()
.map(|u| format!("{u:04X}"))
.collect();
obj(
&mut out,
format!(
"<< /Title <{title}> /Producer (Guara {}) /CreationDate (D:{date}Z) >>",
env!("CARGO_PKG_VERSION")
)
.as_bytes(),
);
for (i, p) in self.pages.iter().enumerate() {
obj(
&mut out,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents {} 0 R >>",
8 + 2 * i
)
.as_bytes(),
);
let mut z = ZlibEncoder::new(vec![], Compression::default());
z.write_all(p.as_bytes()).unwrap();
let z = z.finish().unwrap();
let mut body =
format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", z.len()).into_bytes();
body.extend_from_slice(&z);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
}
let xref = out.len();
out.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes(),
);
for o in &offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R /Info 6 0 R >>\nstartxref\n{xref}\n%%EOF\n",
offsets.len() + 1
)
.as_bytes(),
);
out
}
}
+376
View File
@@ -19,6 +19,14 @@ pub struct CaseInfo {
pub notes: String,
}
/// Situação da trilha de auditoria no momento do relatório.
pub struct AuditNote {
pub entries: usize,
pub ok: bool,
/// Hash da última entrada, para guardar fora da máquina.
pub head: String,
}
pub struct ReportInput<'a> {
pub case: &'a CaseInfo,
pub target: &'a str,
@@ -30,6 +38,7 @@ pub struct ReportInput<'a> {
pub elapsed: f64,
pub recoveries: &'a [RecoverReport],
pub source_hashes: Option<&'a Hashes>,
pub audit: Option<AuditNote>,
}
fn cell(s: &str) -> String {
@@ -190,6 +199,15 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
if r.options.include_allocated { ", incluindo áreas em uso" } else { "" }
));
if let Some(a) = &r.audit {
o.push_str(&format!(
"<h2>Trilha de auditoria</h2><table class=\"kv\"><tr><td>Entradas</td><td>{}</td></tr><tr><td>Encadeamento</td><td>{}</td></tr><tr><td>Hash da última entrada</td><td class=\"mono\">{}</td></tr></table>",
a.entries,
if a.ok { "íntegro" } else { "QUEBRADO: confira com guara audit --verify" },
esc(&a.head)
));
}
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 {
@@ -299,3 +317,361 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
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
}
/// Relatório em PDF (A4), com as mesmas seções do HTML.
pub fn pdf(r: &ReportInput) -> Vec<u8> {
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let stamp = crate::audit::utc_iso(now);
let version = env!("CARGO_PKG_VERSION");
let mut d = Pdf::new(
"Guará: relatório de recuperação",
&format!("Guará {version}"),
);
d.title(
"Relatório de recuperação de arquivos",
&format!("Gerado pelo Guará {version} em {}", fmt_unix(now)),
);
let nf = |v: &str| {
if v.is_empty() {
"não informado".to_string()
} else {
v.to_string()
}
};
d.heading("Identificação do caso");
let mut rows = vec![
("Número do caso", nf(&r.case.case_id), Font::Regular),
(
"Responsável pelo exame",
nf(&r.case.examiner),
Font::Regular,
),
("Organização", nf(&r.case.organization), Font::Regular),
];
if !r.case.notes.is_empty() {
rows.push(("Observações", r.case.notes.clone(), Font::Regular));
}
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
d.kv(&rows);
d.heading("Origem examinada");
let mut rows: Vec<(&str, String, Font)> =
vec![("Dispositivo / imagem", r.target.to_string(), Font::Mono)];
if let Some(l) = r.layout {
rows.push((
"Tamanho",
format!("{} ({} bytes)", human(l.size), l.size),
Font::Regular,
));
rows.push(("Tabela de partições", l.table.to_uppercase(), Font::Regular));
}
rows.push((
"Hash da origem",
match r.source_hashes {
Some(h) => format!("MD5 {}\nSHA-1 {}\nSHA-256 {}", h.md5, h.sha1, h.sha256),
None => "não calculado nesta sessão (use guara hash ou guara image)".into(),
},
if r.source_hashes.is_some() {
Font::Mono
} else {
Font::Regular
},
));
if let Some(t) = r.started_unix {
rows.push(("Início da varredura", fmt_unix(t), Font::Regular));
}
rows.push((
"Duração da varredura",
format!("{:.1} s", r.elapsed),
Font::Regular,
));
rows.push((
"Modo",
format!(
"{}{}",
if r.options.mode == ScanMode::Deep {
"profunda (metadados + assinatura)"
} else {
"rápida (metadados)"
},
if r.options.include_allocated {
", incluindo áreas em uso"
} else {
""
}
),
Font::Regular,
));
rows.push((
"Garantia de leitura",
"A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.".into(),
Font::Regular,
));
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
d.kv(&rows);
if let Some(a) = &r.audit {
d.heading("Trilha de auditoria");
let ok = if a.ok {
"íntegro"
} else {
"QUEBRADO: confira com guara audit --verify"
};
d.kv(&[
("Entradas", &a.entries.to_string(), Font::Regular),
("Encadeamento", ok, Font::Regular),
("Hash da última entrada", &a.head, Font::Mono),
]);
}
if let Some(l) = r.layout {
d.heading("Partições");
let cols = [
Col {
title: "#",
width: 22.0,
font: Font::Regular,
right: false,
},
Col {
title: "Sistema",
width: 80.0,
font: Font::Regular,
right: false,
},
Col {
title: "Início (bytes)",
width: 90.0,
font: Font::Regular,
right: true,
},
Col {
title: "Tamanho",
width: 65.0,
font: Font::Regular,
right: true,
},
Col {
title: "Rótulo",
width: 90.0,
font: Font::Regular,
right: false,
},
Col {
title: "Observação",
width: 168.28,
font: Font::Regular,
right: false,
},
];
let mut rows: Vec<Vec<String>> = l
.partitions
.iter()
.map(|p| {
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("; ");
vec![
(p.index + 1).to_string(),
p.fs.name().to_string(),
p.start.to_string(),
human(p.len),
p.label.clone().unwrap_or_default(),
obs,
]
})
.collect();
for g in &l.gaps {
rows.push(vec![
"-".into(),
"espaço não particionado".into(),
g.start.to_string(),
human(g.len),
String::new(),
String::new(),
]);
}
d.table(&cols, &rows);
}
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;
}
d.heading("Resumo dos achados");
d.kv(&[
(
"Arquivos encontrados",
&format!("{} ({})", r.found.len(), human(bytes)),
Font::Bold,
),
(
"Confiança",
&format!(
"alta {} · média {} · baixa {}",
conf[2], conf[1], conf[0]
),
Font::Regular,
),
(
"Por tipo",
&by_cat
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join(" · "),
Font::Regular,
),
]);
for w in r.warnings {
d.note(w);
}
if !r.recoveries.is_empty() {
d.heading("Arquivos recuperados (integridade)");
for rec in r.recoveries {
d.paragraph(
&format!(
"Destino: {}, {} arquivo(s), {}{}",
rec.destination,
rec.recovered.len(),
human(rec.total_bytes),
if rec.failed.is_empty() {
String::new()
} else {
format!(", {} falha(s)", rec.failed.len())
}
),
9.0,
Font::Regular,
BLACK,
);
let cols = [
Col {
title: "Arquivo",
width: 140.0,
font: Font::Mono,
right: false,
},
Col {
title: "Tamanho",
width: 55.0,
font: Font::Regular,
right: true,
},
Col {
title: "Hashes",
width: 320.28,
font: Font::Mono,
right: false,
},
];
let rows: Vec<Vec<String>> = rec
.recovered
.iter()
.take(MAX_ROWS)
.map(|f| {
vec![
f.path.clone(),
human(f.size),
format!("MD5 {}\nSHA-1 {}\nSHA-256 {}", f.md5, f.sha1, f.sha256),
]
})
.collect();
d.table(&cols, &rows);
}
}
d.heading("Relação de arquivos encontrados");
if r.found.len() > MAX_ROWS {
d.note(&format!(
"Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.",
r.found.len()
));
}
let cols = [
Col {
title: "Nome",
width: 110.0,
font: Font::Regular,
right: false,
},
Col {
title: "Pasta",
width: 110.0,
font: Font::Regular,
right: false,
},
Col {
title: "Tamanho",
width: 50.0,
font: Font::Regular,
right: true,
},
Col {
title: "Modificado (UTC)",
width: 92.0,
font: Font::Regular,
right: false,
},
Col {
title: "Origem",
width: 50.0,
font: Font::Regular,
right: false,
},
Col {
title: "Conf.",
width: 32.0,
font: Font::Regular,
right: false,
},
Col {
title: "Posição",
width: 71.28,
font: Font::Mono,
right: true,
},
];
let rows: Vec<Vec<String>> = r
.found
.iter()
.take(MAX_ROWS)
.map(|f| {
vec![
f.name.clone(),
f.dir.clone().unwrap_or_default(),
human(f.size),
iso(f.modified),
f.origin.label().to_string(),
f.confidence.label_pt().to_string(),
f.first_offset().map(|o| o.to_string()).unwrap_or_default(),
]
})
.collect();
d.table(&cols, &rows);
d.paragraph(
"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.",
8.0,
Font::Regular,
GRAY,
);
d.finish(&stamp.replace(['-', ':', 'T', 'Z'], ""))
}
+21 -3
View File
@@ -675,7 +675,16 @@ impl Engine {
let elapsed = s
.finished_in
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
let html = crate::report::html(&crate::report::ReportInput {
let audit = crate::audit::Log::open_default()
.verify()
.ok()
.filter(|v| v.entries > 0)
.map(|v| crate::report::AuditNote {
entries: v.entries,
ok: v.ok,
head: v.head,
});
let input = crate::report::ReportInput {
case: &case,
target: &j.target,
options: &j.opts,
@@ -686,8 +695,17 @@ impl Engine {
elapsed,
recoveries: &s.recoveries,
source_hashes: hashes.as_ref(),
});
crate::owner::write_owned(Path::new(path), html)?;
audit,
};
let as_pdf = Path::new(path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"));
let bytes = if as_pdf {
crate::report::pdf(&input)
} else {
crate::report::html(&input).into_bytes()
};
crate::owner::write_owned(Path::new(path), bytes)?;
crate::audit::record(
"export.report",
json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }),
+6
View File
@@ -417,8 +417,14 @@ fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
elapsed: 1.5,
recoveries: &[],
source_hashes: Some(&hashes),
audit: Some(report::AuditNote {
entries: 3,
ok: true,
head: "ab".repeat(32),
}),
});
assert!(!html.contains("<script>alert"), "HTML não escapado");
assert!(html.contains("Trilha de auditoria") && html.contains(&"ab".repeat(32)));
assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;.txt"));
assert!(html.contains("2026-001") && html.contains("Perita &lt;Ana&gt;"));
assert!(html.contains(&hashes.sha256));
+180
View File
@@ -0,0 +1,180 @@
//! Relatório em PDF: estrutura do arquivo (xref, páginas), texto com acentos e paginação.
use guara_core::recover::{RecoverReport, RecoveredFile};
use guara_core::report::{self, AuditNote, CaseInfo, ReportInput};
use guara_core::*;
use std::io::Read;
fn found(i: u64) -> Found {
Found {
id: i,
name: format!("fotografia_{i:04}.jpg"),
dir: Some("/Usuários/João/Imagens (cópia)".into()),
category: Category::Image,
ext: "jpg".into(),
size: 1000 + i,
origin: Origin::Carved,
confidence: Confidence::High,
data: DataLoc::Resident { bytes: vec![0; 4] },
modified: Some(1_689_358_925),
created: None,
accessed: None,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note: None,
}
}
fn build(n: u64) -> Vec<u8> {
let files: Vec<Found> = (0..n).map(found).collect();
let rec = RecoverReport {
destination: "/mnt/outro/Recuperados".into(),
recovered: (0..3)
.map(|i| RecoveredFile {
id: i,
path: format!("Fotos/fotografia_{i:04}.jpg"),
size: 1000 + i,
sha256: "ab".repeat(32),
md5: "cd".repeat(16),
sha1: "ef".repeat(20),
confidence: Confidence::High,
origin: Origin::Carved,
partial: false,
})
.collect(),
total_bytes: 3003,
..Default::default()
};
let case = CaseInfo {
case_id: "2026-001".into(),
examiner: "Perita (Ana) \\ Souza".into(),
organization: "Laboratório".into(),
notes: "Observação com acentos: ção, ã, é, ü e “aspas”.".into(),
};
let opts = ScanOptions::default();
report::pdf(&ReportInput {
case: &case,
target: "/dev/sdb",
options: &opts,
layout: None,
found: &files,
warnings: &["aviso importante sobre o disco".to_string()],
started_unix: Some(1_689_358_925),
elapsed: 1.5,
recoveries: &[rec],
source_hashes: None,
audit: Some(AuditNote {
entries: 7,
ok: true,
head: "12".repeat(32),
}),
})
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay[from..]
.windows(needle.len())
.position(|w| w == needle)
.map(|p| p + from)
}
/// Texto dos fluxos de conteúdo, já descomprimidos.
fn streams(pdf: &[u8]) -> Vec<String> {
let mut out = vec![];
let mut at = 0;
while let Some(s) = find(pdf, b">>\nstream\n", at) {
let start = s + 10;
let end = find(pdf, b"\nendstream", start).unwrap();
let mut text = String::new();
flate2::read::ZlibDecoder::new(&pdf[start..end])
.read_to_string(&mut text)
.unwrap_or_else(|e| panic!("fluxo inválido: {e}"));
out.push(text);
at = end;
}
out
}
#[test]
fn pdf_has_a_valid_cross_reference_table() {
let pdf = build(5);
assert!(pdf.starts_with(b"%PDF-1.4"));
assert!(pdf.ends_with(b"%%EOF\n"));
let sx = find(&pdf, b"startxref\n", 0).unwrap() + 10;
let nl = find(&pdf, b"\n", sx).unwrap();
let xref: usize = std::str::from_utf8(&pdf[sx..nl]).unwrap().parse().unwrap();
assert_eq!(&pdf[xref..xref + 4], b"xref");
// cada entrada aponta exatamente para "N 0 obj"
let table = &pdf[xref..];
let header_end = find(table, b"\n", 5).unwrap();
let count: usize = std::str::from_utf8(&table[5..header_end])
.unwrap()
.split(' ')
.nth(1)
.unwrap()
.parse()
.unwrap();
for n in 1..count {
let e = &table[header_end + 1 + n * 20..header_end + 1 + (n + 1) * 20];
let off: usize = std::str::from_utf8(&e[..10]).unwrap().parse().unwrap();
let want = format!("{n} 0 obj");
assert_eq!(&pdf[off..off + want.len()], want.as_bytes(), "objeto {n}");
}
}
#[test]
fn pdf_text_survives_accents_and_escaping() {
let all = streams(&build(5)).join("\n");
// acentos viram bytes WinAnsi em octal; parênteses e barra são escapados
assert!(all.contains("Identifica\\347\\343o do caso"), "{all}");
assert!(all.contains("Perita \\(Ana\\) \\\\ Souza"));
assert!(all.contains("Laborat\\363rio"));
assert!(all.contains("fotografia_0003.jpg"));
assert!(all.contains(&"ab".repeat(32)));
assert!(all.contains(&"12".repeat(32)), "hash da trilha ausente");
// aspas curvas viram os bytes WinAnsi 0x93/0x94
assert!(all.contains("\\223aspas\\224"));
}
#[test]
fn long_lists_paginate_with_numbered_footers() {
let pdf = build(400);
let pages = streams(&pdf);
assert!(pages.len() >= 5, "{} páginas", pages.len());
let last = pages.len();
assert!(pages[last - 1].contains(&format!("p\\341gina {last} de {last}")));
assert!(pages[0].contains(&format!("p\\341gina 1 de {last}")));
// o cabeçalho da tabela se repete nas páginas seguintes
assert!(pages[2].contains("Modificado \\(UTC\\)"));
// todas as linhas aparecem exatamente uma vez
let all = pages.join("\n");
assert_eq!(all.matches("fotografia_0399.jpg").count(), 1);
// a primeira também consta da lista de recuperados
assert_eq!(all.matches("fotografia_0000.jpg").count(), 2);
}
#[test]
fn text_wraps_instead_of_running_off_the_page() {
use guara_core::pdf::{text_width, wrap, Font};
let lines = wrap(&"a".repeat(200), 8.0, Font::Mono, 100.0);
assert!(lines.len() > 5);
assert!(lines
.iter()
.all(|l| text_width(l, 8.0, Font::Mono) <= 100.0 + 1e-6));
let words = wrap(
"uma frase comum com várias palavras curtas",
10.0,
Font::Regular,
90.0,
);
assert!(
words.len() >= 3
&& words
.iter()
.all(|l| text_width(l, 10.0, Font::Regular) <= 90.0)
);
assert_eq!(wrap("a\nb", 10.0, Font::Regular, 100.0), ["a", "b"]);
}