feat: explorador de arquivos, miniaturas, metadados e recursos periciais (0.3.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1457caa453
commit
45d535bdb7
34 files changed
+4433
-365
No files matched your search
@@ -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
|
||||
|
||||
Generated
+218
-2
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -101,6 +101,28 @@ fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem.
|
||||
fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
|
||||
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::<u64>().ok()));
|
||||
let size = req
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s=")))
|
||||
.and_then(|v| v.parse::<u32>().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::<u64>().ok()));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; } }
|
||||
+133
-229
@@ -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/<método> 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 = `<form method="dialog"><h2>${esc(title)}</h2><p>${text || ''}</p>
|
||||
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>` : `<input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">`}</label>`).join('')}
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
|
||||
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 = `<form method="dialog"><h2>${esc(title)}</h2>${html}<menu><button class="btn primary" value="ok">Entendi</button></menu></form>`;
|
||||
@@ -153,7 +171,7 @@ function renderSide() {
|
||||
dl.innerHTML = S.devices.map((d, i) => `
|
||||
<li><button class="dev" data-i="${i}" aria-current="${S.device?.path === d.path}">
|
||||
<span class="ico">${icon(d.kind)}</span>
|
||||
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '—'}${d.readable ? '' : ' · requer admin'}</span></span>
|
||||
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '?'}${d.readable ? '' : ' · requer admin'}</span></span>
|
||||
</button></li>`).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 = `<div class="page">
|
||||
<section class="hero"><img class="hero-logo" src="guara-logo.svg" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
|
||||
<p>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.</p></section>
|
||||
<p>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.</p></section>
|
||||
<div class="cards">
|
||||
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
|
||||
<div class="card"><h3>Confiança por arquivo</h3><p>Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.</p></div>
|
||||
@@ -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 ? '<button class="btn sm" id="elevate" style="margin-left:6px">Reiniciar como administrador</button>' : 'Feche e rode de novo com <code>sudo guara ui</code> (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 <code>${esc(d.mounted_at.join(', '))}</code>). 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 <code>${esc(d.mounted_at.join(', '))}</code>). 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) {
|
||||
<div class="mode"><h3>Criar imagem do disco</h3><p>Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.</p>
|
||||
<button class="btn lg" id="goImage" ${d.readable ? '' : 'disabled'}>Criar imagem…</button></div>
|
||||
</div>
|
||||
<h2 style="margin-top:26px">Ferramentas de perícia</h2>
|
||||
<div class="modes">
|
||||
<div class="mode"><h3>Pesquisar texto no disco</h3><p>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.</p>
|
||||
<input class="input" id="srchTerms" placeholder="termos separados por vírgula" aria-label="Termos a pesquisar" style="margin-bottom:8px">
|
||||
<button class="btn lg" id="goSearch" ${d.readable ? '' : 'disabled'}>Pesquisar</button></div>
|
||||
<div class="mode"><h3>Hashes da origem</h3><p>Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.</p>
|
||||
<button class="btn lg" id="goHash" ${d.readable ? '' : 'disabled'}>Calcular hashes</button></div>
|
||||
</div>
|
||||
<details class="adv"><summary>Opções avançadas</summary>
|
||||
<label class="check"><input type="checkbox" id="optAlloc"><span><b>Incluir áreas ainda em uso</b><br><span class="sub">Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.</span></span></label>
|
||||
<div class="row" id="optCats">${CATS.map(([k, n]) => `<label class="check"><input type="checkbox" value="${k}"> ${n}</label>`).join('')}</div>
|
||||
@@ -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 `<p>Hashes do conteúdo inteiro de <code>${esc(S.device.path)}</code>:</p><dl class="kv"><dt>MD5</dt><dd class="mono">${esc(r.md5)}</dd><dt>SHA-1</dt><dd class="mono">${esc(r.sha1)}</dd><dt>SHA-256</dt><dd class="mono">${esc(r.sha256)}</dd></dl><p class="sub">Eles serão incluídos nos relatórios gerados nesta sessão.</p>`;
|
||||
});
|
||||
} 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 = `<div class="page"><button class="back" id="backBtn">‹ ${esc(S.device?.name || 'Dispositivos')}</button>
|
||||
<h1 id="shTitle">Procurando…</h1><div class="sub">Termos: ${terms.map((t) => `<code>${esc(t)}</code>`).join(', ')}</div>
|
||||
<div id="shProg" style="margin:14px 0"></div><div class="hits" id="hits"></div></div>`;
|
||||
$('#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 ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString('pt-BR')} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
||||
setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString('pt-BR')}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>'));
|
||||
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 = `<div class="page">
|
||||
<button class="back" id="backBtn">‹ Dispositivos</button>
|
||||
<div class="dev-head"><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
|
||||
main.classList.add('fixed');
|
||||
main.innerHTML = `<div class="page wide fill">
|
||||
<div class="xp-top"><button class="back" id="backBtn">‹ Dispositivos</button><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
|
||||
<div id="progressArea"></div>
|
||||
<div id="warnArea"></div>
|
||||
<div class="toolbar">
|
||||
<div class="chips" id="chips" role="group" aria-label="Filtrar por tipo"></div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:0">
|
||||
<input class="input" id="q" type="search" placeholder="Buscar por nome ou pasta" value="${esc(S.filters.query)}" aria-label="Buscar">
|
||||
<select class="select" id="conf" aria-label="Confiança mínima"><option value="">Qualquer confiança</option><option value="medium">Média ou alta</option><option value="high">Só alta</option></select>
|
||||
<select class="select" id="sort" aria-label="Ordenar"><option value="confidence">Mais confiáveis</option><option value="size">Maiores</option><option value="name">Nome</option></select>
|
||||
</div>
|
||||
<div class="split"><div class="table" id="table"></div><aside class="preview" id="preview" aria-live="polite"></aside></div>
|
||||
</div><div id="barHost"></div>`;
|
||||
<div class="xp-host" id="xpHost"></div>
|
||||
</div>`;
|
||||
$('#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 = `<span class="mono">${esc(st.target)}</span>`;
|
||||
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'), `<div class="progress-card">
|
||||
<div class="row spread"><div><b>${esc(p.phase || (running ? 'Iniciando' : ''))}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div>
|
||||
<div class="row">${running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : ''}
|
||||
${!running && st.found ? '<button class="btn sm" data-act="save">Salvar sessão…</button>' : ''}</div></div>
|
||||
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></div>
|
||||
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
||||
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>confiança alta</span></div>
|
||||
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
||||
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||
</div></div>`);
|
||||
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : '';
|
||||
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||
: st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : '';
|
||||
const tools = !running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '';
|
||||
if (running || st.status === 'paused') {
|
||||
setHTML($('#progressArea'), `<div class="progress-card slim">
|
||||
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
|
||||
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||
<div class="stats compact">
|
||||
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos</span></div>
|
||||
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
||||
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div>
|
||||
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
||||
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||
</div></div>`);
|
||||
} else {
|
||||
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString('pt-BR')}</b> arquivos em ${dur(st.elapsed)}</span>
|
||||
<span class="sub">confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}</span>${enr}<span class="grow"></span>${tools}</div>`);
|
||||
}
|
||||
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
||||
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
|
||||
// chips
|
||||
const total = st.found;
|
||||
setHTML($('#chips'), [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
|
||||
.concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => `<button class="chip" data-c="${k}" aria-pressed="${S.filters.category === k}">${n}<small>${st.categories[k]}</small></button>`)).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 `<div class="tr" role="row" data-id="${i.id}" aria-selected="${on || S.current?.id === i.id}" tabindex="0">
|
||||
<input type="checkbox" ${on ? 'checked' : ''} aria-label="Selecionar ${esc(i.name)}">
|
||||
<div class="nm"><b>${esc(i.name)}</b><small>${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}</small></div>
|
||||
<div class="num">${human(i.size)}</div><div class="num" style="text-align:left">${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}</div>
|
||||
<div class="num"><span class="pill ${cc}">${cl}</span></div></div>`;
|
||||
}
|
||||
|
||||
function paintList() {
|
||||
const el = $('#table');
|
||||
if (!el) return;
|
||||
const st = S.status;
|
||||
if (!S.items.length) {
|
||||
setHTML(el, `<div class="empty"><h3>${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}</h3><p>${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.'}</p></div>`);
|
||||
paintBar();
|
||||
return;
|
||||
}
|
||||
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
|
||||
setHTML(el, `<div class="thead"><input type="checkbox" id="selAll" aria-label="Selecionar todos" ${allSel ? 'checked' : ''}><span>Nome</span><span style="text-align:right">Tamanho</span><span>Origem</span><span style="text-align:right">Confiança</span></div>`
|
||||
+ S.items.map(rowHtml).join('')
|
||||
+ (S.items.length < S.total ? `<div class="more"><button class="btn" id="moreBtn">Mostrar mais (${(S.total - S.items.length).toLocaleString('pt-BR')})</button></div>` : ''));
|
||||
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 `<dl class="kv"><dt>Nome</dt><dd>${esc(i.name)}</dd><dt>Pasta</dt><dd>${esc(i.dir || '—')}</dd><dt>Tamanho</dt><dd>${human(i.size)}</dd>
|
||||
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
|
||||
<dt>Confiança</dt><dd><span class="pill ${cc}">${cl}</span></dd>${i.note ? `<dt>Aviso</dt><dd>${esc(i.note)}</dd>` : ''}</dl>`;
|
||||
}
|
||||
|
||||
function paintPreview() {
|
||||
const el = $('#preview');
|
||||
if (!el) return;
|
||||
stopPv?.(); stopPv = null;
|
||||
const i = S.current;
|
||||
if (!i) { el.innerHTML = '<div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar</div></div>'; return; }
|
||||
el.innerHTML = `<div class="viewer" id="pvHost"></div><div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(i)])}</div>${detailsHtml(i)}
|
||||
<div class="row"><button class="btn sm" id="pvFull">Tela cheia</button><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
|
||||
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 = `<div class="lb-head"><button class="icon-btn" id="lbPrev" aria-label="Anterior" ${idx <= 0 ? 'disabled' : ''}>‹</button><button class="icon-btn" id="lbNext" aria-label="Próximo" ${idx >= S.items.length - 1 ? 'disabled' : ''}>›</button>
|
||||
<div class="ttl"><b>${esc(i.name)}</b><small>${esc(KIND_LABEL[kindOf(i)])} · ${human(i.size)} · confiança ${CONF[i.confidence][0]}</small></div>
|
||||
<button class="icon-btn" id="lbClose" aria-label="Fechar"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button></div>
|
||||
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||
<div class="lb-foot"><span class="sub">${i.note ? esc(i.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||
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
|
||||
? '<button class="btn sm" data-bar="all">Selecionar todos os ' + S.total.toLocaleString('pt-BR') + '</button>' : '';
|
||||
const inner = `<span class="info">${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}</span>${moreOffer}
|
||||
${n ? '<button class="btn primary" data-bar="sel">Recuperar selecionados</button>' : '<button class="btn primary" data-bar="rec">Recuperar todos da lista</button>'}`;
|
||||
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 = '<div class="actionbar enter"></div>';
|
||||
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…',
|
||||
|
||||
@@ -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) => `<svg viewBox="0 0 24 24" width="${w}" height="${w}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
|
||||
const I = {
|
||||
grid: svg('<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>'),
|
||||
list: svg('<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/>'),
|
||||
filter: svg('<path d="M4 5h16l-6 8v6l-4-2v-4z"/>'),
|
||||
chev: svg('<path d="M9 6l6 6-6 6"/>', 14),
|
||||
x: svg('<path d="M6 6l12 12M18 6L6 18"/>', 14),
|
||||
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
|
||||
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
|
||||
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 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 = `<div class="xp" data-view="${E.view}">
|
||||
<div class="xp-bar">
|
||||
<button class="icon-btn xp-navbtn" id="xpNavBtn" aria-label="Filtros">${I.filter}</button>
|
||||
<div class="xp-crumb" id="xpCrumb"></div>
|
||||
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
|
||||
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
|
||||
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button></div>
|
||||
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
|
||||
</div>
|
||||
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
|
||||
<section class="xp-main">
|
||||
<div class="xp-chips" id="xpChips"></div>
|
||||
<div class="xp-scroll" id="xpScroll" tabindex="0" role="listbox" aria-multiselectable="true" aria-label="Arquivos encontrados"><div class="xp-sizer" id="xpSizer"></div></div>
|
||||
<div class="xp-actions" id="xpActions"></div>
|
||||
</section>
|
||||
<aside class="xp-insp" id="xpInsp" aria-label="Detalhes"></aside>
|
||||
<div class="xp-veil" id="xpVeil"></div>
|
||||
</div>`;
|
||||
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 `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
|
||||
}
|
||||
|
||||
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'
|
||||
? `<img loading="lazy" decoding="async" alt="" src="${thumbSrc(it, G.tw * (window.devicePixelRatio > 1 ? 1.5 : 1))}" data-th="1">`
|
||||
: `<div class="xp-ico c-${it.category}"><span>${catIco}</span><b>${esc((it.ext || '?').toUpperCase().slice(0, 5))}</b></div>`;
|
||||
return `<div class="xp-tile" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''} style="width:${G.tw}px;height:${G.tw + G.cap}px" title="${esc(it.name)}">
|
||||
<div class="xp-thumb" style="height:${G.tw}px">${media}
|
||||
<span class="xp-chk" data-chk="1" aria-hidden="true"></span>
|
||||
<span class="xp-dot ${cc}" title="Confiança ${cl}"></span>
|
||||
${it.gps ? `<span class="xp-gps" title="Tem localização GPS">${I.pin}</span>` : ''}
|
||||
</div>
|
||||
${G.cap ? `<div class="xp-cap"><b>${esc(it.name)}</b><small>${human(it.size)}${it.when != null ? ' · ' + esc(fmtDay(it.when)) : ''}</small></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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' ? `<img loading="lazy" decoding="async" alt="" src="${thumbUrl(job, it.id, 128)}" data-th="1">` : `<span class="xp-lico c-${it.category}">${catIco}</span>`;
|
||||
return `<div class="xp-lrow" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''}>
|
||||
<span class="xp-chk" data-chk="1" aria-hidden="true"></span><div class="xp-lth">${th}</div>
|
||||
<div class="xp-lnm"><b>${esc(it.name)}</b><small>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</small></div>
|
||||
<span class="xp-lcol">${esc(fmtDate(it.when))}</span><span class="xp-lcol num">${human(it.size)}</span><span class="pill ${cc}">${cl}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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', `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
}, 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
|
||||
? `<span class="info"><b>${n.toLocaleString('pt-BR')}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
|
||||
<button class="btn sm" data-a="clear">Limpar seleção</button>
|
||||
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString('pt-BR')}</button>` : ''}
|
||||
<button class="btn primary" data-a="rec">Recuperar ${n === 1 ? 'selecionado' : 'selecionados'}</button>`
|
||||
: `<span class="info">${E.total.toLocaleString('pt-BR')} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString('pt-BR')})` : ''}</span>
|
||||
${E.total ? '<button class="btn sm" data-a="all">Selecionar tudo</button><button class="btn primary" data-a="recall">Recuperar tudo desta vista</button>' : ''}`;
|
||||
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 = '') =>
|
||||
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString('pt-BR')}</small></button>`;
|
||||
const sec = (title, body) => (body ? `<div class="xp-sec"><h4>${title}</h4>${body}</div>` : '');
|
||||
|
||||
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,
|
||||
`<span class="tw" data-tw="y${y.year}" aria-label="Expandir" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>`)
|
||||
+ (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 ? `<span class="tw" data-tw="d${esc(d.path)}" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>` : '<span class="tw"></span>');
|
||||
}).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 = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
|
||||
+ 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 = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString('pt-BR')}</small>`;
|
||||
}
|
||||
|
||||
function paintChips() {
|
||||
const F = E.filters, chips = [];
|
||||
const add = (label, key) => chips.push(`<button class="chip" data-x="${key}">${label} ${I.x}</button>`);
|
||||
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('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
|
||||
}
|
||||
$('#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]) => `<dt>${k}</dt><dd>${esc(fmtDate(v))}</dd>`).join('');
|
||||
}
|
||||
|
||||
function paintInspector() {
|
||||
stopPv?.(); stopPv = null;
|
||||
const n = selCount();
|
||||
const it = E.items[idxOf(E.curId)];
|
||||
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||
if (n > 1) {
|
||||
const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0);
|
||||
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString('pt-BR')} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''}
|
||||
<div class="row"><button class="btn primary" data-i="rec">Recuperar selecionados</button><button class="btn" data-i="clear">Limpar seleção</button></div></div>`;
|
||||
return;
|
||||
}
|
||||
if (!it) {
|
||||
insp.innerHTML = `${close}<div class="xp-empty-insp"><div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar.<br><span class="sub">Setas navegam, Espaço abre em tela cheia.</span></div></div></div>`;
|
||||
return;
|
||||
}
|
||||
const [cl, cc] = CONF[it.confidence];
|
||||
const url = kindOf(it) === 'image' ? thumbUrl(job, it.id, 640) : fileUrl(job, it.id);
|
||||
insp.innerHTML = `${close}<div class="viewer" id="xpPv"></div>
|
||||
<div class="xp-ititle"><b>${esc(it.name)}</b><span class="pill ${cc}">${cl}</span></div>
|
||||
<div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}</div>
|
||||
${it.note ? `<div class="pv-note bad" style="text-align:left">${esc(it.note)}</div>` : ''}
|
||||
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
|
||||
<div class="xp-meta" id="xpMeta"></div>
|
||||
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
|
||||
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 = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`
|
||||
+ (m.gps ? `<div class="sub" style="font-size:12.5px">Coordenadas: ${m.gps[0].toFixed(5)}, ${m.gps[1].toFixed(5)}</div>` : '');
|
||||
}).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 = `<div class="lb-head"><button class="icon-btn" id="lbPrev" aria-label="Anterior" ${i <= 0 ? 'disabled' : ''}>‹</button><button class="icon-btn" id="lbNext" aria-label="Próximo" ${i >= E.items.length - 1 ? 'disabled' : ''}>›</button>
|
||||
<div class="ttl"><b>${esc(it.name)}</b><small>${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}${it.when != null ? ' · ' + esc(fmtDate(it.when)) : ''} · confiança ${cl} · ${i + 1} de ${E.items.length}</small></div>
|
||||
<button class="icon-btn" id="lbClose" aria-label="Fechar">${I.close}</button></div>
|
||||
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||
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 }); },
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,7 @@ function note(host, html, cls = '') {
|
||||
|
||||
function failure(host, item, err, withHex) {
|
||||
host.innerHTML = '';
|
||||
note(host, `<b>Não foi possível pré-visualizar.</b><br>${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, `<b>Não foi possível pré-visualizar.</b><br>${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);
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Query, String> {
|
||||
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 {} <pasta-de-destino>",
|
||||
@@ -391,8 +542,13 @@ fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String>
|
||||
.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"]
|
||||
);
|
||||
|
||||
@@ -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::<u64>().ok()),
|
||||
);
|
||||
let size = query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("s="))
|
||||
.and_then(|v| v.parse::<u32>().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('&')
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Category>,
|
||||
#[serde(default)]
|
||||
pub min_confidence: Option<Confidence>,
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub year: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub month: Option<u32>,
|
||||
/// Só arquivos sem data conhecida.
|
||||
#[serde(default)]
|
||||
pub undated: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub camera: Option<String>,
|
||||
#[serde(default)]
|
||||
pub origin: Option<Origin>,
|
||||
#[serde(default)]
|
||||
pub min_size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_size: Option<u64>,
|
||||
/// Megapixels mínimos.
|
||||
#[serde(default)]
|
||||
pub min_mp: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub has_gps: Option<bool>,
|
||||
/// Mostra só a primeira ocorrência de cada conteúdo repetido.
|
||||
#[serde(default)]
|
||||
pub dedupe: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
200
|
||||
}
|
||||
|
||||
impl Default for Query {
|
||||
fn default() -> Self {
|
||||
serde_json::from_str("{}").unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Query {
|
||||
pub fn needle(&self) -> Option<String> {
|
||||
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<String>) -> 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<i64, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
|
||||
let mut cams: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut folders: BTreeMap<String, usize> = 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<Value> = {
|
||||
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::<Vec<_>>()
|
||||
})).collect::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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<Key, Arc<Vec<u8>>>,
|
||||
order: VecDeque<Key>,
|
||||
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<Arc<Vec<u8>>> {
|
||||
self.map.get(k).cloned()
|
||||
}
|
||||
pub fn put(&mut self, k: Key, v: Arc<Vec<u8>>) {
|
||||
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<usize>,
|
||||
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<u64> {
|
||||
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)
|
||||
}
|
||||
@@ -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<u8> = 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,
|
||||
},
|
||||
|
||||
@@ -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<i64>) {
|
||||
fn deleted_file(
|
||||
&mut self,
|
||||
name: &str,
|
||||
dir: &str,
|
||||
start: u32,
|
||||
size: u64,
|
||||
times: (Option<i64>, Option<i64>, Option<i64>),
|
||||
) {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct NewFound<'a> {
|
||||
pub origin: Origin,
|
||||
pub data: DataLoc,
|
||||
pub modified: Option<i64>,
|
||||
pub created: Option<i64>,
|
||||
pub accessed: Option<i64>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ struct Rec {
|
||||
parent: u64,
|
||||
name: Option<(String, u8)>,
|
||||
mtime: Option<i64>,
|
||||
ctime: Option<i64>,
|
||||
atime: Option<i64>,
|
||||
data: Option<Data>,
|
||||
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<Rec> {
|
||||
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<Rec> {
|
||||
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,
|
||||
|
||||
@@ -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::<String>();
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<i64>,
|
||||
#[serde(default)]
|
||||
pub camera: Option<String>,
|
||||
#[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<String>) {
|
||||
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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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<u16> {
|
||||
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<u32> {
|
||||
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<Entry> {
|
||||
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<f64> {
|
||||
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<u32> {
|
||||
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<f64> {
|
||||
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<i64> {
|
||||
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::<i64>().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::<u32>().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<usize> {
|
||||
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<String> {
|
||||
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<u8> = d[p + 1..]
|
||||
.iter()
|
||||
.take_while(|&&c| c != b'>')
|
||||
.copied()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
let bytes: Vec<u8> = 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<u16> = 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<ZipEnt> {
|
||||
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<Vec<u8>> {
|
||||
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 `<tag ...>texto</tag>`.
|
||||
fn xml_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
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!("</{tag}>"))? + 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)
|
||||
}
|
||||
@@ -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<String> {
|
||||
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||
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<String> {
|
||||
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`
|
||||
|
||||
@@ -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<i64>,
|
||||
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<i64>) -> 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#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
|
||||
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
|
||||
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
|
||||
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
|
||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||||
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
|
||||
td.n{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;word-break:break-all}
|
||||
.kv td:first-child{width:230px;color:#5b6f6d}.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;font-weight:600}
|
||||
.alta{background:#d7f5e0;color:#146c2e}.media{background:#fff0cf;color:#8a5a00}.baixa{background:#fde0e0;color:#a32121}
|
||||
.note{background:#fff7e0;border-left:4px solid #e0a100;padding:10px 14px;border-radius:6px;margin:8px 0}
|
||||
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
|
||||
</style></head><body><main>"#,
|
||||
);
|
||||
o.push_str("<header><h1>Relatório de recuperação de arquivos</h1><div class=\"mut\">Gerado pelo Guará ");
|
||||
o.push_str(env!("CARGO_PKG_VERSION"));
|
||||
o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
|
||||
|
||||
o.push_str("<h2>Identificação do caso</h2><table class=\"kv\">");
|
||||
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!(
|
||||
"<tr><td>{k}</td><td>{}</td></tr>",
|
||||
esc(if v.is_empty() { "não informado" } else { v })
|
||||
));
|
||||
}
|
||||
if !r.case.notes.is_empty() {
|
||||
o.push_str(&format!(
|
||||
"<tr><td>Observações</td><td>{}</td></tr>",
|
||||
esc(&r.case.notes).replace('\n', "<br>")
|
||||
));
|
||||
}
|
||||
o.push_str("</table>");
|
||||
|
||||
o.push_str("<h2>Origem examinada</h2><table class=\"kv\">");
|
||||
o.push_str(&format!(
|
||||
"<tr><td>Dispositivo / imagem</td><td class=\"mono\">{}</td></tr>",
|
||||
esc(r.target)
|
||||
));
|
||||
if let Some(l) = r.layout {
|
||||
o.push_str(&format!("<tr><td>Tamanho</td><td>{} ({} bytes)</td></tr><tr><td>Tabela de partições</td><td>{}</td></tr>", human(l.size), l.size, esc(&l.table.to_uppercase())));
|
||||
}
|
||||
if let Some(h) = r.source_hashes {
|
||||
o.push_str(&format!(
|
||||
"<tr><td>Hash da origem</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
||||
esc(&h.md5),
|
||||
esc(&h.sha1),
|
||||
esc(&h.sha256)
|
||||
));
|
||||
} else {
|
||||
o.push_str("<tr><td>Hash da origem</td><td class=\"mut\">não calculado nesta sessão (use <span class=\"mono\">guara hash</span> ou <span class=\"mono\">guara image</span>)</td></tr>");
|
||||
}
|
||||
if let Some(t) = r.started_unix {
|
||||
o.push_str(&format!(
|
||||
"<tr><td>Início da varredura</td><td>{}</td></tr>",
|
||||
fmt_unix(t)
|
||||
));
|
||||
}
|
||||
o.push_str(&format!(
|
||||
"<tr><td>Duração da varredura</td><td>{:.1} s</td></tr><tr><td>Modo</td><td>{}{}</td></tr><tr><td>Garantia de leitura</td><td>A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.</td></tr></table>",
|
||||
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("<h2>Partições</h2><table><tr><th>#</th><th>Sistema</th><th>Início (bytes)</th><th>Tamanho</th><th>Rótulo</th><th>Observação</th></tr>");
|
||||
for p in &l.partitions {
|
||||
let obs = [
|
||||
p.lost.then_some("encontrada por varredura".to_string()),
|
||||
p.encrypted
|
||||
.as_ref()
|
||||
.map(|e| format!("cifrada ({e}), não examinada")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
o.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td></tr>",
|
||||
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!("<tr><td>-</td><td>espaço não particionado</td><td class=\"n\">{}</td><td class=\"n\">{}</td><td></td><td></td></tr>", g.start, human(g.len)));
|
||||
}
|
||||
o.push_str("</table>");
|
||||
}
|
||||
|
||||
// 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!("<h2>Resumo dos achados</h2><table class=\"kv\"><tr><td>Arquivos encontrados</td><td><b>{}</b> ({})</td></tr>", r.found.len(), human(bytes)));
|
||||
o.push_str(&format!(
|
||||
"<tr><td>Confiança</td><td><span class=\"pill alta\">alta {}</span> <span class=\"pill media\">média {}</span> <span class=\"pill baixa\">baixa {}</span></td></tr>",
|
||||
conf[2], conf[1], conf[0]
|
||||
));
|
||||
o.push_str(&format!(
|
||||
"<tr><td>Por tipo</td><td>{}</td></tr></table>",
|
||||
by_cat
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
));
|
||||
for w in r.warnings {
|
||||
o.push_str(&format!("<div class=\"note\">{}</div>", esc(w)));
|
||||
}
|
||||
|
||||
if !r.recoveries.is_empty() {
|
||||
o.push_str("<h2>Arquivos recuperados (integridade)</h2>");
|
||||
for rec in r.recoveries {
|
||||
o.push_str(&format!(
|
||||
"<p>Destino: <span class=\"mono\">{}</span>, {} arquivo(s), {}{}</p>",
|
||||
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("<table><tr><th>Arquivo</th><th>Tamanho</th><th>Hashes</th></tr>");
|
||||
for f in rec.recovered.iter().take(MAX_ROWS) {
|
||||
o.push_str(&format!(
|
||||
"<tr><td class=\"mono\">{}</td><td class=\"n\">{}</td><td class=\"mono\">MD5 {}<br>SHA-1 {}<br>SHA-256 {}</td></tr>",
|
||||
esc(&f.path),
|
||||
human(f.size),
|
||||
f.md5,
|
||||
f.sha1,
|
||||
f.sha256
|
||||
));
|
||||
}
|
||||
o.push_str("</table>");
|
||||
}
|
||||
}
|
||||
|
||||
o.push_str("<h2>Relação de arquivos encontrados</h2>");
|
||||
if r.found.len() > MAX_ROWS {
|
||||
o.push_str(&format!("<div class=\"note\">Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.</div>", r.found.len()));
|
||||
}
|
||||
o.push_str("<table><tr><th>Nome</th><th>Pasta</th><th>Tamanho</th><th>Modificado (UTC)</th><th>Criado (UTC)</th><th>Origem</th><th>Confiança</th><th>Posição no disco</th></tr>");
|
||||
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!(
|
||||
"<tr><td>{}</td><td class=\"mono\">{}</td><td class=\"n\">{}</td><td>{}</td><td>{}</td><td>{}</td><td><span class=\"pill {cc}\">{cl}</span></td><td class=\"n mono\">{}</td></tr>",
|
||||
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("</table><footer>Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.</footer></main></body></html>");
|
||||
o
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<u8>,
|
||||
skip: [usize; 256],
|
||||
}
|
||||
|
||||
fn make_pats(o: &SearchOpts) -> Vec<Pat> {
|
||||
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<u8>| {
|
||||
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<u16> = 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<u8>;
|
||||
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
|
||||
}
|
||||
@@ -33,6 +33,8 @@ pub enum JobKind {
|
||||
Scan,
|
||||
Recover,
|
||||
Image,
|
||||
Search,
|
||||
Hash,
|
||||
}
|
||||
|
||||
struct State {
|
||||
@@ -47,6 +49,11 @@ struct State {
|
||||
report: Option<Value>,
|
||||
started: Instant,
|
||||
finished_in: Option<f64>,
|
||||
/// Leitura de datas EXIF/dimensões em andamento: (feitos, total).
|
||||
enrich: Option<(u64, u64)>,
|
||||
recoveries: Vec<recover::RecoverReport>,
|
||||
started_unix: Option<i64>,
|
||||
hits: Vec<Value>,
|
||||
}
|
||||
|
||||
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<HashMap<String, Arc<Job>>>,
|
||||
counter: AtomicU64,
|
||||
thumbs: Mutex<crate::explorer::ThumbCache>,
|
||||
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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
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<crate::meta::Meta> {
|
||||
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<Arc<Vec<u8>>, 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<dyn Source>)> {
|
||||
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<usize> {
|
||||
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<crate::hashing::Hashes>,
|
||||
) -> 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<String> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Calcula MD5/SHA-1/SHA-256 da origem inteira (verificação de integridade).
|
||||
pub fn start_hash(&self, path: &str) -> Result<String> {
|
||||
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<Query> = 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<crate::hashing::Hashes> = 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<Category>,
|
||||
#[serde(default)]
|
||||
pub min_confidence: Option<Confidence>,
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
#[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<DiskLayout>,
|
||||
warnings: Vec<String>,
|
||||
done: bool,
|
||||
#[serde(default)]
|
||||
recoveries: Vec<recover::RecoverReport>,
|
||||
#[serde(default)]
|
||||
started_unix: Option<i64>,
|
||||
}
|
||||
|
||||
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<Job>, cp: Option<Checkpoint>) {
|
||||
}
|
||||
}
|
||||
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<Job>) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<RgbImage> {
|
||||
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<u8> = 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<RgbImage> {
|
||||
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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
@@ -132,9 +132,34 @@ pub struct Found {
|
||||
pub data: DataLoc,
|
||||
/// Segundos desde 1970-01-01 UTC.
|
||||
pub modified: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub created: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub accessed: Option<i64>,
|
||||
/// Data em que a foto foi tirada (EXIF; hora da câmera lida como UTC), s desde 1970.
|
||||
#[serde(default)]
|
||||
pub taken: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub camera: Option<String>,
|
||||
/// 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<u64>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
/// Data mais representativa: quando foi tirada > modificada > criada.
|
||||
pub fn when(&self) -> Option<i64> {
|
||||
self.taken.or(self.modified).or(self.created)
|
||||
}
|
||||
}
|
||||
|
||||
impl Found {
|
||||
pub fn first_offset(&self) -> Option<u64> {
|
||||
match &self.data {
|
||||
|
||||
@@ -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<u8>) -> 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<u8>)], start: usize) -> Vec<u8> {
|
||||
let n = entries.len();
|
||||
let mut table = (n as u16).to_le_bytes().to_vec();
|
||||
let mut data: Vec<u8> = 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<u8> {
|
||||
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<u8> {
|
||||
// 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 <FEFF00500044004600310032> /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<u8> {
|
||||
let mut out = vec![];
|
||||
let mut cd = vec![];
|
||||
for (name, body) in entries {
|
||||
let (method, data): (u16, Vec<u8>) = 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#"<?xml version="1.0"?><cp:coreProperties xmlns:cp="x" xmlns:dc="y" xmlns:dcterms="z"><dc:title>Tese & Defesa</dc:title><dc:creator>Ana Souza</dc:creator><cp:lastModifiedBy>Bruno Lima</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">2023-07-14T18:22:05Z</dcterms:created></cp:coreProperties>"#;
|
||||
let app = "<Properties><Application>Microsoft Office Word</Application><Company>ACME</Company></Properties>";
|
||||
for deflate in [false, true] {
|
||||
let z = zip_store(
|
||||
&[
|
||||
("[Content_Types].xml", "<Types/>"),
|
||||
("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<u8> {
|
||||
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<u8>, at: usize, b: &[u8]| d[at..at + b.len()].copy_from_slice(b);
|
||||
put(&mut disk, 1000, b"... Segredo-123 ...");
|
||||
let u16s: Vec<u8> = "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("<script>alert(1)</script>.txt", vec![1, 2, 3]);
|
||||
let case = report::CaseInfo {
|
||||
case_id: "2026-001".into(),
|
||||
examiner: "Perita <Ana>".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 <b>".to_string()],
|
||||
started_unix: Some(1_689_358_925),
|
||||
elapsed: 1.5,
|
||||
recoveries: &[],
|
||||
source_hashes: Some(&hashes),
|
||||
});
|
||||
assert!(!html.contains("<script>alert"), "HTML não escapado");
|
||||
assert!(html.contains("<script>alert(1)</script>.txt"));
|
||||
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
||||
assert!(html.contains(&hashes.sha256));
|
||||
assert!(html.contains("somente para leitura"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ navegação
|
||||
|
||||
#[test]
|
||||
fn facetas_filtros_e_duplicadas() {
|
||||
let mk = |id: u64,
|
||||
name: &str,
|
||||
dir: Option<&str>,
|
||||
taken: Option<i64>,
|
||||
cam: Option<&str>,
|
||||
sig: Option<u64>| {
|
||||
let mut f = resident(name, vec![0; 10]);
|
||||
f.id = id;
|
||||
f.dir = dir.map(String::from);
|
||||
f.origin = if dir.is_some() {
|
||||
Origin::Fat
|
||||
} else {
|
||||
Origin::Carved
|
||||
};
|
||||
f.taken = taken;
|
||||
f.camera = cam.map(String::from);
|
||||
f.sig = sig;
|
||||
f
|
||||
};
|
||||
let jul23 = 1_689_358_925; // 2023-07-14
|
||||
let jan24 = 1_705_000_000; // 2024-01-11
|
||||
let found = vec![
|
||||
mk(
|
||||
1,
|
||||
"a.jpg",
|
||||
Some("DCIM/100"),
|
||||
Some(jul23),
|
||||
Some("Canon"),
|
||||
Some(7),
|
||||
),
|
||||
mk(
|
||||
2,
|
||||
"b.jpg",
|
||||
Some("DCIM/100"),
|
||||
Some(jan24),
|
||||
Some("Canon"),
|
||||
Some(8),
|
||||
),
|
||||
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
|
||||
mk(4, "d.jpg", None, None, None, None),
|
||||
];
|
||||
let f = explorer::facets(&found);
|
||||
assert_eq!(f["total"], 4);
|
||||
assert_eq!(f["years"][0]["year"], 2024);
|
||||
assert_eq!(f["years"][1]["year"], 2023);
|
||||
assert_eq!(f["years"][1]["count"], 2);
|
||||
assert_eq!(f["undated"], 1);
|
||||
assert_eq!(f["carved"], 1);
|
||||
assert_eq!(f["duplicates"], 1);
|
||||
assert_eq!(f["cameras"][0]["name"], "Canon");
|
||||
let dcim = f["folders"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["path"] == "DCIM")
|
||||
.unwrap();
|
||||
assert_eq!(dcim["count"], 2);
|
||||
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
|
||||
assert_eq!(q(r#"{"folder":"DCIM"}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"folder":"@carved"}"#).select(&found).len(), 1);
|
||||
assert_eq!(q(r#"{"camera":"Canon"}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"undated":true}"#).select(&found).len(), 1);
|
||||
assert_eq!(q(r#"{"dedupe":true}"#).select(&found).len(), 3);
|
||||
assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1);
|
||||
let mut items = q("{}").select(&found);
|
||||
explorer::sort_items(&mut items, Some("date"));
|
||||
assert_eq!(items[0].id, 2); // mais recente primeiro
|
||||
assert_eq!(items.last().unwrap().id, 4); // sem data por último
|
||||
}
|
||||
@@ -32,6 +32,15 @@ fn check(fs: &str, only_carvable: bool) {
|
||||
assert!(seen.insert(o), "[{fs}] achado duplicado no offset {o}");
|
||||
}
|
||||
}
|
||||
if fs != "ext4" {
|
||||
for f in res.found.iter().filter(|f| f.origin != Origin::Carved) {
|
||||
assert!(
|
||||
f.modified.is_some() && f.created.is_some(),
|
||||
"[{fs}] {} sem datas de modificação/criação",
|
||||
f.name
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(before, sha256_file(&img), "a origem foi modificada!");
|
||||
}
|
||||
|
||||
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gera uma imagem FAT32 com muitas fotos JPEG (EXIF de câmeras/datas/GPS) já apagadas.
|
||||
|
||||
Uso: make-photo-fixture.py <saida.img> [quantidade=300] [copias=1]
|
||||
(`copias` repete cada foto N vezes com outro nome: é um jeito rápido de gerar milhares de arquivos.)
|
||||
Serve para testar a navegação por data/câmera/pasta e as miniaturas.
|
||||
"""
|
||||
import os, random, shutil, struct, subprocess, sys, tempfile
|
||||
|
||||
out = sys.argv[1]
|
||||
count = int(sys.argv[2]) if len(sys.argv) > 2 else 300
|
||||
copies = int(sys.argv[3]) if len(sys.argv) > 3 else 1
|
||||
rnd = random.Random(42)
|
||||
|
||||
def ifd_bytes(entries, start):
|
||||
n = len(entries)
|
||||
table = struct.pack('<H', n)
|
||||
data = b''
|
||||
data_off = start + 2 + 12 * n + 4
|
||||
for tag, typ, cnt, val in entries:
|
||||
if isinstance(val, int):
|
||||
vb = struct.pack('<I', val) if typ == 4 else struct.pack('<H', val) + b'\0\0'
|
||||
else:
|
||||
vb = val
|
||||
if len(vb) <= 4:
|
||||
table += struct.pack('<HHI', tag, typ, cnt) + vb.ljust(4, b'\0')
|
||||
else:
|
||||
if len(data) % 2:
|
||||
data += b'\0'
|
||||
table += struct.pack('<HHII', tag, typ, cnt, data_off + len(data))
|
||||
data += vb
|
||||
return table + struct.pack('<I', 0) + data
|
||||
|
||||
def rat(*pairs):
|
||||
return b''.join(struct.pack('<II', a, b) for a, b in pairs)
|
||||
|
||||
def make_exif(make, model, dt, gps, orient):
|
||||
def build(exif_off, gps_off):
|
||||
e0 = [(0x010F, 2, len(make) + 1, make.encode() + b'\0'), (0x0110, 2, len(model) + 1, model.encode() + b'\0'),
|
||||
(0x0112, 3, 1, orient), (0x8769, 4, 1, exif_off)]
|
||||
if gps:
|
||||
e0.append((0x8825, 4, 1, gps_off))
|
||||
return ifd_bytes(e0, 8)
|
||||
ex = [(0x9003, 2, 20, dt.encode() + b'\0')]
|
||||
first = build(0, 0)
|
||||
exif_off = 8 + len(first)
|
||||
exif_ifd = ifd_bytes(ex, exif_off)
|
||||
gps_off = exif_off + len(exif_ifd)
|
||||
gps_ifd = b''
|
||||
if gps:
|
||||
lat, lon = gps
|
||||
def dms(v):
|
||||
v = abs(v); d = int(v); m = int((v - d) * 60); s = round(((v - d) * 60 - m) * 60 * 100)
|
||||
return rat((d, 1), (m, 1), (s, 100))
|
||||
g = [(1, 2, 2, b'S\0' if lat < 0 else b'N\0'), (2, 5, 3, dms(lat)), (3, 2, 2, b'W\0' if lon < 0 else b'E\0'), (4, 5, 3, dms(lon))]
|
||||
gps_ifd = ifd_bytes(g, gps_off)
|
||||
body = build(exif_off, gps_off) + exif_ifd + gps_ifd
|
||||
tiff = b'II*\0' + struct.pack('<I', 8) + body
|
||||
return b'Exif\0\0' + tiff
|
||||
|
||||
def add_exif(jpg_bytes, exif):
|
||||
assert jpg_bytes[:2] == b'\xff\xd8'
|
||||
seg = b'\xff\xe1' + struct.pack('>H', len(exif) + 2) + exif
|
||||
return jpg_bytes[:2] + seg + jpg_bytes[2:]
|
||||
|
||||
cams = [('Canon', 'Canon EOS 80D'), ('Apple', 'iPhone 13'), ('samsung', 'SM-G991B'), ('NIKON CORPORATION', 'NIKON D3500')]
|
||||
places = [(-23.5505, -46.6333), (-8.0476, -34.8770), (-22.9068, -43.1729), (38.7223, -9.1393)]
|
||||
folders = ['DCIM/100CANON', 'DCIM/Camera', 'WhatsApp/Images', 'Viagens/Recife', 'Viagens/Lisboa', 'Familia']
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
files = []
|
||||
for i in range(count):
|
||||
cam = cams[i % 4]
|
||||
y = rnd.choice([2021, 2022, 2023, 2024]); mo = rnd.randint(1, 12); d = rnd.randint(1, 28)
|
||||
dt = f'{y}:{mo:02d}:{d:02d} {rnd.randint(7, 22):02d}:{rnd.randint(0,59):02d}:{rnd.randint(0,59):02d}'
|
||||
folder = folders[i % len(folders)]
|
||||
name = f'IMG_{i:04d}.jpg'
|
||||
p = os.path.join(tmp, name)
|
||||
w, h = rnd.choice([(480, 320), (320, 480), (640, 360), (400, 400)])
|
||||
subprocess.run(['convert', '-seed', str(i), '-size', f'{w}x{h}', 'plasma:fractal', '-gravity', 'center', '-pointsize', '34', '-fill', 'white',
|
||||
'-stroke', 'black', '-strokewidth', '1', '-annotate', '0', f'{y}-{mo:02d}-{d:02d}\n#{i}', '-quality', '82', p], check=True, stderr=subprocess.DEVNULL)
|
||||
raw = open(p, 'rb').read()
|
||||
if i % 9 != 0: # alguns sem EXIF
|
||||
gps = places[i % 4] if i % 3 == 0 else None
|
||||
raw = add_exif(raw, make_exif(cam[0], cam[1], dt, gps, rnd.choice([1, 1, 1, 6, 8])))
|
||||
open(p, 'wb').write(raw)
|
||||
files.append((folder, name, p))
|
||||
|
||||
if copies > 1:
|
||||
base = list(files)
|
||||
for c in range(1, copies):
|
||||
for folder, name, p in base:
|
||||
files.append((folder, name.replace('IMG_', f'IMG{c:03d}_'), p))
|
||||
mb = max(64, len(files) * 40 // 1024 + 48)
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
subprocess.run(['truncate', '-s', f'{mb}M', out], check=True)
|
||||
subprocess.run(['mkfs.vfat', '-F', '32', out], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
made = set()
|
||||
for folder, name, p in files:
|
||||
cur = ''
|
||||
for part in folder.split('/'):
|
||||
cur += '/' + part
|
||||
if cur not in made:
|
||||
subprocess.run(['mmd', '-i', out, '::' + cur], check=True, stderr=subprocess.DEVNULL)
|
||||
made.add(cur)
|
||||
for folder, name, p in files:
|
||||
subprocess.run(['mcopy', '-i', out, p, f'::/{folder}/{name}'], check=True)
|
||||
for folder, name, p in files:
|
||||
subprocess.run(['mdel', '-i', out, f'::/{folder}/{name}'], check=True)
|
||||
shutil.rmtree(tmp)
|
||||
print(f'ok: {out} ({len(files)} fotos apagadas)')
|
||||
Reference in new issue
Block a user