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
@@ -111,8 +111,8 @@ Summary of the [ROADMAP](ROADMAP.md):
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART |
|
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, APFS free map |
|
||||||
| **Next** | Audit log, signed and PDF report, APFS free map, tests on Windows and macOS |
|
| **Next** | Digitally signed report, tests on Windows and macOS |
|
||||||
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
|
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
|
||||||
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
|
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
|
||||||
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
|
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
|
||||||
|
|||||||
+2
-2
@@ -111,8 +111,8 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART |
|
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa livre do APFS |
|
||||||
| **Próximo** | Registro de auditoria, relatório assinado e em PDF, mapa livre do APFS, testes em Windows e macOS |
|
| **Próximo** | Relatório assinado digitalmente, testes em Windows e macOS |
|
||||||
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
|
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
|
||||||
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
|
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
|
||||||
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
|
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
|
||||||
|
|||||||
+1
-1
@@ -667,7 +667,7 @@ async function onProgressClick(e) {
|
|||||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} linhas gravadas`); } catch (err) { toast(err.message); } }
|
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||||
} else if (act === 'report') {
|
} else if (act === 'report') {
|
||||||
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [
|
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.', ok: 'Gerar relatório', fields: [
|
||||||
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
|
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
|
||||||
{ name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' },
|
{ name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' },
|
||||||
{ name: 'notes', label: 'Observações', multiline: true } ] });
|
{ name: 'notes', label: 'Observações', multiline: true } ] });
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export const EN = {
|
|||||||
'{n} linhas gravadas': '{n} rows written',
|
'{n} linhas gravadas': '{n} rows written',
|
||||||
'Relatório de recuperação': 'Recovery report',
|
'Relatório de recuperação': 'Recovery report',
|
||||||
'Gerar relatório': 'Generate report',
|
'Gerar relatório': 'Generate report',
|
||||||
'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.': 'Generates a print-ready HTML report with the examined source, the findings and the hashes of recovered files.',
|
'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.': 'Generates a print-ready report with the examined source, the findings and the hashes of recovered files. End the name in .pdf for a PDF; with .html you get a web page.',
|
||||||
'Salvar em': 'Save to',
|
'Salvar em': 'Save to',
|
||||||
'Número do caso': 'Case number',
|
'Número do caso': 'Case number',
|
||||||
'Responsável pelo exame': 'Examiner',
|
'Responsável pelo exame': 'Examiner',
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ enum Cmd {
|
|||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
filter: FilterArgs,
|
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 {
|
Report {
|
||||||
session: String,
|
session: String,
|
||||||
/// Arquivo HTML de saída
|
/// Arquivo de saída (.pdf ou .html)
|
||||||
out: String,
|
out: String,
|
||||||
#[arg(long, default_value = "")]
|
#[arg(long, default_value = "")]
|
||||||
case: String,
|
case: String,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub mod mp4rebuild;
|
|||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub mod owner;
|
pub mod owner;
|
||||||
pub mod partition;
|
pub mod partition;
|
||||||
|
pub mod pdf;
|
||||||
pub mod raid;
|
pub mod raid;
|
||||||
pub mod raid_detect;
|
pub mod raid_detect;
|
||||||
pub mod reconnect;
|
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,
|
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 struct ReportInput<'a> {
|
||||||
pub case: &'a CaseInfo,
|
pub case: &'a CaseInfo,
|
||||||
pub target: &'a str,
|
pub target: &'a str,
|
||||||
@@ -30,6 +38,7 @@ pub struct ReportInput<'a> {
|
|||||||
pub elapsed: f64,
|
pub elapsed: f64,
|
||||||
pub recoveries: &'a [RecoverReport],
|
pub recoveries: &'a [RecoverReport],
|
||||||
pub source_hashes: Option<&'a Hashes>,
|
pub source_hashes: Option<&'a Hashes>,
|
||||||
|
pub audit: Option<AuditNote>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cell(s: &str) -> String {
|
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 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 {
|
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>");
|
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 {
|
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.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
|
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
|
let elapsed = s
|
||||||
.finished_in
|
.finished_in
|
||||||
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
|
.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,
|
case: &case,
|
||||||
target: &j.target,
|
target: &j.target,
|
||||||
options: &j.opts,
|
options: &j.opts,
|
||||||
@@ -686,8 +695,17 @@ impl Engine {
|
|||||||
elapsed,
|
elapsed,
|
||||||
recoveries: &s.recoveries,
|
recoveries: &s.recoveries,
|
||||||
source_hashes: hashes.as_ref(),
|
source_hashes: hashes.as_ref(),
|
||||||
});
|
audit,
|
||||||
crate::owner::write_owned(Path::new(path), html)?;
|
};
|
||||||
|
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(
|
crate::audit::record(
|
||||||
"export.report",
|
"export.report",
|
||||||
json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }),
|
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,
|
elapsed: 1.5,
|
||||||
recoveries: &[],
|
recoveries: &[],
|
||||||
source_hashes: Some(&hashes),
|
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("<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("<script>alert(1)</script>.txt"));
|
||||||
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
||||||
assert!(html.contains(&hashes.sha256));
|
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"]);
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ recover copies with MD5, SHA-1 and SHA-256 and writes the
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `recover` | Copies the files, checks the hashes, writes the manifest, resumes recovery |
|
| `recover` | Copies the files, checks the hashes, writes the manifest, resumes recovery |
|
||||||
| `hashing`, `search` | Hashes and text search across the whole disk |
|
| `hashing`, `search` | Hashes and text search across the whole disk |
|
||||||
| `report`, `explorer` | HTML report, CSV, explorer filters and groupings |
|
| `report`, `pdf`, `explorer` | HTML and PDF report (the PDF writer is built in, no external library), CSV, explorer filters and groupings |
|
||||||
| `health`, `external` | SMART and external tools (`smartctl`) |
|
| `health`, `external` | SMART and external tools (`smartctl`) |
|
||||||
|
|
||||||
### Service
|
### Service
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linu
|
|||||||
| `hash <source>` | MD5, SHA-1 and SHA-256 of the device |
|
| `hash <source>` | MD5, SHA-1 and SHA-256 of the device |
|
||||||
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
|
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
|
||||||
| `export <session> <csv>` | Spreadsheet of the findings |
|
| `export <session> <csv>` | Spreadsheet of the findings |
|
||||||
| `report <session> <html>` | Printable HTML report |
|
| `report <session> <file>` | Printable report, HTML or PDF (by the file extension) |
|
||||||
| `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain |
|
| `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain |
|
||||||
| `sessions` | Automatically saved sessions |
|
| `sessions` | Automatically saved sessions |
|
||||||
| `ui` | Interface in the browser (127.0.0.1, with token) |
|
| `ui` | Interface in the browser (127.0.0.1, with token) |
|
||||||
@@ -136,4 +136,4 @@ Searches in UTF-8 and UTF-16 (`--no-utf16` turns it off), including free space a
|
|||||||
guara report s.json report.html --case 2026-001 --examiner "Name" --hash-source
|
guara report s.json report.html --case 2026-001 --examiner "Name" --hash-source
|
||||||
```
|
```
|
||||||
|
|
||||||
Includes case identification, source, partitions, summary, hashes and the list of files. `--hash-source` adds the hash of the whole source.
|
Includes case identification, source, partitions, summary, hashes, the audit trail status and the list of files. `--hash-source` adds the hash of the whole source. The format follows the extension: `report.pdf` writes an A4 PDF (standard fonts, nothing external), anything else writes HTML.
|
||||||
@@ -65,7 +65,7 @@ recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `recover` | Copia os arquivos, confere os hashes, grava o manifesto, retoma recuperação |
|
| `recover` | Copia os arquivos, confere os hashes, grava o manifesto, retoma recuperação |
|
||||||
| `hashing`, `search` | Hashes e busca de texto no disco inteiro |
|
| `hashing`, `search` | Hashes e busca de texto no disco inteiro |
|
||||||
| `report`, `explorer` | Relatório HTML, CSV, filtros e agrupamentos do explorador |
|
| `report`, `pdf`, `explorer` | Relatório HTML e PDF (o gerador de PDF é próprio, sem biblioteca externa), CSV, filtros e agrupamentos do explorador |
|
||||||
| `health`, `external` | SMART e ferramentas externas (`smartctl`) |
|
| `health`, `external` | SMART e ferramentas externas (`smartctl`) |
|
||||||
|
|
||||||
### Serviço
|
### Serviço
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e
|
|||||||
| `hash <origem>` | MD5, SHA-1 e SHA-256 do dispositivo |
|
| `hash <origem>` | MD5, SHA-1 e SHA-256 do dispositivo |
|
||||||
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
|
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
|
||||||
| `export <sessão> <csv>` | Planilha dos achados |
|
| `export <sessão> <csv>` | Planilha dos achados |
|
||||||
| `report <sessão> <html>` | Relatório HTML imprimível |
|
| `report <sessão> <arquivo>` | Relatório imprimível, HTML ou PDF (pela extensão do arquivo) |
|
||||||
| `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash |
|
| `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash |
|
||||||
| `sessions` | Sessões salvas automaticamente |
|
| `sessions` | Sessões salvas automaticamente |
|
||||||
| `ui` | Interface no navegador (127.0.0.1, com token) |
|
| `ui` | Interface no navegador (127.0.0.1, com token) |
|
||||||
@@ -136,4 +136,4 @@ Procura em UTF-8 e UTF-16 (`--no-utf16` desliga), inclusive no espaço livre e f
|
|||||||
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
|
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
|
||||||
```
|
```
|
||||||
|
|
||||||
Traz identificação do caso, origem, partições, resumo, hashes e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira.
|
Traz identificação do caso, origem, partições, resumo, hashes, a situação da trilha de auditoria e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira. O formato segue a extensão: `relatorio.pdf` gera um PDF A4 (fontes padrão, nada externo); qualquer outra gera HTML.
|
||||||
Reference in new issue
Block a user