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:
1 parent
26e5ebb592
commit
58940965bb
15 files changed
+1082
-17
No files matched your search
@@ -110,10 +110,10 @@ enum Cmd {
|
||||
#[command(flatten)]
|
||||
filter: FilterArgs,
|
||||
},
|
||||
/// Gera o relatório HTML de recuperação (identificação do caso, origem, achados, hashes)
|
||||
/// Gera o relatório de recuperação (identificação do caso, origem, achados, hashes): PDF se o arquivo terminar em .pdf, senão HTML
|
||||
Report {
|
||||
session: String,
|
||||
/// Arquivo HTML de saída
|
||||
/// Arquivo de saída (.pdf ou .html)
|
||||
out: String,
|
||||
#[arg(long, default_value = "")]
|
||||
case: String,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'], ""))
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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("<script>alert(1)</script>.txt"));
|
||||
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
||||
assert!(html.contains(&hashes.sha256));
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
Reference in new issue
Block a user