From 45d535bdb745cb5cdabde889085122f5e278dd94 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 18 Oct 2026 16:16:20 -0300 Subject: [PATCH] feat: explorador de arquivos, miniaturas, metadados e recursos periciais (0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Explorador com grade/lista virtualizada, navegação por tipo, data EXIF, pasta e câmera, seleção estilo gerenciador de arquivos e visualização rápida. - Miniaturas no núcleo, metadados (EXIF, PDF, Office, MP4), datas MACB do NTFS/FAT/exFAT. - Hashes MD5/SHA-1/SHA-256, busca de texto no disco, exportação CSV, relatório HTML, detecção de duplicadas, tema, correções de interface e textos sem travessões. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 13 + Cargo.lock | 220 ++++++- Cargo.toml | 2 +- README.md | 31 +- SECURITY.md | 2 +- apps/gui/src-tauri/Cargo.toml | 2 +- apps/gui/src-tauri/src/main.rs | 22 + apps/gui/src-tauri/tauri.conf.json | 2 +- apps/gui/ui/app.css | 122 ++++ apps/gui/ui/app.js | 362 ++++------ apps/gui/ui/explorer.js | 630 ++++++++++++++++++ apps/gui/ui/preview.js | 10 +- crates/guara-cli/src/main.rs | 164 ++++- crates/guara-cli/src/serve.rs | 42 ++ crates/guara-core/Cargo.toml | 6 + crates/guara-core/src/explorer.rs | 368 +++++++++++ crates/guara-core/src/fs/exfat.rs | 10 + crates/guara-core/src/fs/fat.rs | 20 +- crates/guara-core/src/fs/mod.rs | 9 + crates/guara-core/src/fs/ntfs.rs | 10 + crates/guara-core/src/hashing.rs | 45 ++ crates/guara-core/src/image.rs | 17 +- crates/guara-core/src/lib.rs | 8 +- crates/guara-core/src/meta.rs | 952 +++++++++++++++++++++++++++ crates/guara-core/src/recover.rs | 24 +- crates/guara-core/src/report.rs | 306 +++++++++ crates/guara-core/src/scan.rs | 7 + crates/guara-core/src/search.rs | 194 ++++++ crates/guara-core/src/service.rs | 419 +++++++++--- crates/guara-core/src/thumb.rs | 126 ++++ crates/guara-core/src/types.rs | 25 + crates/guara-core/tests/forensics.rs | 507 ++++++++++++++ crates/guara-core/tests/recovery.rs | 9 + scripts/make-photo-fixture.py | 112 ++++ 34 files changed, 4433 insertions(+), 365 deletions(-) create mode 100644 apps/gui/ui/explorer.js create mode 100644 crates/guara-core/src/explorer.rs create mode 100644 crates/guara-core/src/hashing.rs create mode 100644 crates/guara-core/src/meta.rs create mode 100644 crates/guara-core/src/report.rs create mode 100644 crates/guara-core/src/search.rs create mode 100644 crates/guara-core/src/thumb.rs create mode 100644 crates/guara-core/tests/forensics.rs create mode 100755 scripts/make-photo-fixture.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df5f4e..441ccc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. +## [0.3.0] - 2026-10-18 + +### Adicionado +- **Explorador de arquivos:** grade de miniaturas e lista com rolagem virtual (milhares de itens), agrupamento por dia/mês, navegação lateral por tipo, data da foto, pasta, câmera e confiança, seleção com Ctrl/Shift/teclado, painel de detalhes e visualização rápida em tela cheia. +- **Miniaturas rápidas** geradas no núcleo (JPEG com redução na decodificação, PNG, GIF, BMP, WebP), com orientação EXIF e uso da miniatura embutida quando a imagem está danificada. +- **Metadados:** EXIF (câmera, lente, datas, GPS), PDF, Office/OpenDocument e MP4/MOV; datas de criação, modificação e acesso do NTFS, FAT e exFAT. +- **Perícia:** hashes MD5, SHA-1 e SHA-256, `guara hash`, `guara search` (texto no disco inteiro em UTF-8 e UTF-16), `guara export` (CSV), `guara report` (relatório HTML com cadeia de custódia) e detecção de duplicadas. Tudo também na interface gráfica. +- Nova identidade visual: logo de pixels que se juntam num lobo-guará de silhueta lisa. + +### Alterado +- A barra inferior e o painel de progresso não são mais recriados a cada atualização. +- Textos sem travessões. + ## [0.2.0] - 2026-10-18 ### Adicionado diff --git a/Cargo.lock b/Cargo.lock index d110d5a..48edef1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -58,6 +64,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.13.2" @@ -82,6 +94,18 @@ dependencies = [ "objc2", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cfg-if" version = "1.0.5" @@ -140,6 +164,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -155,6 +185,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -214,6 +253,26 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -246,9 +305,19 @@ dependencies = [ "r-efi", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "guara-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "clap", "ctrlc", @@ -260,11 +329,17 @@ dependencies = [ [[package]] name = "guara-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ + "flate2", + "image", + "jpeg-decoder", + "jpeg-encoder", "libc", + "md-5", "serde", "serde_json", + "sha1", "sha2", "tempfile", "thiserror", @@ -283,6 +358,32 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -295,6 +396,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "jpeg-encoder" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f" + [[package]] name = "libc" version = "0.2.191" @@ -313,12 +426,52 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nix" version = "0.31.3" @@ -331,6 +484,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "objc2" version = "0.6.5" @@ -358,6 +520,19 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -367,6 +542,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.47" @@ -438,6 +625,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -449,6 +647,12 @@ dependencies = [ "digest", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "strsim" version = "0.11.1" @@ -541,6 +745,12 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "windows-link" version = "0.2.1" @@ -629,6 +839,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 4cc871c..b32f758 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/guara-core", "crates/guara-cli"] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/lucasrguerra/guara" diff --git a/README.md b/README.md index cdb8857..6401606 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec, - A origem é aberta **somente para leitura**. Nada no núcleo escreve no dispositivo recuperado. - A recuperação **recusa destinos no mesmo disco** da origem (Linux/macOS). - **Imagem forense** (`guara image`) tolerante a setores ruins, com mapa de erros e SHA-256: trabalhe sempre na cópia de um disco com defeito. -- Cada recuperação grava `guara-manifesto.json` com o **SHA-256** de todos os arquivos. +- Cada recuperação grava `guara-manifesto.json` com **MD5, SHA-1 e SHA-256** de todos os arquivos. - **Volumes cifrados** (BitLocker, LUKS) são **detectados e avisados**; a v1 não desbloqueia nem varre dentro deles. ## Instalação @@ -48,6 +48,10 @@ guara scan disco.img --recover /mnt/outro-disco/Recuperados guara recover s.json /mnt/outro-disco/Recuperados --category image guara resume s.json # continua após Ctrl+C sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # imagem com mapa de erros +sudo guara hash /dev/sdb # MD5, SHA-1 e SHA-256 do dispositivo +sudo guara search /dev/sdb "contrato" "cpf" # texto em todo o disco (UTF-8 e UTF-16) +guara export s.json achados.csv --category image # planilha dos achados +guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source guara ui # interface no navegador (127.0.0.1 + token) ``` @@ -55,9 +59,28 @@ guara ui # interface no navegador (127.0.0.1 + t ## Interface gráfica -App desktop (Tauri) com barra lateral de dispositivos, mapa do disco, resultados ao vivo, filtros por tipo, busca, confiança por arquivo, pausar/continuar e salvar sessão. Tema **Auto / Claro / Escuro** à sua escolha (lembrado também ao reiniciar como administrador) e layout responsivo. O mesmo front-end roda em `guara ui`. +App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando o Guará acha milhares de itens: -**Pré-visualização antes de recuperar** (lida direto do disco, sem gravar nada): imagens (com zoom), **vídeo (MP4/MOV/WebM)** e áudio com player, **PDF** (página a página), texto, documentos Word/Excel/PowerPoint/OpenDocument (texto extraído), conteúdo de ZIP e visão hexadecimal para o resto. Use ↑/↓ para percorrer a lista, Espaço/Enter ou duplo clique para abrir em tela cheia. Quando um arquivo não abre, o aviso ajuda a julgar se ele está danificado. A reprodução de vídeo depende dos codecs do seu sistema (no Linux, instale os plugins `gstreamer1.0-libav` e `gstreamer1.0-plugins-good`). +- **Grade de miniaturas** (gerada pelo núcleo, rápida mesmo com milhares de fotos) ou **lista**, com controle de tamanho, agrupamento por dia ou mês e rolagem virtual que aguenta dezenas de milhares de arquivos. +- **Navegação lateral** por tipo, **data real da foto (EXIF)**, pasta, **câmera**, confiança, com localização GPS e ocultando duplicadas. +- **Seleção como no Finder/Explorer:** clique, Ctrl/Cmd, Shift, setas, Ctrl+A. Espaço, Enter ou duplo clique abrem a **visualização rápida** em tela cheia com navegação por ← →. +- **Painel de detalhes** com pré-visualização, datas (tirada, modificada, criada, acessada) e metadados. +- Pausar/continuar, salvar sessão, tema **Auto / Claro / Escuro** (lembrado também ao reiniciar como administrador) e layout responsivo. O mesmo front-end roda em `guara ui`. + +**Pré-visualização antes de recuperar** (lida direto do disco, sem gravar nada): imagens, **vídeo (MP4/MOV/WebM)** e áudio com player, **PDF** página a página, texto, documentos Word/Excel/PowerPoint/OpenDocument (texto extraído), conteúdo de ZIP e visão hexadecimal para o resto. Quando um arquivo não abre, o aviso ajuda a julgar se ele está danificado. A reprodução de vídeo depende dos codecs do seu sistema (no Linux, instale os plugins `gstreamer1.0-libav` e `gstreamer1.0-plugins-good`). + +## Recursos periciais + +Pensados para quem precisa documentar o que fez: + +- **Hashes MD5, SHA-1 e SHA-256** de cada arquivo recuperado, da imagem forense e do dispositivo inteiro (`guara hash`). +- **Metadados:** EXIF (câmera, lente, número de série, data, **GPS**), informações de PDF (autor, programa, datas), propriedades de documentos Office e OpenDocument (autor, última modificação por, empresa) e dados de vídeo MP4/MOV (data de gravação, duração, resolução). +- **Linha do tempo:** datas de criação, modificação e acesso lidas do NTFS, FAT e exFAT. +- **Pesquisa de texto no disco inteiro** (`guara search`), inclusive espaço livre e fora de partições, em UTF-8 e UTF-16, mostrando posição, partição e contexto. +- **Exportação CSV** (abre no Excel) e **relatório HTML** pronto para imprimir (`guara report`), com identificação do caso, origem, partições, resumo, hashes e lista dos arquivos. +- **Detecção de duplicadas** por assinatura de conteúdo. + +Escopo: o Guará trabalha com **mídias de armazenamento** (HD, SSD, pendrive, cartões e imagens de disco). Ele **não** desbloqueia celulares nem contorna senhas ou travas de segurança, e não substitui ferramentas de extração de dispositivos móveis. ## Limites conhecidos (v0.1) @@ -70,7 +93,7 @@ Seja realista sobre o que dá para esperar: - **exFAT:** coberto por teste com imagem sintética; falta validar em mais mídias reais. - **Cifragem:** somente detecção. - **Windows/macOS:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Relatos de mídias reais são muito bem-vindos. -- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado — melhoria planejada. +- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado (melhoria planejada). ## Desenvolvimento diff --git a/SECURITY.md b/SECURITY.md index 5816b71..4d471b7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,3 @@ # Segurança -Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades — corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção. +Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades (corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação) devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção. diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index ee04140..3b38d2a 100644 --- a/apps/gui/src-tauri/Cargo.toml +++ b/apps/gui/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "guara-gui" description = "Guará: interface gráfica de recuperação de arquivos" -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/lucasrguerra/guara" diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs index 63b8219..9735b66 100644 --- a/apps/gui/src-tauri/src/main.rs +++ b/apps/gui/src-tauri/src/main.rs @@ -101,6 +101,28 @@ fn elevated_exe() -> Result { /// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem. fn serve_file(eng: &Engine, req: &tauri::http::Request>) -> tauri::http::Response> { use tauri::http::Response; + if let Some(rest) = req.uri().path().strip_prefix("/thumb/") { + let mut it = rest.split('/'); + let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::().ok())); + let size = req + .uri() + .query() + .and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s="))) + .and_then(|v| v.parse::().ok()) + .unwrap_or(256); + let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap(); + let Some(id) = id else { return err(400) }; + return match eng.thumb(job, id, size) { + Ok(b) => Response::builder() + .status(200) + .header("Content-Type", "image/jpeg") + .header("Cache-Control", "private, max-age=3600") + .header("Access-Control-Allow-Origin", "*") + .body(b.as_ref().clone()) + .unwrap_or_else(|_| err(500)), + Err(code) => err(code), + }; + } const CAP: u64 = 8 * 1024 * 1024; let mut it = req.uri().path().trim_start_matches("/file/").split('/'); let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::().ok())); diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json index d828477..39a0e99 100644 --- a/apps/gui/src-tauri/tauri.conf.json +++ b/apps/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Guara", - "version": "0.2.0", + "version": "0.3.0", "identifier": "dev.guara.app", "build": { "frontendDist": "../ui" diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index cf17ae3..8684b18 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -332,3 +332,125 @@ dialog.lightbox[open] { display: grid; } .hero-logo { width: min(440px, 80%); height: auto; display: block; margin: 0 0 22px -4px; } .pill.accent { background: var(--accent-soft); color: var(--accent); } + +/* ------------------------------------------------------------ explorador de arquivos */ +.main.fixed { overflow: hidden; } +.page.wide { max-width: none; padding: 14px 18px 0; } +.page.fill { height: 100%; display: flex; flex-direction: column; } +.xp-top { display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 8px; } +.xp-top h1 { font-size: 24px; } +.xp-top .sub { font-size: 12.5px; } +.xp-host { flex: 1; min-height: 0; padding-bottom: 12px; } +.progress-card.slim { padding: 14px 18px; border-radius: 16px; gap: 10px; margin-bottom: 10px; } +.stats.compact { gap: 22px; } +.stats.compact .stat b { font-size: 20px; } +.done-bar { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 8px 14px; border-radius: 12px; background: var(--accent-soft); margin-bottom: 10px; font-size: 13.5px; } +.done-bar .grow { flex: 1; } + +.xp { position: relative; display: grid; grid-template-columns: 236px minmax(0, 1fr) 340px; grid-template-rows: auto minmax(0, 1fr); gap: 10px 14px; height: 100%; } +.xp-bar { grid-column: 1 / -1; display: flex; gap: 10px; align-items: center; min-width: 0; } +.xp-crumb { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; white-space: nowrap; overflow: hidden; } +.xp-crumb b { font-size: 15px; overflow: hidden; text-overflow: ellipsis; } +.xp-crumb small { color: var(--text-3); font-variant-numeric: tabular-nums; } +.xp-q { flex: 0 1 300px !important; min-width: 120px; } +.xp-bar .theme-switch button { display: inline-grid; place-items: center; padding: 5px 9px; } +.xp-bar input[type="range"] { width: 110px; accent-color: var(--accent); } +.xp-navbtn { display: none; } + +.xp-nav { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 8px 6px 14px; min-height: 0; } +.xp-sec { padding: 4px 0 6px; } +.xp-sec h4 { margin: 8px 10px 4px; font-size: 11.5px; letter-spacing: .02em; color: var(--text-3); font-weight: 600; } +.xp-node { display: flex; align-items: center; gap: 4px; width: 100%; border: 0; background: transparent; cursor: pointer; text-align: left; padding: 6px 8px 6px calc(8px + var(--d, 0) * 14px); border-radius: 9px; font-size: 13.5px; color: var(--text); } +.xp-node:hover { background: color-mix(in srgb, var(--text) 6%, transparent); } +.xp-node.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.xp-node .l { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.xp-node small { color: var(--text-3); font-variant-numeric: tabular-nums; font-weight: 400; } +.xp-node .tw { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; flex: none; transition: transform .15s; } +.xp-node .tw:hover { background: color-mix(in srgb, var(--text) 10%, transparent); } + +.xp-main { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; } +.xp-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; padding: 0 12px; } +.xp-chips:not(:empty) { padding: 10px 12px 2px; } +.xp-chips .chip { display: inline-flex; gap: 6px; align-items: center; background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.xp-scroll { position: relative; flex: 1; min-height: 0; overflow-y: auto; outline: none; } +.xp-sizer { position: relative; width: 100%; } +.xp-row { position: absolute; left: 0; right: 0; display: flex; gap: 10px; padding: 0 12px; box-sizing: border-box; } +.xp-row.head { align-items: flex-end; } +.xp-head { display: flex; align-items: baseline; gap: 10px; width: 100%; padding: 0 2px 6px; border-bottom: 1px solid var(--line); } +.xp-head b::first-letter { text-transform: uppercase; } +.xp-head b { font-size: 14px; } .xp-head small { color: var(--text-3); } .xp-hsel { margin-left: auto; border: 0; background: transparent; color: var(--accent); cursor: pointer; font-size: 12.5px; opacity: 0; } +.xp-head:hover .xp-hsel, .xp-hsel:focus-visible { opacity: 1; } + +.xp-tile { position: relative; flex: none; border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none; } +.xp-thumb { position: relative; border-radius: 12px; overflow: hidden; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); } +.xp-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--surface-2); } +.xp-tile[aria-selected="true"] .xp-thumb { box-shadow: 0 0 0 3px var(--accent); } +.xp-tile[data-cur="1"]:focus-visible .xp-thumb, .xp-tile[data-cur="1"] .xp-thumb { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); outline-offset: 2px; } +.xp-ico { width: 100%; height: 100%; display: grid; place-content: center; text-align: center; gap: 2px; color: #fff; } +.xp-ico span { font-size: 34px; line-height: 1; } .xp-ico b { font-size: 12px; letter-spacing: .04em; opacity: .9; } +.c-document { background: linear-gradient(145deg, #3b82f6, #1d4ed8); } .c-video { background: linear-gradient(145deg, #8b5cf6, #5b21b6); } +.c-audio { background: linear-gradient(145deg, #ec4899, #9d174d); } .c-archive { background: linear-gradient(145deg, #f59e0b, #b45309); } +.c-database { background: linear-gradient(145deg, #14b8a6, #0f766e); } .c-other { background: linear-gradient(145deg, #64748b, #334155); } .c-image { background: linear-gradient(145deg, #0ea5e9, #0369a1); } +.c-bad { background: linear-gradient(145deg, #6b7280, #374151); } +.xp-chk { position: absolute; top: 7px; left: 7px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, .35); box-shadow: inset 0 0 0 2px #fff; opacity: 0; transition: opacity .12s; backdrop-filter: blur(4px); } +.xp-tile:hover .xp-chk, .xp-tile[aria-selected="true"] .xp-chk, .xp-lrow:hover .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { opacity: 1; } +.xp-tile[aria-selected="true"] .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 12.5l4 4 8-9'/%3E%3C/svg%3E") center / 14px no-repeat; } +.xp-dot { position: absolute; right: 7px; bottom: 7px; width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 0 2px rgba(255, 255, 255, .85); } +.xp-dot.ok { background: var(--ok); } .xp-dot.warn { background: var(--warn); } .xp-dot.bad { background: var(--bad); } +.xp-gps { position: absolute; left: 7px; bottom: 6px; color: #fff; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); } +.xp-cap { padding: 5px 2px 0; min-width: 0; } +.xp-cap b { display: block; font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.xp-cap small { display: block; font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.xp-lrow { position: relative; display: grid; grid-template-columns: 26px 40px minmax(0, 1fr) 132px 76px 58px; gap: 12px; align-items: center; width: 100%; height: 50px; padding: 0 10px; border-radius: 10px; cursor: pointer; user-select: none; } +.xp-lrow:hover { background: var(--surface-2); } +.xp-lrow[aria-selected="true"] { background: var(--accent-soft); } +.xp-lrow .xp-chk { position: static; opacity: .0; } +.xp-lth { width: 40px; height: 40px; border-radius: 8px; overflow: hidden; background: var(--surface-2); display: grid; place-items: center; } +.xp-lth img { width: 100%; height: 100%; object-fit: cover; } +.xp-lth .xp-ico span { font-size: 18px; } .xp-lth .xp-ico b { display: none; } +.xp-lico { font-size: 18px; width: 100%; height: 100%; display: grid; place-items: center; color: #fff; } +.xp-lnm { min-width: 0; } .xp-lnm b { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xp-lnm small { display: block; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.xp-lcol { color: var(--text-2); font-size: 13px; white-space: nowrap; } .xp-lcol.num { text-align: right; font-variant-numeric: tabular-nums; } + +.xp-actions { display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--surface); } +.xp-actions .info { color: var(--text-2); font-size: 13.5px; margin-right: auto; } + +.xp-insp { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; } +.xp-insp .viewer { max-height: 38vh; min-height: 150px; } +.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; } +.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; } +.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; } +.xp-insp-x { display: none; align-self: flex-end; } +.xp-veil { display: none; } +.kv dd { overflow-wrap: anywhere; } + +@media (max-width: 1280px) { .xp { grid-template-columns: 208px minmax(0, 1fr) 300px; } } +@media (max-width: 1100px) { + .xp { grid-template-columns: minmax(0, 1fr); } + .xp-bar { flex-wrap: wrap; } + .xp-navbtn { display: inline-grid; } + .xp-nav { position: fixed; inset: 0 auto 0 0; width: min(86vw, 310px); z-index: 40; border-radius: 0 var(--radius) var(--radius) 0; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); } + .xp.show-nav .xp-nav { transform: none; } + .xp-insp { position: fixed; left: 0; right: 0; bottom: 0; max-height: 80vh; z-index: 40; border-radius: 20px 20px 0 0; transform: translateY(104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); padding-bottom: calc(14px + env(safe-area-inset-bottom)); } + .xp.show-insp .xp-insp { transform: none; } + .xp-insp-x { display: inline-grid; } + .xp-veil { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .32); opacity: 0; pointer-events: none; transition: opacity .2s; display: block; } + .xp.show-nav .xp-veil, .xp.show-insp .xp-veil { opacity: 1; pointer-events: auto; } + .xp-q { flex: 1 1 140px !important; } + .xp-crumb { flex: 1 1 100%; order: -1; } +} +@media (max-width: 860px) { + .page.wide { padding: 10px 10px 0; } + .xp-lrow { grid-template-columns: 26px 40px minmax(0, 1fr) 64px; } .xp-lrow .xp-lcol { display: none; } + .xp-bar input[type="range"] { display: none !important; } +} + +.fld { display: grid; gap: 4px; margin: 10px 0; font-size: 13px; color: var(--text-2); } +.fld textarea { resize: vertical; font: inherit; } +.hits { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } +.hit { display: grid; grid-template-columns: 130px 80px 90px minmax(0, 1fr); gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--line); font-size: 13px; align-items: start; } +.hit.head { background: var(--surface-2); color: var(--text-3); font-size: 12px; } +.hit .ctx { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; color: var(--text-2); } +.hit mark { background: var(--accent-soft); color: var(--accent); padding: 0 2px; border-radius: 3px; font-weight: 600; } +@media (max-width: 860px) { .hit { grid-template-columns: 100px 1fr; } .hit span:nth-child(2), .hit span:nth-child(3) { display: none; } } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 71ab29a..102cf3f 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -1,7 +1,7 @@ -// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`: +// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`: // • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/ com token. -import { renderPreview, kindOf, KIND_LABEL } from './preview.js'; +import { createExplorer } from './explorer.js'; const tauri = window.__TAURI__; const params = new URLSearchParams(location.search); @@ -25,6 +25,7 @@ async function rpc(method, p = {}) { const isWin = /Windows/i.test(navigator.userAgent); const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost') : ''; +const thumbUrl = (job, id, s = 256) => `${fileBase}/thumb/${job}/${id}?s=${s}` + (tauri ? '' : `&t=${encodeURIComponent(token || '')}`); const fileUrl = (job, id) => `${fileBase}/file/${job}/${id}` + (tauri ? '' : `?t=${encodeURIComponent(token || '')}`); // Atualiza o HTML só quando mudou (evita piscar e perder o foco/hover). @@ -63,7 +64,7 @@ const dur = (s) => { s = Math.round(s); return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`; }; -const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : '—'); +const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data'); const CATS = [ ['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'], ['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'], @@ -103,6 +104,23 @@ function ask({ title, text, value, placeholder, ok = 'OK', pick }) { }); } +function askForm({ title, text, fields, ok = 'OK' }) { + return new Promise((resolve) => { + const d = $('#dlg'); + d.innerHTML = `

${esc(title)}

${text || ''}

+ ${fields.map((f) => ``).join('')} +
`; + d.onclose = () => { + if (d.returnValue !== 'ok') return resolve(null); + const out = {}; + d.querySelectorAll('[name]').forEach((el) => (out[el.name] = el.value.trim())); + resolve(out); + }; + d.showModal(); + d.querySelector('[name]')?.focus(); + }); +} + function info({ title, html }) { const d = $('#dlg'); d.innerHTML = `

${esc(title)}

${html}
`; @@ -153,7 +171,7 @@ function renderSide() { dl.innerHTML = S.devices.map((d, i) => `
  • `).join(''); dl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openDevice(S.devices[+b.dataset.i]); setMenu(false); })); const sl = $('#sessionList'); @@ -197,12 +215,13 @@ $('#loadSessionBtn').onclick = async () => { // ------------------------------------------------------------------ início function renderHome() { + main.classList.remove('fixed'); stopPoll(); S.device = null; S.job = null; renderSide(); main.innerHTML = `

    Encontre o que você achou que tinha perdido.

    -

    Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo — arquivos apagados, partições perdidas e espaço não particionado — sem escrever nada no disco de origem.

    +

    Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.

    Uma varredura, tudo junto

    Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.

    Confiança por arquivo

    Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.

    @@ -214,6 +233,7 @@ function renderHome() { // ------------------------------------------------------------------ dispositivo async function openDevice(d) { + main.classList.remove('fixed'); stopPoll(); S.device = d; S.job = null; S.layout = null; renderSide(); @@ -252,7 +272,7 @@ function renderDevice(err) { const notes = []; if (!d.readable) notes.push(['bad', 'Este disco exige permissão de administrador. ' + (tauri ? '' : 'Feche e rode de novo com sudo guara ui (Linux/macOS) ou como administrador (Windows).')]); if (err) notes.push(['bad', esc(err)]); - if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em ${esc(d.mounted_at.join(', '))}). O Guará só lê, mas evite gravar nele enquanto recupera — qualquer escrita pode sobrescrever o que você quer salvar.`]); + if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em ${esc(d.mounted_at.join(', '))}). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.`]); if (d.trim) notes.push(['warn', 'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.']); (l?.warnings || []).forEach((w) => notes.push(['warn', esc(w)])); @@ -272,6 +292,14 @@ function renderDevice(err) {

    Criar imagem do disco

    Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.

    +

    Ferramentas de perícia

    +
    +

    Pesquisar texto no disco

    Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.

    + +
    +

    Hashes da origem

    Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.

    +
    +
    Opções avançadas
    ${CATS.map(([k, n]) => ``).join('')}
    @@ -287,6 +315,9 @@ function renderDevice(err) { $('#goDeep')?.addEventListener('click', () => startScan(opts('deep'))); $('#goQuick')?.addEventListener('click', () => startScan(opts('quick'))); $('#goImage')?.addEventListener('click', startImage); + $('#goSearch')?.addEventListener('click', startSearch); + $('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); }); + $('#goHash')?.addEventListener('click', startHash); } async function startScan(options) { @@ -312,80 +343,96 @@ async function startImage() { } catch (e) { toast(e.message); } } +async function startHash() { + try { + const { job } = await rpc('hash_start', { path: S.device.path }); + watchAux(job, 'Calculando hashes da origem', (st) => { + const r = st.report || {}; + S.sourceHashes = { ...r, path: S.device.path }; + return `

    Hashes do conteúdo inteiro de ${esc(S.device.path)}:

    MD5
    ${esc(r.md5)}
    SHA-1
    ${esc(r.sha1)}
    SHA-256
    ${esc(r.sha256)}

    Eles serão incluídos nos relatórios gerados nesta sessão.

    `; + }); + } catch (e) { toast(e.message); } +} + +async function startSearch() { + const terms = ($('#srchTerms')?.value || '').split(',').map((t) => t.trim()).filter(Boolean); + if (!terms.length) { toast('Digite pelo menos um termo.'); $('#srchTerms')?.focus(); return; } + try { + const { job } = await rpc('search_start', { path: S.device.path, terms, ignore_case: true, utf16: true }); + openSearch(job, terms); + } catch (e) { toast(e.message); } +} + +function openSearch(job, terms) { + stopPoll(); + EX?.destroy(); EX = null; + S.job = null; + main.classList.remove('fixed'); + main.innerHTML = `
    +

    Procurando…

    Termos: ${terms.map((t) => `${esc(t)}`).join(', ')}
    +
    `; + $('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome()); + const termRe = new RegExp(terms.map((t) => esc(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi'); + const tick2 = async () => { + let st, res; + try { st = await rpc('status', { job }); res = await rpc('search_results', { job, offset: 0, limit: 1000 }); } catch { return false; } + const run = st.status === 'running'; + const p = st.progress || {}; + const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0; + $('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString('pt-BR')} ocorrência${res.total === 1 ? '' : 's'}`; + setHTML($('#shProg'), run ? `
    ${pct}% · ${res.total.toLocaleString('pt-BR')} até agora
    ` : st.status === 'error' ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : ''); + setHTML($('#hits'), res.items.length ? `
    PosiçãoPartiçãoCodificaçãoContexto
    ` + res.items.map((h) => `
    ${h.offset.toLocaleString('pt-BR')}${h.partition ? '#' + h.partition : 'fora'}${esc(h.encoding)}${esc(h.context).replace(termRe, (m) => `${m}`)}
    `).join('') : (run ? '' : '

    Nada encontrado

    Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).

    ')); + return run; + }; + $('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); }; + (async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })(); +} + // ------------------------------------------------------------------ varredura +let EX = null; // explorador de arquivos (grade, navegação, detalhes) + function openJob(s) { stopPoll(); + EX?.destroy(); + EX = null; S.job = s.job; - S.filters = { category: null, minConfidence: null, query: '', sort: 'confidence' }; - S.items = []; S.total = 0; S.selected = new Set(); S.allFiltered = false; S.current = null; S.status = null; + S.status = null; renderSide(); renderScan(); tick(); S.poll = setInterval(tick, 500); - main.scrollTop = 0; } function stopPoll() { clearInterval(S.poll); S.poll = null; } -let lastFound = -1, lastFilterKey = ''; async function tick() { if (!S.job) return; let st; try { st = await rpc('status', { job: S.job }); } catch (e) { stopPoll(); toast(e.message); return; } S.status = st; - const key = JSON.stringify(S.filters); - if (st.found !== lastFound || key !== lastFilterKey) { - lastFound = st.found; lastFilterKey = key; - await loadResults(true); - } paintStatus(); - if (st.status !== 'running') { + EX?.onStatus(st); + // continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas + if (st.status !== 'running' && !st.enriching) { stopPoll(); if (st.status === 'error') toast(st.error); - await loadResults(true); - paintStatus(); + } else if (!S.poll) { + S.poll = setInterval(tick, 700); } } -async function loadResults(reset) { - const f = S.filters; - const query = { sort: f.sort, offset: reset ? 0 : S.items.length, limit: 200 }; - if (f.category) query.category = f.category; - if (f.minConfidence) query.min_confidence = f.minConfidence; - if (f.query) query.query = f.query; - const r = await rpc('results', { job: S.job, query }); - S.items = reset ? r.items : S.items.concat(r.items); - S.total = r.total; - paintList(); -} - function renderScan() { - main.innerHTML = `
    - -

    Procurando…

    + main.classList.add('fixed'); + main.innerHTML = `
    +

    Procurando…

    -
    -
    -
    -
    - - - -
    -
    -
    `; +
    +
    `; $('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome()); $('#progressArea').onclick = onProgressClick; - $('#chips').onclick = (e) => { const c = e.target.closest('.chip'); if (c) { S.filters.category = c.dataset.c || null; resetSel(); tick(); } }; - let t; - $('#q').oninput = (e) => { clearTimeout(t); t = setTimeout(() => { S.filters.query = e.target.value; resetSel(); tick(); }, 250); }; - $('#conf').onchange = (e) => { S.filters.minConfidence = e.target.value || null; resetSel(); tick(); }; - $('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); }; - wireTable(); - paintPreview(); + EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast }); } -const resetSel = () => { S.selected = new Set(); S.allFiltered = false; }; function paintStatus() { const st = S.status; @@ -397,26 +444,27 @@ function paintStatus() { $('#scanSub').innerHTML = `${esc(st.target)}`; const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0; const eta = rate && running ? dur((p.total - p.done) / rate) : null; - setHTML($('#progressArea'), `
    -
    ${esc(p.phase || (running ? 'Iniciando' : ''))}${pct != null ? ` · ${pct}%` : ''}
    -
    ${running ? '' : st.status === 'paused' ? '' : ''} - ${!running && st.found ? '' : ''}
    -
    -
    -
    ${st.found.toLocaleString('pt-BR')}arquivos encontrados
    -
    ${dur(st.elapsed)}decorrido${eta ? ' · faltam ~' + eta : ''}
    -
    ${st.confidence.high}confiança alta
    -
    ${st.confidence.medium}média
    -
    ${st.confidence.low}baixa
    -
    `); + const enr = st.enriching ? `Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}` : ''; + const buttons = running ? '' + : st.status === 'paused' ? '' : ''; + const tools = !running && st.found ? '' : ''; + if (running || st.status === 'paused') { + setHTML($('#progressArea'), `
    +
    ${esc(p.phase || 'Iniciando')}${pct != null ? ` · ${pct}%` : ''}
    ${buttons}
    +
    +
    +
    ${st.found.toLocaleString('pt-BR')}arquivos
    +
    ${dur(st.elapsed)}decorrido${eta ? ' · faltam ~' + eta : ''}
    +
    ${st.confidence.high}alta
    +
    ${st.confidence.medium}média
    +
    ${st.confidence.low}baixa
    +
    `); + } else { + setHTML($('#progressArea'), `
    ${st.found.toLocaleString('pt-BR')} arquivos em ${dur(st.elapsed)} + confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}${enr}${tools}
    `); + } setHTML($('#warnArea'), (st.warnings || []).map((w) => `
    ${warnSvg}
    ${esc(w)}
    `).join('') + (st.error ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : '')); - // chips - const total = st.found; - setHTML($('#chips'), [``] - .concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => ``)).join('')); - const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || ''; - paintBar(); } async function onProgressClick(e) { @@ -425,171 +473,27 @@ async function onProgressClick(e) { if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); } else if (act === 'stop') await rpc('scan_stop', { job: S.job }); else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); } - else if (act === 'save') { + else if (act === 'csv') { + 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('pt-BR')} 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: [ + { 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 } ] }); + if (f) { + try { + const hashes = S.sourceHashes && S.sourceHashes.path === (S.status?.target) ? { md5: S.sourceHashes.md5, sha1: S.sourceHashes.sha1, sha256: S.sourceHashes.sha256 } : null; + await rpc('export_report', { job: S.job, path: f.path, case: { case_id: f.case_id, examiner: f.examiner, organization: f.organization, notes: f.notes }, hashes }); + toast('Relatório gravado'); + } catch (err) { toast(err.message); } + } + } else if (act === 'save') { const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' }); if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (err) { toast(err.message); } } } } -function rowHtml(i) { - const [cl, cc] = CONF[i.confidence]; - const on = S.allFiltered || S.selected.has(i.id); - return `
    - -
    ${esc(i.name)}${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}
    -
    ${human(i.size)}
    ${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}
    -
    ${cl}
    `; -} - -function paintList() { - const el = $('#table'); - if (!el) return; - const st = S.status; - if (!S.items.length) { - setHTML(el, `

    ${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}

    ${st && st.status === 'running' ? 'Os arquivos aparecem aqui assim que são encontrados.' : (S.filters.category || S.filters.query || S.filters.minConfidence) ? 'Nenhum arquivo combina com os filtros.' : 'Não encontramos arquivos recuperáveis neste disco.'}

    `); - paintBar(); - return; - } - const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered; - setHTML(el, `
    NomeTamanhoOrigemConfiança
    ` - + S.items.map(rowHtml).join('') - + (S.items.length < S.total ? `
    ` : '')); - paintBar(); -} - -// Eventos da tabela por delegação (o HTML é recriado, o listener não). -function wireTable() { - const el = $('#table'); - el.addEventListener('click', (e) => { - if (e.target.id === 'moreBtn') return loadResults(false); - const row = e.target.closest('.tr'); - if (e.target.id === 'selAll') { - if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel(); - el._h = null; paintList(); - return; - } - if (!row) return; - const id = +row.dataset.id; - if (e.target.matches('input[type=checkbox]')) { - if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); } - e.target.checked ? S.selected.add(id) : S.selected.delete(id); - row.setAttribute('aria-selected', e.target.checked); - el._h = null; - paintBar(); - return; - } - focusItem(S.items.find((i) => i.id === id)); - }); - el.addEventListener('dblclick', (e) => { if (e.target.closest('.tr')) openLightbox(); }); -} - -let stopPv = null; -function focusItem(item, { scroll = false } = {}) { - if (!item) return; - S.current = item; - document.querySelectorAll('.tr').forEach((r) => { - const on = +r.dataset.id === item.id || S.selected.has(+r.dataset.id) || S.allFiltered; - r.setAttribute('aria-selected', on); - if (scroll && +r.dataset.id === item.id) r.scrollIntoView({ block: 'nearest' }); - }); - paintPreview(); -} - -function detailsHtml(i) { - const [cl, cc] = CONF[i.confidence]; - return `
    Nome
    ${esc(i.name)}
    Pasta
    ${esc(i.dir || '—')}
    Tamanho
    ${human(i.size)}
    -
    Modificado
    ${date(i.modified)}
    Origem
    ${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}
    -
    Confiança
    ${cl}
    ${i.note ? `
    Aviso
    ${esc(i.note)}
    ` : ''}
    `; -} - -function paintPreview() { - const el = $('#preview'); - if (!el) return; - stopPv?.(); stopPv = null; - const i = S.current; - if (!i) { el.innerHTML = '
    Selecione um arquivo para pré-visualizar
    '; return; } - el.innerHTML = `
    ${esc(KIND_LABEL[kindOf(i)])}
    ${detailsHtml(i)} -
    `; - stopPv = renderPreview($('#pvHost'), i, { url: fileUrl(S.job, i.id) }); - $('#pvFull').onclick = openLightbox; - $('#recOne').onclick = () => recover({ ids: [i.id] }); -} - -// ------------------------------------------------------------------ tela cheia -let stopLb = null; -function openLightbox() { - const i = S.current; - if (!i) return; - const d = $('#lightbox'); - const idx = S.items.findIndex((x) => x.id === i.id); - d.innerHTML = `
    -
    ${esc(i.name)}${esc(KIND_LABEL[kindOf(i)])} · ${human(i.size)} · confiança ${CONF[i.confidence][0]}
    -
    -
    -
    ${i.note ? esc(i.note) : '← → para navegar · Esc para fechar'}
    `; - stopLb?.(); - stopLb = renderPreview($('#lbHost', d), i, { url: fileUrl(S.job, i.id), large: true }); - const go = (n) => { const t = S.items[idx + n]; if (t) { focusItem(t, { scroll: true }); openLightbox(); } }; - $('#lbPrev', d).onclick = () => go(-1); - $('#lbNext', d).onclick = () => go(1); - $('#lbClose', d).onclick = () => d.close(); - $('#lbRec', d).onclick = () => { d.close(); recover({ ids: [i.id] }); }; - d.onclose = () => { stopLb?.(); stopLb = null; }; - d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); }; - if (!d.open) d.showModal(); -} - -// Teclado: ↑/↓ percorre a lista; Espaço/Enter abre a tela cheia. -document.addEventListener('keydown', (e) => { - if (!S.job || document.querySelector('dialog[open]') || /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName) && document.activeElement.type !== 'checkbox') return; - const idx = S.items.findIndex((x) => x.id === S.current?.id); - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - const t = S.items[Math.min(S.items.length - 1, Math.max(0, idx + (e.key === 'ArrowDown' ? 1 : -1)))]; - focusItem(t, { scroll: true }); - } else if ((e.key === ' ' || e.key === 'Enter') && S.current && !e.target.closest?.('button')) { - e.preventDefault(); - openLightbox(); - } -}); - -function selCount() { return S.allFiltered ? S.total : S.selected.size; } - -function paintBar() { - const host = $('#barHost'); - if (!host) return; - const st = S.status; - const n = selCount(); - const canAll = st && st.found > 0 && st.status !== 'error'; - if (!n && !canAll) { host.innerHTML = ''; host._h = null; return; } - const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size - ? '' : ''; - const inner = `${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}${moreOffer} - ${n ? '' : ''}`; - let bar = host.firstElementChild; - if (!bar) { - // A barra é criada uma vez (com animação de entrada) e depois só tem o texto atualizado. - host.innerHTML = '
    '; - bar = host.firstElementChild; - bar.onclick = (e) => { - const act = e.target.closest('[data-bar]')?.dataset.bar; - if (act === 'all') { S.allFiltered = true; $('#table')._h = null; paintList(); } - else if (act === 'sel') recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }); - else if (act === 'rec') recover({ filter: currentFilter() }); - }; - } - setHTML(bar, inner); -} - -function currentFilter() { - const f = S.filters; - const q = { sort: f.sort, limit: 1 }; - if (f.category) q.category = f.category; - if (f.minConfidence) q.min_confidence = f.minConfidence; - if (f.query) q.query = f.query; - return q; -} - async function recover(sel) { const dest = await ask({ title: 'Recuperar para…', diff --git a/apps/gui/ui/explorer.js b/apps/gui/ui/explorer.js new file mode 100644 index 0000000..d84af74 --- /dev/null +++ b/apps/gui/ui/explorer.js @@ -0,0 +1,630 @@ +// Explorador de arquivos recuperados: grade de miniaturas (virtualizada, aguenta +// dezenas de milhares de itens), navegação por tipo/data/pasta/câmera, seleção +// estilo gerenciador de arquivos (Ctrl/Shift/teclado), painel de detalhes e +// visualização rápida em tela cheia. + +import { renderPreview, kindOf, KIND_LABEL } from './preview.js'; + +const CAT = { + image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'], + archive: ['Compactados', '🗜'], database: ['Bancos de dados', '🗄'], other: ['Outros', '📦'], +}; +const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] }; +const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro']; +const SORTS = [ + ['date', 'Data (mais recentes)'], ['date_asc', 'Data (mais antigas)'], ['name', 'Nome'], ['size', 'Tamanho'], ['confidence', 'Confiança'], +]; +const GAP = 10; +const PAD = 12; + +const svg = (d, w = 18) => `${d}`; +const I = { + grid: svg(''), + list: svg(''), + filter: svg(''), + chev: svg('', 14), + x: svg('', 14), + pin: svg('', 14), + warn: svg('', 22), + close: svg('', 20), +}; + +export function createExplorer(deps) { + const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps; + const E = { + filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' }, + sort: 'date', + view: 'grid', + tile: 150, + items: [], + total: 0, + sel: new Set(), + allFiltered: false, + anchor: -1, + curId: null, + facets: null, + expanded: new Set(), + rows: [], + height: 0, + rendered: new Map(), + reloadToken: 0, + lastReload: 0, + lastFound: -1, + wasEnriching: false, + status: null, + }; + try { + E.view = localStorage.getItem('guara-view') || 'grid'; + E.tile = +localStorage.getItem('guara-tile') || 150; + } catch { /* sem armazenamento */ } + + host.innerHTML = `
    +
    + +
    + + +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    `; + const $ = (s) => host.querySelector(s); + const root = $('.xp'), scroll = $('#xpScroll'), sizer = $('#xpSizer'), nav = $('#xpNav'), insp = $('#xpInsp'), actions = $('#xpActions'); + $('#xpSort').value = E.sort; + $('#xpSize').value = E.tile; + + // ------------------------------------------------------------ dados + const toQuery = () => { + const f = E.filters; + const q = {}; + if (f.category) q.category = f.category; + if (f.year) q.year = f.year; + if (f.month) q.month = f.month; + if (f.undated) q.undated = true; + if (f.folder) q.folder = f.folder; + if (f.camera) q.camera = f.camera; + if (f.origin) q.origin = f.origin; + if (f.minConf) q.min_confidence = f.minConf; + if (f.gps) q.has_gps = true; + if (f.dedupe) q.dedupe = true; + if (f.query) q.query = f.query; + return q; + }; + const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v)); + + async function reload() { + const token = ++E.reloadToken; + E.lastReload = Date.now(); + const base = { ...toQuery(), sort: E.sort }; + let items = [], total = 0; + for (;;) { + let r; + try { r = await rpc('results', { job, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; } + if (token !== E.reloadToken) return; + items = items.concat(r.items); + total = r.total; + if (!r.items.length || items.length >= total || items.length >= 30000) break; + if (items.length === 2000) { E.items = items; E.total = total; relayout(); paintAll(); } + } + E.items = items; + E.total = total; + relayout(); + paintAll(); + } + + async function loadFacets() { + try { E.facets = await rpc('facets', { job }); } catch { return; } + paintNav(); + } + + const reloadAll = () => { reload(); loadFacets(); }; + + // ------------------------------------------------------------ layout virtual + // Agrupa por dia; se houver poucos itens por dia, agrupa por mês. + let groupBy = 'day'; + const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7)); + const dayLabel = (k) => { + if (k === 'Sem data') return k; + return groupBy === 'day' + ? new Date(k + 'T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) + : new Date(k + '-01T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', month: 'long', year: 'numeric' }); + }; + const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString('pt-BR', { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' })); + const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString('pt-BR', { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' })); + + function geometry() { + const W = Math.max(200, scroll.clientWidth - PAD * 2); + if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 }; + const T = E.tile; + const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP))); + const tw = Math.floor((W - GAP * (cols - 1)) / cols); + const cap = tw >= 128 ? 42 : 0; + return { W, cols, tw, cap, rowH: tw + cap + GAP }; + } + + let G = geometry(); + function relayout() { + G = geometry(); + const grouped = E.view === 'grid' && (E.sort === 'date' || E.sort === 'date_asc'); + if (grouped) { + groupBy = 'day'; + const days = new Set(E.items.map((it) => dayKey(it.when))); + if (E.items.length / Math.max(1, days.size) < 2.5) groupBy = 'month'; + } + const rows = []; + let y = PAD; + let cur = null, rowItems = []; + const flush = () => { + if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; } + }; + E.items.forEach((it, i) => { + if (grouped) { + const k = dayKey(it.when); + if (k !== cur) { + flush(); + cur = k; + rows.push({ t: 'head', y, h: 40, key: k, start: i }); + y += 40; + } + } + rowItems.push(i); + if (rowItems.length === G.cols) flush(); + }); + flush(); + if (grouped) { + // contagem por grupo + for (let r = 0; r < rows.length; r++) { + if (rows[r].t !== 'head') continue; + let n = 0; + for (let i = rows[r].start; i < E.items.length && dayKey(E.items[i].when) === rows[r].key; i++) n++; + rows[r].count = n; + } + } + E.rows = rows; + E.height = y + PAD; + sizer.style.height = `${E.height}px`; + for (const el of E.rendered.values()) el.remove(); + E.rendered.clear(); + renderWindow(); + } + + function rowAt(top) { + let lo = 0, hi = E.rows.length - 1, ans = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (E.rows[mid].y + E.rows[mid].h < top) lo = mid + 1; else { ans = mid; hi = mid - 1; } + } + return ans; + } + + function renderWindow() { + if (!E.rows.length) { for (const el of E.rendered.values()) el.remove(); E.rendered.clear(); return; } + const top = Math.max(0, scroll.scrollTop - 400), bottom = scroll.scrollTop + scroll.clientHeight + 400; + const a = rowAt(top); + let b = a; + while (b < E.rows.length && E.rows[b].y < bottom) b++; + for (const [ri, el] of E.rendered) { + if (ri < a || ri >= b) { el.remove(); E.rendered.delete(ri); } + } + const frag = document.createDocumentFragment(); + for (let ri = a; ri < b; ri++) { + if (E.rendered.has(ri)) continue; + const row = E.rows[ri]; + const el = document.createElement('div'); + el.className = `xp-row ${row.t}`; + el.style.top = `${row.y}px`; + el.style.height = `${row.h}px`; + el.dataset.ri = ri; + el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join(''); + E.rendered.set(ri, el); + frag.appendChild(el); + } + sizer.appendChild(frag); + } + + // ------------------------------------------------------------ itens + const isSel = (id) => E.allFiltered || E.sel.has(id); + + function headHtml(row) { + return `
    ${esc(dayLabel(row.key))}${row.count} ${row.count === 1 ? 'item' : 'itens'}
    `; + } + + function thumbSrc(it, px) { + const s = px <= 200 ? 256 : px <= 330 ? 384 : 640; + return thumbUrl(job, it.id, s); + } + + function tileHtml(i) { + const it = E.items[i]; + const [cl, cc] = CONF[it.confidence]; + const on = isSel(it.id); + const [catName, catIco] = CAT[it.category] || CAT.other; + const media = it.category === 'image' + ? `` + : `
    ${catIco}${esc((it.ext || '?').toUpperCase().slice(0, 5))}
    `; + return `
    +
    ${media} + + + ${it.gps ? `${I.pin}` : ''} +
    + ${G.cap ? `
    ${esc(it.name)}${human(it.size)}${it.when != null ? ' · ' + esc(fmtDay(it.when)) : ''}
    ` : ''} +
    `; + } + + function listHtml(i) { + const it = E.items[i]; + const [cl, cc] = CONF[it.confidence]; + const on = isSel(it.id); + const [, catIco] = CAT[it.category] || CAT.other; + const th = it.category === 'image' ? `` : `${catIco}`; + return `
    +
    ${th}
    +
    ${esc(it.name)}${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}
    + ${esc(fmtDate(it.when))}${human(it.size)}${cl} +
    `; + } + + // imagens que falham viram um aviso (arquivo possivelmente danificado) + scroll.addEventListener('error', (e) => { + const img = e.target; + if (img.tagName !== 'IMG' || !img.dataset.th) return; + const wrap = img.closest('.xp-thumb, .xp-lth'); + img.remove(); + wrap?.insertAdjacentHTML('afterbegin', `
    ${I.warn}?
    `); + }, true); + + // ------------------------------------------------------------ seleção + const selCount = () => (E.allFiltered ? E.total : E.sel.size); + const idxOf = (id) => E.items.findIndex((x) => x.id === id); + + function paintSel() { + const set = (el) => { + const id = +el.dataset.id; + el.setAttribute('aria-selected', String(isSel(id))); + if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur; + }; + scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set); + paintActions(); + } + + function selectRange(a, b) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + for (let i = lo; i <= hi; i++) E.sel.add(E.items[i].id); + } + + function setCursor(i, { extend = false, toggle = false, reveal = false } = {}) { + if (i < 0 || i >= E.items.length) return; + const it = E.items[i]; + if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(E.items.map((x) => x.id)); } + if (extend && E.anchor >= 0) { + E.sel = new Set(); + selectRange(E.anchor, i); + } else if (toggle) { + if (E.sel.has(it.id)) E.sel.delete(it.id); else E.sel.add(it.id); + E.anchor = i; + } else { + E.sel = new Set([it.id]); + E.anchor = i; + } + E.curId = it.id; + if (reveal) revealIndex(i); + paintSel(); + schedInspector(); + if (matchMedia('(max-width: 1100px)').matches && !extend && !toggle) root.classList.add('show-insp'); + } + + function revealIndex(i) { + const ri = E.rows.findIndex((r) => r.t === 'row' && r.idx.includes(i)); + if (ri < 0) return; + const r = E.rows[ri]; + if (r.y < scroll.scrollTop + 4) scroll.scrollTop = r.y - 8; + else if (r.y + r.h > scroll.scrollTop + scroll.clientHeight) scroll.scrollTop = r.y + r.h - scroll.clientHeight + 8; + renderWindow(); + } + + scroll.addEventListener('scroll', () => renderWindow(), { passive: true }); + new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll); + + scroll.addEventListener('click', (e) => { + const hs = e.target.closest('[data-hsel]'); + if (hs) { + const start = +hs.dataset.hsel, n = +hs.dataset.n; + if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(); } + for (let i = start; i < start + n; i++) E.sel.add(E.items[i].id); + paintSel(); + return; + } + const el = e.target.closest('.xp-tile, .xp-lrow'); + if (!el) return; + const i = +el.dataset.i; + if (e.target.closest('[data-chk]')) setCursor(i, { toggle: true }); + else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey }); + }); + scroll.addEventListener('dblclick', (e) => { + const el = e.target.closest('.xp-tile, .xp-lrow'); + if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); } + }); + scroll.addEventListener('keydown', (e) => { + if (!E.items.length) return; + let i = idxOf(E.curId); + if (i < 0) i = 0; + const cols = G.cols; + const move = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols, PageDown: cols * 4, PageUp: -cols * 4 }[e.key]; + if (move != null) { + e.preventDefault(); + setCursor(Math.max(0, Math.min(E.items.length - 1, idxOf(E.curId) < 0 ? 0 : i + move)), { extend: e.shiftKey, reveal: true }); + } else if (e.key === 'Home') { e.preventDefault(); setCursor(0, { extend: e.shiftKey, reveal: true }); } + else if (e.key === 'End') { e.preventDefault(); setCursor(E.items.length - 1, { extend: e.shiftKey, reveal: true }); } + else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (E.curId == null) setCursor(0); openLightbox(idxOf(E.curId)); } + else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); E.allFiltered = true; paintSel(); } + else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); } + }); + + // ------------------------------------------------------------ barra de ações + function paintActions() { + const n = selCount(); + const html = n + ? `${n.toLocaleString('pt-BR')} selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''} + + ${!E.allFiltered && E.total > n ? `` : ''} + ` + : `${E.total.toLocaleString('pt-BR')} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString('pt-BR')})` : ''} + ${E.total ? '' : ''}`; + if (actions._h !== html) { actions._h = html; actions.innerHTML = html; } + } + actions.addEventListener('click', (e) => { + const a = e.target.closest('[data-a]')?.dataset.a; + if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); } + else if (a === 'all') { E.allFiltered = true; paintSel(); } + else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] }); + else if (a === 'recall') onRecover({ filter: toQuery() }); + }); + + // ------------------------------------------------------------ navegação lateral + function paintNav() { + const f = E.facets; + if (!f) return; + const F = E.filters; + const node = (label, count, attrs, active, depth = 0, extra = '') => + ``; + const sec = (title, body) => (body ? `

    ${title}

    ${body}
    ` : ''); + + const cats = Object.keys(CAT).filter((k) => f.categories[k]).map((k) => node(`${CAT[k][1]} ${CAT[k][0]}`, f.categories[k], `data-f="category" data-v="${k}"`, F.category === k)).join(''); + const years = f.years.map((y) => { + const open = E.expanded.has('y' + y.year) || F.year === y.year; + return node(String(y.year), y.count, `data-f="year" data-v="${y.year}"`, F.year === y.year && !F.month, 0, + `${I.chev}`) + + (open ? y.months.map((m) => node(MONTHS[m.month - 1], m.count, `data-f="month" data-y="${y.year}" data-v="${m.month}"`, F.year === y.year && F.month === m.month, 1)).join('') : ''); + }).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : ''); + const folders = f.folders.filter((d) => { + // só mostra se todos os ancestrais estiverem expandidos + const parts = d.path.split('/'); + for (let k = 1; k < parts.length; k++) if (!E.expanded.has('d' + parts.slice(0, k).join('/')) && !(F.folder || '').startsWith(parts.slice(0, k).join('/'))) return false; + return true; + }).map((d) => { + const hasKids = f.folders.some((o) => o.path.startsWith(d.path + '/')); + const open = E.expanded.has('d' + d.path) || (F.folder || '').startsWith(d.path + '/'); + return node(esc(d.name), d.count, `data-f="folder" data-v="${esc(d.path)}"`, F.folder === d.path, d.depth, + hasKids ? `${I.chev}` : ''); + }).join('') + + (f.root ? node('/ (raiz)', f.root, 'data-f="folder" data-v="@raiz"', F.folder === '@raiz') : '') + + (f.carved ? node('Por assinatura', f.carved, 'data-f="folder" data-v="@carved"', F.folder === '@carved') : ''); + const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join(''); + const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join(''); + const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '') + + (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : ''); + + const html = `
    ${node('Tudo', f.total, 'data-f="all"', !anyFilter())}
    ` + + sec('Tipos', cats) + sec('Datas', years) + sec('Pastas', folders) + sec('Câmeras', cams) + sec('Confiança', conf) + sec('Outros', extra); + if (nav._h !== html) { nav._h = html; const st = nav.scrollTop; nav.innerHTML = html; nav.scrollTop = st; } + } + + nav.addEventListener('click', (e) => { + const tw = e.target.closest('[data-tw]'); + if (tw) { + const k = tw.dataset.tw; + if (E.expanded.has(k)) E.expanded.delete(k); else E.expanded.add(k); + nav._h = null; paintNav(); + e.stopPropagation(); + return; + } + const b = e.target.closest('.xp-node'); + if (!b) return; + const F = E.filters; + const f = b.dataset.f, v = b.dataset.v; + if (f === 'all') clearFilters(false); + else if (f === 'year') { const same = F.year === +v && !F.month; F.year = same ? null : +v; F.month = null; F.undated = false; E.expanded.add('y' + v); } + else if (f === 'month') { const same = F.year === +b.dataset.y && F.month === +v; F.year = same ? null : +b.dataset.y; F.month = same ? null : +v; F.undated = false; } + else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; } + else if (f === 'gps') F.gps = !F.gps; + else if (f === 'dedupe') F.dedupe = !F.dedupe; + else F[f] = F[f] === v ? null : v; + if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder); + nav._h = null; + root.classList.remove('show-nav'); + changed(); + }); + + function clearFilters(refresh = true) { + E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' }; + $('#xpQ').value = ''; + if (refresh) changed(); else changed(); + } + + function changed() { + E.sel = new Set(); E.allFiltered = false; E.curId = null; E.anchor = -1; + scroll.scrollTop = 0; + paintCrumb(); paintChips(); paintNav(); paintInspector(); + reload(); + } + + function paintCrumb() { + const F = E.filters, parts = []; + if (F.category) parts.push(CAT[F.category][0]); + if (F.year) parts.push(String(F.year)); + if (F.month) parts.push(MONTHS[F.month - 1]); + if (F.undated) parts.push('Sem data'); + if (F.folder) parts.push(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : F.folder.split('/').join(' › ')); + if (F.camera) parts.push(F.camera); + $('#xpCrumb').innerHTML = `${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}${E.total.toLocaleString('pt-BR')}`; + } + + function paintChips() { + const F = E.filters, chips = []; + const add = (label, key) => chips.push(``); + if (F.category) add(CAT[F.category][0], 'category'); + if (F.year) add(String(F.year), 'year'); + if (F.month) add(MONTHS[F.month - 1], 'month'); + if (F.undated) add('Sem data', 'undated'); + if (F.folder) add(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : esc(F.folder), 'folder'); + if (F.camera) add(esc(F.camera), 'camera'); + if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf'); + if (F.gps) add('Com GPS', 'gps'); + if (F.dedupe) add('Sem duplicadas', 'dedupe'); + if (F.query) add(`“${esc(F.query)}”`, 'query'); + $('#xpChips').innerHTML = chips.length ? chips.join('') + '' : ''; + } + $('#xpChips').addEventListener('click', (e) => { + const k = e.target.closest('[data-x]')?.dataset.x; + if (!k) return; + if (k === 'all') return clearFilters(); + E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' ? false : null; + if (k === 'year') E.filters.month = null; + if (k === 'query') $('#xpQ').value = ''; + nav._h = null; + changed(); + }); + + // ------------------------------------------------------------ barra superior + let qt; + $('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); }); + $('#xpSort').addEventListener('change', (e) => { E.sort = e.target.value; reload(); }); + const paintView = () => { + root.dataset.view = E.view; + host.querySelectorAll('[data-view]').forEach((b) => { if (b.tagName === 'BUTTON') b.setAttribute('aria-pressed', String(b.dataset.view === E.view)); }); + $('#xpSize').style.display = E.view === 'grid' ? '' : 'none'; + }; + host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => { + E.view = b.dataset.view; + try { localStorage.setItem('guara-view', E.view); } catch { /* */ } + paintView(); relayout(); + })); + $('#xpSize').addEventListener('input', (e) => { + E.tile = +e.target.value; + try { localStorage.setItem('guara-tile', String(E.tile)); } catch { /* */ } + relayout(); + }); + $('#xpNavBtn').onclick = () => root.classList.toggle('show-nav'); + $('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp'); + + // ------------------------------------------------------------ painel de detalhes + let inspT, stopPv = null, metaToken = 0; + const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); }; + + function timesHtml(it) { + const rows = [['Tirada em', it.taken], ['Modificado', it.modified], ['Criado', it.created], ['Acessado', it.accessed]].filter(([, v]) => v != null); + return rows.map(([k, v]) => `
    ${k}
    ${esc(fmtDate(v))}
    `).join(''); + } + + function paintInspector() { + stopPv?.(); stopPv = null; + const n = selCount(); + const it = E.items[idxOf(E.curId)]; + const close = ``; + if (n > 1) { + const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0); + insp.innerHTML = `${close}
    ${n.toLocaleString('pt-BR')} itens selecionados${sizeSum != null ? `${human(sizeSum)} no total` : ''} +
    `; + return; + } + if (!it) { + insp.innerHTML = `${close}
    Selecione um arquivo para pré-visualizar.
    Setas navegam, Espaço abre em tela cheia.
    `; + return; + } + const [cl, cc] = CONF[it.confidence]; + const url = kindOf(it) === 'image' ? thumbUrl(job, it.id, 640) : fileUrl(job, it.id); + insp.innerHTML = `${close}
    +
    ${esc(it.name)}${cl}
    +
    ${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}
    + ${it.note ? `
    ${esc(it.note)}
    ` : ''} +
    Pasta
    ${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}
    ${timesHtml(it)}${it.camera ? `
    Câmera
    ${esc(it.camera)}
    ` : ''}${it.px ? `
    Resolução
    ${it.px[0]} × ${it.px[1]} px
    ` : ''}
    +
    +
    `; + stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image' }); + const my = ++metaToken; + rpc('metadata', { job, id: it.id }).then((m) => { + if (my !== metaToken || !$('#xpMeta')) return; + const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k)); + if (!rows.length) return; + $('#xpMeta').innerHTML = `
    Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}
    ${rows.map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join('')}
    ` + + (m.gps ? `
    Coordenadas: ${m.gps[0].toFixed(5)}, ${m.gps[1].toFixed(5)}
    ` : ''); + }).catch(() => {}); + } + insp.addEventListener('click', (e) => { + const a = e.target.closest('[data-i]')?.dataset.i; + if (a === 'full') openLightbox(idxOf(E.curId)); + else if (a === 'rec1') onRecover({ ids: [E.curId] }); + else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] }); + else if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); } + else if (a === 'close') root.classList.remove('show-insp'); + }); + + // ------------------------------------------------------------ tela cheia + let stopLb = null; + function openLightbox(i) { + const it = E.items[i]; + if (!it) return; + const d = document.querySelector('#lightbox'); + const [cl] = CONF[it.confidence]; + d.innerHTML = `
    +
    ${esc(it.name)}${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}${it.when != null ? ' · ' + esc(fmtDate(it.when)) : ''} · confiança ${cl} · ${i + 1} de ${E.items.length}
    +
    +
    +
    ${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}
    `; + stopLb?.(); + stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true }); + const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } }; + d.querySelector('#lbPrev').onclick = () => go(-1); + d.querySelector('#lbNext').onclick = () => go(1); + d.querySelector('#lbClose').onclick = () => d.close(); + d.querySelector('#lbRec').onclick = () => { d.close(); onRecover({ ids: [it.id] }); }; + d.onclose = () => { stopLb?.(); stopLb = null; scroll.focus({ preventScroll: true }); }; + d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); }; + if (!d.open) d.showModal(); + } + + // ------------------------------------------------------------ paint geral + function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); } + + paintView(); paintCrumb(); paintActions(); paintInspector(); + reloadAll(); + + return { + /** Chamado a cada atualização de status da varredura. */ + onStatus(st) { + E.status = st; + const enriching = !!st.enriching; + const now = Date.now(); + const foundChanged = st.found !== E.lastFound; + const justDone = st.status !== 'running' && E.running; + E.running = st.status === 'running'; + if ((foundChanged && now - E.lastReload > 1500) || justDone || (E.wasEnriching && !enriching) || (enriching && now - E.lastReload > 2500)) { + E.lastFound = st.found; + reloadAll(); + } + E.wasEnriching = enriching; + }, + query: () => toQuery(), + destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; }, + focus() { scroll.focus({ preventScroll: true }); }, + }; +} diff --git a/apps/gui/ui/preview.js b/apps/gui/ui/preview.js index 25de0f8..b6fd798 100644 --- a/apps/gui/ui/preview.js +++ b/apps/gui/ui/preview.js @@ -90,7 +90,7 @@ function note(host, html, cls = '') { function failure(host, item, err, withHex) { host.innerHTML = ''; - note(host, `Não foi possível pré-visualizar.
    ${esc(err?.message || err || '')} O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito — isso também indica que a chance de recuperação é menor.`, 'bad'); + note(host, `Não foi possível pré-visualizar.
    ${esc(err?.message || err || '')} O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.`, 'bad'); return withHex ? hex(host, item, host.dataset.url, false) : null; } @@ -121,7 +121,7 @@ async function text(host, item, url, large) { if (item.size > b.length) note(host, `Mostrando o início (${human(b.length)} de ${human(item.size)}).`); } -async function image(host, item, url, state) { +async function image(host, item, url, state, thumb) { await new Promise((resolve) => { const img = new Image(); img.alt = `Pré-visualização de ${item.name}`; @@ -133,7 +133,7 @@ async function image(host, item, url, state) { host.appendChild(img); host.classList.add('zoomable'); img.onclick = () => host.classList.toggle('actual'); - note(host, `${img.naturalWidth} × ${img.naturalHeight} px — clique para alternar entre ajustar e tamanho real.`); + if (!thumb) note(host, `${img.naturalWidth} × ${img.naturalHeight} px. Clique para alternar entre ajustar e tamanho real.`); resolve(); }; img.onerror = () => { if (!state.dead) failure(host, item, 'A imagem não pôde ser decodificada.', true); resolve(); }; @@ -272,7 +272,7 @@ async function zip(host, item, url, state) { // ------------------------------------------------------------------ API /** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */ -export function renderPreview(host, item, { url, large = false }) { +export function renderPreview(host, item, { url, large = false, thumb = false }) { const state = { dead: false, cleanup: [] }; host.dataset.url = url; host.classList.remove('zoomable', 'actual'); @@ -281,7 +281,7 @@ export function renderPreview(host, item, { url, large = false }) { (async () => { try { if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; } - if (kind === 'image') await image(host, item, url, state); + if (kind === 'image') await image(host, item, url, state, thumb); else if (kind === 'video') media(host, item, url, 'video', state); else if (kind === 'audio') media(host, item, url, 'audio', state); else if (kind === 'pdf') await pdf(host, item, url, state, large); diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index 4ebc96e..a03cd77 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use std::time::Duration; #[derive(Parser)] -#[command(name = "guara", version, about = "Guará — recuperação de arquivos (somente leitura na origem)", long_about = None)] +#[command(name = "guara", version, about = "Guará: recuperação de arquivos (somente leitura na origem)", long_about = None)] struct Cli { #[command(subcommand)] cmd: Cmd, @@ -50,6 +50,50 @@ enum Cmd { }, /// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia Image { path: String, dest: String }, + /// Exporta os achados de uma sessão para CSV (abre no Excel) + Export { + session: String, + /// Arquivo CSV de saída + csv: String, + #[command(flatten)] + filter: FilterArgs, + }, + /// Gera o relatório HTML de recuperação (identificação do caso, origem, achados, hashes) + Report { + session: String, + /// Arquivo HTML de saída + out: String, + #[arg(long, default_value = "")] + case: String, + #[arg(long, default_value = "")] + examiner: String, + #[arg(long, default_value = "")] + org: String, + #[arg(long, default_value = "")] + notes: String, + /// Calcula MD5/SHA-1/SHA-256 da origem e inclui no relatório (pode demorar) + #[arg(long)] + hash_source: bool, + }, + /// Procura texto em todo o disco, inclusive espaço livre (UTF-8 e UTF-16) + Search { + path: String, + /// Termos a procurar + #[arg(required = true)] + terms: Vec, + /// Diferencia maiúsculas de minúsculas + #[arg(long)] + case_sensitive: bool, + /// Não procura em UTF-16 + #[arg(long)] + no_utf16: bool, + #[arg(long, default_value_t = 200)] + max: usize, + #[arg(long)] + json: bool, + }, + /// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade) + Hash { path: String }, /// Abre a interface gráfica no navegador (modo local) Ui { #[arg(long, default_value_t = 0)] @@ -202,7 +246,9 @@ fn run(cli: Cli) -> Result<(), String> { "a varredura dessa sessão não terminou; use `guara resume` primeiro".into(), ); } - recover_job(&eng, &job, &dest, &filter) + recover_job(&eng, &job, &dest, &filter)?; + eng.save_session(&job, &session).map_err(s)?; + Ok(()) } Cmd::Image { path, dest } => { let job = eng.start_image(&path, &dest).map_err(s)?; @@ -220,6 +266,107 @@ fn run(cli: Cli) -> Result<(), String> { } Ok(()) } + Cmd::Export { + session, + csv, + filter, + } => { + let job = eng.load_session(&session).map_err(s)?; + let q = query_from(&filter)?; + let r = eng + .rpc("export_csv", json!({ "job": job, "path": csv, "query": q })) + .map_err(s)?; + println!("{} linha(s) gravadas em {csv}", r["rows"]); + Ok(()) + } + Cmd::Report { + session, + out, + case, + examiner, + org, + notes, + hash_source, + } => { + let job = eng.load_session(&session).map_err(s)?; + let mut hashes = Value::Null; + if hash_source { + let st = eng.status(&job).map_err(s)?; + let target = st["target"].as_str().unwrap_or("").to_string(); + let hj = eng.start_hash(&target).map_err(s)?; + let hst = wait(&eng, &hj, None)?; + eprintln!(); + if hst["status"] == "error" { + return Err(hst["error"].as_str().unwrap_or("falha").to_string()); + } + hashes = hst["report"].clone(); + } + let case = json!({ "case_id": case, "examiner": examiner, "organization": org, "notes": notes }); + eng.rpc( + "export_report", + json!({ "job": job, "path": out, "case": case, "hashes": hashes }), + ) + .map_err(s)?; + println!("Relatório gravado em {out}"); + Ok(()) + } + Cmd::Search { + path, + terms, + case_sensitive, + no_utf16, + max, + json, + } => { + let opts = json!({ "path": path, "terms": terms, "ignore_case": !case_sensitive, "utf16": !no_utf16, "max_hits": max }); + let job = eng.rpc("search_start", opts).map_err(s)?["job"] + .as_str() + .unwrap_or("") + .to_string(); + let st = wait(&eng, &job, None)?; + eprintln!(); + if st["status"] == "error" { + return Err(st["error"].as_str().unwrap_or("falha").to_string()); + } + let res = eng + .rpc("search_results", json!({ "job": job, "limit": 2000 })) + .map_err(s)?; + if json { + println!("{}", serde_json::to_string_pretty(&res).unwrap()); + return Ok(()); + } + for h in res["items"].as_array().into_iter().flatten() { + println!( + "{:>14} {} {:<6} {:<8} {}", + h["offset"], + h["partition"] + .as_u64() + .map(|p| format!("part.{p}")) + .unwrap_or_else(|| "fora".into()), + h["encoding"].as_str().unwrap_or(""), + h["term"].as_str().unwrap_or(""), + h["context"].as_str().unwrap_or("") + ); + } + println!("{} ocorrência(s).", res["total"]); + Ok(()) + } + Cmd::Hash { path } => { + let job = eng.start_hash(&path).map_err(s)?; + let st = wait(&eng, &job, None)?; + eprintln!(); + if st["status"] == "error" { + return Err(st["error"].as_str().unwrap_or("falha").to_string()); + } + let r = &st["report"]; + println!( + "MD5 {}\nSHA-1 {}\nSHA-256 {}", + r["md5"].as_str().unwrap_or(""), + r["sha1"].as_str().unwrap_or(""), + r["sha256"].as_str().unwrap_or("") + ); + Ok(()) + } Cmd::Ui { port, no_open } => serve::run(port, !no_open), } } @@ -246,6 +393,7 @@ fn query_from(f: &FilterArgs) -> Result { sort: Some("confidence".into()), offset: 0, limit: 2000, + ..Default::default() }) } @@ -290,6 +438,9 @@ fn drive_scan( } if let Some(dest) = recover { recover_job(eng, job, dest, filter)?; + if let Some(p) = &out.session { + eng.save_session(job, p).map_err(s)?; + } } else if st["found"].as_u64().unwrap_or(0) > 0 && out.session.is_some() { eprintln!( "Para recuperar: guara recover {} ", @@ -391,8 +542,13 @@ fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result .and_then(|v| v.checked_div(t)) .map_or(" … ".to_string(), |v| format!("{v:>3}%")); let found = st["found"].as_u64().unwrap_or(0); + let found = if st["hits"].as_u64().unwrap_or(0) > 0 { + st["hits"].as_u64().unwrap_or(0) + } else { + found + }; let line = format!( - "{pct} {} · {found} arquivo(s) encontrado(s) · {:.0}s", + "{pct} {} · {found} resultado(s) · {:.0}s", p["phase"].as_str().unwrap_or(""), st["elapsed"].as_f64().unwrap_or(0.0) ); @@ -443,7 +599,7 @@ fn print_layout(v: &Value) { fn print_summary(st: &Value) { println!( - "Concluído em {:.1}s — {} arquivo(s) encontrado(s).", + "Concluído em {:.1}s: {} arquivo(s) encontrado(s).", st["elapsed"].as_f64().unwrap_or(0.0), st["found"] ); diff --git a/crates/guara-cli/src/serve.rs b/crates/guara-cli/src/serve.rs index 773459a..e67f093 100644 --- a/crates/guara-cli/src/serve.rs +++ b/crates/guara-cli/src/serve.rs @@ -11,6 +11,7 @@ const CSS: &str = include_str!("../../../apps/gui/ui/app.css"); const JS: &str = include_str!("../../../apps/gui/ui/app.js"); const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js"); const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg"); +const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js"); const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg"); const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs"); const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs"); @@ -85,6 +86,12 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) { (Method::Get, "/guara-logo.svg") => { let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml"))); } + (Method::Get, "/explorer.js") => { + let _ = req.respond( + Response::from_string(EXPLORER_JS) + .with_header(ct("text/javascript; charset=utf-8")), + ); + } (Method::Get, "/guara.svg") => { let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml"))); } @@ -98,6 +105,41 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) { Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")), ); } + (Method::Get, p) if p.starts_with("/thumb/") => { + let tok_ok = query + .split('&') + .any(|kv| kv.strip_prefix("t=") == Some(token)); + if !tok_ok { + let _ = req.respond(Response::from_string("token inválido").with_status_code(401)); + return; + } + let mut it = p.trim_start_matches("/thumb/").split('/'); + let (job, id) = ( + it.next().unwrap_or(""), + it.next().and_then(|s| s.parse::().ok()), + ); + let size = query + .split('&') + .find_map(|kv| kv.strip_prefix("s=")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(256); + let Some(id) = id else { + let _ = req.respond(Response::from_string("id inválido").with_status_code(400)); + return; + }; + match eng.thumb(job, id, size) { + Ok(bytes) => { + let resp = Response::from_data(bytes.as_ref().clone()) + .with_header(ct("image/jpeg")) + .with_header(header("Cache-Control", "private, max-age=3600")); + let _ = req.respond(resp); + } + Err(code) => { + let _ = + req.respond(Response::from_string("sem miniatura").with_status_code(code)); + } + } + } (Method::Get, p) if p.starts_with("/file/") => { let tok_ok = query .split('&') diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml index 6f4bb60..86da513 100644 --- a/crates/guara-core/Cargo.toml +++ b/crates/guara-core/Cargo.toml @@ -11,6 +11,12 @@ rust-version.workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +sha1 = "0.10" +md-5 = "0.10" +jpeg-decoder = { version = "0.3", default-features = false } +jpeg-encoder = "0.6" +image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] } +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } thiserror = "2" [target.'cfg(unix)'.dependencies] diff --git a/crates/guara-core/src/explorer.rs b/crates/guara-core/src/explorer.rs new file mode 100644 index 0000000..98112f5 --- /dev/null +++ b/crates/guara-core/src/explorer.rs @@ -0,0 +1,368 @@ +//! Navegação estilo gerenciador de arquivos: filtros, ordenação, facetas +//! (tipos, datas, pastas, câmeras) e cache de miniaturas. + +use crate::types::*; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::sync::{Arc, Condvar, Mutex}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Query { + #[serde(default)] + pub category: Option, + #[serde(default)] + pub min_confidence: Option, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub sort: Option, + #[serde(default)] + pub offset: usize, + #[serde(default = "default_limit")] + pub limit: usize, + /// Pasta (prefixo), "@carved" (achados por assinatura) ou "@raiz". + #[serde(default)] + pub folder: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub month: Option, + /// Só arquivos sem data conhecida. + #[serde(default)] + pub undated: Option, + #[serde(default)] + pub camera: Option, + #[serde(default)] + pub origin: Option, + #[serde(default)] + pub min_size: Option, + #[serde(default)] + pub max_size: Option, + /// Megapixels mínimos. + #[serde(default)] + pub min_mp: Option, + #[serde(default)] + pub has_gps: Option, + /// Mostra só a primeira ocorrência de cada conteúdo repetido. + #[serde(default)] + pub dedupe: Option, +} + +fn default_limit() -> usize { + 200 +} + +impl Default for Query { + fn default() -> Self { + serde_json::from_str("{}").unwrap() + } +} + +impl Query { + pub fn needle(&self) -> Option { + self.query + .as_deref() + .map(|x| x.to_lowercase()) + .filter(|x| !x.is_empty()) + } + + /// Itens que atendem ao filtro (na ordem de achado), já sem duplicadas se pedido. + pub fn select<'a>(&self, found: &'a [Found]) -> Vec<&'a Found> { + let needle = self.needle(); + let mut v: Vec<&Found> = found.iter().filter(|f| self.matches(f, &needle)).collect(); + if self.dedupe == Some(true) { + let mut seen = std::collections::HashSet::new(); + v.retain(|f| f.sig.map(|g| seen.insert((g, f.size))).unwrap_or(true)); + } + v + } + + pub fn matches(&self, f: &Found, needle: &Option) -> bool { + if self.category.is_some_and(|c| f.category != c) { + return false; + } + if self.min_confidence.is_some_and(|c| f.confidence < c) { + return false; + } + if self.origin.is_some_and(|o| f.origin != o) { + return false; + } + if self.min_size.is_some_and(|m| f.size < m) || self.max_size.is_some_and(|m| f.size > m) { + return false; + } + if let Some(n) = needle { + let hay = + format!("{} {}", f.full_path(), f.camera.as_deref().unwrap_or("")).to_lowercase(); + if !hay.contains(n.as_str()) { + return false; + } + } + if let Some(folder) = &self.folder { + let ok = match folder.as_str() { + "@carved" => f.origin == Origin::Carved, + "@raiz" => { + f.origin != Origin::Carved + && f.dir.as_deref().map(str::is_empty).unwrap_or(true) + } + p => f + .dir + .as_deref() + .is_some_and(|d| d == p || d.starts_with(&format!("{p}/"))), + }; + if !ok { + return false; + } + } + if self.year.is_some() || self.month.is_some() { + let Some(t) = f.when() else { return false }; + let (y, m, _) = crate::meta::ymd(t); + if self.year.is_some_and(|yy| yy != y) || self.month.is_some_and(|mm| mm != m) { + return false; + } + } + if self.undated == Some(true) && f.when().is_some() { + return false; + } + if let Some(c) = &self.camera { + if f.camera.as_deref() != Some(c.as_str()) { + return false; + } + } + if let Some(mp) = self.min_mp { + match f.px { + Some((w, h)) if (w as f64 * h as f64) / 1e6 >= mp => {} + _ => return false, + } + } + if self.has_gps == Some(true) && !f.gps { + return false; + } + true + } +} + +pub fn sort_items(items: &mut [&Found], sort: Option<&str>) { + match sort { + Some("name") => items.sort_by_key(|f| f.name.to_lowercase()), + Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)), + Some("date") => items.sort_by(|a, b| b.when().cmp(&a.when()).then(a.id.cmp(&b.id))), + Some("date_asc") => items.sort_by(|a, b| match (a.when(), b.when()) { + (Some(x), Some(y)) => x.cmp(&y).then(a.id.cmp(&b.id)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + _ => a.id.cmp(&b.id), + }), + Some("confidence") => { + items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size))) + } + _ => {} + } +} + +pub fn item_json(f: &Found) -> Value { + json!({ + "id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category, + "size": f.size, "origin": f.origin, "confidence": f.confidence, + "modified": f.modified, "created": f.created, "accessed": f.accessed, + "taken": f.taken, "when": f.when(), "camera": f.camera, "px": f.px, "gps": f.gps, + "note": f.note, + }) +} + +/// Contagens para a navegação lateral. +pub fn facets(found: &[Found]) -> Value { + let mut cats: HashMap<&'static str, usize> = HashMap::new(); + let mut years: BTreeMap)> = BTreeMap::new(); + let mut cams: BTreeMap = BTreeMap::new(); + let mut folders: BTreeMap = BTreeMap::new(); + let (mut undated, mut carved, mut root, mut gps) = (0usize, 0usize, 0usize, 0usize); + let mut conf = [0usize; 3]; + let mut seen_sig = std::collections::HashSet::new(); + let mut dups = 0usize; + for f in found { + if let Some(g) = f.sig { + if !seen_sig.insert((g, f.size)) { + dups += 1; + } + } + *cats.entry(cat_key(f.category)).or_default() += 1; + conf[f.confidence as usize] += 1; + match f.when() { + Some(t) => { + let (y, m, _) = crate::meta::ymd(t); + let e = years.entry(y).or_default(); + e.0 += 1; + *e.1.entry(m).or_default() += 1; + } + None => undated += 1, + } + if let Some(c) = &f.camera { + *cams.entry(c.clone()).or_default() += 1; + } + if f.gps { + gps += 1; + } + if f.origin == Origin::Carved { + carved += 1; + } else if let Some(d) = f.dir.as_deref().filter(|d| !d.is_empty()) { + let mut acc = String::new(); + for part in d.split('/') { + if !acc.is_empty() { + acc.push('/'); + } + acc.push_str(part); + *folders.entry(acc.clone()).or_default() += 1; + } + } else { + root += 1; + } + } + let cams_v: Vec = { + let mut v: Vec<_> = cams.into_iter().collect(); + v.sort_by_key(|x| std::cmp::Reverse(x.1)); + v.into_iter() + .map(|(n, c)| json!({ "name": n, "count": c })) + .collect() + }; + json!({ + "total": found.len(), + "categories": cats, + "confidence": { "low": conf[0], "medium": conf[1], "high": conf[2] }, + "years": years.iter().rev().map(|(y, (n, ms))| json!({ + "year": y, "count": n, + "months": ms.iter().rev().map(|(m, c)| json!({"month": m, "count": c})).collect::>() + })).collect::>(), + "undated": undated, + "cameras": cams_v, + "folders": folders.iter().map(|(p, c)| json!({ + "path": p, "name": p.rsplit('/').next().unwrap_or(p), "depth": p.matches('/').count(), "count": c + })).collect::>(), + "carved": carved, + "root": root, + "gps": gps, + "duplicates": dups, + }) +} + +pub fn cat_key(c: Category) -> &'static str { + match c { + Category::Image => "image", + Category::Document => "document", + Category::Video => "video", + Category::Audio => "audio", + Category::Archive => "archive", + Category::Database => "database", + Category::Other => "other", + } +} + +// ------------------------------------------------------------------ cache de miniaturas + +type Key = (String, u64, u32); + +pub struct ThumbCache { + map: HashMap>>, + order: VecDeque, + bytes: usize, +} + +impl ThumbCache { + const CAP: usize = 128 * 1024 * 1024; + pub fn new() -> Self { + Self { + map: HashMap::new(), + order: VecDeque::new(), + bytes: 0, + } + } + pub fn get(&self, k: &Key) -> Option>> { + self.map.get(k).cloned() + } + pub fn put(&mut self, k: Key, v: Arc>) { + self.bytes += v.len(); + if self.map.insert(k.clone(), v).is_none() { + self.order.push_back(k); + } + while self.bytes > Self::CAP { + let Some(old) = self.order.pop_front() else { + break; + }; + if let Some(v) = self.map.remove(&old) { + self.bytes -= v.len(); + } + } + } +} + +impl Default for ThumbCache { + fn default() -> Self { + Self::new() + } +} + +/// Limita quantas miniaturas são decodificadas ao mesmo tempo (memória/CPU). +pub struct Gate { + n: Mutex, + cv: Condvar, + max: usize, +} + +pub struct Permit<'a>(&'a Gate); + +impl Gate { + pub fn new(max: usize) -> Self { + Self { + n: Mutex::new(0), + cv: Condvar::new(), + max: max.max(1), + } + } + pub fn acquire(&self) -> Permit<'_> { + let mut n = self.n.lock().unwrap(); + while *n >= self.max { + n = self.cv.wait(n).unwrap(); + } + *n += 1; + Permit(self) + } +} + +impl Drop for Permit<'_> { + fn drop(&mut self) { + *self.0.n.lock().unwrap() -= 1; + self.0.cv.notify_one(); + } +} + +/// Assinatura rápida: tamanho + FNV-1a do início (32 KiB) e do fim (32 KiB). +pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option { + use crate::recover::read_found_at; + if f.size == 0 { + return None; + } + let mut h: u64 = 0xcbf29ce484222325 ^ f.size; + let mut feed = |off: u64| { + let mut buf = vec![0u8; 32 * 1024]; + let mut got = 0; + while got < buf.len() { + match read_found_at(src, f, off + got as u64, &mut buf[got..]) { + Ok(0) | Err(_) => break, + Ok(n) => got += n, + } + } + for &b in &buf[..got] { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + got + }; + let a = feed(0); + if a == 0 { + return None; + } + if f.size > 64 * 1024 { + feed(f.size - 32 * 1024); + } + Some(h) +} diff --git a/crates/guara-core/src/fs/exfat.rs b/crates/guara-core/src/fs/exfat.rs index 3307c50..e0e1599 100644 --- a/crates/guara-core/src/fs/exfat.rs +++ b/crates/guara-core/src/fs/exfat.rs @@ -174,6 +174,14 @@ fn walk( let mdate = u16::from_le_bytes([e[14], e[15]]); let mtime = u16::from_le_bytes([e[12], e[13]]); let mod_unix = super::dos_to_unix(mdate, mtime); + let cre_unix = super::dos_to_unix( + u16::from_le_bytes([e[10], e[11]]), + u16::from_le_bytes([e[8], e[9]]), + ); + let acc_unix = super::dos_to_unix( + u16::from_le_bytes([e[18], e[19]]), + u16::from_le_bytes([e[16], e[17]]), + ); let mut stream: Option<&[u8]> = None; let mut name_u16: Vec = vec![]; for s in 1..=secondary.min(19) { @@ -259,6 +267,8 @@ fn walk( origin: Origin::Exfat, data: DataLoc::Extents { extents: ex }, modified: mod_unix, + created: cre_unix, + accessed: acc_unix, base: conf, note, }, diff --git a/crates/guara-core/src/fs/fat.rs b/crates/guara-core/src/fs/fat.rs index d1530b4..e9a6a85 100644 --- a/crates/guara-core/src/fs/fat.rs +++ b/crates/guara-core/src/fs/fat.rs @@ -237,6 +237,11 @@ impl Walk<'_, '_, '_, '_> { u16::from_le_bytes([e[24], e[25]]), u16::from_le_bytes([e[22], e[23]]), ); + let ctime = super::dos_to_unix( + u16::from_le_bytes([e[16], e[17]]), + u16::from_le_bytes([e[14], e[15]]), + ); + let atime = super::dos_to_unix(u16::from_le_bytes([e[18], e[19]]), 0); self.count += 1; if self.count % 512 == 0 { (self.ctx.progress)(self.count, 0); @@ -277,7 +282,7 @@ impl Walk<'_, '_, '_, '_> { if size == 0 || !self.fat.valid(start) { continue; } - self.deleted_file(&name, path, start, size, mtime); + self.deleted_file(&name, path, start, size, (ctime, mtime, atime)); } } @@ -291,7 +296,14 @@ impl Walk<'_, '_, '_, '_> { v } - fn deleted_file(&mut self, name: &str, dir: &str, start: u32, size: u64, mtime: Option) { + fn deleted_file( + &mut self, + name: &str, + dir: &str, + start: u32, + size: u64, + times: (Option, Option, Option), + ) { let fat = self.fat; let cb = fat.cluster_bytes(); let need = size.div_ceil(cb); @@ -336,7 +348,9 @@ impl Walk<'_, '_, '_, '_> { data: DataLoc::Extents { extents: trim_extents(extents, size), }, - modified: mtime, + modified: times.1, + created: times.0, + accessed: times.2, base: conf, note, }; diff --git a/crates/guara-core/src/fs/mod.rs b/crates/guara-core/src/fs/mod.rs index b6671b1..452cd3b 100644 --- a/crates/guara-core/src/fs/mod.rs +++ b/crates/guara-core/src/fs/mod.rs @@ -47,6 +47,8 @@ pub struct NewFound<'a> { pub origin: Origin, pub data: DataLoc, pub modified: Option, + pub created: Option, + pub accessed: Option, pub base: Confidence, pub note: Option<&'a str>, } @@ -98,6 +100,13 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found { confidence: conf, data: n.data, modified: n.modified, + created: n.created, + accessed: n.accessed, + taken: None, + camera: None, + px: None, + gps: false, + sig: None, note, } } diff --git a/crates/guara-core/src/fs/ntfs.rs b/crates/guara-core/src/fs/ntfs.rs index a0a4da6..72fb4fa 100644 --- a/crates/guara-core/src/fs/ntfs.rs +++ b/crates/guara-core/src/fs/ntfs.rs @@ -22,6 +22,8 @@ struct Rec { parent: u64, name: Option<(String, u8)>, mtime: Option, + ctime: Option, + atime: Option, data: Option, has_attr_list: bool, } @@ -199,6 +201,8 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan { origin: Origin::Ntfs, data, modified: rec.mtime, + created: rec.ctime, + accessed: rec.atime, base: conf, note: if rec.has_attr_list { Some("registro com lista de atributos: pode estar incompleto") @@ -320,6 +324,8 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option { parent: 0, name: None, mtime: None, + ctime: None, + atime: None, data: None, has_attr_list: false, }; @@ -341,7 +347,11 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option { 0x10 if !nonres => { let vo = le16(r, p + 20) as usize; if p + vo + 16 <= r.len() { + rec.ctime = filetime_to_unix(le64(r, p + vo)); rec.mtime = filetime_to_unix(le64(r, p + vo + 8)); + if p + vo + 32 <= r.len() { + rec.atime = filetime_to_unix(le64(r, p + vo + 24)); + } } } 0x20 => rec.has_attr_list = true, diff --git a/crates/guara-core/src/hashing.rs b/crates/guara-core/src/hashing.rs new file mode 100644 index 0000000..66c18c6 --- /dev/null +++ b/crates/guara-core/src/hashing.rs @@ -0,0 +1,45 @@ +//! Hashes de integridade usados em perícia: MD5, SHA-1 e SHA-256 numa única passada. + +use md5::Md5; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hashes { + pub md5: String, + pub sha1: String, + pub sha256: String, +} + +#[derive(Default)] +pub struct MultiHasher { + md5: Md5, + sha1: Sha1, + sha256: Sha256, +} + +impl MultiHasher { + pub fn new() -> Self { + Self::default() + } + pub fn update(&mut self, data: &[u8]) { + self.md5.update(data); + self.sha1.update(data); + self.sha256.update(data); + } + pub fn finalize(self) -> Hashes { + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + Hashes { + md5: hex(&self.md5.finalize()), + sha1: hex(&self.sha1.finalize()), + sha256: hex(&self.sha256.finalize()), + } + } +} + +pub fn hash_bytes(data: &[u8]) -> Hashes { + let mut h = MultiHasher::new(); + h.update(data); + h.finalize() +} diff --git a/crates/guara-core/src/image.rs b/crates/guara-core/src/image.rs index f472ff1..c6eb6e1 100644 --- a/crates/guara-core/src/image.rs +++ b/crates/guara-core/src/image.rs @@ -2,9 +2,9 @@ //! erros e hash. Serve para trabalhar sempre sobre a cópia. use crate::error::{Error, Result}; +use crate::hashing::MultiHasher; use crate::source::Source; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::io::Write; use std::path::Path; @@ -12,6 +12,10 @@ use std::path::Path; pub struct ImageReport { pub bytes: u64, pub sha256: String, + #[serde(default)] + pub md5: String, + #[serde(default)] + pub sha1: String, /// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros. pub bad_ranges: Vec<(u64, u64)>, pub cancelled: bool, @@ -38,7 +42,7 @@ pub fn create_image( })?; let size = src.size(); let mut rep = ImageReport::default(); - let mut hasher = Sha256::new(); + let mut hasher = MultiHasher::new(); let mut buf = vec![0u8; BLOCK]; let mut pos = 0u64; while pos < size { @@ -72,11 +76,10 @@ pub fn create_image( } out.flush()?; rep.bytes = pos; - rep.sha256 = hasher - .finalize() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); + let h = hasher.finalize(); + rep.sha256 = h.sha256; + rep.md5 = h.md5; + rep.sha1 = h.sha1; let map = dest.with_extension("map.json"); std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?; Ok(rep) diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 3d3d79a..7702937 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -1,4 +1,4 @@ -//! Guará — núcleo de recuperação de arquivos. +//! Guará: núcleo de recuperação de arquivos. //! //! Princípio central: a origem é aberta **somente para leitura**; nada neste //! crate escreve no dispositivo que está sendo recuperado. @@ -6,15 +6,21 @@ pub mod carve; pub mod device; pub mod error; +pub mod explorer; pub mod fs; +pub mod hashing; pub mod image; +pub mod meta; pub mod partition; pub mod recover; +pub mod report; pub mod scan; +pub mod search; pub mod service; pub mod settings; pub mod signatures; pub mod source; +pub mod thumb; pub mod types; pub use error::{Error, Result}; diff --git a/crates/guara-core/src/meta.rs b/crates/guara-core/src/meta.rs new file mode 100644 index 0000000..c3c37f7 --- /dev/null +++ b/crates/guara-core/src/meta.rs @@ -0,0 +1,952 @@ +//! Extração de metadados de arquivos recuperados: EXIF (câmera, datas, GPS), +//! informações de PDF, propriedades de documentos Office/OpenDocument e +//! dados de vídeo MP4/MOV. Tudo é leitura posicional, sem gravar em disco. + +use crate::recover::read_found_at; +use crate::source::Source; +use crate::types::Found; +use serde::{Deserialize, Serialize}; +use std::io::Read; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Meta { + pub kind: String, + pub fields: Vec<(String, String)>, + /// (latitude, longitude) em graus decimais. + pub gps: Option<(f64, f64)>, + /// Data em que a foto foi tirada (EXIF), s desde 1970 (hora da câmera lida como UTC). + #[serde(default)] + pub taken: Option, + #[serde(default)] + pub camera: Option, + #[serde(default)] + pub px: Option<(u32, u32)>, + /// Orientação EXIF (1..8). + #[serde(default)] + pub orientation: u16, +} + +impl Meta { + fn add(&mut self, k: &str, v: impl Into) { + let v = v.into(); + let v = v.trim().to_string(); + if !v.is_empty() { + self.fields.push((k.to_string(), v)); + } + } +} + +struct Fr<'a> { + src: &'a dyn Source, + f: &'a Found, +} + +impl Fr<'_> { + fn read(&self, off: u64, len: usize) -> Vec { + let mut out = vec![0u8; len.min(self.f.size.saturating_sub(off) as usize)]; + let mut got = 0; + while got < out.len() { + match read_found_at(self.src, self.f, off + got as u64, &mut out[got..]) { + Ok(0) | Err(_) => break, + Ok(n) => got += n, + } + } + out.truncate(got); + out + } +} + +pub fn extract(src: &dyn Source, f: &Found) -> Meta { + let fr = Fr { src, f }; + let mut m = Meta::default(); + match f.ext.to_ascii_lowercase().as_str() { + "jpg" | "jpeg" => { + m.kind = "Imagem JPEG".into(); + jpeg(&fr, &mut m); + } + "png" => { + m.kind = "Imagem PNG".into(); + let h = fr.read(0, 32); + if h.len() >= 24 && &h[12..16] == b"IHDR" { + m.px = Some((be32(&h[16..]), be32(&h[20..]))); + m.add( + "Dimensões", + format!("{} × {} px", be32(&h[16..]), be32(&h[20..])), + ); + } + } + "gif" => { + m.kind = "Imagem GIF".into(); + let h = fr.read(0, 16); + if h.len() >= 10 { + m.px = Some((le16(&h[6..]) as u32, le16(&h[8..]) as u32)); + m.add( + "Dimensões", + format!("{} × {} px", le16(&h[6..]), le16(&h[8..])), + ); + } + } + "bmp" => { + m.kind = "Imagem BMP".into(); + let h = fr.read(0, 32); + if h.len() >= 26 { + m.px = Some((le32(&h[18..]), (le32(&h[22..]) as i32).unsigned_abs())); + m.add( + "Dimensões", + format!( + "{} × {} px", + le32(&h[18..]), + (le32(&h[22..]) as i32).unsigned_abs() + ), + ); + } + } + "pdf" => { + m.kind = "Documento PDF".into(); + pdf(&fr, &mut m); + } + "docx" | "xlsx" | "pptx" => { + m.kind = "Documento Office".into(); + ooxml(&fr, &mut m); + } + "odt" | "ods" | "odp" => { + m.kind = "Documento OpenDocument".into(); + odf(&fr, &mut m); + } + "mp4" | "mov" | "m4v" | "m4a" | "3gp" => { + m.kind = "Mídia MP4/MOV".into(); + mp4(&fr, &mut m); + } + _ => {} + } + m +} + +fn be16(b: &[u8]) -> u16 { + u16::from_be_bytes([b[0], b[1]]) +} +fn be32(b: &[u8]) -> u32 { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) +} +fn le16(b: &[u8]) -> u16 { + u16::from_le_bytes([b[0], b[1]]) +} +fn le32(b: &[u8]) -> u32 { + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) +} + +// ------------------------------------------------------------------ EXIF + +fn jpeg(fr: &Fr, m: &mut Meta) { + let d = fr.read(0, 256 * 1024); + jpeg_bytes(&d, m); +} + +/// Lê os segmentos de cabeçalho de um JPEG (já em memória): dimensões e EXIF. +pub fn jpeg_bytes(d: &[u8], m: &mut Meta) { + let mut p = 2usize; + while p + 4 <= d.len() && d[p] == 0xFF { + let mk = d[p + 1]; + if mk == 0xDA || mk == 0xD9 { + break; + } + let len = be16(&d[p + 2..]) as usize; + if len < 2 || p + 2 + len > d.len() { + break; + } + if mk == 0xE1 && d[p + 4..].starts_with(b"Exif\0\0") { + exif(&d[p + 10..p + 2 + len], m); + } + if (mk == 0xC0 || mk == 0xC2) && p + 9 <= d.len() { + m.px = Some((be16(&d[p + 7..]) as u32, be16(&d[p + 5..]) as u32)); + m.add( + "Dimensões", + format!("{} × {} px", be16(&d[p + 7..]), be16(&d[p + 5..])), + ); + } + p += 2 + len; + } +} + +/// Miniatura JPEG embutida no EXIF (IFD1), útil quando a imagem principal está danificada. +pub fn exif_thumbnail(d: &[u8]) -> Option> { + let mut p = 2usize; + while p + 4 <= d.len() && d[p] == 0xFF { + let mk = d[p + 1]; + if mk == 0xDA || mk == 0xD9 { + return None; + } + let len = be16(&d[p + 2..]) as usize; + if len < 2 || p + 2 + len > d.len() { + return None; + } + if mk == 0xE1 && d[p + 4..].starts_with(b"Exif\0\0") { + let tiff = &d[p + 10..p + 2 + len]; + if tiff.len() < 8 { + return None; + } + let le = match &tiff[0..2] { + b"II" => true, + b"MM" => false, + _ => return None, + }; + let t = Tiff { d: tiff, le }; + let ifd0 = t.u32(4)? as usize; + let n = t.u16(ifd0)? as usize; + let ifd1 = t.u32(ifd0 + 2 + n * 12)? as usize; + if ifd1 == 0 { + return None; + } + let (mut off, mut length) = (None, None); + for e in t.ifd(ifd1) { + match e.tag { + 0x0201 => off = t.u32(e.pos), + 0x0202 => length = t.u32(e.pos), + _ => {} + } + } + let (o, l) = (off? as usize, length? as usize); + return tiff.get(o..o + l).map(|b| b.to_vec()); + } + p += 2 + len; + } + None +} + +struct Tiff<'a> { + d: &'a [u8], + le: bool, +} + +struct Entry { + tag: u16, + typ: u16, + count: u32, + pos: usize, // onde estão os dados do valor dentro do TIFF +} + +impl Tiff<'_> { + fn u16(&self, o: usize) -> Option { + let b = self.d.get(o..o + 2)?; + Some(if self.le { + u16::from_le_bytes([b[0], b[1]]) + } else { + u16::from_be_bytes([b[0], b[1]]) + }) + } + fn u32(&self, o: usize) -> Option { + let b = self.d.get(o..o + 4)?; + Some(if self.le { + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) + } else { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) + }) + } + fn ifd(&self, off: usize) -> Vec { + let mut v = vec![]; + let Some(n) = self.u16(off) else { return v }; + for i in 0..(n as usize).min(200) { + let e = off + 2 + i * 12; + let (Some(tag), Some(typ), Some(count)) = + (self.u16(e), self.u16(e + 2), self.u32(e + 4)) + else { + break; + }; + let size = match typ { + 1 | 2 | 6 | 7 => 1, + 3 | 8 => 2, + 4 | 9 | 11 => 4, + 5 | 10 | 12 => 8, + _ => 1, + } * count as usize; + let pos = if size <= 4 { + e + 8 + } else { + self.u32(e + 8).unwrap_or(0) as usize + }; + v.push(Entry { + tag, + typ, + count, + pos, + }); + } + v + } + fn ascii(&self, e: &Entry) -> String { + let end = (e.pos + e.count as usize).min(self.d.len()); + let b = self.d.get(e.pos..end).unwrap_or(&[]); + String::from_utf8_lossy(b) + .trim_end_matches('\0') + .trim() + .to_string() + } + fn rational(&self, e: &Entry, i: usize) -> Option { + let o = e.pos + i * 8; + let (n, d) = (self.u32(o)?, self.u32(o + 4)?); + (d != 0).then(|| n as f64 / d as f64) + } + fn num(&self, e: &Entry) -> Option { + match e.typ { + 3 => self.u16(e.pos).map(u32::from), + 4 => self.u32(e.pos), + _ => None, + } + } +} + +fn exif(d: &[u8], m: &mut Meta) { + if d.len() < 8 { + return; + } + let le = match &d[0..2] { + b"II" => true, + b"MM" => false, + _ => return, + }; + let t = Tiff { d, le }; + let Some(ifd0) = t.u32(4) else { return }; + let mut exif_ptr = None; + let mut gps_ptr = None; + let (mut make, mut model) = (String::new(), String::new()); + for e in t.ifd(ifd0 as usize) { + match e.tag { + 0x010F => { + make = t.ascii(&e); + m.add("Fabricante da câmera", make.clone()); + } + 0x0110 => { + model = t.ascii(&e); + m.add("Modelo da câmera", model.clone()); + } + 0x0112 => m.orientation = t.num(&e).unwrap_or(1) as u16, + 0x0131 => m.add("Software", t.ascii(&e)), + 0x0132 => m.add("Data (EXIF)", exif_date(&t.ascii(&e))), + 0x013B => m.add("Autor", t.ascii(&e)), + 0x8298 => m.add("Copyright", t.ascii(&e)), + 0x8769 => exif_ptr = t.u32(e.pos), + 0x8825 => gps_ptr = t.u32(e.pos), + _ => {} + } + } + let first = make.split_whitespace().next().unwrap_or("").to_lowercase(); + let cam = if model.is_empty() { + make.clone() + } else if make.is_empty() || (!first.is_empty() && model.to_lowercase().starts_with(&first)) { + model.clone() + } else { + format!("{make} {model}") + }; + if !cam.trim().is_empty() { + m.camera = Some(cam.trim().to_string()); + } + if let Some(o) = exif_ptr { + let (mut w, mut h) = (None, None); + for e in t.ifd(o as usize) { + match e.tag { + 0x9003 => { + m.taken = exif_epoch(&t.ascii(&e)); + m.add("Foto tirada em", exif_date(&t.ascii(&e))); + } + 0x9004 => m.add("Digitalizada em", exif_date(&t.ascii(&e))), + 0xA434 => m.add("Lente", t.ascii(&e)), + 0xA431 => m.add("Nº de série do corpo", t.ascii(&e)), + 0xA430 => m.add("Proprietário da câmera", t.ascii(&e)), + 0xA002 => w = t.num(&e), + 0xA003 => h = t.num(&e), + 0x829A => { + if let Some(v) = t.rational(&e, 0) { + m.add( + "Exposição", + if v > 0.0 && v < 1.0 { + format!("1/{:.0} s", 1.0 / v) + } else { + format!("{v:.1} s") + }, + ); + } + } + 0x829D => { + if let Some(v) = t.rational(&e, 0) { + m.add("Abertura", format!("f/{v:.1}")); + } + } + 0x8827 => { + if let Some(v) = t.num(&e) { + m.add("ISO", v.to_string()); + } + } + 0x920A => { + if let Some(v) = t.rational(&e, 0) { + m.add("Distância focal", format!("{v:.0} mm")); + } + } + _ => {} + } + } + if let (Some(w), Some(h)) = (w, h) { + if m.px.is_none() { + m.px = Some((w, h)); + } + if !m.fields.iter().any(|(k, _)| k == "Dimensões") { + m.add("Dimensões", format!("{w} × {h} px")); + } + } + } + if let Some(o) = gps_ptr { + let (mut lat, mut lon, mut lat_ref, mut lon_ref, mut alt) = (None, None, 'N', 'E', None); + for e in t.ifd(o as usize) { + let dms = |e: &Entry| -> Option { + Some(t.rational(e, 0)? + t.rational(e, 1)? / 60.0 + t.rational(e, 2)? / 3600.0) + }; + match e.tag { + 0x0001 => lat_ref = t.ascii(&e).chars().next().unwrap_or('N'), + 0x0002 => lat = dms(&e), + 0x0003 => lon_ref = t.ascii(&e).chars().next().unwrap_or('E'), + 0x0004 => lon = dms(&e), + 0x0006 => alt = t.rational(&e, 0), + 0x001D => m.add("Data do GPS", t.ascii(&e).replace(':', "-")), + _ => {} + } + } + if let (Some(a), Some(b)) = (lat, lon) { + let a = if lat_ref == 'S' { -a } else { a }; + let b = if lon_ref == 'W' { -b } else { b }; + if a.abs() <= 90.0 && b.abs() <= 180.0 && !(a == 0.0 && b == 0.0) { + m.gps = Some((a, b)); + m.add("Localização (GPS)", format!("{a:.6}, {b:.6}")); + if let Some(h) = alt { + m.add("Altitude", format!("{h:.0} m")); + } + } + } + } +} + +/// "2023:07:14 18:22:05" -> segundos desde 1970 (hora da câmera lida como UTC) +fn exif_epoch(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() < 19 || b[4] != b':' || b[7] != b':' { + return None; + } + let n = |a: usize, c: usize| s.get(a..c)?.parse::().ok(); + let (y, mo, d, h, mi, se) = ( + n(0, 4)?, + n(5, 7)?, + n(8, 10)?, + n(11, 13)?, + n(14, 16)?, + n(17, 19)?, + ); + if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || y < 1970 { + return None; + } + Some(crate::fs::days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se) +} + +/// "2023:07:14 18:22:05" -> "14/07/2023 18:22:05" +fn exif_date(s: &str) -> String { + let b = s.as_bytes(); + if b.len() >= 19 && b[4] == b':' && b[7] == b':' { + format!("{}/{}/{} {}", &s[8..10], &s[5..7], &s[0..4], &s[11..19]) + } else { + s.to_string() + } +} + +// ------------------------------------------------------------------ PDF + +fn pdf(fr: &Fr, m: &mut Meta) { + let size = fr.f.size; + let head = fr.read(0, 96 * 1024); + let tail = if size > 192 * 1024 { + fr.read(size - 96 * 1024, 96 * 1024) + } else { + Vec::new() + }; + let mut all = head.clone(); + all.extend_from_slice(&tail); + if head.starts_with(b"%PDF-") { + m.add( + "Versão do PDF", + String::from_utf8_lossy(&head[5..8.min(head.len())]).to_string(), + ); + } + for (key, label) in [ + ("/Title", "Título"), + ("/Author", "Autor"), + ("/Subject", "Assunto"), + ("/Creator", "Criado com"), + ("/Producer", "Gerado por"), + ] { + if let Some(v) = pdf_string(&all, key) { + m.add(label, v); + } + } + for (key, label) in [ + ("/CreationDate", "Criado em"), + ("/ModDate", "Modificado em"), + ] { + if let Some(v) = pdf_string(&all, key) { + m.add(label, pdf_date(&v)); + } + } + let pages = find_all(&all, b"/Count ").into_iter().filter_map(|i| { + let s: String = all[i + 7..] + .iter() + .take(8) + .map(|&c| c as char) + .take_while(|c| c.is_ascii_digit()) + .collect(); + s.parse::().ok() + }); + if let Some(n) = pages.max() { + m.add("Páginas (aprox.)", n.to_string()); + } + if !find_all(&all, b"/Encrypt").is_empty() { + m.add("Criptografado", "sim"); + } +} + +fn find_all(h: &[u8], n: &[u8]) -> Vec { + let mut v = vec![]; + let mut i = 0; + while i + n.len() <= h.len() { + if &h[i..i + n.len()] == n { + v.push(i); + i += n.len(); + } else { + i += 1; + } + } + v +} + +fn pdf_string(d: &[u8], key: &str) -> Option { + let k = key.as_bytes(); + for at in find_all(d, k) { + let mut p = at + k.len(); + // evita /Creator casar com /CreatorXyz + if d.get(p).map(|c| c.is_ascii_alphanumeric()).unwrap_or(false) { + continue; + } + while d.get(p).map(|c| c.is_ascii_whitespace()).unwrap_or(false) { + p += 1; + } + match d.get(p)? { + b'(' => { + let mut depth = 0; + let mut out = vec![]; + let mut i = p; + while i < d.len() && out.len() < 400 { + let c = d[i]; + match c { + b'\\' if i + 1 < d.len() => { + i += 1; + if d[i].is_ascii_digit() && d[i] < b'8' { + // escape octal \ddd (1 a 3 dígitos) + let mut v = 0u32; + let mut n = 0; + while n < 3 && i < d.len() && d[i] >= b'0' && d[i] < b'8' { + v = v * 8 + (d[i] - b'0') as u32; + i += 1; + n += 1; + } + out.push((v & 0xFF) as u8); + continue; + } + out.push(match d[i] { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + o => o, + }); + } + b'(' => { + depth += 1; + if depth > 1 { + out.push(c); + } + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + i += 1; + } + return Some(pdf_text(&out)); + } + b'<' if d.get(p + 1) != Some(&b'<') => { + let hex: Vec = d[p + 1..] + .iter() + .take_while(|&&c| c != b'>') + .copied() + .filter(|c| c.is_ascii_hexdigit()) + .collect(); + let bytes: Vec = hex + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap(), 16).unwrap_or(0)) + .collect(); + return Some(pdf_text(&bytes)); + } + _ => {} + } + } + None +} + +fn pdf_text(b: &[u8]) -> String { + if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF { + let u: Vec = b[2..] + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + return String::from_utf16_lossy(&u); + } + // PDFDocEncoding ~ Latin-1 na prática + b.iter().map(|&c| c as char).collect() +} + +/// "D:20230714182205-03'00'" -> "14/07/2023 18:22:05" +fn pdf_date(s: &str) -> String { + let t = s.trim_start_matches("D:"); + let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 8 { + let g = |a: usize, b: usize| digits.get(a..b).unwrap_or("00"); + return format!( + "{}/{}/{} {}:{}:{}", + g(6, 8), + g(4, 6), + g(0, 4), + g(8, 10), + g(10, 12), + g(12, 14) + ); + } + s.to_string() +} + +// ------------------------------------------------------------------ ZIP (Office/ODF) + +struct ZipEnt { + name: String, + method: u16, + csize: u64, + usize: u64, + off: u64, +} + +fn zip_dir(fr: &Fr) -> Vec { + let size = fr.f.size; + let tail_len = size.min(66_000); + let tail = fr.read(size - tail_len, tail_len as usize); + let Some(e) = (0..tail.len().saturating_sub(21)) + .rev() + .find(|&i| tail[i..].starts_with(b"PK\x05\x06")) + else { + return vec![]; + }; + let n = le16(&tail[e + 10..]) as usize; + let cd_size = le32(&tail[e + 12..]) as usize; + let cd_off = le32(&tail[e + 16..]) as u64; + if cd_size > 8 << 20 || cd_off + cd_size as u64 > size { + return vec![]; + } + let cd = fr.read(cd_off, cd_size); + let mut out = vec![]; + let mut p = 0; + while out.len() < n.min(5000) && p + 46 <= cd.len() && &cd[p..p + 4] == b"PK\x01\x02" { + let nl = le16(&cd[p + 28..]) as usize; + let xl = le16(&cd[p + 30..]) as usize; + let cl = le16(&cd[p + 32..]) as usize; + if p + 46 + nl > cd.len() { + break; + } + out.push(ZipEnt { + name: String::from_utf8_lossy(&cd[p + 46..p + 46 + nl]).to_string(), + method: le16(&cd[p + 10..]), + csize: le32(&cd[p + 20..]) as u64, + usize: le32(&cd[p + 24..]) as u64, + off: le32(&cd[p + 42..]) as u64, + }); + p += 46 + nl + xl + cl; + } + out +} + +fn zip_read(fr: &Fr, e: &ZipEnt) -> Option> { + if e.usize > 4 << 20 { + return None; + } + let lh = fr.read(e.off, 30); + if lh.len() < 30 || &lh[0..4] != b"PK\x03\x04" { + return None; + } + let start = e.off + 30 + le16(&lh[26..]) as u64 + le16(&lh[28..]) as u64; + let data = fr.read(start, e.csize as usize); + match e.method { + 0 => Some(data), + 8 => { + let mut out = Vec::new(); + flate2::read::DeflateDecoder::new(&data[..]) + .take(4 << 20) + .read_to_end(&mut out) + .ok()?; + Some(out) + } + _ => None, + } +} + +/// Texto do primeiro elemento `texto`. +fn xml_tag(xml: &str, tag: &str) -> Option { + let open = format!("<{tag}"); + let mut from = 0; + while let Some(i) = xml[from..].find(&open) { + let s = from + i + open.len(); + match xml[s..].chars().next() { + Some('>') | Some(' ') | Some('/') => { + let gt = xml[s..].find('>')? + s; + if xml[..gt].ends_with('/') { + return None; + } + let end = xml[gt + 1..].find(&format!(""))? + gt + 1; + return Some(unescape(&xml[gt + 1..end])); + } + _ => from = s, + } + } + None +} + +fn unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +/// "2023-07-14T18:22:05Z" -> "14/07/2023 18:22:05 (UTC)" +fn iso_date(s: &str) -> String { + let b = s.as_bytes(); + if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' { + let utc = if s.ends_with('Z') { " (UTC)" } else { "" }; + return format!( + "{}/{}/{} {}{}", + &s[8..10], + &s[5..7], + &s[0..4], + &s[11..19], + utc + ); + } + s.to_string() +} + +fn ooxml(fr: &Fr, m: &mut Meta) { + let ents = zip_dir(fr); + let get = |n: &str| { + ents.iter() + .find(|e| e.name == n) + .and_then(|e| zip_read(fr, e)) + .map(|b| String::from_utf8_lossy(&b).to_string()) + }; + if let Some(x) = get("docProps/core.xml") { + for (tag, label) in [ + ("dc:title", "Título"), + ("dc:subject", "Assunto"), + ("dc:creator", "Autor"), + ("cp:lastModifiedBy", "Última modificação por"), + ("cp:keywords", "Palavras-chave"), + ] { + if let Some(v) = xml_tag(&x, tag) { + m.add(label, v); + } + } + for (tag, label) in [ + ("dcterms:created", "Criado em"), + ("dcterms:modified", "Modificado em"), + ] { + if let Some(v) = xml_tag(&x, tag) { + m.add(label, iso_date(&v)); + } + } + } + if let Some(x) = get("docProps/app.xml") { + for (tag, label) in [ + ("Application", "Aplicativo"), + ("Company", "Empresa"), + ("Pages", "Páginas"), + ("Words", "Palavras"), + ] { + if let Some(v) = xml_tag(&x, tag) { + m.add(label, v); + } + } + } +} + +fn odf(fr: &Fr, m: &mut Meta) { + let ents = zip_dir(fr); + let Some(x) = ents + .iter() + .find(|e| e.name == "meta.xml") + .and_then(|e| zip_read(fr, e)) + .map(|b| String::from_utf8_lossy(&b).to_string()) + else { + return; + }; + for (tag, label) in [ + ("dc:title", "Título"), + ("meta:initial-creator", "Criado por"), + ("dc:creator", "Última modificação por"), + ("meta:generator", "Aplicativo"), + ] { + if let Some(v) = xml_tag(&x, tag) { + m.add(label, v); + } + } + for (tag, label) in [ + ("meta:creation-date", "Criado em"), + ("dc:date", "Modificado em"), + ] { + if let Some(v) = xml_tag(&x, tag) { + m.add(label, iso_date(&v)); + } + } +} + +// ------------------------------------------------------------------ MP4 / MOV + +fn mp4(fr: &Fr, m: &mut Meta) { + let size = fr.f.size; + let mut p = 0u64; + for _ in 0..64 { + let h = fr.read(p, 16); + if h.len() < 8 { + return; + } + let mut sz = be32(&h) as u64; + if sz == 1 && h.len() >= 16 { + sz = u64::from_be_bytes(h[8..16].try_into().unwrap()); + } else if sz == 0 { + sz = size - p; + } + if sz < 8 { + return; + } + if &h[4..8] == b"moov" { + let body = fr.read(p + 8, (sz - 8).min(4 << 20) as usize); + moov(&body, m); + return; + } + p += sz; + if p >= size { + return; + } + } +} + +fn boxes(d: &[u8]) -> Vec<(&[u8], &[u8])> { + let mut v = vec![]; + let mut p = 0; + while p + 8 <= d.len() { + let sz = be32(&d[p..]) as usize; + if sz < 8 || p + sz > d.len() { + break; + } + v.push((&d[p + 4..p + 8], &d[p + 8..p + sz])); + p += sz; + } + v +} + +fn moov(d: &[u8], m: &mut Meta) { + const EPOCH_1904: u64 = 2_082_844_800; + for (ty, body) in boxes(d) { + match ty { + b"mvhd" if body.len() >= 24 => { + let v = body[0]; + let (created, ts, dur) = if v == 1 && body.len() >= 32 { + ( + u64::from_be_bytes(body[4..12].try_into().unwrap()), + be32(&body[20..]) as u64, + u64::from_be_bytes(body[24..32].try_into().unwrap()), + ) + } else { + ( + be32(&body[4..]) as u64, + be32(&body[12..]) as u64, + be32(&body[16..]) as u64, + ) + }; + if created > EPOCH_1904 { + m.add( + "Gravado em (metadado)", + fmt_unix((created - EPOCH_1904) as i64), + ); + } + if ts > 0 && dur > 0 { + let s = dur / ts; + m.add( + "Duração", + format!("{}:{:02}:{:02}", s / 3600, s % 3600 / 60, s % 60), + ); + } + } + b"trak" => { + for (t2, b2) in boxes(body) { + if t2 == b"tkhd" && b2.len() >= 84 { + let off = if b2[0] == 1 { 88 } else { 76 }; + if b2.len() >= off + 8 { + let (w, h) = (be32(&b2[off..]) >> 16, be32(&b2[off + 4..]) >> 16); + if w > 0 && h > 0 && !m.fields.iter().any(|(k, _)| k == "Dimensões") { + m.add("Dimensões", format!("{w} × {h} px")); + } + } + } + } + } + _ => {} + } + } +} + +pub fn fmt_unix(t: i64) -> String { + let days = t.div_euclid(86400); + let rem = t.rem_euclid(86400); + let (y, mo, d) = civil_from_days(days); + format!( + "{d:02}/{mo:02}/{y} {:02}:{:02}:{:02} (UTC)", + rem / 3600, + rem % 3600 / 60, + rem % 60 + ) +} + +/// (ano, mês, dia) de um instante em segundos desde 1970. +pub fn ymd(t: i64) -> (i64, u32, u32) { + let (y, m, d) = civil_from_days(t.div_euclid(86400)); + (y, m as u32, d as u32) +} + +/// Inverso de `days_from_civil` (Howard Hinnant). +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs index 8a14def..b9c2174 100644 --- a/crates/guara-core/src/recover.rs +++ b/crates/guara-core/src/recover.rs @@ -3,10 +3,10 @@ use crate::error::{Error, Result}; use crate::fs::sanitize_name; +use crate::hashing::{Hashes, MultiHasher}; use crate::source::{read_vec, Source}; use crate::types::*; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; @@ -17,6 +17,10 @@ pub struct RecoveredFile { pub path: String, pub size: u64, pub sha256: String, + #[serde(default)] + pub md5: String, + #[serde(default)] + pub sha1: String, pub confidence: Confidence, pub origin: Origin, pub partial: bool, @@ -111,7 +115,7 @@ pub fn recover( } used.insert(path.clone()); match write_file(src, f, &path) { - Ok(hash) => { + Ok(h) => { report.total_bytes += f.size; report.recovered.push(RecoveredFile { id: f.id, @@ -121,7 +125,9 @@ pub fn recover( .to_string_lossy() .replace('\\', "/"), size: f.size, - sha256: hash, + sha256: h.sha256, + md5: h.md5, + sha1: h.sha1, confidence: f.confidence, origin: f.origin, partial: f.confidence == Confidence::Low, @@ -135,7 +141,7 @@ pub fn recover( "ferramenta": "Guará", "versao": env!("CARGO_PKG_VERSION"), "gerado_em": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0), - "algoritmo_hash": "SHA-256", + "algoritmos_hash": ["MD5", "SHA-1", "SHA-256"], "relatorio": &report, }); std::fs::write( @@ -165,12 +171,12 @@ fn target_rel(f: &Found) -> PathBuf { } /// Escreve o conteúdo e devolve o SHA-256. -pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result { +pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result { let mut out = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path)?; - let mut hasher = Sha256::new(); + let mut hasher = MultiHasher::new(); match &f.data { DataLoc::Resident { bytes } => { out.write_all(bytes)?; @@ -204,11 +210,7 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result { let _ = out.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(m as u64)); } out.flush()?; - Ok(hasher - .finalize() - .iter() - .map(|b| format!("{b:02x}")) - .collect()) + Ok(hasher.finalize()) } /// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset` diff --git a/crates/guara-core/src/report.rs b/crates/guara-core/src/report.rs new file mode 100644 index 0000000..a0f3567 --- /dev/null +++ b/crates/guara-core/src/report.rs @@ -0,0 +1,306 @@ +//! 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}{}
    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 +} diff --git a/crates/guara-core/src/scan.rs b/crates/guara-core/src/scan.rs index 0708e22..0c54e0d 100644 --- a/crates/guara-core/src/scan.rs +++ b/crates/guara-core/src/scan.rs @@ -245,6 +245,13 @@ impl CarveEvents for Handler<'_> { extents: vec![Extent::new(off, hit.len)], }, modified: None, + created: None, + accessed: None, + taken: None, + camera: None, + px: None, + gps: false, + sig: None, note: if hit.confidence == Confidence::Low { Some("sem rodapé/estrutura completa: pode estar truncado".into()) } else { diff --git a/crates/guara-core/src/search.rs b/crates/guara-core/src/search.rs new file mode 100644 index 0000000..c1cc15b --- /dev/null +++ b/crates/guara-core/src/search.rs @@ -0,0 +1,194 @@ +//! Busca de texto bruta em todo o disco (inclusive espaço livre e não particionado), +//! em UTF-8 e UTF-16LE, útil quando não há estrutura de arquivo para recuperar. + +use crate::source::Source; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchOpts { + pub terms: Vec, + /// Ignora maiúsculas/minúsculas (letras ASCII). + #[serde(default = "yes")] + pub ignore_case: bool, + /// Procura também a codificação UTF-16 (Windows, Office, bancos de dados). + #[serde(default = "yes")] + pub utf16: bool, + #[serde(default = "default_max")] + pub max_hits: usize, +} + +fn yes() -> bool { + true +} +fn default_max() -> usize { + 20_000 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hit { + pub offset: u64, + pub term: String, + pub encoding: String, + pub context: String, +} + +const CHUNK: usize = 8 * 1024 * 1024; +const CTX: usize = 48; + +struct Pat { + term: String, + enc: &'static str, + bytes: Vec, + skip: [usize; 256], +} + +fn make_pats(o: &SearchOpts) -> Vec { + let mut v = vec![]; + for t in o.terms.iter().filter(|t| !t.is_empty()) { + let folded = if o.ignore_case { + t.to_lowercase() + } else { + t.clone() + }; + let mut add = |enc: &'static str, bytes: Vec| { + if bytes.is_empty() { + return; + } + let mut skip = [bytes.len(); 256]; + for (i, &b) in bytes[..bytes.len() - 1].iter().enumerate() { + skip[b as usize] = bytes.len() - 1 - i; + } + v.push(Pat { + term: t.clone(), + enc, + bytes, + skip, + }); + }; + add("UTF-8", folded.as_bytes().to_vec()); + if o.utf16 { + add( + "UTF-16", + folded + .encode_utf16() + .flat_map(|c| c.to_le_bytes()) + .collect(), + ); + } + } + v +} + +fn find_all(hay: &[u8], p: &Pat, mut f: impl FnMut(usize) -> bool) { + let n = p.bytes.len(); + if hay.len() < n { + return; + } + let mut i = 0; + while i + n <= hay.len() { + let last = hay[i + n - 1]; + if last == p.bytes[n - 1] && hay[i..i + n] == p.bytes[..] { + if !f(i) { + return; + } + i += 1; + } else { + i += p.skip[last as usize]; + } + } +} + +fn snippet(buf: &[u8], at: usize, len: usize, utf16: bool) -> String { + let a = at.saturating_sub(CTX); + let b = (at + len + CTX).min(buf.len()); + let s: String = if utf16 { + let a = a & !1; + let u: Vec = buf[a..b] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + String::from_utf16_lossy(&u) + } else { + String::from_utf8_lossy(&buf[a..b]).into_owned() + }; + s.chars() + .map(|c| if c.is_control() { '·' } else { c }) + .collect() +} + +/// Varre `src` inteira. Retorna o total de ocorrências. `on_hit` pode devolver false para parar. +pub fn search( + src: &dyn Source, + o: &SearchOpts, + cancelled: &(dyn Fn() -> bool + Sync), + progress: &mut dyn FnMut(u64, u64), + on_hit: &mut dyn FnMut(Hit) -> bool, +) -> usize { + let pats = make_pats(o); + if pats.is_empty() { + return 0; + } + let overlap = pats.iter().map(|p| p.bytes.len()).max().unwrap_or(1) + 2 * CTX; + let size = src.size(); + let mut pos = 0u64; + let mut hits = 0usize; + let mut buf = vec![0u8; CHUNK + overlap]; + 'outer: while pos < size { + if cancelled() { + break; + } + let want = ((size - pos) as usize).min(CHUNK + overlap); + let mut got = 0; + while got < want { + match src.read_at(pos + got as u64, &mut buf[got..want]) { + Ok(0) => break, + Ok(n) => got += n, + Err(_) => { + // setor ruim: pula este trecho + for b in &mut buf[got..want] { + *b = 0; + } + got = want; + } + } + } + if got == 0 { + break; + } + let fresh = got.min(CHUNK); // início de ocorrências aceitas neste bloco + let folded: Vec; + let hay: &[u8] = if o.ignore_case { + folded = buf[..got].to_ascii_lowercase(); + &folded + } else { + &buf[..got] + }; + let mut chunk_hits: Vec<(usize, usize)> = vec![]; // (offset no bloco, índice do padrão) + for (pi, p) in pats.iter().enumerate() { + find_all(hay, p, |i| { + if i < fresh { + chunk_hits.push((i, pi)); + } + true + }); + } + chunk_hits.sort(); + for (i, pi) in chunk_hits { + let p = &pats[pi]; + hits += 1; + let h = Hit { + offset: pos + i as u64, + term: p.term.clone(), + encoding: p.enc.to_string(), + context: snippet(&buf[..got], i, p.bytes.len(), p.enc == "UTF-16"), + }; + if !on_hit(h) || hits >= o.max_hits { + break 'outer; + } + } + pos += CHUNK as u64; + progress(pos.min(size), size); + } + progress(size, size); + hits +} diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 1ca06e4..5767207 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -33,6 +33,8 @@ pub enum JobKind { Scan, Recover, Image, + Search, + Hash, } struct State { @@ -47,6 +49,11 @@ struct State { report: Option, started: Instant, finished_in: Option, + /// Leitura de datas EXIF/dimensões em andamento: (feitos, total). + enrich: Option<(u64, u64)>, + recoveries: Vec, + started_unix: Option, + hits: Vec, } pub struct Job { @@ -88,6 +95,13 @@ impl Job { report: None, started: Instant::now(), finished_in: None, + enrich: None, + recoveries: vec![], + started_unix: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs() as i64), + hits: vec![], }), }) } @@ -128,6 +142,8 @@ impl Sink for JobSink { pub struct Engine { jobs: Mutex>>, counter: AtomicU64, + thumbs: Mutex, + gate: crate::explorer::Gate, } impl Default for Engine { @@ -141,6 +157,13 @@ impl Engine { Self { jobs: Mutex::new(HashMap::new()), counter: AtomicU64::new(1), + thumbs: Mutex::new(crate::explorer::ThumbCache::new()), + gate: crate::explorer::Gate::new( + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2) + .min(6), + ), } } @@ -231,53 +254,63 @@ impl Engine { "error": s.error, "report": s.report, "elapsed": elapsed, + "hits": s.hits.len(), + "enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })), })) } pub fn results(&self, id: &str, q: &Query) -> Result { let j = self.job(id)?; let s = j.st.lock().unwrap(); - let needle = q - .query - .as_deref() - .map(|x| x.to_lowercase()) - .filter(|x| !x.is_empty()); - let mut items: Vec<&Found> = s - .found - .iter() - .filter(|f| q.category.map(|c| f.category == c).unwrap_or(true)) - .filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true)) - .filter(|f| { - needle - .as_ref() - .map(|n| f.full_path().to_lowercase().contains(n.as_str())) - .unwrap_or(true) - }) - .collect(); - match q.sort.as_deref() { - Some("name") => items.sort_by_key(|f| f.name.to_lowercase()), - Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)), - Some("confidence") => { - items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size))) - } - _ => {} - } + let mut items: Vec<&Found> = q.select(&s.found); + crate::explorer::sort_items(&mut items, q.sort.as_deref()); let total = items.len(); let page: Vec = items .into_iter() .skip(q.offset) - .take(q.limit.clamp(1, 2000)) - .map(|f| { - json!({ - "id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category, - "size": f.size, "origin": f.origin, "confidence": f.confidence, - "modified": f.modified, "note": f.note, - }) - }) + .take(q.limit.clamp(1, 5000)) + .map(crate::explorer::item_json) .collect(); Ok(json!({ "total": total, "items": page })) } + /// Contagens para a navegação lateral (tipos, datas, pastas, câmeras). + pub fn facets(&self, id: &str) -> Result { + let j = self.job(id)?; + let s = j.st.lock().unwrap(); + Ok(crate::explorer::facets(&s.found)) + } + + /// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo. + pub fn metadata(&self, id: &str, file: u64) -> Result { + let (f, src) = self.open_file(id, file)?; + Ok(crate::meta::extract(src.as_ref(), &f)) + } + + /// Miniatura JPEG (cache em memória; decodificações limitadas em paralelo). + pub fn thumb(&self, job: &str, id: u64, size: u32) -> std::result::Result>, u16> { + let size = size.clamp(48, 640); + let key = (job.to_string(), id, size); + if let Some(v) = self.thumbs.lock().unwrap().get(&key) { + return if v.is_empty() { Err(422) } else { Ok(v) }; + } + let (f, src) = self.open_file(job, id).map_err(|_| 404u16)?; + if f.category != Category::Image { + return Err(415); + } + let _permit = self.gate.acquire(); + if let Some(v) = self.thumbs.lock().unwrap().get(&key) { + return if v.is_empty() { Err(422) } else { Ok(v) }; + } + let out = Arc::new(crate::thumb::thumbnail(src.as_ref(), &f, size).unwrap_or_default()); + self.thumbs.lock().unwrap().put(key, out.clone()); + if out.is_empty() { + Err(422) + } else { + Ok(out) + } + } + /// Achado + origem para leitura posicional (streaming de pré-visualização). pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc)> { let j = self.job(id)?; @@ -310,25 +343,7 @@ impl Engine { .iter() .filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone())) .collect(), - (None, Some(q)) => { - let needle = q - .query - .as_deref() - .map(|x| x.to_lowercase()) - .filter(|x| !x.is_empty()); - s.found - .iter() - .filter(|f| q.category.map(|c| f.category == c).unwrap_or(true)) - .filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true)) - .filter(|f| { - needle - .as_ref() - .map(|n| f.full_path().to_lowercase().contains(n.as_str())) - .unwrap_or(true) - }) - .cloned() - .collect() - } + (None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(), (None, None) => s.found.clone(), } }; @@ -344,6 +359,7 @@ impl Engine { None, ); let j2 = job.clone(); + let parent = sj.clone(); std::thread::spawn(move || { let cancelled = || j2.cancelled(); let mut prog = |done: usize, total: usize, bytes: u64| { @@ -360,6 +376,7 @@ impl Engine { match res { Ok(rep) => { s.report = serde_json::to_value(&rep).ok(); + parent.st.lock().unwrap().recoveries.push(rep.clone()); s.status = if rep.cancelled { Status::Stopped } else { @@ -422,6 +439,156 @@ impl Engine { Ok(job.id.clone()) } + // ---------------------------------------------------------------- exportação e relatório + + /// CSV dos achados que atendem ao filtro (ou todos). Devolve quantas linhas gravou. + pub fn export_csv(&self, id: &str, path: &str, q: Option<&Query>) -> Result { + let j = self.job(id)?; + let s = j.st.lock().unwrap(); + let items: Vec<&Found> = match q { + Some(q) => q.select(&s.found), + None => s.found.iter().collect(), + }; + std::fs::write(path, crate::report::csv(&items))?; + Ok(items.len()) + } + + /// Relatório HTML de recuperação (identificação do caso, origem, achados, hashes). + pub fn export_report( + &self, + id: &str, + path: &str, + case: crate::report::CaseInfo, + hashes: Option, + ) -> Result<()> { + let j = self.job(id)?; + let s = j.st.lock().unwrap(); + let elapsed = s + .finished_in + .unwrap_or_else(|| s.started.elapsed().as_secs_f64()); + let html = crate::report::html(&crate::report::ReportInput { + case: &case, + target: &j.target, + options: &j.opts, + layout: s.layout.as_ref(), + found: &s.found, + warnings: &s.warnings, + started_unix: s.started_unix, + elapsed, + recoveries: &s.recoveries, + source_hashes: hashes.as_ref(), + }); + std::fs::write(path, html)?; + Ok(()) + } + + // ---------------------------------------------------------------- busca de texto e hash da origem + + pub fn start_search(&self, path: &str, opts: crate::search::SearchOpts) -> Result { + if opts.terms.iter().all(|t| t.trim().is_empty()) { + return Err(Error::msg("informe pelo menos um termo")); + } + let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; + let job = self.register( + JobKind::Search, + path.to_string(), + ScanOptions::default(), + Some(src.clone()), + ); + let j2 = job.clone(); + std::thread::spawn(move || { + let layout = crate::partition::analyze(src.as_ref(), false); + let cancelled = || j2.cancelled(); + let mut prog = |d: u64, t: u64| { + j2.st.lock().unwrap().progress = Progress { + phase: "Procurando texto no disco".into(), + done: d, + total: t, + }; + }; + let mut on_hit = |h: crate::search::Hit| { + let part = layout + .partitions + .iter() + .find(|p| h.offset >= p.start && h.offset < p.start + p.len) + .map(|p| p.index + 1); + let mut s = j2.st.lock().unwrap(); + s.hits.push(json!({ + "offset": h.offset, "term": h.term, "encoding": h.encoding, + "context": h.context, "partition": part, + })); + true + }; + crate::search::search(src.as_ref(), &opts, &cancelled, &mut prog, &mut on_hit); + let mut s = j2.st.lock().unwrap(); + s.status = if j2.stop_requested.load(Ordering::Relaxed) { + Status::Stopped + } else { + Status::Done + }; + s.finished_in = Some(s.started.elapsed().as_secs_f64()); + }); + Ok(job.id.clone()) + } + + pub fn search_results(&self, id: &str, offset: usize, limit: usize) -> Result { + let j = self.job(id)?; + let s = j.st.lock().unwrap(); + Ok(json!({ + "total": s.hits.len(), + "items": s.hits.iter().skip(offset).take(limit.clamp(1, 2000)).cloned().collect::>(), + })) + } + + /// Calcula MD5/SHA-1/SHA-256 da origem inteira (verificação de integridade). + pub fn start_hash(&self, path: &str) -> Result { + let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; + let job = self.register( + JobKind::Hash, + path.to_string(), + ScanOptions::default(), + None, + ); + let j2 = job.clone(); + std::thread::spawn(move || { + let size = src.size(); + let mut h = crate::hashing::MultiHasher::new(); + let mut buf = vec![0u8; 4 << 20]; + let mut pos = 0u64; + let mut err = None; + while pos < size && !j2.cancelled() { + let want = ((size - pos) as usize).min(buf.len()); + match crate::source::read_exact_at(src.as_ref(), pos, &mut buf[..want]) { + Ok(()) => { + h.update(&buf[..want]); + pos += want as u64; + } + Err(e) => { + err = Some(format!("falha de leitura em {pos}: {e}. Para discos com defeito use a imagem forense.")); + break; + } + } + j2.st.lock().unwrap().progress = Progress { + phase: "Calculando hashes da origem".into(), + done: pos, + total: size, + }; + } + let mut s = j2.st.lock().unwrap(); + if let Some(e) = err { + s.error = Some(e); + s.status = Status::Error; + } else if pos < size { + s.status = Status::Stopped; + } else { + s.report = serde_json::to_value(h.finalize()).ok(); + s.status = Status::Done; + } + s.finished_in = Some(s.started.elapsed().as_secs_f64()); + }); + Ok(job.id.clone()) + } + // ---------------------------------------------------------------- sessões pub fn save_session(&self, id: &str, path: &str) -> Result<()> { @@ -436,6 +603,8 @@ impl Engine { layout: s.layout.clone(), warnings: s.warnings.clone(), done: s.status == Status::Done, + recoveries: s.recoveries.clone(), + started_unix: s.started_unix, }; std::fs::write(path, serde_json::to_vec(&data)?)?; Ok(()) @@ -458,6 +627,8 @@ impl Engine { s.found = data.found; s.layout = data.layout; s.warnings = data.warnings; + s.recoveries = data.recoveries; + s.started_unix = data.started_unix.or(s.started_unix); s.checkpoint = data.checkpoint; s.status = if data.done { Status::Done @@ -465,6 +636,9 @@ impl Engine { Status::Paused }; } + if data.done { + spawn_enrich(job.clone()); + } Ok(job.id.clone()) } @@ -506,6 +680,49 @@ impl Engine { json!({}) } "status" => self.status(&s("job")?)?, + "facets" => self.facets(&s("job")?)?, + "export_csv" => { + let q: Option = p + .get("query") + .cloned() + .filter(|v| !v.is_null()) + .map(serde_json::from_value) + .transpose()?; + json!({ "rows": self.export_csv(&s("job")?, &s("path")?, q.as_ref())? }) + } + "export_report" => { + let case: crate::report::CaseInfo = p + .get("case") + .cloned() + .filter(|v| !v.is_null()) + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + let hashes: Option = p + .get("hashes") + .cloned() + .filter(|v| !v.is_null()) + .map(serde_json::from_value) + .transpose()?; + self.export_report(&s("job")?, &s("path")?, case, hashes)?; + json!({}) + } + "search_start" => { + let opts: crate::search::SearchOpts = serde_json::from_value(p.clone())?; + json!({ "job": self.start_search(&s("path")?, opts)? }) + } + "search_results" => self.search_results( + &s("job")?, + p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize, + p.get("limit").and_then(|v| v.as_u64()).unwrap_or(200) as usize, + )?, + "hash_start" => json!({ "job": self.start_hash(&s("path")?)? }), + "metadata" => json!(self.metadata( + &s("job")?, + p.get("id") + .and_then(|v| v.as_u64()) + .ok_or_else(|| Error::msg("id ausente"))? + )?), "results" => { let q: Query = p .get("query") @@ -556,38 +773,7 @@ impl Engine { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Query { - #[serde(default)] - pub category: Option, - #[serde(default)] - pub min_confidence: Option, - #[serde(default)] - pub query: Option, - #[serde(default)] - pub sort: Option, - #[serde(default)] - pub offset: usize, - #[serde(default = "default_limit")] - pub limit: usize, -} - -fn default_limit() -> usize { - 200 -} - -impl Default for Query { - fn default() -> Self { - Self { - category: None, - min_confidence: None, - query: None, - sort: None, - offset: 0, - limit: 200, - } - } -} +pub use crate::explorer::Query; #[derive(Serialize, Deserialize)] struct SessionFile { @@ -599,19 +785,13 @@ struct SessionFile { layout: Option, warnings: Vec, done: bool, + #[serde(default)] + recoveries: Vec, + #[serde(default)] + started_unix: Option, } -fn cat_key(c: Category) -> &'static str { - match c { - Category::Image => "image", - Category::Document => "document", - Category::Video => "video", - Category::Audio => "audio", - Category::Archive => "archive", - Category::Database => "database", - Category::Other => "other", - } -} +use crate::explorer::cat_key; fn open_error(path: &str, e: std::io::Error) -> Error { if e.kind() == std::io::ErrorKind::PermissionDenied { @@ -653,6 +833,55 @@ fn spawn_scan(job: Arc, cp: Option) { } } s.finished_in = Some(s.started.elapsed().as_secs_f64()); + let done = s.status == Status::Done; + drop(s); + if done { + spawn_enrich(job); + } + }); +} + +/// Lê EXIF/dimensões das imagens achadas (em segundo plano) para permitir +/// navegar por data, câmera e resolução. +fn spawn_enrich(job: Arc) { + std::thread::spawn(move || { + let Some(src) = job.src.clone() else { return }; + let todo: Vec<(u64, Found)> = { + let s = job.st.lock().unwrap(); + s.found + .iter() + .filter(|f| { + f.sig.is_none() + || (f.category == Category::Image && f.px.is_none() && f.taken.is_none()) + }) + .map(|f| (f.id, f.clone())) + .collect() + }; + if todo.is_empty() { + return; + } + let total = todo.len() as u64; + for (i, (id, f)) in todo.into_iter().enumerate() { + if job.cancelled() && job.stop_requested.load(Ordering::Relaxed) { + break; + } + let sig = crate::explorer::quick_sig(src.as_ref(), &f); + let m = (f.category == Category::Image && f.px.is_none() && f.taken.is_none()) + .then(|| crate::meta::extract(src.as_ref(), &f)); + let mut s = job.st.lock().unwrap(); + if let Some(&k) = s.index.get(&id) { + let t = &mut s.found[k]; + t.sig = sig; + if let Some(m) = m { + t.taken = m.taken; + t.camera = m.camera.clone(); + t.px = m.px.or(t.px); + t.gps = m.gps.is_some(); + } + } + s.enrich = Some((i as u64 + 1, total)); + } + job.st.lock().unwrap().enrich = None; }); } diff --git a/crates/guara-core/src/thumb.rs b/crates/guara-core/src/thumb.rs new file mode 100644 index 0000000..ff8733b --- /dev/null +++ b/crates/guara-core/src/thumb.rs @@ -0,0 +1,126 @@ +//! Miniaturas para a grade de arquivos. Decodifica com redução na própria +//! decodificação (JPEG) para ser rápido mesmo com milhares de fotos e, se a +//! imagem estiver danificada, tenta a miniatura embutida no EXIF. + +use crate::recover::read_found_at; +use crate::source::Source; +use crate::types::Found; +use image::{imageops, ImageBuffer, Rgb, RgbImage}; + +const MAX_READ: u64 = 64 * 1024 * 1024; + +fn read_all(src: &dyn Source, f: &Found) -> Option> { + if f.size == 0 || f.size > MAX_READ { + return None; + } + let mut v = vec![0u8; f.size as usize]; + let mut got = 0; + while got < v.len() { + match read_found_at(src, f, got as u64, &mut v[got..]) { + Ok(0) | Err(_) => break, + Ok(n) => got += n, + } + } + v.truncate(got); + (!v.is_empty()).then_some(v) +} + +/// Miniatura JPEG com o maior lado = `max` (None se não for imagem ou não decodificar). +pub fn thumbnail(src: &dyn Source, f: &Found, max: u32) -> Option> { + let data = read_all(src, f)?; + let img = match f.ext.to_ascii_lowercase().as_str() { + "jpg" | "jpeg" => decode_jpeg(&data, max).or_else(|| { + let t = crate::meta::exif_thumbnail(&data)?; + decode_jpeg(&t, max) + })?, + "png" | "gif" | "bmp" | "webp" => decode_other(&data, max)?, + _ => return None, + }; + encode(&img) +} + +fn decode_jpeg(data: &[u8], max: u32) -> Option { + let mut m = crate::meta::Meta::default(); + crate::meta::jpeg_bytes(data, &mut m); + let orientation = m.orientation; + let mut dec = jpeg_decoder::Decoder::new(std::io::Cursor::new(data)); + dec.read_info().ok()?; + let _ = dec.scale(max.min(65535) as u16, max.min(65535) as u16); + let px = dec.decode().ok()?; + let info = dec.info()?; + let (w, h) = (info.width as u32, info.height as u32); + let rgb: Vec = match info.pixel_format { + jpeg_decoder::PixelFormat::RGB24 => px, + jpeg_decoder::PixelFormat::L8 => px.iter().flat_map(|&g| [g, g, g]).collect(), + jpeg_decoder::PixelFormat::L16 => px + .chunks_exact(2) + .flat_map(|c| [c[0], c[0], c[0]]) + .collect(), + jpeg_decoder::PixelFormat::CMYK32 => px + .chunks_exact(4) + .flat_map(|c| { + // Adobe CMYK vem invertido + let k = c[3] as u32; + [ + (c[0] as u32 * k / 255) as u8, + (c[1] as u32 * k / 255) as u8, + (c[2] as u32 * k / 255) as u8, + ] + }) + .collect(), + }; + let img: RgbImage = ImageBuffer::from_raw(w, h, rgb)?; + Some(orient(fit(img, max), orientation)) +} + +fn decode_other(data: &[u8], max: u32) -> Option { + let dynimg = image::load_from_memory(data).ok()?; + let rgba = dynimg.to_rgba8(); + // Achata a transparência sobre cinza claro. + let mut rgb = RgbImage::new(rgba.width(), rgba.height()); + for (x, y, p) in rgba.enumerate_pixels() { + let a = p[3] as u32; + let mix = |c: u8| ((c as u32 * a + 240 * (255 - a)) / 255) as u8; + rgb.put_pixel(x, y, Rgb([mix(p[0]), mix(p[1]), mix(p[2])])); + } + Some(fit(rgb, max)) +} + +fn fit(img: RgbImage, max: u32) -> RgbImage { + let (w, h) = (img.width(), img.height()); + if w.max(h) <= max { + return img; + } + let (nw, nh) = if w >= h { + (max, (h as u64 * max as u64 / w as u64).max(1) as u32) + } else { + ((w as u64 * max as u64 / h as u64).max(1) as u32, max) + }; + imageops::resize(&img, nw, nh, imageops::FilterType::Triangle) +} + +fn orient(img: RgbImage, o: u16) -> RgbImage { + match o { + 2 => imageops::flip_horizontal(&img), + 3 => imageops::rotate180(&img), + 4 => imageops::flip_vertical(&img), + 5 => imageops::flip_horizontal(&imageops::rotate90(&img)), + 6 => imageops::rotate90(&img), + 7 => imageops::flip_horizontal(&imageops::rotate270(&img)), + 8 => imageops::rotate270(&img), + _ => img, + } +} + +fn encode(img: &RgbImage) -> Option> { + let mut out = Vec::new(); + let enc = jpeg_encoder::Encoder::new(&mut out, 80); + enc.encode( + img.as_raw(), + img.width() as u16, + img.height() as u16, + jpeg_encoder::ColorType::Rgb, + ) + .ok()?; + Some(out) +} diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs index 6b9600c..4a91e57 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -132,9 +132,34 @@ pub struct Found { pub data: DataLoc, /// Segundos desde 1970-01-01 UTC. pub modified: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub accessed: Option, + /// Data em que a foto foi tirada (EXIF; hora da câmera lida como UTC), s desde 1970. + #[serde(default)] + pub taken: Option, + #[serde(default)] + pub camera: Option, + /// Largura × altura em pixels, quando conhecidas. + #[serde(default)] + pub px: Option<(u32, u32)>, + /// Há coordenadas GPS nos metadados. + #[serde(default)] + pub gps: bool, + /// Assinatura rápida do conteúdo (tamanho + início + fim) para achar duplicadas. + #[serde(default)] + pub sig: Option, pub note: Option, } +impl Found { + /// Data mais representativa: quando foi tirada > modificada > criada. + pub fn when(&self) -> Option { + self.taken.or(self.modified).or(self.created) + } +} + impl Found { pub fn first_offset(&self) -> Option { match &self.data { diff --git a/crates/guara-core/tests/forensics.rs b/crates/guara-core/tests/forensics.rs new file mode 100644 index 0000000..9d0de30 --- /dev/null +++ b/crates/guara-core/tests/forensics.rs @@ -0,0 +1,507 @@ +//! Recursos periciais: hashes, metadados (EXIF/PDF/Office/MP4), miniaturas, +//! busca de texto, exportações e navegação (facetas/duplicadas). + +use guara_core::explorer::{self, Query}; +use guara_core::hashing::hash_bytes; +use guara_core::source::MemSource; +use guara_core::*; +use std::io::Write; + +fn resident(name: &str, bytes: Vec) -> Found { + let ext = name.rsplit('.').next().unwrap_or("").to_string(); + Found { + id: 1, + name: name.into(), + dir: None, + category: signatures::category_for_ext(&ext), + ext, + size: bytes.len() as u64, + origin: Origin::Carved, + confidence: Confidence::High, + data: DataLoc::Resident { bytes }, + modified: None, + created: None, + accessed: None, + taken: None, + camera: None, + px: None, + gps: false, + sig: None, + note: None, + } +} + +// ------------------------------------------------------------------ hashes + +#[test] +fn hashes_conferem_com_vetores_conhecidos() { + let h = hash_bytes(b"abc"); + assert_eq!(h.md5, "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!(h.sha1, "a9993e364706816aba3e25717850c26c9cd0d89d"); + assert_eq!( + h.sha256, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); +} + +// ------------------------------------------------------------------ EXIF + +fn ifd(entries: &[(u16, u16, u32, Vec)], start: usize) -> Vec { + let n = entries.len(); + let mut table = (n as u16).to_le_bytes().to_vec(); + let mut data: Vec = vec![]; + let data_off = start + 2 + 12 * n + 4; + for (tag, typ, cnt, val) in entries { + table.extend(tag.to_le_bytes()); + table.extend(typ.to_le_bytes()); + table.extend(cnt.to_le_bytes()); + if val.len() <= 4 { + let mut v = val.clone(); + v.resize(4, 0); + table.extend(v); + } else { + if data.len() % 2 == 1 { + data.push(0); + } + table.extend(((data_off + data.len()) as u32).to_le_bytes()); + data.extend(val); + } + } + table.extend([0, 0, 0, 0]); + table.extend(data); + table +} + +fn rat(p: &[(u32, u32)]) -> Vec { + p.iter() + .flat_map(|(a, b)| a.to_le_bytes().into_iter().chain(b.to_le_bytes())) + .collect() +} + +fn jpeg_with_exif(orientation: u16, w: usize, h: usize) -> Vec { + // pixels: gradiente + let mut px = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + px.extend([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]); + } + } + let mut jpg = vec![]; + jpeg_encoder::Encoder::new(&mut jpg, 85) + .encode(&px, w as u16, h as u16, jpeg_encoder::ColorType::Rgb) + .unwrap(); + let asc = |s: &str| { + let mut v = s.as_bytes().to_vec(); + v.push(0); + v + }; + let make = asc("Canon"); + let model = asc("Canon EOS 80D"); + let dt = asc("2023:07:14 18:22:05"); + let e0 = |exif: u32, gps: u32| { + ifd( + &[ + (0x010F, 2, make.len() as u32, make.clone()), + (0x0110, 2, model.len() as u32, model.clone()), + (0x0112, 3, 1, orientation.to_le_bytes().to_vec()), + (0x8769, 4, 1, exif.to_le_bytes().to_vec()), + (0x8825, 4, 1, gps.to_le_bytes().to_vec()), + ], + 8, + ) + }; + let first = e0(0, 0); + let exif_off = 8 + first.len(); + let exif_ifd = ifd(&[(0x9003, 2, dt.len() as u32, dt.clone())], exif_off); + let gps_off = exif_off + exif_ifd.len(); + let gps_ifd = ifd( + &[ + (1, 2, 2, b"S\0".to_vec()), + (2, 5, 3, rat(&[(23, 1), (33, 1), (1800, 100)])), + (3, 2, 2, b"W\0".to_vec()), + (4, 5, 3, rat(&[(46, 1), (38, 1), (0, 1)])), + ], + gps_off, + ); + let mut tiff = b"II*\0".to_vec(); + tiff.extend(8u32.to_le_bytes()); + tiff.extend(e0(exif_off as u32, gps_off as u32)); + tiff.extend(exif_ifd); + tiff.extend(gps_ifd); + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend(tiff); + let mut out = vec![0xFF, 0xD8, 0xFF, 0xE1]; + out.extend(((app1.len() + 2) as u16).to_be_bytes()); + out.extend(app1); + out.extend(&jpg[2..]); + out +} + +#[test] +fn exif_camera_data_e_gps() { + let f = resident("foto.jpg", jpeg_with_exif(1, 64, 48)); + let m = meta::extract(&MemSource(vec![]), &f); + assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D")); + assert_eq!(m.px, Some((64, 48))); + // 2023-07-14 18:22:05 lido como UTC + assert_eq!(m.taken, Some(1_689_358_925)); + let (lat, lon) = m.gps.unwrap(); + assert!((lat + 23.5550).abs() < 0.001, "{lat}"); + assert!((lon + 46.6333).abs() < 0.001, "{lon}"); + assert!(m + .fields + .iter() + .any(|(k, v)| k == "Foto tirada em" && v == "14/07/2023 18:22:05")); +} + +#[test] +fn miniatura_respeita_orientacao_e_tamanho() { + let src = MemSource(vec![]); + // 64x48 com orientação 6 (girar 90 graus) => retrato 48x64, reduzido para caber em 32 + let f = resident("foto.jpg", jpeg_with_exif(6, 64, 48)); + let t = thumb::thumbnail(&src, &f, 32).expect("miniatura"); + let mut dec = jpeg_decoder::Decoder::new(&t[..]); + dec.decode().unwrap(); + let i = dec.info().unwrap(); + assert_eq!((i.width, i.height), (24, 32)); + // PNG também + let mut img = ::image::RgbImage::new(100, 50); + for p in img.pixels_mut() { + *p = ::image::Rgb([200, 30, 30]); + } + let mut png = std::io::Cursor::new(vec![]); + img.write_to(&mut png, ::image::ImageFormat::Png).unwrap(); + let f = resident("x.png", png.into_inner()); + let t = thumb::thumbnail(&src, &f, 40).expect("miniatura png"); + let mut dec = jpeg_decoder::Decoder::new(&t[..]); + dec.decode().unwrap(); + assert_eq!( + (dec.info().unwrap().width, dec.info().unwrap().height), + (40, 20) + ); + // lixo não vira miniatura + assert!(thumb::thumbnail(&src, &resident("x.jpg", vec![1, 2, 3, 4, 5]), 32).is_none()); +} + +// ------------------------------------------------------------------ PDF, Office, MP4 + +#[test] +fn metadados_de_pdf() { + let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count 12 /Kids [] >>\nendobj\n3 0 obj\n<< /Title (Relat\\363rio Final) /Author (Maria \\(Perita\\) Silva) /Creator (LibreOffice Writer) /Producer /CreationDate (D:20230714182205-03'00') >>\nendobj\ntrailer\n%%EOF\n".to_vec(); + let m = meta::extract(&MemSource(vec![]), &resident("a.pdf", pdf)); + let get = |k: &str| { + m.fields + .iter() + .find(|(a, _)| a == k) + .map(|(_, v)| v.clone()) + }; + assert_eq!(get("Título").as_deref(), Some("Relatório Final")); + assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva")); + assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer")); + assert_eq!(get("Gerado por").as_deref(), Some("PDF12")); + assert_eq!(get("Criado em").as_deref(), Some("14/07/2023 18:22:05")); + assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12")); +} + +fn zip_store(entries: &[(&str, &str)], deflate: bool) -> Vec { + let mut out = vec![]; + let mut cd = vec![]; + for (name, body) in entries { + let (method, data): (u16, Vec) = if deflate { + let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default()); + e.write_all(body.as_bytes()).unwrap(); + (8, e.finish().unwrap()) + } else { + (0, body.as_bytes().to_vec()) + }; + let off = out.len() as u32; + let crc = signatures::crc32(body.as_bytes()); + let mut lh = b"PK\x03\x04".to_vec(); + lh.extend([20, 0, 0, 0]); + lh.extend(method.to_le_bytes()); + lh.extend([0, 0, 0, 0]); + lh.extend(crc.to_le_bytes()); + lh.extend((data.len() as u32).to_le_bytes()); + lh.extend((body.len() as u32).to_le_bytes()); + lh.extend((name.len() as u16).to_le_bytes()); + lh.extend([0, 0]); + lh.extend(name.as_bytes()); + out.extend(&lh); + out.extend(&data); + let mut c = b"PK\x01\x02".to_vec(); + c.extend([20, 0, 20, 0, 0, 0]); + c.extend(method.to_le_bytes()); + c.extend([0, 0, 0, 0]); + c.extend(crc.to_le_bytes()); + c.extend((data.len() as u32).to_le_bytes()); + c.extend((body.len() as u32).to_le_bytes()); + c.extend((name.len() as u16).to_le_bytes()); + c.extend([0; 12]); + c.extend(off.to_le_bytes()); + c.extend(name.as_bytes()); + cd.extend(c); + } + let cd_off = out.len() as u32; + out.extend(&cd); + out.extend(b"PK\x05\x06\0\0\0\0"); + out.extend((entries.len() as u16).to_le_bytes()); + out.extend((entries.len() as u16).to_le_bytes()); + out.extend((cd.len() as u32).to_le_bytes()); + out.extend(cd_off.to_le_bytes()); + out.extend([0, 0]); + out +} + +#[test] +fn metadados_de_documentos_office() { + let core = r#"Tese & DefesaAna SouzaBruno Lima2023-07-14T18:22:05Z"#; + let app = "Microsoft Office WordACME"; + for deflate in [false, true] { + let z = zip_store( + &[ + ("[Content_Types].xml", ""), + ("docProps/core.xml", core), + ("docProps/app.xml", app), + ], + deflate, + ); + let m = meta::extract(&MemSource(vec![]), &resident("t.docx", z)); + let get = |k: &str| { + m.fields + .iter() + .find(|(a, _)| a == k) + .map(|(_, v)| v.clone()) + }; + assert_eq!( + get("Título").as_deref(), + Some("Tese & Defesa"), + "deflate={deflate}" + ); + assert_eq!(get("Autor").as_deref(), Some("Ana Souza")); + assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima")); + assert_eq!( + get("Criado em").as_deref(), + Some("14/07/2023 18:22:05 (UTC)") + ); + assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word")); + assert_eq!(get("Empresa").as_deref(), Some("ACME")); + } +} + +#[test] +fn metadados_de_mp4() { + fn bx(ty: &[u8; 4], body: &[u8]) -> Vec { + let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend(ty); + v.extend(body); + v + } + let created_1904: u32 = 1_689_358_925 + 2_082_844_800; + let mut mvhd = vec![0, 0, 0, 0]; + mvhd.extend(created_1904.to_be_bytes()); + mvhd.extend(created_1904.to_be_bytes()); + mvhd.extend(1000u32.to_be_bytes()); // timescale + mvhd.extend(125_000u32.to_be_bytes()); // 125 s + mvhd.extend(vec![0u8; 80]); + let mut tkhd = vec![0u8; 84]; + tkhd[76..80].copy_from_slice(&(1920u32 << 16).to_be_bytes()); + tkhd[80..84].copy_from_slice(&(1080u32 << 16).to_be_bytes()); + let moov = bx( + b"moov", + &[bx(b"mvhd", &mvhd), bx(b"trak", &bx(b"tkhd", &tkhd))].concat(), + ); + let mut file = bx(b"ftyp", b"isom\0\0\0\0isom"); + file.extend(moov); + file.extend(bx(b"mdat", &[0u8; 32])); + let m = meta::extract(&MemSource(vec![]), &resident("v.mp4", file)); + let get = |k: &str| { + m.fields + .iter() + .find(|(a, _)| a == k) + .map(|(_, v)| v.clone()) + }; + assert_eq!(get("Duração").as_deref(), Some("0:02:05")); + assert_eq!( + get("Gravado em (metadado)").as_deref(), + Some("14/07/2023 18:22:05 (UTC)") + ); + assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px")); +} + +// ------------------------------------------------------------------ busca de texto + +#[test] +fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() { + let mb = 1024 * 1024; + let mut disk = vec![0u8; 20 * mb]; + let put = |d: &mut Vec, at: usize, b: &[u8]| d[at..at + b.len()].copy_from_slice(b); + put(&mut disk, 1000, b"... Segredo-123 ..."); + let u16s: Vec = "segredo-123" + .encode_utf16() + .flat_map(|c| c.to_le_bytes()) + .collect(); + put(&mut disk, 3 * mb + 6, &u16s); + // exatamente cruzando o limite do primeiro bloco de 8 MiB + put(&mut disk, 8 * mb - 4, b"SEGREDO-123"); + let opts = search::SearchOpts { + terms: vec!["segredo-123".into()], + ignore_case: true, + utf16: true, + max_hits: 100, + }; + let mut hits = vec![]; + let n = search::search( + &MemSource(disk.clone()), + &opts, + &|| false, + &mut |_, _| {}, + &mut |h| { + hits.push(h); + true + }, + ); + assert_eq!(n, 3, "{hits:?}"); + assert_eq!(hits[0].offset, 1004); + assert_eq!(hits[0].encoding, "UTF-8"); + assert!(hits[0].context.contains("Segredo-123")); + assert_eq!(hits[1].offset, 3 * mb as u64 + 6); + assert_eq!(hits[1].encoding, "UTF-16"); + assert_eq!(hits[2].offset, 8 * mb as u64 - 4); + // sem ignorar maiúsculas só acha a grafia exata + let opts = search::SearchOpts { + terms: vec!["Segredo-123".into()], + ignore_case: false, + utf16: false, + max_hits: 100, + }; + let n = search::search( + &MemSource(disk), + &opts, + &|| false, + &mut |_, _| {}, + &mut |_| true, + ); + assert_eq!(n, 1); +} + +// ------------------------------------------------------------------ exportações + +#[test] +fn csv_escapa_e_neutraliza_formulas() { + let mut f = resident("=SOMA(A1).xlsx", vec![1]); + f.note = Some("diz \"oi\"; ok".into()); + f.camera = Some("Cam;X".into()); + let csv = report::csv(&[&f]); + assert!(csv.starts_with('\u{feff}')); + assert!(csv.contains("\"'=SOMA(A1).xlsx\""), "{csv}"); + assert!(csv.contains("\"diz \"\"oi\"\"; ok\"")); + assert_eq!(csv.lines().count(), 2); +} + +#[test] +fn relatorio_html_escapa_conteudo_e_traz_o_caso() { + let f = resident(".txt", vec![1, 2, 3]); + let case = report::CaseInfo { + case_id: "2026-001".into(), + examiner: "Perita ".into(), + organization: "Lab".into(), + notes: "obs".into(), + }; + let opts = ScanOptions::default(); + let hashes = hash_bytes(b"disco"); + let html = report::html(&report::ReportInput { + case: &case, + target: "/dev/sdb", + options: &opts, + layout: None, + found: &[f], + warnings: &["aviso ".to_string()], + started_unix: Some(1_689_358_925), + elapsed: 1.5, + recoveries: &[], + source_hashes: Some(&hashes), + }); + assert!(!html.contains("