feat(report): new cover and layout for the HTML and PDF reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13f380291f
commit
fdf1e20cfb
10 files changed
+834
-208
No files matched your search
@@ -7,6 +7,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versio
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Recovery report redesigned. The HTML is now an A4 paginated document (Paged.js embedded, still a single offline file) with a full-bleed deep teal cover where the Guará logo is the hero, a sector-map motif drawn from the findings (one cell per file, colored by confidence), headline numbers, a meta grid and the TLP:AMBER badge, plus a table of contents, running headers and page counters. The PDF gets the same cover (logo embedded as JPEG) and restyled inner pages. All forensic content is unchanged.
|
||||||
- Documentation brought up to date (ROADMAP version table, platform matrix, version stamps) and rewritten without em dashes. The interface screenshots were retaken from the current build, and `scripts/screenshots.sh` now regenerates them.
|
- Documentation brought up to date (ROADMAP version table, platform matrix, version stamps) and rewritten without em dashes. The interface screenshots were retaken from the current build, and `scripts/screenshots.sh` now regenerates them.
|
||||||
|
|
||||||
## [1.5.0] - 2026-11-03
|
## [1.5.0] - 2026-11-03
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/);
|
|||||||
## [Não lançado]
|
## [Não lançado]
|
||||||
|
|
||||||
### Alterado
|
### Alterado
|
||||||
|
- Relatório de recuperação redesenhado. O HTML agora é um documento A4 paginado (Paged.js embutido, continua um único arquivo offline) com capa de página inteira em verde-petróleo escuro onde o logo do Guará é o destaque, um mapa de setores desenhado a partir dos achados (uma célula por arquivo, colorida pela confiança), números principais, grade de metadados e o selo TLP:AMBER, além de sumário, cabeçalhos corridos e numeração de páginas. O PDF ganhou a mesma capa (logo embutido como JPEG) e páginas internas com novo visual. Todo o conteúdo forense continua igual.
|
||||||
- Documentação atualizada (tabela de versões do ROADMAP, matriz de plataformas, versões citadas) e reescrita sem travessões. As capturas de tela da interface foram refeitas a partir da versão atual, e o `scripts/screenshots.sh` agora as regera.
|
- Documentação atualizada (tabela de versões do ROADMAP, matriz de plataformas, versões citadas) e reescrita sem travessões. As capturas de tela da interface foram refeitas a partir da versão atual, e o `scripts/screenshots.sh` agora as regera.
|
||||||
|
|
||||||
## [1.5.0] - 2026-11-03
|
## [1.5.0] - 2026-11-03
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* @license Paged.js v0.4.3 | MIT | https://gitlab.coko.foundation/pagedjs/pagedjs
|
||||||
|
*/
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).PagedPolyfill=t()}(this,(function(){"use strict";function e(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var t,n,r={exports:{}},a={exports:{}};var i,o,s,l,d=function(){},p=d(),c=function(e){return e!==p&&null!==e};function u(){return l||(l=1,s=(n?t:(n=1,t=function(){try{return Object.keys("primitive"),!0}catch(e){return!1}}))()?Object.keys:function(){if(o)return i;o=1;var e=c,t=Object.keys;return i=function(n){return t(e(n)?Object(n):n)}}()),s}var m,h,g=c,f=function(e){if(!g(e))throw new TypeError("Cannot use null or undefined");return e};var y,b,S=function(){var e,t=Object.assign;return"function"==typeof t&&(t(e={foo:"raz"},{bar:"dwa"},{trzy:"trzy"}),e.foo+e.bar+e.trzy==="razdwatrzy")}()?Object.assign:function(){if(h)return m;h=1;var e=u(),t=f,n=Math.max;return m=function(r,a){var i,o,s,l=n(arguments.length,2);for(r=Object(t(r)),s=function(e){try{r[e]=a[e]}catch(e){i||(i=e)}},o=1;o<l;++o)e(a=arguments[o]).forEach(s);if(void 0!==i)throw i;return r},m}(),v=c,x=Array.prototype.forEach,k=Object.create,w="razdwatrzy";var C,T="function"==typeof w.contains&&!0===w.contains("dwa")&&!1===w.contains("foo")?String.prototype.contains:function(){if(b)return y;b=1;var e=String.prototype.indexOf;return y=function(t){return e.call(this,t,arguments[1])>-1},y}(),_=S,z=function(e){var t=k(null);return x.call(arguments,(function(e){v(e)&&function(e,t){var n;for(n in e)t[n]=e[n]}(Object(e),t)})),t},O=function(e){return"function"==typeof e},A=T;C=a.exports=function(e,t){var n,r,a,i,o;return arguments.length<2||"string"!=typeof e?(i=t,t=e,e=null):i=arguments[2],null==e?(n=a=!0,r=!1):(n=A.call(e,"c"),r=A.call(e,"e"),a=A.call(e,"w")),o={value:t,configurable:n,enumerable:r,writable:a},i?_(z(i),o):o},C.gs=function(e,t,n){var r,a,i,o;return"string"!=typeof e?(i=n,n=t,t=e,e=null):i=arguments[3],null==t?t=void 0:O(t)?null==n?n=void 0:O(n)||(i=n,n=void 0):(i=t,t=n=void 0),null==e?(r=!0,a=!1):(r=A.call(e,"c"),a=A.call(e,"e")),o={get:t,set:n,configurable:r,enumerable:a},i?_(z(i),o):o};var E=a.exports,j=function(e){if("function"!=typeof e)throw new TypeError(e+" is not a function");return e};!function(e,t){var n,r,a,i,o,s,l,d=E,p=j,c=Function.prototype.apply,u=Function.prototype.call,m=Object.create,h=Object.defineProperty,g=Object.defineProperties,f=Object.prototype.hasOwnProperty,y={configurable:!0,enumerable:!1,writable:!0};n=function(e,t){var n;return p(t),f.call(this,"__ee__")?n=this.__ee__:(n=y.value=m(null),h(this,"__ee__",y),y.value=null),n[e]?"object"==typeof n[e]?n[e].push(t):n[e]=[n[e],t]:n[e]=t,this},r=function(e,t){var r,i;return p(t),i=this,n.call(this,e,r=function(){a.call(i,e,r),c.call(t,this,arguments)}),r.__eeOnceListener__=t,this},a=function(e,t){var n,r,a,i;if(p(t),!f.call(this,"__ee__"))return this;if(!(n=this.__ee__)[e])return this;if("object"==typeof(r=n[e]))for(i=0;a=r[i];++i)a!==t&&a.__eeOnceListener__!==t||(2===r.length?n[e]=r[i?0:1]:r.splice(i,1));else r!==t&&r.__eeOnceListener__!==t||delete n[e];return this},i=function(e){var t,n,r,a,i;if(f.call(this,"__ee__")&&(a=this.__ee__[e]))if("object"==typeof a){for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];for(a=a.slice(),t=0;r=a[t];++t)c.call(r,this,i)}else switch(arguments.length){case 1:u.call(a,this);break;case 2:u.call(a,this,arguments[1]);break;case 3:u.call(a,this,arguments[1],arguments[2]);break;default:for(n=arguments.length,i=new Array(n-1),t=1;t<n;++t)i[t-1]=arguments[t];c.call(a,this,i)}},o={on:n,once:r,off:a,emit:i},s={on:d(n),once:d(r),off:d(a),emit:d(i)},l=g({},s),e.exports=t=function(e){return null==e?m(l):g(Object(e),s)},t.methods=o}(r,r.exports);var W=r.exports,L=e(W);class B{constructor(e){this.context=e||this,this.hooks=[]}register(){for(var e=0;e<arguments.length;++e)if("function"==typeof arguments[e])this.hooks.push(arguments[e]);else for(var t=0;t<arguments[e].length;++t)this.hooks.push(arguments[e][t])}trigger(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);a&&"function"==typeof a.then?n.push(a):n.push(new Promise(((e,t)=>{e(a)})))})),Promise.all(n)}triggerSync(){var e=arguments,t=this.context,n=[];return this.hooks.forEach((function(r){var a=r.apply(t,e);n.push(a)})),n}list(){return this.hooks}clear(){return this.hooks=[]}}function P(e){if(!e)return;let t;if(void 0!==e.getBoundingClientRect)t=e.getBoundingClientRect();else{let n=document.createRange();n.selectNode(e),t=n.getBoundingClientRect()}return t}function q(e){if(!e)return;let t;if(void 0!==e.getClientRects)t=e.getClientRects();else{let n=document.createRange();n.selectNode(e),t=n.getClientRects()}return t}function M(){var e=(new Date).getTime();return"undefined"!=typeof performance&&"function"==typeof performance.now&&(e+=performance.now()),"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replaceLine truncated
|
||||||
+158
-31
@@ -11,6 +11,8 @@ use std::io::Write;
|
|||||||
const W: f64 = 595.28;
|
const W: f64 = 595.28;
|
||||||
const H: f64 = 841.89;
|
const H: f64 = 841.89;
|
||||||
const MARGIN: f64 = 40.0;
|
const MARGIN: f64 = 40.0;
|
||||||
|
/// Top of the text area on inner pages (leaves room for the running header).
|
||||||
|
const TOP: f64 = H - 60.0;
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
pub enum Font {
|
pub enum Font {
|
||||||
@@ -179,12 +181,22 @@ pub struct Pdf {
|
|||||||
pages: Vec<String>,
|
pages: Vec<String>,
|
||||||
cur: String,
|
cur: String,
|
||||||
y: f64,
|
y: f64,
|
||||||
|
/// Running header (left text, right TLP label) drawn on every inner page.
|
||||||
|
header: Option<(String, String)>,
|
||||||
|
/// Leading full-bleed pages that get no running header/footer.
|
||||||
|
covers: usize,
|
||||||
|
/// Baseline JPEG (`/DCTDecode`): bytes, pixel width, pixel height.
|
||||||
|
image: Option<(&'static [u8], u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rgb = (f64, f64, f64);
|
type Rgb = (f64, f64, f64);
|
||||||
pub const TEAL: Rgb = (0.059, 0.463, 0.431);
|
pub const TEAL: Rgb = (0.059, 0.463, 0.431);
|
||||||
pub const GRAY: Rgb = (0.36, 0.43, 0.42);
|
pub const GRAY: Rgb = (0.36, 0.43, 0.42);
|
||||||
pub const BLACK: Rgb = (0.06, 0.12, 0.12);
|
pub const BLACK: Rgb = (0.06, 0.12, 0.12);
|
||||||
|
pub const DEEP: Rgb = (0.027, 0.212, 0.200);
|
||||||
|
pub const AMBER: Rgb = (0.70, 0.42, 0.0);
|
||||||
|
const ZEBRA: Rgb = (0.953, 0.976, 0.973);
|
||||||
|
const HAIR: Rgb = (0.86, 0.91, 0.90);
|
||||||
|
|
||||||
impl Pdf {
|
impl Pdf {
|
||||||
pub fn new(title: &str, footer: &str) -> Self {
|
pub fn new(title: &str, footer: &str) -> Self {
|
||||||
@@ -193,7 +205,10 @@ impl Pdf {
|
|||||||
footer: footer.into(),
|
footer: footer.into(),
|
||||||
pages: vec![],
|
pages: vec![],
|
||||||
cur: String::new(),
|
cur: String::new(),
|
||||||
y: H - MARGIN,
|
y: TOP,
|
||||||
|
header: None,
|
||||||
|
covers: 0,
|
||||||
|
image: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +217,10 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new_page(&mut self) {
|
fn new_page(&mut self) {
|
||||||
if !self.cur.is_empty() || self.y != H - MARGIN {
|
if !self.cur.is_empty() || self.y != TOP {
|
||||||
self.pages.push(std::mem::take(&mut self.cur));
|
self.pages.push(std::mem::take(&mut self.cur));
|
||||||
}
|
}
|
||||||
self.y = H - MARGIN;
|
self.y = TOP;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure(&mut self, h: f64) {
|
fn ensure(&mut self, h: f64) {
|
||||||
@@ -214,7 +229,7 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn put(&mut self, x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb) {
|
pub fn put(&mut self, x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb) {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,20 +243,80 @@ impl Pdf {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Rgb) {
|
pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Rgb) {
|
||||||
self.cur.push_str(&format!(
|
self.cur.push_str(&format!(
|
||||||
"{:.3} {:.3} {:.3} rg {x:.2} {y:.2} {w:.2} {h:.2} re f\n",
|
"{:.3} {:.3} {:.3} rg {x:.2} {y:.2} {w:.2} {h:.2} re f\n",
|
||||||
c.0, c.1, c.2
|
c.0, c.1, c.2
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn line(&mut self, x1: f64, x2: f64, y: f64, c: Rgb, w: f64) {
|
pub fn line(&mut self, x1: f64, x2: f64, y: f64, c: Rgb, w: f64) {
|
||||||
self.cur.push_str(&format!(
|
self.cur.push_str(&format!(
|
||||||
"{:.3} {:.3} {:.3} RG {w} w {x1:.2} {y:.2} m {x2:.2} {y:.2} l S\n",
|
"{:.3} {:.3} {:.3} RG {w} w {x1:.2} {y:.2} m {x2:.2} {y:.2} l S\n",
|
||||||
c.0, c.1, c.2
|
c.0, c.1, c.2
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Page size in points (A4).
|
||||||
|
pub const SIZE: (f64, f64) = (W, H);
|
||||||
|
|
||||||
|
/// Sets the running header shown on inner pages: `left` in muted caps, `tlp` in amber.
|
||||||
|
pub fn running_header(&mut self, left: &str, tlp: &str) {
|
||||||
|
self.header = Some((left.into(), tlp.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the single JPEG image (drawn with [`Pdf::image`]); must be baseline DCT.
|
||||||
|
pub fn set_image(&mut self, jpeg: &'static [u8], w: u32, h: u32) {
|
||||||
|
self.image = Some((jpeg, w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws the registered image at `(x, y)` (bottom-left) scaled to `w` x `h` points.
|
||||||
|
pub fn image(&mut self, x: f64, y: f64, w: f64, h: f64) {
|
||||||
|
self.cur
|
||||||
|
.push_str(&format!("q {w:.2} 0 0 {h:.2} {x:.2} {y:.2} cm /Im1 Do Q\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the current page as a full-bleed cover (no running header or footer).
|
||||||
|
/// Must be called before any inner content is laid out.
|
||||||
|
pub fn end_cover(&mut self) {
|
||||||
|
self.pages.push(std::mem::take(&mut self.cur));
|
||||||
|
self.covers = self.pages.len();
|
||||||
|
self.y = TOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a fresh page unless the current one is still empty.
|
||||||
|
pub fn page_break(&mut self) {
|
||||||
|
if !self.cur.is_empty() {
|
||||||
|
self.new_page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row of KPI tiles: (label, value, detail). Value colored by `accent`.
|
||||||
|
pub fn kpis(&mut self, items: &[(&str, String, String, Rgb)]) {
|
||||||
|
let gap = 8.0;
|
||||||
|
let n = items.len().max(1) as f64;
|
||||||
|
let w = (Self::content_width() - gap * (n - 1.0)) / n;
|
||||||
|
let h = 58.0;
|
||||||
|
self.ensure(h + 8.0);
|
||||||
|
let top = self.y;
|
||||||
|
for (i, (l, v, d, c)) in items.iter().enumerate() {
|
||||||
|
let x = MARGIN + i as f64 * (w + gap);
|
||||||
|
self.rect(x, top - h, w, h, ZEBRA);
|
||||||
|
self.rect(x, top - 2.5, w, 2.5, *c);
|
||||||
|
self.put(
|
||||||
|
x + 8.0,
|
||||||
|
top - 15.0,
|
||||||
|
&l.to_uppercase(),
|
||||||
|
6.5,
|
||||||
|
Font::Mono,
|
||||||
|
GRAY,
|
||||||
|
);
|
||||||
|
self.put(x + 8.0, top - 38.0, v, 20.0, Font::Bold, *c);
|
||||||
|
self.put(x + 8.0, top - 51.0, d, 7.5, Font::Regular, GRAY);
|
||||||
|
}
|
||||||
|
self.y -= h + 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn space(&mut self, h: f64) {
|
pub fn space(&mut self, h: f64) {
|
||||||
self.y -= h;
|
self.y -= h;
|
||||||
}
|
}
|
||||||
@@ -255,10 +330,13 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn heading(&mut self, s: &str) {
|
pub fn heading(&mut self, s: &str) {
|
||||||
self.ensure(50.0);
|
self.ensure(70.0);
|
||||||
self.y -= 14.0;
|
if self.y < TOP {
|
||||||
self.put(MARGIN, self.y - 11.0, s, 12.0, Font::Bold, TEAL);
|
self.y -= 16.0;
|
||||||
self.y -= 20.0;
|
}
|
||||||
|
self.rect(MARGIN, self.y - 3.0, 22.0, 3.0, TEAL);
|
||||||
|
self.put(MARGIN, self.y - 21.0, s, 15.0, Font::Bold, DEEP);
|
||||||
|
self.y -= 32.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paragraph(&mut self, s: &str, size: f64, f: Font, c: Rgb) {
|
pub fn paragraph(&mut self, s: &str, size: f64, f: Font, c: Rgb) {
|
||||||
@@ -326,13 +404,7 @@ impl Pdf {
|
|||||||
let h = size * 1.4 + 2.0 * pad;
|
let h = size * 1.4 + 2.0 * pad;
|
||||||
p.ensure(h + 2.0 * size * 1.4);
|
p.ensure(h + 2.0 * size * 1.4);
|
||||||
let top = p.y;
|
let top = p.y;
|
||||||
p.rect(
|
p.rect(MARGIN, top - h, Self::content_width(), h, DEEP);
|
||||||
MARGIN,
|
|
||||||
top - h,
|
|
||||||
Self::content_width(),
|
|
||||||
h,
|
|
||||||
(0.906, 0.953, 0.949),
|
|
||||||
);
|
|
||||||
let mut x = MARGIN;
|
let mut x = MARGIN;
|
||||||
for c in cols {
|
for c in cols {
|
||||||
let tx = if c.right {
|
let tx = if c.right {
|
||||||
@@ -346,14 +418,14 @@ impl Pdf {
|
|||||||
c.title,
|
c.title,
|
||||||
size,
|
size,
|
||||||
Font::Bold,
|
Font::Bold,
|
||||||
(0.043, 0.31, 0.29),
|
(1.0, 1.0, 1.0),
|
||||||
);
|
);
|
||||||
x += c.width;
|
x += c.width;
|
||||||
}
|
}
|
||||||
p.y -= h;
|
p.y -= h;
|
||||||
};
|
};
|
||||||
header(self);
|
header(self);
|
||||||
for row in rows {
|
for (ri, row) in rows.iter().enumerate() {
|
||||||
let cells: Vec<Vec<String>> = cols
|
let cells: Vec<Vec<String>> = cols
|
||||||
.iter()
|
.iter()
|
||||||
.zip(row)
|
.zip(row)
|
||||||
@@ -366,6 +438,9 @@ impl Pdf {
|
|||||||
header(self);
|
header(self);
|
||||||
}
|
}
|
||||||
let top = self.y;
|
let top = self.y;
|
||||||
|
if ri % 2 == 1 {
|
||||||
|
self.rect(MARGIN, top - h, Self::content_width(), h, ZEBRA);
|
||||||
|
}
|
||||||
let mut x = MARGIN;
|
let mut x = MARGIN;
|
||||||
for (c, lines) in cols.iter().zip(&cells) {
|
for (c, lines) in cols.iter().zip(&cells) {
|
||||||
for (i, l) in lines.iter().enumerate() {
|
for (i, l) in lines.iter().enumerate() {
|
||||||
@@ -385,10 +460,10 @@ impl Pdf {
|
|||||||
}
|
}
|
||||||
x += c.width;
|
x += c.width;
|
||||||
}
|
}
|
||||||
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
|
self.line(MARGIN, W - MARGIN, top - h, HAIR, 0.4);
|
||||||
self.y -= h;
|
self.y -= h;
|
||||||
}
|
}
|
||||||
self.y -= 6.0;
|
self.y -= 8.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finalizes and serializes the PDF document. `date` is the UTC creation timestamp (`YYYYMMDDHHMMSS`).
|
/// Finalizes and serializes the PDF document. `date` is the UTC creation timestamp (`YYYYMMDDHHMMSS`).
|
||||||
@@ -397,16 +472,51 @@ impl Pdf {
|
|||||||
self.pages.push(std::mem::take(&mut self.cur));
|
self.pages.push(std::mem::take(&mut self.cur));
|
||||||
}
|
}
|
||||||
let total = self.pages.len();
|
let total = self.pages.len();
|
||||||
for (i, p) in self.pages.iter_mut().enumerate() {
|
for (i, p) in self.pages.iter_mut().enumerate().skip(self.covers) {
|
||||||
let label = format!("{} · página {} de {total}", self.footer, i + 1);
|
let mut extra = String::new();
|
||||||
let x = W - MARGIN - text_width(&label, 8.0, Font::Regular);
|
let mut put = |x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb| {
|
||||||
p.push_str(&format!(
|
extra.push_str(&format!(
|
||||||
"BT /F1 8 Tf {:.3} {:.3} {:.3} rg {x:.2} 24 Td ({}) Tj ET\n",
|
"BT /{} {size:.1} Tf {:.3} {:.3} {:.3} rg {x:.2} {y:.2} Td ({}) Tj ET\n",
|
||||||
GRAY.0,
|
f.res(),
|
||||||
GRAY.1,
|
c.0,
|
||||||
GRAY.2,
|
c.1,
|
||||||
escape(&label)
|
c.2,
|
||||||
|
escape(s)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if let Some((left, tlp)) = &self.header {
|
||||||
|
put(MARGIN + 20.0, H - 34.0, left, 7.0, Font::Mono, GRAY);
|
||||||
|
let tw = text_width(tlp, 7.5, Font::Bold);
|
||||||
|
put(W - MARGIN - tw, H - 34.0, tlp, 7.5, Font::Bold, AMBER);
|
||||||
|
}
|
||||||
|
put(MARGIN, 24.0, &self.footer, 7.5, Font::Mono, GRAY);
|
||||||
|
let label = format!("página {} de {total}", i + 1);
|
||||||
|
let x = W - MARGIN - text_width(&label, 8.0, Font::Bold);
|
||||||
|
put(x, 24.0, &label, 8.0, Font::Bold, DEEP);
|
||||||
|
if self.header.is_some() {
|
||||||
|
if self.image.is_some() {
|
||||||
|
extra.push_str(&format!(
|
||||||
|
"q 15 0 0 15 {MARGIN:.2} {:.2} cm /Im1 Do Q\n",
|
||||||
|
H - 38.5
|
||||||
|
));
|
||||||
|
}
|
||||||
|
extra.push_str(&format!(
|
||||||
|
"{:.3} {:.3} {:.3} RG 0.4 w {MARGIN:.2} {y:.2} m {:.2} {y:.2} l S\n",
|
||||||
|
HAIR.0,
|
||||||
|
HAIR.1,
|
||||||
|
HAIR.2,
|
||||||
|
W - MARGIN,
|
||||||
|
y = H - 44.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
extra.push_str(&format!(
|
||||||
|
"{:.3} {:.3} {:.3} RG 0.4 w {MARGIN:.2} 36 m {:.2} 36 l S\n",
|
||||||
|
HAIR.0,
|
||||||
|
HAIR.1,
|
||||||
|
HAIR.2,
|
||||||
|
W - MARGIN
|
||||||
));
|
));
|
||||||
|
p.push_str(&extra);
|
||||||
}
|
}
|
||||||
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
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 offsets: Vec<usize> = vec![];
|
||||||
@@ -447,11 +557,18 @@ impl Pdf {
|
|||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
|
// optional image XObject goes right after the last (page, content) pair
|
||||||
|
let img_obj = 7 + 2 * total;
|
||||||
|
let xobj = if self.image.is_some() {
|
||||||
|
format!(" /XObject << /Im1 {img_obj} 0 R >>")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
for (i, p) in self.pages.iter().enumerate() {
|
for (i, p) in self.pages.iter().enumerate() {
|
||||||
obj(
|
obj(
|
||||||
&mut out,
|
&mut out,
|
||||||
format!(
|
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 >>",
|
"<< /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 >>{xobj} >> /Contents {} 0 R >>",
|
||||||
8 + 2 * i
|
8 + 2 * i
|
||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
@@ -465,6 +582,16 @@ impl Pdf {
|
|||||||
body.extend_from_slice(b"\nendstream");
|
body.extend_from_slice(b"\nendstream");
|
||||||
obj(&mut out, &body);
|
obj(&mut out, &body);
|
||||||
}
|
}
|
||||||
|
if let Some((jpeg, w, h)) = self.image {
|
||||||
|
let mut body = format!(
|
||||||
|
"<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n",
|
||||||
|
jpeg.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
body.extend_from_slice(jpeg);
|
||||||
|
body.extend_from_slice(b"\nendstream");
|
||||||
|
obj(&mut out, &body);
|
||||||
|
}
|
||||||
let xref = out.len();
|
let xref = out.len();
|
||||||
out.extend_from_slice(
|
out.extend_from_slice(
|
||||||
format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes(),
|
format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes(),
|
||||||
|
|||||||
+657
-174
@@ -112,24 +112,7 @@ const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz012
|
|||||||
|
|
||||||
/// Encodes the embedded logo as a `data:image/png;base64,...` URI.
|
/// Encodes the embedded logo as a `data:image/png;base64,...` URI.
|
||||||
fn logo_data_uri() -> String {
|
fn logo_data_uri() -> String {
|
||||||
let mut out = String::with_capacity(LOGO_PNG.len().div_ceil(3) * 4 + 22);
|
format!("data:image/png;base64,{}", b64(LOGO_PNG))
|
||||||
out.push_str("data:image/png;base64,");
|
|
||||||
for chunk in LOGO_PNG.chunks(3) {
|
|
||||||
let b0 = chunk[0];
|
|
||||||
let b1 = chunk.get(1).copied();
|
|
||||||
let b2 = chunk.get(2).copied();
|
|
||||||
out.push(B64[(b0 >> 2) as usize] as char);
|
|
||||||
out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
|
|
||||||
out.push(match b1 {
|
|
||||||
Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char,
|
|
||||||
None => '=',
|
|
||||||
});
|
|
||||||
out.push(match b2 {
|
|
||||||
Some(b2) => B64[(b2 & 0b111111) as usize] as char,
|
|
||||||
None => '=',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn esc(s: &str) -> String {
|
fn esc(s: &str) -> String {
|
||||||
@@ -156,45 +139,356 @@ fn human(n: u64) -> String {
|
|||||||
|
|
||||||
const MAX_ROWS: usize = 20_000;
|
const MAX_ROWS: usize = 20_000;
|
||||||
|
|
||||||
pub fn html(r: &ReportInput) -> String {
|
/// Paged.js (MIT, v0.4.3), inlined so the HTML report paginates to A4 with
|
||||||
let now = std::time::SystemTime::now()
|
/// running headers and a table of contents while staying a single offline file.
|
||||||
|
const PAGED_JS: &str = include_str!("../assets/paged.polyfill.min.js");
|
||||||
|
/// 96 px logo for the running header of inner pages (the full logo is used on the cover).
|
||||||
|
const LOGO_MARK_PNG: &[u8] = include_bytes!("../assets/logo-mark.png");
|
||||||
|
/// Logo composited on the PDF cover background with the orange glow baked in
|
||||||
|
/// (baseline JPEG, embedded as-is through `/DCTDecode`). Regenerate with PIL if
|
||||||
|
/// the logo or the cover color changes.
|
||||||
|
const LOGO_COVER_JPG: &[u8] = include_bytes!("../assets/logo-cover.jpg");
|
||||||
|
const LOGO_COVER_PX: u32 = 900;
|
||||||
|
|
||||||
|
/// Traffic Light Protocol label printed on every report (Ciberfauna default).
|
||||||
|
pub const TLP: &str = "TLP:AMBER";
|
||||||
|
|
||||||
|
const MAP_COLS: usize = 30;
|
||||||
|
const MAP_ROWS: usize = 20;
|
||||||
|
|
||||||
|
fn b64(data: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||||
|
for chunk in data.chunks(3) {
|
||||||
|
let b0 = chunk[0];
|
||||||
|
let b1 = chunk.get(1).copied();
|
||||||
|
let b2 = chunk.get(2).copied();
|
||||||
|
out.push(B64[(b0 >> 2) as usize] as char);
|
||||||
|
out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
|
||||||
|
out.push(match b1 {
|
||||||
|
Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char,
|
||||||
|
None => '=',
|
||||||
|
});
|
||||||
|
out.push(match b2 {
|
||||||
|
Some(b2) => B64[(b2 & 0b111111) as usize] as char,
|
||||||
|
None => '=',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregates shared by the HTML and PDF renderers.
|
||||||
|
struct Summary {
|
||||||
|
by_cat: std::collections::BTreeMap<&'static str, usize>,
|
||||||
|
/// Counts by confidence: `[low, medium, high]`.
|
||||||
|
conf: [usize; 3],
|
||||||
|
bytes: u64,
|
||||||
|
recovered: usize,
|
||||||
|
recovered_bytes: u64,
|
||||||
|
/// Disk "sector map": files in disk order, one cell each (or a bucket of
|
||||||
|
/// files), holding the best confidence (0 = empty, 1 low, 2 medium, 3 high).
|
||||||
|
cells: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize(r: &ReportInput) -> Summary {
|
||||||
|
let mut by_cat = std::collections::BTreeMap::new();
|
||||||
|
let mut conf = [0usize; 3];
|
||||||
|
let mut bytes = 0u64;
|
||||||
|
let n = MAP_COLS * MAP_ROWS;
|
||||||
|
let mut cells = vec![0u8; n];
|
||||||
|
// one cell per file in disk order (bucketed when there are more files than
|
||||||
|
// cells), spread over the whole map like a defragmenter view
|
||||||
|
let mut order: Vec<(u64, u64, u8)> = Vec::with_capacity(r.found.len());
|
||||||
|
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;
|
||||||
|
order.push((
|
||||||
|
f.first_offset().unwrap_or(u64::MAX),
|
||||||
|
f.id,
|
||||||
|
f.confidence as u8 + 1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
order.sort_unstable();
|
||||||
|
let count = order.len().max(1);
|
||||||
|
for (rank, (_, _, c)) in order.iter().enumerate() {
|
||||||
|
let idx = (rank * n / count).min(n - 1);
|
||||||
|
cells[idx] = cells[idx].max(*c);
|
||||||
|
}
|
||||||
|
Summary {
|
||||||
|
by_cat,
|
||||||
|
conf,
|
||||||
|
bytes,
|
||||||
|
recovered: r.recoveries.iter().map(|x| x.recovered.len()).sum(),
|
||||||
|
recovered_bytes: r.recoveries.iter().map(|x| x.total_bytes).sum(),
|
||||||
|
cells,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0)
|
||||||
let mut o = String::new();
|
}
|
||||||
o.push_str(
|
|
||||||
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
|
|
||||||
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
|
|
||||||
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
|
|
||||||
.brand{display:flex;align-items:center;gap:10px}.brand img{width:30px;height:30px;object-fit:contain}
|
|
||||||
.badges{display:flex;gap:6px;margin-top:8px}.badge{font:700 11px ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;padding:2px 8px;border-radius:3px;border:1px solid #0f766e;color:#0f766e;background:#e7f3f2}
|
|
||||||
.badge-tlp{color:#8a5a00;border-color:#8a5a00;background:#fff0cf}
|
|
||||||
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
|
|
||||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
||||||
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
|
|
||||||
td.n{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;word-break:break-all}
|
|
||||||
.kv td:first-child{width:230px;color:#5b6f6d}.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;font-weight:600}
|
|
||||||
.alta{background:#d7f5e0;color:#146c2e}.media{background:#fff0cf;color:#8a5a00}.baixa{background:#fde0e0;color:#a32121}
|
|
||||||
.note{background:#fff7e0;border-left:4px solid #e0a100;padding:10px 14px;border-radius:6px;margin:8px 0}
|
|
||||||
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
|
|
||||||
</style></head><body><main>"#,
|
|
||||||
);
|
|
||||||
o.push_str("<header><div class=\"brand\"><img src=\"");
|
|
||||||
o.push_str(&logo_data_uri());
|
|
||||||
o.push_str("\" alt=\"\"><h1>Relatório de recuperação de arquivos</h1></div><div class=\"badges\"><span class=\"badge badge-tlp\">TLP:AMBER</span><span class=\"badge\">Uso autorizado</span></div><div class=\"mut\">Gerado pelo Guará ");
|
|
||||||
o.push_str(env!("CARGO_PKG_VERSION"));
|
|
||||||
o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
|
|
||||||
|
|
||||||
o.push_str("<h2>Identificação do caso</h2><table class=\"kv\">");
|
fn mode_label(o: &ScanOptions) -> String {
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
if o.mode == ScanMode::Deep {
|
||||||
|
"profunda (metadados + assinatura)"
|
||||||
|
} else {
|
||||||
|
"rápida (metadados)"
|
||||||
|
},
|
||||||
|
if o.include_allocated {
|
||||||
|
", incluindo áreas em uso"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partition_obs(p: &PartInfo) -> String {
|
||||||
|
[
|
||||||
|
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("; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSING: &str = "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.";
|
||||||
|
|
||||||
|
const CSS: &str = r#"
|
||||||
|
:root{color-scheme:light;--deep:#073633;--teal:#0f766e;--aqua:#2dd4bf;--ink:#10201f;--muted:#5b6f6d;--faint:#8aa09d;--line:#dce8e6;--mist:#f1f7f6;--amber:#b36b00;--orange:#f97316;
|
||||||
|
--body:Inter,system-ui,"Segoe UI",Roboto,sans-serif;--mono:ui-monospace,"JetBrains Mono","IBM Plex Mono",Menlo,Consolas,monospace}
|
||||||
|
*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||||
|
body{margin:0;font:10px/1.5 var(--body);color:var(--ink);background:#fff}
|
||||||
|
@page{size:A4;margin:24mm 16mm 20mm;
|
||||||
|
@top-left{content:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22'%3E%3Cimage href='data:image/png;base64,{{MARK}}' width='22' height='22'/%3E%3C/svg%3E");vertical-align:bottom;padding-bottom:4mm;width:25%}
|
||||||
|
@top-center{content:"Guará · relatório de recuperação";font:500 8px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint);vertical-align:bottom;padding-bottom:5mm;width:50%;text-align:center;white-space:nowrap}
|
||||||
|
@top-right{content:"\25CF {{TLP}}";width:25%;text-align:right;white-space:nowrap;font:700 7.5px var(--mono);letter-spacing:.12em;color:var(--amber);vertical-align:bottom;padding-bottom:5mm}
|
||||||
|
@bottom-left{content:"{{FOOT}}";font:400 7.5px var(--mono);color:var(--faint);vertical-align:top;padding-top:5mm;width:75%}
|
||||||
|
@bottom-right{content:counter(page) " / " counter(pages);font:600 8px var(--mono);color:var(--ink);vertical-align:top;padding-top:5mm}
|
||||||
|
}
|
||||||
|
@page cover{margin:0;@top-left{content:none}@top-center{content:none}@top-right{content:none}@bottom-left{content:none}@bottom-right{content:none}}
|
||||||
|
.cover{page:cover;break-after:page;width:210mm;height:297mm;position:relative;overflow:hidden;color:#fff;
|
||||||
|
background:radial-gradient(120mm 120mm at 150mm 110mm,rgba(249,115,22,.16),transparent 70%),linear-gradient(165deg,#0b2b28 0%,#061a18 55%,#03100f 100%)}
|
||||||
|
.cover .map{position:absolute;left:0;bottom:0;width:210mm;height:140mm;opacity:.9}
|
||||||
|
.cover .map svg{display:block;width:100%;height:100%}
|
||||||
|
.cover .glow{position:absolute;right:-40mm;top:30mm;width:180mm;height:180mm;border-radius:50%;background:radial-gradient(circle,rgba(251,146,60,.42) 0%,rgba(249,115,22,.16) 38%,transparent 68%)}
|
||||||
|
.cover .hero{position:absolute;right:-24mm;top:42mm;width:150mm;height:150mm;object-fit:contain;filter:drop-shadow(0 6mm 14mm rgba(0,0,0,.45))}
|
||||||
|
.cover .top{position:relative;padding:16mm 18mm 0;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.cover .word{font:700 11px var(--mono);letter-spacing:.42em;color:#fff}
|
||||||
|
.cover .chips{display:flex;gap:6px}
|
||||||
|
.chip{font:700 8.5px var(--mono);letter-spacing:.16em;padding:3px 8px;border-radius:2px;border:1px solid currentColor;text-transform:uppercase}
|
||||||
|
.cover .tlp{color:#ffc000}.cover .auth{color:#9fd8d0}
|
||||||
|
.cover .eyebrow{position:relative;font:500 8.5px var(--mono);letter-spacing:.22em;text-transform:uppercase;color:var(--aqua);margin:44mm 18mm 0;max-width:100mm}
|
||||||
|
.cover h1{position:relative;font:800 44px/1.03 var(--body);letter-spacing:-.025em;margin:5mm 18mm 0;max-width:98mm;color:#fff;text-shadow:0 2px 12px rgba(0,0,0,.4)}
|
||||||
|
.cover .sub{position:relative;font:400 12.5px/1.55 var(--body);color:#c3dcd8;margin:6mm 18mm 0;max-width:88mm}
|
||||||
|
.cover .strip{position:absolute;left:18mm;right:18mm;bottom:52mm;display:grid;grid-template-columns:repeat(4,1fr);gap:4mm}
|
||||||
|
.cover .strip div{border-top:2px solid var(--aqua);padding-top:2.5mm;background:linear-gradient(rgba(3,16,15,.72),rgba(3,16,15,.35));padding:2.5mm 3mm 3mm}
|
||||||
|
.cover .strip div.o{border-top-color:var(--orange)}
|
||||||
|
.cover .strip b{display:block;font:800 24px/1.1 var(--body);letter-spacing:-.02em;color:#fff}
|
||||||
|
.cover .strip span{font:500 7px/1.3 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:#9fc4bf}
|
||||||
|
.cover .cap{position:absolute;left:18mm;bottom:40mm;font:400 7px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:#7fa9a3;margin:0}
|
||||||
|
.cover .cap i{display:inline-block;width:7px;height:7px;margin:0 4px 0 10px;vertical-align:-1px;border-radius:1px}
|
||||||
|
.cover .meta{position:absolute;left:18mm;right:18mm;bottom:13mm;display:grid;grid-template-columns:1.1fr 1.6fr 1.1fr .9fr;gap:6mm;padding-top:4mm;border-top:1px solid rgba(159,216,208,.35);margin:0}
|
||||||
|
.cover .meta dt{font:500 7px var(--mono);letter-spacing:.16em;text-transform:uppercase;color:#7fa9a3;margin-bottom:2px}
|
||||||
|
.cover .meta dd{margin:0;font:500 9.5px/1.45 var(--body);color:#fff;word-break:break-all}
|
||||||
|
.toc{break-after:page}
|
||||||
|
.toc ol{list-style:none;margin:6mm 0 0;padding:0}
|
||||||
|
.toc a{display:flex;align-items:baseline;gap:8px;color:var(--ink);text-decoration:none;padding:7px 0;border-bottom:1px solid var(--line);font:500 11.5px var(--body)}
|
||||||
|
.toc a .k{font:600 9px var(--mono);color:var(--teal);width:22px}
|
||||||
|
.toc a::after{content:target-counter(attr(href),page);font:600 10px var(--mono);color:var(--teal);margin-left:auto}
|
||||||
|
section{margin:0 0 9mm}
|
||||||
|
h2{font:700 19px/1.15 var(--body);letter-spacing:-.01em;color:var(--deep);margin:0 0 3.5mm;break-after:avoid}
|
||||||
|
h2::before{content:"";display:block;width:26px;height:4px;background:var(--teal);margin-bottom:3mm;border-radius:2px}
|
||||||
|
h3{font:600 11px var(--body);color:var(--deep);margin:4mm 0 2mm;break-after:avoid}
|
||||||
|
p{margin:0 0 2.5mm}.mut{color:var(--muted)}
|
||||||
|
.lead{font-size:11px;color:var(--muted);max-width:150mm;margin-bottom:4mm}
|
||||||
|
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:3.5mm;margin:0 0 5mm}
|
||||||
|
.kpi{border-top:3px solid var(--teal);background:var(--mist);padding:3mm 3.5mm 3.5mm;break-inside:avoid}
|
||||||
|
.kpi .l{font:500 7px/1.3 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
|
||||||
|
.kpi .v{font:800 24px/1.1 var(--body);letter-spacing:-.02em;margin:1.5mm 0 1mm;color:var(--deep)}
|
||||||
|
.kpi .d{font-size:8.5px;color:var(--muted)}
|
||||||
|
.kpi.hi{border-top-color:#16a34a}.kpi.or{border-top-color:var(--orange)}
|
||||||
|
.bar{display:flex;height:9px;border-radius:2px;overflow:hidden;background:var(--line);margin:1mm 0 2mm}
|
||||||
|
.bar i{display:block;height:100%}
|
||||||
|
.legend{display:flex;gap:12px;font:500 8px var(--mono);color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
|
||||||
|
.legend i{display:inline-block;width:8px;height:8px;border-radius:1px;margin-right:4px;vertical-align:-1px}
|
||||||
|
.c-alta{background:#16a34a}.c-media{background:#d97706}.c-baixa{background:#dc2626}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:0 0 3mm}
|
||||||
|
th,td{padding:4.5px 7px;text-align:left;vertical-align:top;font-size:8.8px;border-bottom:1px solid var(--line)}
|
||||||
|
thead th{background:var(--deep);color:#fff;font:600 7.5px var(--mono);letter-spacing:.08em;text-transform:uppercase}
|
||||||
|
tbody tr:nth-child(even) td{background:var(--mist)}
|
||||||
|
tr{break-inside:avoid}
|
||||||
|
td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||||
|
.mono{font-family:var(--mono);font-size:8px;word-break:break-all}
|
||||||
|
.kv td:first-child{width:46mm;color:var(--muted);font:500 7.5px var(--mono);letter-spacing:.1em;text-transform:uppercase;padding-top:6px}
|
||||||
|
.kv td{font-size:9.5px}
|
||||||
|
.hashes div{white-space:nowrap}.hashes b{display:inline-block;width:46px;font:600 7px var(--mono);color:var(--muted);letter-spacing:.06em}
|
||||||
|
.pill{display:inline-block;padding:0 7px;border-radius:99px;font-size:8px;font-weight:700;white-space:nowrap}
|
||||||
|
.alta{background:#d7f5e0;color:#146c2e}.media{background:#fff0cf;color:#8a5a00}.baixa{background:#fde0e0;color:#a32121}
|
||||||
|
.note{border-left:3px solid #d97706;background:#fff8e8;padding:3mm 4mm;margin:0 0 3mm;break-inside:avoid}
|
||||||
|
.note b{display:block;font:600 7px var(--mono);letter-spacing:.14em;text-transform:uppercase;color:#8a5a00;margin-bottom:1mm}
|
||||||
|
.note.info{border-left-color:var(--teal);background:var(--mist)}.note.info b{color:var(--teal)}
|
||||||
|
.note.bad{border-left-color:#dc2626;background:#fdecec}.note.bad b{color:#a32121}
|
||||||
|
@media screen{body{background:#4a5b59}.pagedjs_pages{padding:8mm 0}.pagedjs_page{background:#fff;margin:0 auto 8mm;box-shadow:0 4px 24px rgba(0,0,0,.35)}}
|
||||||
|
@media print{body{background:#fff}}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn html(r: &ReportInput) -> String {
|
||||||
|
let now = now_unix();
|
||||||
|
let s = summarize(r);
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
let nf = |v: &str| {
|
||||||
|
if v.is_empty() {
|
||||||
|
"não informado".to_string()
|
||||||
|
} else {
|
||||||
|
esc(v)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let case_label = if r.case.case_id.is_empty() {
|
||||||
|
"sem número".to_string()
|
||||||
|
} else {
|
||||||
|
r.case.case_id.clone()
|
||||||
|
};
|
||||||
|
let foot = format!(
|
||||||
|
"Guará {version} · caso {} · gerado em {}",
|
||||||
|
case_label,
|
||||||
|
fmt_unix(now)
|
||||||
|
)
|
||||||
|
.replace(['"', '\\', '\n', '\r'], " ");
|
||||||
|
let mut o = String::with_capacity(PAGED_JS.len() + LOGO_PNG.len() * 2 + 64 * 1024);
|
||||||
|
o.push_str("<!doctype html><html lang=\"pt-BR\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Guará: relatório de recuperação</title><style>");
|
||||||
|
o.push_str(
|
||||||
|
&CSS.replace("{{MARK}}", &b64(LOGO_MARK_PNG))
|
||||||
|
.replace("{{TLP}}", TLP)
|
||||||
|
.replace("{{FOOT}}", &foot),
|
||||||
|
);
|
||||||
|
o.push_str("</style></head><body>");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- cover
|
||||||
|
let total = r.found.len();
|
||||||
|
o.push_str("<section class=\"cover\"><div class=\"map\">");
|
||||||
|
o.push_str(§or_map_svg(&s.cells));
|
||||||
|
o.push_str("</div><div class=\"glow\"></div><img class=\"hero\" alt=\"Guará\" src=\"");
|
||||||
|
o.push_str(&logo_data_uri());
|
||||||
|
o.push_str("\"><div class=\"top\"><div class=\"word\">GUARÁ</div><div class=\"chips\"><span class=\"chip auth\">Uso autorizado</span><span class=\"chip tlp\">");
|
||||||
|
o.push_str(TLP);
|
||||||
|
o.push_str("</span></div></div><div class=\"eyebrow\">Ciberfauna · Recuperação forense de arquivos</div><h1>Relatório de recuperação de arquivos</h1>");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<p class=\"sub\">Exame somente leitura de <b>{}</b>: o que foi encontrado, o que foi recuperado e os hashes que provam a integridade de cada arquivo.</p>",
|
||||||
|
esc(short_target(r.target))
|
||||||
|
));
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<div class=\"strip\"><div><b>{total}</b><span>arquivos encontrados</span></div><div><b>{}</b><span>confiança alta</span></div><div class=\"o\"><b>{}</b><span>recuperados com hash</span></div><div class=\"o\"><b>{}</b><span>volume encontrado</span></div></div>",
|
||||||
|
s.conf[2],
|
||||||
|
s.recovered,
|
||||||
|
human(s.bytes)
|
||||||
|
));
|
||||||
|
o.push_str("<p class=\"cap\">Mapa da origem: arquivos em ordem de posição no disco<i style=\"background:#2dd4bf\"></i>alta<i style=\"background:#f59e0b\"></i>média<i style=\"background:#f87171\"></i>baixa</p>");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<dl class=\"meta\"><div><dt>Emissão</dt><dd>{}</dd></div><div><dt>Origem</dt><dd>{}</dd></div><div><dt>Caso · Perito</dt><dd>{} · {}</dd></div><div><dt>Classificação</dt><dd>{TLP}</dd></div></dl></section>",
|
||||||
|
fmt_unix(now),
|
||||||
|
esc(short_target(r.target)),
|
||||||
|
esc(&case_label),
|
||||||
|
nf(&r.case.examiner)
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- TOC
|
||||||
|
let mut toc: Vec<(&str, &str)> = vec![
|
||||||
|
("resumo", "Resumo dos achados"),
|
||||||
|
("caso", "Identificação do caso"),
|
||||||
|
("origem", "Origem examinada"),
|
||||||
|
];
|
||||||
|
if r.audit.is_some() {
|
||||||
|
toc.push(("auditoria", "Trilha de auditoria"));
|
||||||
|
}
|
||||||
|
if r.layout.is_some() {
|
||||||
|
toc.push(("particoes", "Partições"));
|
||||||
|
}
|
||||||
|
if !r.recoveries.is_empty() {
|
||||||
|
toc.push(("recuperados", "Arquivos recuperados (integridade)"));
|
||||||
|
}
|
||||||
|
toc.push(("relacao", "Relação de arquivos encontrados"));
|
||||||
|
o.push_str("<section class=\"toc\"><h2>Sumário</h2><ol>");
|
||||||
|
for (i, (id, t)) in toc.iter().enumerate() {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<li><a href=\"#{id}\"><span class=\"k\">{:02}</span>{t}</a></li>",
|
||||||
|
i + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str(&format!(
|
||||||
|
"</ol><p class=\"mut\" style=\"margin-top:8mm\">Gerado pelo Guará {version} em {}. {CLOSING}</p></section>",
|
||||||
|
fmt_unix(now)
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- summary
|
||||||
|
let pct = |n: usize| {
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
n as f64 * 100.0 / total as f64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
o.push_str("<section id=\"resumo\"><h2>Resumo dos achados</h2>");
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<p class=\"lead\">Arquivos encontrados: <b>{total}</b> ({}). A confiança indica a chance de o conteúdo estar íntegro.</p>",
|
||||||
|
human(s.bytes)
|
||||||
|
));
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<div class=\"kpis\"><div class=\"kpi\"><div class=\"l\">Encontrados</div><div class=\"v\">{total}</div><div class=\"d\">{}</div></div><div class=\"kpi hi\"><div class=\"l\">Confiança alta</div><div class=\"v\">{}</div><div class=\"d\">{:.0}% do total</div></div><div class=\"kpi or\"><div class=\"l\">Recuperados</div><div class=\"v\">{}</div><div class=\"d\">{} com hash</div></div><div class=\"kpi\"><div class=\"l\">Duração</div><div class=\"v\">{:.1} s</div><div class=\"d\">varredura {}</div></div></div>",
|
||||||
|
human(s.bytes),
|
||||||
|
s.conf[2],
|
||||||
|
pct(s.conf[2]),
|
||||||
|
s.recovered,
|
||||||
|
human(s.recovered_bytes),
|
||||||
|
r.elapsed,
|
||||||
|
if r.options.mode == ScanMode::Deep { "profunda" } else { "rápida" }
|
||||||
|
));
|
||||||
|
o.push_str("<h3>Confiança</h3><div class=\"bar\">");
|
||||||
|
for (n, c) in [
|
||||||
|
(s.conf[2], "c-alta"),
|
||||||
|
(s.conf[1], "c-media"),
|
||||||
|
(s.conf[0], "c-baixa"),
|
||||||
|
] {
|
||||||
|
if n > 0 {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<i class=\"{c}\" style=\"width:{:.2}%\"></i>",
|
||||||
|
pct(n)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.push_str(&format!(
|
||||||
|
"</div><div class=\"legend\"><span><i class=\"c-alta\"></i>alta {}</span><span><i class=\"c-media\"></i>média {}</span><span><i class=\"c-baixa\"></i>baixa {}</span></div>",
|
||||||
|
s.conf[2], s.conf[1], s.conf[0]
|
||||||
|
));
|
||||||
|
o.push_str("<h3>Por tipo</h3><table><thead><tr><th>Tipo</th><th style=\"text-align:right\">Arquivos</th><th style=\"text-align:right\">Proporção</th></tr></thead><tbody>");
|
||||||
|
for (k, v) in &s.by_cat {
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<tr><td>{k}</td><td class=\"n\">{v}</td><td class=\"n\">{:.1}%</td></tr>",
|
||||||
|
pct(*v)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str("</tbody></table>");
|
||||||
|
for w in r.warnings {
|
||||||
|
o.push_str(&format!("<div class=\"note\"><b>Aviso</b>{}</div>", esc(w)));
|
||||||
|
}
|
||||||
|
o.push_str("</section>");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- case
|
||||||
|
o.push_str("<section id=\"caso\"><h2>Identificação do caso</h2><table class=\"kv\"><tbody>");
|
||||||
for (k, v) in [
|
for (k, v) in [
|
||||||
("Número do caso", &r.case.case_id),
|
("Número do caso", &r.case.case_id),
|
||||||
("Responsável pelo exame", &r.case.examiner),
|
("Responsável pelo exame", &r.case.examiner),
|
||||||
("Organização", &r.case.organization),
|
("Organização", &r.case.organization),
|
||||||
] {
|
] {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!("<tr><td>{k}</td><td>{}</td></tr>", nf(v)));
|
||||||
"<tr><td>{k}</td><td>{}</td></tr>",
|
|
||||||
esc(if v.is_empty() { "não informado" } else { v })
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if !r.case.notes.is_empty() {
|
if !r.case.notes.is_empty() {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
@@ -202,9 +496,12 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
esc(&r.case.notes).replace('\n', "<br>")
|
esc(&r.case.notes).replace('\n', "<br>")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str("</table>");
|
o.push_str(&format!(
|
||||||
|
"<tr><td>Classificação</td><td><span class=\"pill media\">{TLP}</span> uso autorizado; compartilhe só com quem precisa conhecer o caso</td></tr></tbody></table></section>"
|
||||||
|
));
|
||||||
|
|
||||||
o.push_str("<h2>Origem examinada</h2><table class=\"kv\">");
|
// ---------------------------------------------------------------- source
|
||||||
|
o.push_str("<section id=\"origem\"><h2>Origem examinada</h2><table class=\"kv\"><tbody>");
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>Dispositivo / imagem</td><td class=\"mono\">{}</td></tr>",
|
"<tr><td>Dispositivo / imagem</td><td class=\"mono\">{}</td></tr>",
|
||||||
esc(r.target)
|
esc(r.target)
|
||||||
@@ -214,7 +511,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
}
|
}
|
||||||
if let Some(h) = r.source_hashes {
|
if let Some(h) = r.source_hashes {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>Hash da origem</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
"<tr><td>Hash da origem</td><td class=\"mono hashes\"><div><b>MD5</b>{}</div><div><b>SHA-1</b>{}</div><div><b>SHA-256</b>{}</div></td></tr>",
|
||||||
esc(&h.md5),
|
esc(&h.md5),
|
||||||
esc(&h.sha1),
|
esc(&h.sha1),
|
||||||
esc(&h.sha256)
|
esc(&h.sha256)
|
||||||
@@ -229,34 +526,26 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>Duração da varredura</td><td>{:.1} s</td></tr><tr><td>Modo</td><td>{}{}</td></tr><tr><td>Garantia de leitura</td><td>A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.</td></tr></table>",
|
"<tr><td>Duração da varredura</td><td>{:.1} s</td></tr><tr><td>Modo</td><td>{}</td></tr></tbody></table><div class=\"note info\"><b>Garantia de leitura</b>A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.</div></section>",
|
||||||
r.elapsed,
|
r.elapsed,
|
||||||
if r.options.mode == ScanMode::Deep { "profunda (metadados + assinatura)" } else { "rápida (metadados)" },
|
mode_label(r.options)
|
||||||
if r.options.include_allocated { ", incluindo áreas em uso" } else { "" }
|
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- audit
|
||||||
if let Some(a) = &r.audit {
|
if let Some(a) = &r.audit {
|
||||||
o.push_str(&format!(
|
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>",
|
"<section id=\"auditoria\"><h2>Trilha de auditoria</h2><table class=\"kv\"><tbody><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></tbody></table>",
|
||||||
a.entries,
|
a.entries,
|
||||||
if a.ok { "íntegro" } else { "QUEBRADO: confira com guara audit --verify" },
|
if a.ok { "<span class=\"pill alta\">íntegro</span>" } else { "<span class=\"pill baixa\">QUEBRADO: confira com guara audit --verify</span>" },
|
||||||
esc(&a.head)
|
esc(&a.head)
|
||||||
));
|
));
|
||||||
|
o.push_str("<div class=\"note info\"><b>Cadeia de custódia</b>Arquive o hash da última entrada fora desta máquina: ele permite provar depois que a trilha não foi alterada.</div></section>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- partitions
|
||||||
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("<section id=\"particoes\"><h2>Partições</h2><table><thead><tr><th>#</th><th>Sistema</th><th style=\"text-align:right\">Início (bytes)</th><th style=\"text-align:right\">Tamanho</th><th>Rótulo</th><th>Observação</th></tr></thead><tbody>");
|
||||||
for p in &l.partitions {
|
for p in &l.partitions {
|
||||||
let obs = [
|
|
||||||
p.lost.then_some("encontrada por varredura".to_string()),
|
|
||||||
p.encrypted
|
|
||||||
.as_ref()
|
|
||||||
.map(|e| format!("cifrada ({e}), não examinada")),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("; ");
|
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td>{}</td><td>{}</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td></tr>",
|
"<tr><td>{}</td><td>{}</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td></tr>",
|
||||||
p.index + 1,
|
p.index + 1,
|
||||||
@@ -264,43 +553,18 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
p.start,
|
p.start,
|
||||||
human(p.len),
|
human(p.len),
|
||||||
esc(p.label.as_deref().unwrap_or("")),
|
esc(p.label.as_deref().unwrap_or("")),
|
||||||
esc(&obs)
|
esc(&partition_obs(p))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for g in &l.gaps {
|
for g in &l.gaps {
|
||||||
o.push_str(&format!("<tr><td>-</td><td>espaço não particionado</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td></td><td></td></tr>", g.start, human(g.len)));
|
o.push_str(&format!("<tr><td>-</td><td>espaço não particionado</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td></td><td></td></tr>", g.start, human(g.len)));
|
||||||
}
|
}
|
||||||
o.push_str("</table>");
|
o.push_str("</tbody></table></section>");
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
let mut by_cat = std::collections::BTreeMap::new();
|
|
||||||
let mut conf = [0usize; 3];
|
|
||||||
let mut bytes = 0u64;
|
|
||||||
for f in r.found {
|
|
||||||
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
|
|
||||||
conf[f.confidence as usize] += 1;
|
|
||||||
bytes += f.size;
|
|
||||||
}
|
|
||||||
o.push_str(&format!("<h2>Resumo dos achados</h2><table class=\"kv\"><tr><td>Arquivos encontrados</td><td><b>{}</b> ({})</td></tr>", r.found.len(), human(bytes)));
|
|
||||||
o.push_str(&format!(
|
|
||||||
"<tr><td>Confiança</td><td><span class=\"pill alta\">alta {}</span> <span class=\"pill media\">média {}</span> <span class=\"pill baixa\">baixa {}</span></td></tr>",
|
|
||||||
conf[2], conf[1], conf[0]
|
|
||||||
));
|
|
||||||
o.push_str(&format!(
|
|
||||||
"<tr><td>Por tipo</td><td>{}</td></tr></table>",
|
|
||||||
by_cat
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{k}: {v}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" · ")
|
|
||||||
));
|
|
||||||
for w in r.warnings {
|
|
||||||
o.push_str(&format!("<div class=\"note\">{}</div>", esc(w)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- recoveries
|
||||||
if !r.recoveries.is_empty() {
|
if !r.recoveries.is_empty() {
|
||||||
o.push_str("<h2>Arquivos recuperados (integridade)</h2>");
|
o.push_str("<section id=\"recuperados\"><h2>Arquivos recuperados (integridade)</h2>");
|
||||||
for rec in r.recoveries {
|
for rec in r.recoveries {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<p>Destino: <span class=\"mono\">{}</span>, {} arquivo(s), {}{}</p>",
|
"<p>Destino: <span class=\"mono\">{}</span>, {} arquivo(s), {}{}</p>",
|
||||||
@@ -313,10 +577,10 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
format!(", {} falha(s)", rec.failed.len())
|
format!(", {} falha(s)", rec.failed.len())
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
o.push_str("<table><tr><th>Arquivo</th><th>Tamanho</th><th>Hashes</th></tr>");
|
o.push_str("<table><thead><tr><th>Arquivo</th><th style=\"text-align:right\">Tamanho</th><th>Hashes</th></tr></thead><tbody>");
|
||||||
for f in rec.recovered.iter().take(MAX_ROWS) {
|
for f in rec.recovered.iter().take(MAX_ROWS) {
|
||||||
o.push_str(&format!(
|
o.push_str(&format!(
|
||||||
"<tr><td class=\"mono\">{}</td><td class=\"n\">{}</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
"<tr><td class=\"mono\">{}</td><td class=\"n\">{}</td><td class=\"mono hashes\"><div><b>MD5</b>{}</div><div><b>SHA-1</b>{}</div><div><b>SHA-256</b>{}</div></td></tr>",
|
||||||
esc(&f.path),
|
esc(&f.path),
|
||||||
human(f.size),
|
human(f.size),
|
||||||
f.md5,
|
f.md5,
|
||||||
@@ -324,15 +588,17 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
f.sha256
|
f.sha256
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str("</table>");
|
o.push_str("</tbody></table>");
|
||||||
}
|
}
|
||||||
|
o.push_str("</section>");
|
||||||
}
|
}
|
||||||
|
|
||||||
o.push_str("<h2>Relação de arquivos encontrados</h2>");
|
// ---------------------------------------------------------------- findings
|
||||||
|
o.push_str("<section id=\"relacao\"><h2>Relação de arquivos encontrados</h2>");
|
||||||
if r.found.len() > MAX_ROWS {
|
if r.found.len() > MAX_ROWS {
|
||||||
o.push_str(&format!("<div class=\"note\">Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.</div>", r.found.len()));
|
o.push_str(&format!("<div class=\"note\"><b>Lista truncada</b>Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.</div>", r.found.len()));
|
||||||
}
|
}
|
||||||
o.push_str("<table><tr><th>Nome</th><th>Pasta</th><th>Tamanho</th><th>Modificado (UTC)</th><th>Criado (UTC)</th><th>Origem</th><th>Confiança</th><th>Posição no disco</th></tr>");
|
o.push_str("<table><thead><tr><th>Nome</th><th>Pasta</th><th style=\"text-align:right\">Tamanho</th><th>Modificado (UTC)</th><th>Criado (UTC)</th><th>Origem</th><th>Confiança</th><th style=\"text-align:right\">Posição no disco</th></tr></thead><tbody>");
|
||||||
for f in r.found.iter().take(MAX_ROWS) {
|
for f in r.found.iter().take(MAX_ROWS) {
|
||||||
let (cl, cc) = match f.confidence {
|
let (cl, cc) = match f.confidence {
|
||||||
Confidence::High => ("alta", "alta"),
|
Confidence::High => ("alta", "alta"),
|
||||||
@@ -350,27 +616,134 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
|||||||
f.first_offset().map(|o| o.to_string()).unwrap_or_default()
|
f.first_offset().map(|o| o.to_string()).unwrap_or_default()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
o.push_str("</table><footer>Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.</footer></main></body></html>");
|
o.push_str(&format!(
|
||||||
|
"</tbody></table><div class=\"note info\"><b>Leitura</b>{CLOSING}</div></section>"
|
||||||
|
));
|
||||||
|
o.push_str("<script>");
|
||||||
|
o.push_str(PAGED_JS);
|
||||||
|
o.push_str("</script></body></html>");
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last path component of the target (for the cover subtitle).
|
||||||
|
fn short_target(t: &str) -> &str {
|
||||||
|
t.rsplit(['/', '\\']).find(|p| !p.is_empty()).unwrap_or(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cover motif: a defrag-style map where each lit cell is a found file (in disk
|
||||||
|
/// order), colored by confidence. It shows as a band between the subtitle and the
|
||||||
|
/// headline strip and fades out before the numbers and the meta grid.
|
||||||
|
fn sector_map_svg(cells: &[u8]) -> String {
|
||||||
|
let mut o = format!(
|
||||||
|
"<svg viewBox=\"0 0 {} {}\" preserveAspectRatio=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><defs><linearGradient id=\"fade\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\".3\" stop-color=\"#fff\" stop-opacity=\"1\"/><stop offset=\".42\" stop-color=\"#fff\" stop-opacity=\"1\"/><stop offset=\".55\" stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\" stop-opacity=\"0\"/></linearGradient><mask id=\"m\"><rect width=\"100%\" height=\"100%\" fill=\"url(#fade)\"/></mask></defs><g mask=\"url(#m)\">",
|
||||||
|
MAP_COLS * 7,
|
||||||
|
MAP_ROWS * 7
|
||||||
|
);
|
||||||
|
for (i, c) in cells.iter().enumerate() {
|
||||||
|
// the map shows as a band above the headline strip and fades out before the
|
||||||
|
// numbers and the meta grid, so the white text stays legible
|
||||||
|
let (x, y) = ((i % MAP_COLS) * 7, (MAP_ROWS - 1 - i / MAP_COLS) * 7);
|
||||||
|
let fill = match c {
|
||||||
|
3 => "#2dd4bf\" fill-opacity=\".9",
|
||||||
|
2 => "#f59e0b\" fill-opacity=\".9",
|
||||||
|
1 => "#f87171\" fill-opacity=\".9",
|
||||||
|
_ => "#2dd4bf\" fill-opacity=\".07",
|
||||||
|
};
|
||||||
|
o.push_str(&format!(
|
||||||
|
"<rect x=\"{x}.5\" y=\"{y}.5\" width=\"6\" height=\"6\" rx=\".6\" fill=\"{fill}\"/>"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
o.push_str("</g></svg>");
|
||||||
o
|
o
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates an A4 PDF recovery report containing the same sections as the HTML report.
|
/// Generates an A4 PDF recovery report containing the same sections as the HTML report.
|
||||||
pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
||||||
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
|
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY, TEAL};
|
||||||
let now = std::time::SystemTime::now()
|
let now = now_unix();
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs() as i64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let stamp = crate::audit::utc_iso(now);
|
let stamp = crate::audit::utc_iso(now);
|
||||||
let version = env!("CARGO_PKG_VERSION");
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
let s = summarize(r);
|
||||||
|
let case_label = if r.case.case_id.is_empty() {
|
||||||
|
"sem número".to_string()
|
||||||
|
} else {
|
||||||
|
r.case.case_id.clone()
|
||||||
|
};
|
||||||
let mut d = Pdf::new(
|
let mut d = Pdf::new(
|
||||||
"Guará: relatório de recuperação",
|
"Guará: relatório de recuperação",
|
||||||
&format!("Guará {version}"),
|
&format!(
|
||||||
);
|
"Guará {version} · caso {case_label} · gerado em {}",
|
||||||
d.title(
|
fmt_unix(now)
|
||||||
"Relatório de recuperação de arquivos",
|
),
|
||||||
&format!("Gerado pelo Guará {version} em {}", fmt_unix(now)),
|
|
||||||
);
|
);
|
||||||
|
d.set_image(LOGO_COVER_JPG, LOGO_COVER_PX, LOGO_COVER_PX);
|
||||||
|
pdf_cover(&mut d, r, &s, &case_label, now);
|
||||||
|
d.running_header("GUARÁ · RELATÓRIO DE RECUPERAÇÃO", &format!("• {TLP}"));
|
||||||
|
|
||||||
|
let total = r.found.len();
|
||||||
|
let pct = |n: usize| {
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
n as f64 * 100.0 / total as f64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d.heading("Resumo dos achados");
|
||||||
|
d.kpis(&[
|
||||||
|
("Encontrados", total.to_string(), human(s.bytes), TEAL),
|
||||||
|
(
|
||||||
|
"Confiança alta",
|
||||||
|
s.conf[2].to_string(),
|
||||||
|
format!("{:.0}% do total", pct(s.conf[2])),
|
||||||
|
(0.086, 0.639, 0.290),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Recuperados",
|
||||||
|
s.recovered.to_string(),
|
||||||
|
format!("{} com hash", human(s.recovered_bytes)),
|
||||||
|
(0.976, 0.451, 0.086),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Duração",
|
||||||
|
format!("{:.1} s", r.elapsed),
|
||||||
|
format!(
|
||||||
|
"varredura {}",
|
||||||
|
if r.options.mode == ScanMode::Deep {
|
||||||
|
"profunda"
|
||||||
|
} else {
|
||||||
|
"rápida"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
TEAL,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
d.kv(&[
|
||||||
|
(
|
||||||
|
"Arquivos encontrados",
|
||||||
|
&format!("{} ({})", total, human(s.bytes)),
|
||||||
|
Font::Bold,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Confiança",
|
||||||
|
&format!(
|
||||||
|
"alta {} · média {} · baixa {}",
|
||||||
|
s.conf[2], s.conf[1], s.conf[0]
|
||||||
|
),
|
||||||
|
Font::Regular,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Por tipo",
|
||||||
|
&s.by_cat
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{k}: {v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · "),
|
||||||
|
Font::Regular,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
for w in r.warnings {
|
||||||
|
d.note(w);
|
||||||
|
}
|
||||||
|
|
||||||
let nf = |v: &str| {
|
let nf = |v: &str| {
|
||||||
if v.is_empty() {
|
if v.is_empty() {
|
||||||
@@ -392,6 +765,11 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
if !r.case.notes.is_empty() {
|
if !r.case.notes.is_empty() {
|
||||||
rows.push(("Observações", r.case.notes.clone(), Font::Regular));
|
rows.push(("Observações", r.case.notes.clone(), Font::Regular));
|
||||||
}
|
}
|
||||||
|
rows.push((
|
||||||
|
"Classificação",
|
||||||
|
format!("{TLP} (uso autorizado)"),
|
||||||
|
Font::Bold,
|
||||||
|
));
|
||||||
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
|
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
|
||||||
d.kv(&rows);
|
d.kv(&rows);
|
||||||
|
|
||||||
@@ -426,23 +804,7 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
format!("{:.1} s", r.elapsed),
|
format!("{:.1} s", r.elapsed),
|
||||||
Font::Regular,
|
Font::Regular,
|
||||||
));
|
));
|
||||||
rows.push((
|
rows.push(("Modo", mode_label(r.options), Font::Regular));
|
||||||
"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((
|
rows.push((
|
||||||
"Garantia de leitura",
|
"Garantia de leitura",
|
||||||
"A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.".into(),
|
"A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.".into(),
|
||||||
@@ -542,43 +904,6 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
d.table(&cols, &rows);
|
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() {
|
if !r.recoveries.is_empty() {
|
||||||
d.heading("Arquivos recuperados (integridade)");
|
d.heading("Arquivos recuperados (integridade)");
|
||||||
for rec in r.recoveries {
|
for rec in r.recoveries {
|
||||||
@@ -703,11 +1028,169 @@ pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
d.table(&cols, &rows);
|
d.table(&cols, &rows);
|
||||||
d.paragraph(
|
d.paragraph(CLOSING, 8.0, Font::Regular, GRAY);
|
||||||
"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'], ""))
|
d.finish(&stamp.replace(['-', ':', 'T', 'Z'], ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full-bleed PDF cover: deep teal page, glowing logo bleeding off the right
|
||||||
|
/// edge, sector-map motif, headline strip and meta grid (mirrors the HTML cover).
|
||||||
|
fn pdf_cover(d: &mut crate::pdf::Pdf, r: &ReportInput, s: &Summary, case: &str, now: i64) {
|
||||||
|
use crate::pdf::{Font, Pdf};
|
||||||
|
let (w, h) = Pdf::SIZE;
|
||||||
|
let mm = 72.0 / 25.4;
|
||||||
|
// background must equal the JPEG's flat background so the logo blends in
|
||||||
|
let bg = (7.0 / 255.0, 26.0 / 255.0, 25.0 / 255.0);
|
||||||
|
let mix = |c: (f64, f64, f64), a: f64| {
|
||||||
|
(
|
||||||
|
bg.0 + (c.0 - bg.0) * a,
|
||||||
|
bg.1 + (c.1 - bg.1) * a,
|
||||||
|
bg.2 + (c.2 - bg.2) * a,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
d.rect(0.0, 0.0, w, h, bg);
|
||||||
|
// sector map: bottom 140 mm, fading toward the top
|
||||||
|
let cell = w / MAP_COLS as f64;
|
||||||
|
for (i, c) in s.cells.iter().enumerate() {
|
||||||
|
let (col, row) = (i % MAP_COLS, i / MAP_COLS);
|
||||||
|
// a band above the headline strip, gone behind the text. Unlike the HTML, it
|
||||||
|
// starts below the logo: the cover JPEG is opaque and would cut the map with a
|
||||||
|
// hard edge
|
||||||
|
let t = 1.0 - (row as f64 + 0.5) / MAP_ROWS as f64;
|
||||||
|
let fade = if t < 0.345 {
|
||||||
|
0.0
|
||||||
|
} else if t < 0.45 {
|
||||||
|
1.0
|
||||||
|
} else if t < 0.55 {
|
||||||
|
((0.55 - t) / 0.1).sqrt()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let (color, a) = match c {
|
||||||
|
3 => ((0.176, 0.831, 0.749), 0.85),
|
||||||
|
2 => ((0.961, 0.620, 0.043), 0.85),
|
||||||
|
1 => ((0.973, 0.443, 0.443), 0.85),
|
||||||
|
_ => ((0.176, 0.831, 0.749), 0.08),
|
||||||
|
};
|
||||||
|
let y = row as f64 * cell;
|
||||||
|
d.rect(
|
||||||
|
col as f64 * cell + 0.6,
|
||||||
|
y + 0.6,
|
||||||
|
cell - 1.2,
|
||||||
|
cell - 1.2,
|
||||||
|
mix(color, a * fade * fade),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// hero logo, bleeding off the right edge
|
||||||
|
let lw = 165.0 * mm;
|
||||||
|
d.image(w - lw + 26.0 * mm, h - 40.0 * mm - lw, lw, lw);
|
||||||
|
let white = (1.0, 1.0, 1.0);
|
||||||
|
let aqua = (0.176, 0.831, 0.749);
|
||||||
|
let soft = (0.62, 0.77, 0.75);
|
||||||
|
let x0 = 18.0 * mm;
|
||||||
|
// top bar: wordmark + chips
|
||||||
|
d.put(x0, h - 20.0 * mm, "G U A R Á", 11.0, Font::Bold, white);
|
||||||
|
let tlp_w = crate::pdf::text_width(TLP, 8.0, Font::Mono) + 12.0;
|
||||||
|
let tx = w - x0 - tlp_w;
|
||||||
|
let chip = |d: &mut Pdf, x: f64, wd: f64, label: &str, c: (f64, f64, f64)| {
|
||||||
|
let y = h - 21.5 * mm;
|
||||||
|
d.rect(x, y, wd, 0.8, c);
|
||||||
|
d.rect(x, y + 14.0, wd, 0.8, c);
|
||||||
|
d.rect(x, y, 0.8, 14.8, c);
|
||||||
|
d.rect(x + wd - 0.8, y, 0.8, 14.8, c);
|
||||||
|
d.put(x + 6.0, y + 4.5, label, 8.0, Font::Mono, c);
|
||||||
|
};
|
||||||
|
chip(d, tx, tlp_w, TLP, (1.0, 0.753, 0.0));
|
||||||
|
let auth = "USO AUTORIZADO";
|
||||||
|
let aw = crate::pdf::text_width(auth, 8.0, Font::Mono) + 12.0;
|
||||||
|
chip(d, tx - aw - 6.0, aw, auth, (0.62, 0.85, 0.82));
|
||||||
|
// eyebrow, title, subtitle
|
||||||
|
d.put(
|
||||||
|
x0,
|
||||||
|
h - 72.0 * mm,
|
||||||
|
"CIBERFAUNA · RECUPERAÇÃO FORENSE DE ARQUIVOS",
|
||||||
|
8.0,
|
||||||
|
Font::Mono,
|
||||||
|
aqua,
|
||||||
|
);
|
||||||
|
for (i, l) in ["Relatório de", "recuperação", "de arquivos"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
d.put(
|
||||||
|
x0,
|
||||||
|
h - 88.0 * mm - i as f64 * 38.0,
|
||||||
|
l,
|
||||||
|
36.0,
|
||||||
|
Font::Bold,
|
||||||
|
white,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let sub = format!(
|
||||||
|
"Exame somente leitura de {}: o que foi encontrado, o que foi recuperado e os hashes que provam a integridade de cada arquivo.",
|
||||||
|
short_target(r.target)
|
||||||
|
);
|
||||||
|
for (i, l) in crate::pdf::wrap(&sub, 11.0, Font::Regular, 88.0 * mm)
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
d.put(
|
||||||
|
x0,
|
||||||
|
h - 136.0 * mm - i as f64 * 16.0,
|
||||||
|
l,
|
||||||
|
11.0,
|
||||||
|
Font::Regular,
|
||||||
|
soft,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// headline strip
|
||||||
|
let sw = (w - 2.0 * x0 - 3.0 * 4.0 * mm) / 4.0;
|
||||||
|
let sy = 52.0 * mm;
|
||||||
|
let orange = (0.976, 0.451, 0.086);
|
||||||
|
for (i, (v, l, c)) in [
|
||||||
|
(r.found.len().to_string(), "ARQUIVOS ENCONTRADOS", aqua),
|
||||||
|
(s.conf[2].to_string(), "CONFIANÇA ALTA", aqua),
|
||||||
|
(s.recovered.to_string(), "RECUPERADOS COM HASH", orange),
|
||||||
|
(human(s.bytes), "VOLUME ENCONTRADO", orange),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let x = x0 + i as f64 * (sw + 4.0 * mm);
|
||||||
|
d.rect(x, sy, sw, 50.0, (0.012, 0.063, 0.059));
|
||||||
|
d.rect(x, sy + 48.5, sw, 1.5, *c);
|
||||||
|
d.put(x + 8.0, sy + 22.0, v, 20.0, Font::Bold, white);
|
||||||
|
d.put(x + 8.0, sy + 9.0, l, 6.5, Font::Mono, soft);
|
||||||
|
}
|
||||||
|
// meta grid over a hairline
|
||||||
|
let my = 16.0 * mm;
|
||||||
|
d.rect(x0, my + 28.0, w - 2.0 * x0, 0.6, (0.33, 0.48, 0.46));
|
||||||
|
let cols = [
|
||||||
|
("EMISSÃO", fmt_unix(now), 0.0),
|
||||||
|
("ORIGEM", short_target(r.target).to_string(), 0.22),
|
||||||
|
(
|
||||||
|
"CASO · PERITO",
|
||||||
|
format!(
|
||||||
|
"{case} · {}",
|
||||||
|
if r.case.examiner.is_empty() {
|
||||||
|
"não informado"
|
||||||
|
} else {
|
||||||
|
&r.case.examiner
|
||||||
|
}
|
||||||
|
),
|
||||||
|
0.55,
|
||||||
|
),
|
||||||
|
("CLASSIFICAÇÃO", TLP.to_string(), 0.82),
|
||||||
|
];
|
||||||
|
let span = w - 2.0 * x0;
|
||||||
|
for (i, (k, v, f)) in cols.iter().enumerate() {
|
||||||
|
let x = x0 + span * f;
|
||||||
|
let next = cols.get(i + 1).map(|c| c.2).unwrap_or(1.0);
|
||||||
|
let cw = span * (next - f) - 10.0;
|
||||||
|
d.put(x, my + 16.0, k, 6.5, Font::Mono, (0.50, 0.66, 0.64));
|
||||||
|
let line = crate::pdf::wrap(v, 8.5, Font::Regular, cw);
|
||||||
|
for (j, l) in line.iter().take(2).enumerate() {
|
||||||
|
d.put(x, my + 4.0 - j as f64 * 11.0, l, 8.5, Font::Regular, white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.end_cover();
|
||||||
|
}
|
||||||
@@ -88,6 +88,12 @@ fn streams(pdf: &[u8]) -> Vec<String> {
|
|||||||
while let Some(s) = find(pdf, b">>\nstream\n", at) {
|
while let Some(s) = find(pdf, b">>\nstream\n", at) {
|
||||||
let start = s + 10;
|
let start = s + 10;
|
||||||
let end = find(pdf, b"\nendstream", start).unwrap();
|
let end = find(pdf, b"\nendstream", start).unwrap();
|
||||||
|
// the cover logo is a raw JPEG (`/DCTDecode`), not a content stream
|
||||||
|
let dict_start = pdf[..s].windows(2).rposition(|w| w == b"<<").unwrap();
|
||||||
|
if find(&pdf[..s], b"/DCTDecode", dict_start).is_some() {
|
||||||
|
at = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
flate2::read::ZlibDecoder::new(&pdf[start..end])
|
flate2::read::ZlibDecoder::new(&pdf[start..end])
|
||||||
.read_to_string(&mut text)
|
.read_to_string(&mut text)
|
||||||
@@ -146,7 +152,11 @@ fn long_lists_paginate_with_numbered_footers() {
|
|||||||
assert!(pages.len() >= 5, "{} páginas", pages.len());
|
assert!(pages.len() >= 5, "{} páginas", pages.len());
|
||||||
let last = pages.len();
|
let last = pages.len();
|
||||||
assert!(pages[last - 1].contains(&format!("p\\341gina {last} de {last}")));
|
assert!(pages[last - 1].contains(&format!("p\\341gina {last} de {last}")));
|
||||||
assert!(pages[0].contains(&format!("p\\341gina 1 de {last}")));
|
// page 1 is the full-bleed cover (no footer); numbering continues on page 2
|
||||||
|
assert!(!pages[0].contains("p\\341gina 1 de"));
|
||||||
|
assert!(pages[1].contains(&format!("p\\341gina 2 de {last}")));
|
||||||
|
assert!(pages[0].contains("/Im1 Do"), "logo da capa ausente");
|
||||||
|
assert!(pages[1].contains("TLP:AMBER"));
|
||||||
// the table header row is repeated at the top of each continuation page
|
// the table header row is repeated at the top of each continuation page
|
||||||
assert!(pages[2].contains("Modificado \\(UTC\\)"));
|
assert!(pages[2].contains("Modificado \\(UTC\\)"));
|
||||||
// every table row appears exactly once across all pages
|
// every table row appears exactly once across all pages
|
||||||
|
|||||||
+1
-1
@@ -148,7 +148,7 @@ 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, 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.
|
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. Both open with a full-page cover (logo, headline numbers, TLP:AMBER classification); the HTML is a self-contained A4 document that paginates in the browser and prints as is.
|
||||||
|
|
||||||
## `update`: updating
|
## `update`: updating
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -148,7 +148,7 @@ 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, 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.
|
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. Os dois abrem com uma capa de página inteira (logo, números principais, classificação TLP:AMBER); o HTML é um documento A4 autocontido que pagina no navegador e imprime como está.
|
||||||
|
|
||||||
## `update`: atualizações
|
## `update`: atualizações
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user