From 58940965bb9c9cd3024f01e384d735561dc1f710 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 16:09:46 -0300 Subject: [PATCH] =?UTF-8?q?Relat=C3=B3rio=20em=20PDF=20(A4)=20com=20gerado?= =?UTF-8?q?r=20pr=C3=B3prio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 4 +- README.pt-BR.md | 4 +- apps/gui/ui/app.js | 2 +- apps/gui/ui/i18n-en.js | 2 +- crates/guara-cli/src/main.rs | 4 +- crates/guara-core/src/lib.rs | 1 + crates/guara-core/src/pdf.rs | 484 +++++++++++++++++++++++++++ crates/guara-core/src/report.rs | 376 +++++++++++++++++++++ crates/guara-core/src/service.rs | 24 +- crates/guara-core/tests/forensics.rs | 6 + crates/guara-core/tests/pdf.rs | 180 ++++++++++ docs/architecture.md | 2 +- docs/cli.md | 4 +- docs/pt-BR/arquitetura.md | 2 +- docs/pt-BR/cli.md | 4 +- 15 files changed, 1082 insertions(+), 17 deletions(-) create mode 100644 crates/guara-core/src/pdf.rs create mode 100644 crates/guara-core/tests/pdf.rs diff --git a/README.md b/README.md index ba958a2..0359f6f 100644 --- a/README.md +++ b/README.md @@ -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 | -| **Next** | Audit log, signed and PDF report, APFS free map, tests on Windows and macOS | +| **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** | 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 | | **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 | diff --git a/README.pt-BR.md b/README.pt-BR.md index 2692e21..0aea4d1 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -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 | -| **Próximo** | Registro de auditoria, relatório assinado e em PDF, mapa livre do APFS, testes em Windows e macOS | +| **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** | 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 | | **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 | diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 08942ce..4b9d350 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -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' }); 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') { - 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: '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 } ] }); diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index d20fac2..2bb5d69 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -320,7 +320,7 @@ export const EN = { '{n} linhas gravadas': '{n} rows written', 'Relatório de recuperação': 'Recovery 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', 'Número do caso': 'Case number', 'Responsável pelo exame': 'Examiner', diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index 81f935d..52d4f1d 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -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, diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 552746e..deb4e40 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -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; diff --git a/crates/guara-core/src/pdf.rs b/crates/guara-core/src/pdf.rs new file mode 100644 index 0000000..651b093 --- /dev/null +++ b/crates/guara-core/src/pdf.rs @@ -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::() * 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 { + 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, + 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]) { + 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> = 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 { + 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 = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); + let mut offsets: Vec = vec![]; + let mut obj = |out: &mut Vec, 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::>() + .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 + } +} diff --git a/crates/guara-core/src/report.rs b/crates/guara-core/src/report.rs index a613b8e..454a812 100644 --- a/crates/guara-core/src/report.rs +++ b/crates/guara-core/src/report.rs @@ -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, } 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!( + "

Trilha de auditoria

Entradas{}
Encadeamento{}
Hash da última entrada{}
", + 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("

Partições

"); 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("
#SistemaInício (bytes)TamanhoRótuloObservação
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.
"); o } + +/// Relatório em PDF (A4), com as mesmas seções do HTML. +pub fn pdf(r: &ReportInput) -> Vec { + 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> = 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::>() + .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::>() + .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> = 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> = 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'], "")) +} diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 08643de..7796af7 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -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 }), diff --git a/crates/guara-core/tests/forensics.rs b/crates/guara-core/tests/forensics.rs index 4a8bd42..1c11cd2 100644 --- a/crates/guara-core/tests/forensics.rs +++ b/crates/guara-core/tests/forensics.rs @@ -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("