//! Exportação para análise e relatório de recuperação (cadeia de custódia): //! CSV dos achados e um relatório HTML autocontido, pronto para imprimir. use crate::hashing::Hashes; use crate::meta::fmt_unix; use crate::recover::RecoverReport; use crate::types::*; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CaseInfo { #[serde(default)] pub case_id: String, #[serde(default)] pub examiner: String, #[serde(default)] pub organization: String, #[serde(default)] pub notes: String, } pub struct ReportInput<'a> { pub case: &'a CaseInfo, pub target: &'a str, pub options: &'a ScanOptions, pub layout: Option<&'a DiskLayout>, pub found: &'a [Found], pub warnings: &'a [String], pub started_unix: Option, pub elapsed: f64, pub recoveries: &'a [RecoverReport], pub source_hashes: Option<&'a Hashes>, } fn cell(s: &str) -> String { // aspas e escapa; neutraliza "injeção de fórmula" em planilhas let mut t = s.replace('"', "\"\""); if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { t.insert(0, '\''); } format!("\"{t}\"") } fn iso(t: Option) -> String { match t { Some(t) => { let (y, m, d) = crate::meta::ymd(t); let r = t.rem_euclid(86400); format!( "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", r / 3600, r % 3600 / 60, r % 60 ) } None => String::new(), } } /// CSV com `;` (abre direto no Excel em português) e BOM UTF-8. pub fn csv(found: &[&Found]) -> String { let mut out = String::from("\u{feff}id;nome;pasta;extensao;categoria;tamanho_bytes;origem;confianca;modificado_utc;criado_utc;acessado_utc;foto_tirada;camera;largura;altura;gps;deslocamento_no_disco;observacao\r\n"); for f in found { let (w, h) = f.px.map(|(w, h)| (w.to_string(), h.to_string())) .unwrap_or_default(); let off = f.first_offset().map(|o| o.to_string()).unwrap_or_default(); let cat = crate::explorer::cat_key(f.category); let row = [ f.id.to_string(), cell(&f.name), cell(f.dir.as_deref().unwrap_or("")), cell(&f.ext), cat.to_string(), f.size.to_string(), format!("{:?}", f.origin).to_lowercase(), format!("{:?}", f.confidence).to_lowercase(), iso(f.modified), iso(f.created), iso(f.accessed), iso(f.taken), cell(f.camera.as_deref().unwrap_or("")), w, h, if f.gps { "sim".into() } else { String::new() }, off, cell(f.note.as_deref().unwrap_or("")), ]; out.push_str(&row.join(";")); out.push_str("\r\n"); } out } fn esc(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn human(n: u64) -> String { const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; let mut v = n as f64; let mut i = 0; while v >= 1024.0 && i < 4 { v /= 1024.0; i += 1; } if i == 0 { format!("{n} B") } else { format!("{v:.1} {}", U[i]) } } const MAX_ROWS: usize = 20_000; pub fn html(r: &ReportInput) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let mut o = String::new(); o.push_str( r#"Guará: relatório de recuperação
"#, ); o.push_str("

Relatório de recuperação de arquivos

Gerado pelo Guará "); o.push_str(env!("CARGO_PKG_VERSION")); o.push_str(&format!(" em {}
", fmt_unix(now))); o.push_str("

Identificação do caso

"); for (k, v) in [ ("Número do caso", &r.case.case_id), ("Responsável pelo exame", &r.case.examiner), ("Organização", &r.case.organization), ] { o.push_str(&format!( "", esc(if v.is_empty() { "não informado" } else { v }) )); } if !r.case.notes.is_empty() { o.push_str(&format!( "", esc(&r.case.notes).replace('\n', "
") )); } o.push_str("
{k}{}
Observações{}
"); o.push_str("

Origem examinada

"); o.push_str(&format!( "", esc(r.target) )); if let Some(l) = r.layout { o.push_str(&format!("", human(l.size), l.size, esc(&l.table.to_uppercase()))); } if let Some(h) = r.source_hashes { o.push_str(&format!( "", esc(&h.md5), esc(&h.sha1), esc(&h.sha256) )); } else { o.push_str(""); } if let Some(t) = r.started_unix { o.push_str(&format!( "", fmt_unix(t) )); } o.push_str(&format!( "
Dispositivo / imagem{}
Tamanho{} ({} bytes)
Tabela de partições{}
Hash da origemMD5 {}
SHA-1 {}
SHA-256 {}
Hash da origemnão calculado nesta sessão (use guara hash ou guara image)
Início da varredura{}
Duração da varredura{:.1} s
Modo{}{}
Garantia de leituraA origem foi aberta somente para leitura; nenhuma escrita foi feita nela.
", r.elapsed, if r.options.mode == ScanMode::Deep { "profunda (metadados + assinatura)" } else { "rápida (metadados)" }, if r.options.include_allocated { ", incluindo áreas em uso" } else { "" } )); if let Some(l) = r.layout { o.push_str("

Partições

"); 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::>() .join("; "); o.push_str(&format!( "", p.index + 1, esc(p.fs.name()), p.start, human(p.len), esc(p.label.as_deref().unwrap_or("")), esc(&obs) )); } for g in &l.gaps { o.push_str(&format!("", g.start, human(g.len))); } o.push_str("
#SistemaInício (bytes)TamanhoRótuloObservação
{}{}{}{}{}{}
-espaço não particionado{}{}
"); } // Resumo 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!("

Resumo dos achados

", r.found.len(), human(bytes))); o.push_str(&format!( "", conf[2], conf[1], conf[0] )); o.push_str(&format!( "
Arquivos encontrados{} ({})
Confiançaalta {} média {} baixa {}
Por tipo{}
", by_cat .iter() .map(|(k, v)| format!("{k}: {v}")) .collect::>() .join(" · ") )); for w in r.warnings { o.push_str(&format!("
{}
", esc(w))); } if !r.recoveries.is_empty() { o.push_str("

Arquivos recuperados (integridade)

"); for rec in r.recoveries { o.push_str(&format!( "

Destino: {}, {} arquivo(s), {}{}

", esc(&rec.destination), rec.recovered.len(), human(rec.total_bytes), if rec.failed.is_empty() { String::new() } else { format!(", {} falha(s)", rec.failed.len()) } )); o.push_str(""); for f in rec.recovered.iter().take(MAX_ROWS) { o.push_str(&format!( "", esc(&f.path), human(f.size), f.md5, f.sha1, f.sha256 )); } o.push_str("
ArquivoTamanhoHashes
{}{}MD5 {}
SHA-1 {}
SHA-256 {}
"); } } o.push_str("

Relação de arquivos encontrados

"); if r.found.len() > MAX_ROWS { o.push_str(&format!("
Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.
", r.found.len())); } o.push_str(""); for f in r.found.iter().take(MAX_ROWS) { let (cl, cc) = match f.confidence { Confidence::High => ("alta", "alta"), Confidence::Medium => ("média", "media"), Confidence::Low => ("baixa", "baixa"), }; o.push_str(&format!( "", esc(&f.name), esc(f.dir.as_deref().unwrap_or("")), human(f.size), iso(f.modified), iso(f.created), match f.origin { Origin::Carved => "assinatura", Origin::Ntfs => "NTFS", Origin::Fat => "FAT", Origin::Exfat => "exFAT", }, f.first_offset().map(|o| o.to_string()).unwrap_or_default() )); } o.push_str("
NomePastaTamanhoModificado (UTC)Criado (UTC)OrigemConfiançaPosição no disco
{}{}{}{}{}{}{cl}{}
"); o }