3 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 45d535bdb7 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
- 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>
2026-10-18 16:16:20 -03:00
Lucas RayanandClaude Sonnet 5.5 1457caa453 feat(brand): nova logo — pixels que se juntam num lobo-guará de silhueta lisa
Validada pelo autor. Gerada de forma determinística por scripts/make-logo.py;
ícones do app regenerados a partir dela.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 15:53:34 -03:00
Lucas RayanandClaude Sonnet 5.5 c5c94a9956 feat: pré-visualização completa, tema, nova identidade e correções (0.2.0)
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
- Preview de imagem, vídeo/MP4, áudio, PDF (pdf.js), texto, Office/ODF, ZIP e hex,
  lido sob demanda com Range; tela cheia com navegação por teclado.
- Seletor de tema Auto/Claro/Escuro persistido (vale também como administrador).
- Identidade verde-azulada com logo de pixels que formam um lobo.
- Corrige reinício como administrador no AppImage (usa $APPIMAGE) e mostra o motivo.
- Corrige barra inferior piscando (render idempotente, barra criada uma vez).
- Corrige FAT32 em mídias pequenas não reconhecido.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 15:34:01 -03:00
51 changed files with 6042 additions and 392 deletions

No files matched your search

+26
View File
@@ -2,6 +2,32 @@
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
- **Pré-visualização completa:** imagem (zoom), vídeo (MP4 e outros), áudio, PDF (pdf.js incluso, funciona offline), texto, Word/Excel/PowerPoint/OpenDocument, conteúdo de ZIP e hexadecimal; tela cheia com navegação por teclado (↑ ↓ ← → Espaço Esc). O conteúdo é lido sob demanda com suporte a Range, sem gravar em disco.
- Seletor de tema **Auto / Claro / Escuro**; a escolha é salva e vale também no app reiniciado como administrador.
- Nova identidade visual em verde-azulado, com logo de pixels que se juntam até formar um lobo-guará.
### Corrigido
- Reiniciar como administrador no Linux: no AppImage o executável fica num ponto de montagem inacessível ao root; agora usa o próprio `.AppImage`, mostra o motivo se falhar e fecha o app antigo só quando o novo abre.
- Barra de ações inferior piscava a cada atualização; agora é criada uma vez e só atualiza o texto. O painel de progresso também deixou de ser recriado a cada ciclo.
- FAT32 em mídias pequenas (menos de 65 525 clusters) não era reconhecido.
- O tema não acompanhava a escolha ao abrir como root (o WebView do root usava outro tema do sistema).
## [0.1.0] - 2026-10-18
### Adicionado
Generated
+218 -2
View File
@@ -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.1.0"
version = "0.3.0"
dependencies = [
"clap",
"ctrlc",
@@ -260,11 +329,17 @@ dependencies = [
[[package]]
name = "guara-core"
version = "0.1.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
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "0.1.0"
version = "0.3.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
+29 -4
View File
@@ -1,4 +1,4 @@
<p align="center"><img src="apps/gui/ui/guara.svg" width="96" alt="Guará"></p>
<p align="center"><img src="apps/gui/ui/guara-logo.svg" width="360" alt="Guará"></p>
<h1 align="center">Guará</h1>
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
@@ -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,7 +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, pré-visualização de imagens, confiança por arquivo, pausar/continuar e salvar sessão. Tema claro/escuro automático 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:
- **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)
@@ -68,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
View File
@@ -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.
+4 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
version = "0.1.0"
version = "0.3.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
@@ -20,6 +20,9 @@ tauri-plugin-dialog = "2"
serde_json = "1"
guara-core = { path = "../../../crates/guara-core" }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
lto = "thin"
codegen-units = 1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 455 KiB

+144 -18
View File
@@ -16,40 +16,166 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json:
}
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
/// manifesto já exige elevação.
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
#[tauri::command]
fn relaunch_elevated() -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
use tauri::Emitter;
let exe = elevated_exe()?;
let cfg = guara_core::settings::config_dir();
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
let _ = std::fs::write(&log, b"");
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
let err = out.try_clone().map_err(|e| e.to_string())?;
let pid = std::process::id().to_string();
#[cfg(target_os = "linux")]
{
let mut cmd = std::process::Command::new("pkexec");
cmd.arg("env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] {
let mut cmd = {
let mut c = std::process::Command::new("pkexec");
c.arg("/usr/bin/env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
if let Ok(v) = std::env::var(k) {
cmd.arg(format!("{k}={v}"));
c.arg(format!("{k}={v}"));
}
}
cmd.arg(exe);
cmd.spawn().map_err(|e| format!("pkexec indisponível: {e}"))?;
std::process::exit(0);
}
c.arg(format!("GUARA_CONFIG_DIR={}", cfg.display()));
c.arg(format!("GUARA_REPLACE_PID={pid}"));
c.arg(&exe);
c
};
#[cfg(target_os = "macos")]
{
let script = format!("do shell script \"'{}' &> /dev/null &\" with administrator privileges", exe.display());
std::process::Command::new("osascript").args(["-e", &script]).spawn().map_err(|e| e.to_string())?;
std::process::exit(0);
let mut cmd = {
let shell = format!(
"GUARA_CONFIG_DIR='{}' GUARA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
cfg.display(),
pid,
exe.display()
);
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
let mut c = std::process::Command::new("osascript");
c.args(["-e", &script]);
c
};
cmd.stdout(out).stderr(err);
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
std::thread::spawn(move || {
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso.
let status = child.wait();
if matches!(&status, Ok(s) if s.success()) {
let _ = std::fs::remove_file(&log);
return;
}
let detail = std::fs::read_to_string(&log).unwrap_or_default();
let msg = match status.ok().and_then(|s| s.code()) {
Some(126) => "Autorização cancelada.".to_string(),
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
};
let _ = app.emit("elevate-failed", msg);
let _ = std::fs::remove_file(&log);
});
Ok(())
}
#[cfg(windows)]
{
let _ = exe;
let _ = app;
Err("o Guará já roda como administrador no Windows".into())
}
}
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o
/// próprio arquivo `.AppImage` (variável `APPIMAGE`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> {
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
return Ok(p);
}
std::env::current_exe().map_err(|e| e.to_string())
}
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`),
/// 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()));
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) };
let range = req.headers().get("range").and_then(|v| v.to_str().ok());
let slice = match eng.file_slice(job, id, range) {
Ok(s) => s,
Err(code) => return err(code),
};
let total = slice.total;
let start = slice.start;
let want = slice.len();
let mut headers = slice.headers();
let partial = slice.partial || want > CAP;
let Ok(data) = slice.read_all(CAP) else { return err(500) };
let mut b = Response::builder().status(if partial { 206 } else { 200 });
headers.retain(|(k, _)| *k != "Content-Range");
for (k, v) in &headers {
b = b.header(*k, v);
}
if partial && !data.is_empty() {
b = b.header("Content-Range", format!("bytes {}-{}/{}", start, start + data.len() as u64 - 1, total));
}
b.body(data).unwrap_or_else(|_| err(500))
}
fn main() {
let engine = Arc::new(Engine::new());
let for_protocol = engine.clone();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(State(Arc::new(Engine::new())))
.manage(State(engine))
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
let eng = for_protocol.clone();
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
})
.setup(|_app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
#[cfg(unix)]
if let Some(pid) = std::env::var("GUARA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200));
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
unsafe {
if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM);
}
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
.run(tauri::generate_context!())
.expect("erro ao iniciar o Guará");
+14 -4
View File
@@ -1,9 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guara",
"version": "0.1.0",
"version": "0.3.0",
"identifier": "dev.guara.app",
"build": { "frontendDist": "../ui" },
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
@@ -16,7 +18,9 @@
"resizable": true
}
],
"security": { "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
}
},
"bundle": {
"active": true,
@@ -24,6 +28,12 @@
"category": "Utility",
"shortDescription": "Recuperação de arquivos",
"longDescription": "Guará recupera arquivos apagados e partições perdidas de HD, SSD e pendrive, sempre em modo somente leitura na origem.",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+207 -8
View File
@@ -7,10 +7,10 @@
--text-2: #6e6e73;
--text-3: #8e8e93;
--line: rgba(0, 0, 0, 0.09);
--accent: #d9541e;
--accent-2: #ff8a3d;
--accent: #0f766e;
--accent-2: #14b8a6;
--accent-ink: #ffffff;
--accent-soft: rgba(217, 84, 30, 0.1);
--accent-soft: rgba(15, 118, 110, 0.11);
--ok: #1a8f3c;
--warn: #b26a00;
--bad: #d12b2b;
@@ -34,10 +34,10 @@
--text-2: #a1a1a6;
--text-3: #7d7d82;
--line: rgba(255, 255, 255, 0.1);
--accent: #ff7a3d;
--accent-2: #ff9d5c;
--accent-ink: #1a0b03;
--accent-soft: rgba(255, 122, 61, 0.15);
--accent: #2dd4bf;
--accent-2: #5eead4;
--accent-ink: #042f2e;
--accent-soft: rgba(45, 212, 191, 0.15);
--ok: #32d74b;
--warn: #ffb340;
--bad: #ff6961;
@@ -48,6 +48,32 @@
}
}
:root[data-theme="dark"] {
--bg: #000000;
--surface: #1c1c1e;
--surface-2: #232326;
--side: rgba(28, 28, 30, 0.8);
--text: #f5f5f7;
--text-2: #a1a1a6;
--text-3: #7d7d82;
--line: rgba(255, 255, 255, 0.1);
--accent: #2dd4bf;
--accent-2: #5eead4;
--accent-ink: #042f2e;
--accent-soft: rgba(45, 212, 191, 0.15);
--ok: #32d74b;
--warn: #ffb340;
--bad: #ff6961;
--ok-soft: rgba(50, 215, 75, 0.15);
--warn-soft: rgba(255, 179, 64, 0.15);
--bad-soft: rgba(255, 105, 97, 0.15);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
}
:root { color-scheme: light dark; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
@@ -211,7 +237,8 @@ input[type="checkbox"] { accent-color: var(--accent); width: 17px; height: 17px;
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
.kv dt { color: var(--text-3); } .kv dd { margin: 0; overflow-wrap: anywhere; }
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; animation: rise .3s; }
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; }
.actionbar.enter { animation: rise .3s; }
.actionbar .info { color: var(--text-2); font-size: 14px; }
dialog { border: 0; border-radius: 22px; padding: 26px; width: min(520px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
@@ -255,3 +282,175 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* ------------------------------------------------------------ tema */
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 12px; color: var(--text-2); }
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
/* ------------------------------------------------------------ pré-visualização */
.viewer { position: relative; min-height: 160px; max-height: 52vh; overflow: auto; border-radius: 12px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); display: flex; flex-direction: column; align-items: center; }
.viewer.large { max-height: none; height: 100%; min-height: 0; }
.viewer > * { max-width: 100%; }
.pv-img { display: block; max-width: 100%; max-height: 46vh; object-fit: contain; margin: auto; }
.viewer.large .pv-img { max-height: 100%; }
.viewer.zoomable .pv-img { cursor: zoom-in; }
.viewer.actual .pv-img { max-width: none; max-height: none; cursor: zoom-out; }
.viewer.actual { align-items: flex-start; }
.pv-video { width: 100%; max-height: 46vh; background: #000; border-radius: 12px; }
.viewer.large .pv-video { max-height: 100%; height: 100%; }
.pv-audio { width: calc(100% - 24px); margin: auto 12px; }
.pv-note { width: 100%; padding: 8px 12px; font-size: 12.5px; color: var(--text-2); text-align: center; }
.pv-note.bad { text-align: left; color: var(--text); background: var(--warn-soft); border-radius: 10px; margin: 8px; width: calc(100% - 16px); font-size: 13.5px; }
.pv-loading { height: 160px; display: grid; place-items: center; width: 100%; }
.pv-loading i { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.pv-text, .pv-hex { margin: 0; padding: 12px 14px; width: 100%; font: 12.5px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); text-align: left; }
.pv-hex { white-space: pre; overflow-x: auto; overflow-wrap: normal; font-size: 12px; }
.pv-hex .o { color: var(--text-3); } .pv-hex .a { color: var(--accent); }
.pv-page { width: 100%; background: var(--surface); padding: 18px 22px; font-family: Georgia, "Times New Roman", serif; font-size: 14px; line-height: 1.6; text-align: left; }
.pv-page p { margin: 0 0 .8em; white-space: pre-wrap; } .pv-page h4 { margin: 1em 0 .4em; font-family: var(--font); font-size: 16px; }
.pv-sheet { width: 100%; overflow: auto; text-align: left; }
.pv-sheet table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
.pv-sheet th, .pv-sheet td { border: 1px solid var(--line); padding: 4px 8px; white-space: nowrap; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
.pv-sheet th { background: var(--surface); color: var(--text-3); font-weight: 500; position: sticky; top: 0; }
.pv-slide { width: calc(100% - 20px); margin: 10px; padding: 14px 16px; background: var(--surface); border-radius: 10px; box-shadow: 0 0 0 1px var(--line); text-align: left; }
.pv-slide small { color: var(--text-3); } .pv-slide h4 { margin: 4px 0 6px; font-size: 16px; } .pv-slide p { margin: 0 0 4px; font-size: 13.5px; }
.pv-zip { list-style: none; margin: 0; padding: 4px 0; width: 100%; text-align: left; font-size: 13px; }
.pv-zip li { display: flex; justify-content: space-between; gap: 12px; padding: 5px 14px; border-bottom: 1px solid var(--line); }
.pv-zip li span { overflow-wrap: anywhere; } .pv-zip small { color: var(--text-3); flex: none; }
.pv-pdfbar { position: sticky; top: 0; z-index: 2; display: flex; gap: 10px; align-items: center; justify-content: center; padding: 6px; width: 100%; background: var(--side); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
dialog.lightbox { width: min(1180px, 96vw); height: min(90vh, 920px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
dialog.lightbox[open] { display: grid; }
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
.lb-head .ttl { flex: 1; min-width: 0; } .lb-head .ttl b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lb-head .ttl small { color: var(--text-2); }
.lb-body { padding: 12px; min-height: 0; }
.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; } }
+168 -156
View File
@@ -1,6 +1,8 @@
// 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 { createExplorer } from './explorer.js';
const tauri = window.__TAURI__;
const params = new URLSearchParams(location.search);
let token = params.get('token');
@@ -21,6 +23,33 @@ async function rpc(method, p = {}) {
return body;
}
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).
function setHTML(el, html) {
if (!el || el._h === html) return false;
el._h = html;
el.innerHTML = html;
return true;
}
// ------------------------------------------------------------------ tema
function applyTheme(t) {
const root = document.documentElement;
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme;
document.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === (t || 'auto'))));
}
try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { applyTheme('auto'); }
document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
const t = b.dataset.themePick;
applyTheme(t);
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
try { await rpc('settings_set', { theme: t }); } catch { /* segue só com o armazenamento local */ }
}));
// ------------------------------------------------------------------ utilidades
const $ = (s, el = document) => el.querySelector(s);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -35,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'],
@@ -75,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>`;
@@ -125,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');
@@ -169,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"><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>
<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>
<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>
@@ -186,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();
@@ -224,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)]));
@@ -244,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>
@@ -259,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) {
@@ -284,77 +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());
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(); };
paintPreview();
$('#progressArea').onclick = onProgressClick;
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;
@@ -366,122 +444,54 @@ 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;
$('#progressArea').innerHTML = `<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" id="pauseBtn">Pausar</button><button class="btn sm danger" id="stopBtn">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" id="resumeBtn">Continuar</button>' : ''}
${!running && st.found ? '<button class="btn sm" id="saveBtn">Salvar sessão…</button>' : ''}</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">
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></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>confiança alta</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>`;
$('#pauseBtn')?.addEventListener('click', async () => { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); });
$('#stopBtn')?.addEventListener('click', async () => { await rpc('scan_stop', { job: S.job }); });
$('#resumeBtn')?.addEventListener('click', async () => { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); });
$('#saveBtn')?.addEventListener('click', async () => {
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 (e) { toast(e.message); } }
});
$('#warnArea').innerHTML = (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;
$('#chips').innerHTML = [`<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('');
$('#chips').querySelectorAll('.chip').forEach((c) => (c.onclick = () => { S.filters.category = c.dataset.c || null; resetSel(); tick(); }));
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
paintBar();
</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>` : ''));
}
function paintList() {
const el = $('#table');
if (!el) return;
const st = S.status;
if (!S.items.length) {
el.innerHTML = `<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>`;
return;
}
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
el.innerHTML = `<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((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>`;
}).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>` : '');
$('#selAll').onchange = (e) => {
if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel();
paintList(); paintBar();
};
el.querySelectorAll('.tr').forEach((row) => {
const id = +row.dataset.id;
const cb = $('input', row);
cb.onclick = (e) => { e.stopPropagation(); if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); } cb.checked ? S.selected.add(id) : S.selected.delete(id); paintBar(); row.setAttribute('aria-selected', cb.checked); };
row.onclick = () => focusItem(S.items.find((i) => i.id === id));
row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusItem(S.items.find((i) => i.id === id)); } };
});
$('#moreBtn')?.addEventListener('click', () => loadResults(false));
paintBar();
}
async function focusItem(item) {
S.current = item;
document.querySelectorAll('.tr').forEach((r) => r.setAttribute('aria-selected', (+r.dataset.id === item.id) || S.selected.has(+r.dataset.id) || S.allFiltered));
paintPreview();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(item.ext) && item.size < 8 * 1024 * 1024) {
async function onProgressClick(e) {
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
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 === '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 p = await rpc('preview', { job: S.job, id: item.id });
if (S.current?.id === item.id && p.mime) $('.thumb', $('#preview')).innerHTML = `<img alt="Pré-visualização de ${esc(item.name)}" src="data:${p.mime};base64,${p.base64}">`;
} catch { /* sem prévia */ }
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 paintPreview() {
const el = $('#preview');
if (!el) return;
const i = S.current;
if (!i) { el.innerHTML = `<div class="thumb">Selecione um arquivo para ver os detalhes</div>`; return; }
const [cl, cc] = CONF[i.confidence];
el.innerHTML = `<div class="thumb">${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}</div>
<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>
<div class="row"><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
$('#recOne').onclick = () => recover({ ids: [i.id] });
}
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 = ''; return; }
const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size
? `<button class="btn sm" id="selFiltered">Selecionar todos os ${S.total.toLocaleString('pt-BR')}</button>` : '';
host.innerHTML = `<div class="actionbar"><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" id="recSel">Recuperar selecionados</button>' : '<button class="btn primary" id="recAll">Recuperar todos da lista</button>'}</div>`;
$('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); });
$('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }));
$('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() }));
}
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) {
@@ -540,6 +550,8 @@ async function openJobFromList(s) { openJob(s); }
const v = await rpc('version');
$('#versionTag').textContent = `v${v.version}`;
S.defaults = await rpc('defaults');
try { const st = await rpc('settings_get'); if (st?.theme) { applyTheme(st.theme); try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } } } catch { /* usa o local */ }
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
} catch (e) {
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
return;
+630
View File
@@ -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 }); },
};
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100" role="img" aria-label="Guará"><defs><linearGradient id="wh" gradientUnits="userSpaceOnUse" x1="58" y1="0" x2="160" y2="100"><stop offset="0" stop-color="#3BE3CC"/> <stop offset="0.45" stop-color="#12B5A8"/> <stop offset="1" stop-color="#0B6A86"/></linearGradient><mask id="wm" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="100"><g shape-rendering="crispEdges"><rect x="80" y="0" width="81" height="100" fill="#fff"/><rect x="72.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="52.38" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="62.38" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="47.38" width="5.24" height="5.24" fill="#fff"/><rect x="57.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="87.38" width="5.24" height="5.24" fill="#fff"/><rect x="74.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="54.88" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="69.88" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="Line truncated

After

Width:  |  Height:  |  Size: 20 KiB

+1 -17
View File
@@ -1,17 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff8a3d"/>
<stop offset="1" stop-color="#d9441c"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="30" fill="url(#g)"/>
<!-- orelhas grandes e focinho longo do lobo-guará -->
<path d="M30 22 L52 50 L38 62 Z" fill="#fff"/>
<path d="M98 22 L76 50 L90 62 Z" fill="#fff"/>
<path d="M38 62 L52 50 Q64 44 76 50 L90 62 L74 96 Q64 108 54 96 Z" fill="#fff"/>
<path d="M54 80 Q64 90 74 80 L69 96 Q64 100 59 96 Z" fill="#3a1608"/>
<circle cx="53" cy="68" r="4" fill="#3a1608"/>
<circle cx="75" cy="68" r="4" fill="#3a1608"/>
<path d="M54 52 Q64 47 74 52 L70 60 Q64 56 58 60 Z" fill="#ffd2b0"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#083344"/></linearGradient><clipPath id="ic"><rect width="128" height="128" rx="29"/></clipPath></defs><rect width="128" height="128" rx="29" fill="url(#bg)"/><g clip-path="url(#ic)"><g transform="translate(-13 18) scale(0.86)"><defs><linearGradient id="ih" gradientUnits="userSpaceOnUse" x1="58" y1="0" x2="160" y2="100"><stop offset="0" stop-color="#d9fff7"/> <stop offset="0.5" stop-color="#8df1e0"/> <stop offset="1" stop-color="#3fc8cf"/></linearGradient><mask id="im" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="100"><g shape-rendering="crispEdges"><rect x="80" y="0" width="81" height="100" fill="#fff"/><rect x="67.38" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="62.38" width="5.24" height="5.24" fill="#fff"/><rect x="67.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="79.88" width="5.24" height="5.24" fill="#fff"/><rect x="74.88" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="52.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="89.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="57.38" width="5.24" height="5.24" fill="#fff"/><rect x="62.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="32.38" width="2.74" height="2.74" fill="#ffLine truncated

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 18 KiB

+2 -1
View File
@@ -26,13 +26,14 @@
<ul id="sessionList" class="list"></ul>
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
</nav>
<footer class="side-foot"><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
<footer class="side-foot"><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
</aside>
<div class="scrim" id="scrim"></div>
<main class="main" id="main" tabindex="-1"></main>
</div>
<dialog id="dlg"></dialog>
<dialog id="lightbox" class="lightbox" aria-label="Pré-visualização"></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="app.js" type="module"></script>
</body>
+297
View File
@@ -0,0 +1,297 @@
// Pré-visualização de arquivos recuperados.
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal.
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']);
const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
export function kindOf(item) {
const e = (item.ext || '').toLowerCase();
if (IMG.has(e)) return 'image';
if (VID.has(e)) return 'video';
if (AUD.has(e)) return 'audio';
if (e === 'pdf') return 'pdf';
if (OFFICE.has(e)) return 'office';
if (ZIPS.has(e)) return 'zip';
if (TXT.has(e)) return 'text';
return 'hex';
}
export const KIND_LABEL = {
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
zip: 'Conteúdo do arquivo compactado', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
};
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = (n) => {
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0, v = n;
while (v >= 1024 && i < 3) { v /= 1024; i++; }
return i ? `${v.toFixed(1)} ${u[i]}` : `${n} B`;
};
async function fetchRange(url, a, b) {
const r = await fetch(url, { headers: { Range: `bytes=${a}-${b}` } });
if (!r.ok) throw new Error(`leitura falhou (${r.status})`);
return new Uint8Array(await r.arrayBuffer());
}
// ------------------------------------------------------------------ ZIP mínimo
async function zipDir(url, size) {
const tailLen = Math.min(size, 66000);
const tail = await fetchRange(url, size - tailLen, size - 1);
const dv = new DataView(tail.buffer);
let e = -1;
for (let i = tail.length - 22; i >= 0; i--) if (dv.getUint32(i, true) === 0x06054b50) { e = i; break; }
if (e < 0) throw new Error('diretório do ZIP não encontrado (arquivo possivelmente incompleto)');
const n = dv.getUint16(e + 10, true);
const cdSize = dv.getUint32(e + 12, true);
const cdOff = dv.getUint32(e + 16, true);
if (cdOff + cdSize > size) throw new Error('diretório do ZIP inconsistente');
const cd = await fetchRange(url, cdOff, cdOff + cdSize - 1);
const cv = new DataView(cd.buffer);
const out = [];
let p = 0;
for (let i = 0; i < n && p + 46 <= cd.length; i++) {
if (cv.getUint32(p, true) !== 0x02014b50) break;
const nl = cv.getUint16(p + 28, true), xl = cv.getUint16(p + 30, true), cl = cv.getUint16(p + 32, true);
out.push({
method: cv.getUint16(p + 10, true), csize: cv.getUint32(p + 20, true), usize: cv.getUint32(p + 24, true),
off: cv.getUint32(p + 42, true), name: new TextDecoder().decode(cd.subarray(p + 46, p + 46 + nl)),
});
p += 46 + nl + xl + cl;
}
return out;
}
async function zipRead(url, e, cap = 8 << 20) {
if (e.usize > cap) throw new Error('parte grande demais');
const lh = await fetchRange(url, e.off, e.off + 29);
const dv = new DataView(lh.buffer);
const start = e.off + 30 + dv.getUint16(26, true) + dv.getUint16(28, true);
const data = e.csize ? await fetchRange(url, start, start + e.csize - 1) : new Uint8Array(0);
if (e.method === 0) return data;
if (e.method !== 8) throw new Error('compressão não suportada');
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
// ------------------------------------------------------------------ renderizadores
function note(host, html, cls = '') {
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
}
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, o que também indica que a chance de recuperação é menor.`, 'bad');
return withHex ? hex(host, item, host.dataset.url, false) : null;
}
async function hex(host, item, url, large) {
const n = Math.min(item.size, large ? 4096 : 1024);
if (!n) { note(host, 'Arquivo vazio.'); return; }
const b = await fetchRange(url, 0, n - 1);
const lines = [];
for (let i = 0; i < b.length; i += 16) {
const row = [...b.subarray(i, i + 16)];
const h = row.map((x) => x.toString(16).padStart(2, '0')).join(' ').padEnd(47);
const a = row.map((x) => (x >= 32 && x < 127 ? String.fromCharCode(x) : '·')).join('');
lines.push(`<span class="o">${i.toString(16).padStart(6, '0')}</span> ${h} <span class="a">${esc(a)}</span>`);
}
host.insertAdjacentHTML('beforeend', `<pre class="pv-hex">${lines.join('\n')}</pre>`);
if (item.size > n) note(host, `Mostrando os primeiros ${human(n)} de ${human(item.size)}.`);
}
async function text(host, item, url, large) {
const cap = large ? 262144 : 24576;
const b = await fetchRange(url, 0, Math.min(item.size, cap) - 1);
const sample = b.subarray(0, 2048);
const bin = sample.filter((x) => x === 0 || (x < 9) ).length;
if (bin > sample.length * 0.02) { note(host, 'Este arquivo não parece ser texto.'); return hex(host, item, url, large); }
let s = new TextDecoder('utf-8', { fatal: false }).decode(b);
if ((s.match(/�/g) || []).length > s.length * 0.02) s = new TextDecoder('windows-1252').decode(b);
host.insertAdjacentHTML('beforeend', `<pre class="pv-text">${esc(s)}</pre>`);
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, thumb) {
await new Promise((resolve) => {
const img = new Image();
img.alt = `Pré-visualização de ${item.name}`;
img.className = 'pv-img';
img.decoding = 'async';
img.onload = () => {
if (state.dead) return resolve();
host.innerHTML = '';
host.appendChild(img);
host.classList.add('zoomable');
img.onclick = () => host.classList.toggle('actual');
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(); };
img.src = url;
});
}
function media(host, item, url, tag, state) {
const el = document.createElement(tag);
el.controls = true;
el.preload = 'metadata';
el.className = `pv-${tag}`;
if (tag === 'video') el.playsInline = true;
el.onerror = () => { if (!state.dead) failure(host, item, `Não foi possível reproduzir este ${tag === 'video' ? 'vídeo' : 'áudio'}.`, true); };
el.src = url;
host.innerHTML = '';
host.appendChild(el);
state.cleanup.push(() => { el.pause?.(); el.removeAttribute('src'); el.load?.(); });
}
let pdfjsPromise;
function loadPdfjs() {
pdfjsPromise ??= import('./vendor/pdf.min.mjs').then((m) => {
m.GlobalWorkerOptions.workerSrc = new URL('./vendor/pdf.worker.min.mjs', import.meta.url).href;
return m;
});
return pdfjsPromise;
}
async function pdf(host, item, url, state, large) {
const pdfjs = await loadPdfjs();
const task = pdfjs.getDocument({ url, disableAutoFetch: true, isEvalSupported: false });
state.cleanup.push(() => task.destroy());
const doc = await task.promise;
if (state.dead) return;
host.innerHTML = '<div class="pv-pdfbar"><button class="btn sm" data-d="-1" aria-label="Página anterior">‹</button><span class="pg"></span><button class="btn sm" data-d="1" aria-label="Próxima página">›</button></div><div class="pv-pdfwrap"><canvas class="pv-canvas"></canvas></div>';
const canvas = host.querySelector('canvas');
const label = host.querySelector('.pg');
let n = 1, busy = false;
async function draw() {
if (busy || state.dead) return;
busy = true;
try {
const page = await doc.getPage(n);
const base = page.getViewport({ scale: 1 });
const width = Math.max(240, (host.clientWidth || 320) - 16);
const scale = (width / base.width) * (large ? 1 : 1);
const dpr = window.devicePixelRatio || 1;
const vp = page.getViewport({ scale: scale * dpr });
canvas.width = vp.width; canvas.height = vp.height;
canvas.style.width = `${vp.width / dpr}px`;
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
label.textContent = `Página ${n} de ${doc.numPages}`;
} catch (e) {
if (!state.dead) { host.innerHTML = ''; failure(host, item, e, true); }
}
busy = false;
}
host.querySelectorAll('[data-d]').forEach((b) => (b.onclick = () => { n = Math.min(doc.numPages, Math.max(1, n + +b.dataset.d)); draw(); }));
await draw();
}
const colIndex = (ref) => [...ref.replace(/\d+/g, '')].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
const colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(65 + ((i - 1) % 26)) + s; i = Math.floor((i - 1) / 26); } return s; };
async function office(host, item, url, state) {
const ents = await zipDir(url, item.size);
const find = (n) => ents.find((e) => e.name === n);
const ext = item.ext.toLowerCase();
let html = '';
if (ext === 'docx') {
const d = xml(await zipRead(url, find('word/document.xml')));
const paras = [...d.getElementsByTagName('w:p')].slice(0, 400).map((p) => {
let t = '';
p.querySelectorAll('t, tab, br').forEach((n) => { t += n.localName === 't' ? n.textContent : n.localName === 'tab' ? '\t' : '\n'; });
const h = p.querySelector('pStyle')?.getAttribute('w:val') || '';
const tag = /^(Heading|Ttulo|Title)/i.test(h) ? 'h4' : 'p';
return t.trim() ? `<${tag}>${esc(t)}</${tag}>` : '';
});
html = `<div class="pv-page">${paras.join('') || '<p class="sub">Sem texto.</p>'}</div>`;
} else if (ext === 'odt') {
const d = xml(await zipRead(url, find('content.xml')));
const els = [...d.getElementsByTagName('text:p'), ...[]].slice(0, 400);
html = `<div class="pv-page">${els.map((p) => (p.textContent.trim() ? `<p>${esc(p.textContent)}</p>` : '')).join('') || '<p class="sub">Sem texto.</p>'}</div>`;
} else if (ext === 'xlsx') {
const ss = find('xl/sharedStrings.xml');
const strings = ss ? [...xml(await zipRead(url, ss)).getElementsByTagName('si')].map((si) => si.textContent) : [];
const sheet = ents.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0];
const d = xml(await zipRead(url, sheet));
const rows = [...d.getElementsByTagName('row')].slice(0, 80);
let maxc = 0;
const grid = rows.map((r) => {
const cells = {};
[...r.getElementsByTagName('c')].forEach((c) => {
const i = colIndex(c.getAttribute('r') || 'A1');
const v = c.getElementsByTagName('v')[0]?.textContent ?? c.getElementsByTagName('t')[0]?.textContent ?? '';
cells[i] = c.getAttribute('t') === 's' ? strings[+v] ?? '' : v;
maxc = Math.max(maxc, i + 1);
});
return [r.getAttribute('r'), cells];
});
maxc = Math.min(maxc, 16);
html = `<div class="pv-sheet"><table><thead><tr><th></th>${Array.from({ length: maxc }, (_, i) => `<th>${colName(i)}</th>`).join('')}</tr></thead><tbody>${grid
.map(([n, cells]) => `<tr><th>${esc(n)}</th>${Array.from({ length: maxc }, (_, i) => `<td>${esc(cells[i] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else if (ext === 'ods') {
const d = xml(await zipRead(url, find('content.xml')));
const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80);
html = `<div class="pv-sheet"><table><tbody>${rows.map((r) => `<tr>${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => `<td>${esc(c.textContent)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else {
// pptx / odp: texto por slide
let slides;
if (ext === 'pptx') {
const files = ents.filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).slice(0, 60);
slides = [];
for (const f of files) {
const d = xml(await zipRead(url, f));
slides.push([...d.getElementsByTagName('a:p')].map((p) => p.textContent).filter((t) => t.trim()));
}
} else {
const d = xml(await zipRead(url, find('content.xml')));
slides = [...d.getElementsByTagName('draw:page')].slice(0, 60).map((pg) => [...pg.getElementsByTagName('text:p')].map((p) => p.textContent).filter((t) => t.trim()));
}
html = slides.map((s, i) => `<div class="pv-slide"><small>Slide ${i + 1}</small>${s.map((t, j) => (j === 0 ? `<h4>${esc(t)}</h4>` : `<p>${esc(t)}</p>`)).join('') || '<p class="sub">Sem texto.</p>'}</div>`).join('');
}
if (state.dead) return;
host.innerHTML = html;
note(host, 'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.');
}
async function zip(host, item, url, state) {
const ents = await zipDir(url, item.size);
if (state.dead) return;
host.innerHTML = `<ul class="pv-zip">${ents.slice(0, 400).map((e) => `<li><span>${esc(e.name)}</span><small>${e.name.endsWith('/') ? 'pasta' : human(e.usize)}</small></li>`).join('')}</ul>`;
note(host, `${ents.length} item(ns) no arquivo compactado.`);
}
// ------------------------------------------------------------------ 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, thumb = false }) {
const state = { dead: false, cleanup: [] };
host.dataset.url = url;
host.classList.remove('zoomable', 'actual');
host.innerHTML = '<div class="pv-loading"><i></i></div>';
const kind = kindOf(item);
(async () => {
try {
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
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);
else if (kind === 'office') await office(host, item, url, state);
else if (kind === 'zip') await zip(host, item, url, state);
else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); }
else { host.innerHTML = ''; await hex(host, item, url, large); }
} catch (e) {
if (!state.dead) await failure(host, item, e, kind !== 'hex' && kind !== 'text');
}
})();
return () => { state.dead = true; state.cleanup.forEach((f) => { try { f(); } catch { /* ignora */ } }); };
}
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+3
View File
@@ -0,0 +1,3 @@
# Bibliotecas de terceiros
- **pdf.js** 4.10.38 (build "legacy"), © Mozilla, licença Apache-2.0 (`PDFJS-LICENSE`). Usada só para pré-visualizar PDFs, localmente e sem internet. Origem: https://github.com/mozilla/pdf.js
+21
View File
@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var t={9306:(t,e,i)=>{var s=i(4901),n=i(6823),r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not a function")}},3506:(t,e,i)=>{var s=i(3925),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r("Can't set "+n(t)+" as a prototype")}},7080:(t,e,i)=>{var s=i(4402).has;t.exports=function(t){s(t);return t}},679:(t,e,i)=>{var s=i(1625),n=TypeError;t.exports=function(t,e){if(s(e,t))return t;throw new n("Incorrect invocation")}},8551:(t,e,i)=>{var s=i(34),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not an object")}},7811:t=>{t.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(t,e,i)=>{var s=i(4576),n=i(6706),r=i(2195),a=s.ArrayBuffer,o=s.TypeError;t.exports=a&&n(a.prototype,"byteLength","get")||function(t){if("ArrayBuffer"!==r(t))throw new o("ArrayBuffer expected");return t.byteLength}},3238:(t,e,i)=>{var s=i(4576),n=i(7476),r=i(7394),a=s.ArrayBuffer,o=a&&a.prototype,l=o&&n(o.slice);t.exports=function(t){if(0!==r(t))return!1;if(!l)return!1;try{l(t,0,0);return!1}catch(t){return!0}}},5169:(t,e,i)=>{var s=i(3238),n=TypeError;t.exports=function(t){if(s(t))throw new n("ArrayBuffer is detached");return t}},5636:(t,e,i)=>{var s=i(4576),n=i(9504),r=i(6706),a=i(7696),o=i(5169),l=i(7394),h=i(4483),c=i(1548),d=s.structuredClone,u=s.ArrayBuffer,p=s.DataView,g=Math.min,f=u.prototype,m=p.prototype,b=n(f.slice),v=r(f,"resizable","get"),y=r(f,"maxByteLength","get"),w=n(m.getInt8),A=n(m.setInt8);t.exports=(c||h)&&function(t,e,i){var s,n=l(t),r=void 0===e?n:a(e),f=!v||!v(t);o(t);if(c){t=d(t,{transfer:[t]});if(n===r&&(i||f))return t}if(n>=r&&(!i||f))s=b(t,0,r);else{var m=i&&!f&&y?{maxByteLength:y(t)}:void 0;s=new u(r,m);for(var x=new p(t),_=new p(s),E=g(r,n),S=0;S<E;S++)A(_,S,w(x,S))}c||h(t);return s}},4644:(t,e,i)=>{var s,n,r,a=i(7811),o=i(3724),l=i(4576),h=i(4901),c=i(34),d=i(9297),u=i(6955),p=i(6823),g=i(6699),f=i(6840),m=i(2106),b=i(1625),v=i(2787),y=i(2967),w=i(8227),A=i(3392),x=i(1181),_=x.enforce,E=x.get,S=l.Int8Array,C=S&&S.prototype,T=l.Uint8ClampedArray,M=T&&T.prototype,P=S&&v(S),D=C&&v(C),k=Object.prototype,R=l.TypeError,I=w("toStringTag"),L=A("TYPED_ARRAY_TAG"),O="TypedArrayConstructor",N=a&&!!y&&"Opera"!==u(l.opera),B=!1,H={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},U={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(t){var e=v(t);if(c(e)){var i=E(e);return i&&d(i,O)?i[O]:getTypedArrayConstructor(e)}},isTypedArray=function(t){if(!c(t))return!1;var e=u(t);return d(H,e)||d(U,e)};for(s in H)(r=(n=l[s])&&n.prototype)?_(r)[O]=n:N=!1;for(s in U)(r=(n=l[s])&&n.prototype)&&(_(r)[O]=n);if(!N||!h(P)||P===Function.prototype){P=function TypedArray(){throw new R("Incorrect invocation")};if(N)for(s in H)l[s]&&y(l[s],P)}if(!N||!D||D===k){D=P.prototype;if(N)for(s in H)l[s]&&y(l[s].prototype,D)}N&&v(M)!==D&&y(M,D);if(o&&!d(D,I)){B=!0;m(D,I,{configurable:!0,get:function(){return c(this)?this[L]:void 0}});for(s in H)l[s]&&g(l[s],L,s)}t.exports={NATIVE_ARRAY_BUFFER_VIEWS:N,TYPED_ARRAY_TAG:B&&L,aTypedArray:function(t){if(isTypedArray(t))return t;throw new R("Target is not a typed array")},aTypedArrayConstructor:function(t){if(h(t)&&(!y||b(P,t)))return t;throw new R(p(t)+" is not a typed array constructor")},exportTypedArrayMethod:function(t,e,i,s){if(o){if(i)for(var n in H){var r=l[n];if(r&&d(r.prototype,t))try{delete r.prototype[t]}catch(i){try{r.prototype[t]=e}catch(t){}}}D[t]&&!i||f(D,t,i?e:N&&C[t]||e,s)}},exportTypedArrayStaticMethod:function(t,e,i){var s,n;if(o){if(y){if(i)for(s in H)if((n=l[s])&&d(n,t))try{delete n[t]}catch(t){}if(P[t]&&!i)return;try{return f(P,t,i?e:N&&P[t]||e)}catch(t){}}for(s in H)!(n=l[s])||n[t]&&!i||f(n,t,e)}},getTypedArrayConstructor,isView:function isView(t){if(!c(t))return!1;var e=u(t);return"DataView"===e||d(H,e)||d(U,e)},isTypedArray,TypedArray:P,TypedArrayPrototype:D}},5370:(t,e,i)=>{var s=i(6198);t.exports=function(t,e,i){for(var n=0,r=arguments.length>2?i:s(e),a=new t(r);r>n;)a[n]=e[n++];return a}},9617:(t,e,i)=>{var s=i(5397),n=i(5610),r=i(6198),createMethod=function(t){return function(e,i,a){var o=s(e),l=r(o);if(0===l)return!t&&-1;var h,c=n(a,l);if(t&&i!=i){for(;l>c;)if((h=o[c++])!=h)return!0}else for(;l>c;c++)if((t||c in o)&&o[c]===i)return t||c||0;return!t&&-1}};t.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(t,e,i)=>{var s=i(3724),n=i(4376),r=TypeError,a=Object.getOwnPropertyDescriptor,o=s&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(t){return t instanceof TypeError}}();t.exports=o?function(t,e){if(n(t)&&!a(t,"length").writable)throw new r("Cannot set read only .length");return t.length=e}:function(t,e){return t.length=e}},7680:(t,e,i)=>{var s=i(9504);t.exports=s([].slice)},7628:(t,e,i)=>{var s=i(6198);t.exports=function(t,e){for(var i=s(t),n=new e(i),r=0;r<i;r++)n[r]=t[i-r-1];return n}},9928:(t,e,i)=>{Line truncated
+21
View File
@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var e={9306:(e,t,i)=>{var a=i(4901),r=i(6823),s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not a function")}},3506:(e,t,i)=>{var a=i(3925),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s("Can't set "+r(e)+" as a prototype")}},7080:(e,t,i)=>{var a=i(4402).has;e.exports=function(e){a(e);return e}},679:(e,t,i)=>{var a=i(1625),r=TypeError;e.exports=function(e,t){if(a(t,e))return e;throw new r("Incorrect invocation")}},8551:(e,t,i)=>{var a=i(34),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not an object")}},7811:e=>{e.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(e,t,i)=>{var a=i(4576),r=i(6706),s=i(2195),n=a.ArrayBuffer,o=a.TypeError;e.exports=n&&r(n.prototype,"byteLength","get")||function(e){if("ArrayBuffer"!==s(e))throw new o("ArrayBuffer expected");return e.byteLength}},3238:(e,t,i)=>{var a=i(4576),r=i(7476),s=i(7394),n=a.ArrayBuffer,o=n&&n.prototype,g=o&&r(o.slice);e.exports=function(e){if(0!==s(e))return!1;if(!g)return!1;try{g(e,0,0);return!1}catch(e){return!0}}},5169:(e,t,i)=>{var a=i(3238),r=TypeError;e.exports=function(e){if(a(e))throw new r("ArrayBuffer is detached");return e}},5636:(e,t,i)=>{var a=i(4576),r=i(9504),s=i(6706),n=i(7696),o=i(5169),g=i(7394),c=i(4483),l=i(1548),h=a.structuredClone,C=a.ArrayBuffer,Q=a.DataView,E=Math.min,u=C.prototype,d=Q.prototype,f=r(u.slice),p=s(u,"resizable","get"),m=s(u,"maxByteLength","get"),y=r(d.getInt8),w=r(d.setInt8);e.exports=(l||c)&&function(e,t,i){var a,r=g(e),s=void 0===t?r:n(t),u=!p||!p(e);o(e);if(l){e=h(e,{transfer:[e]});if(r===s&&(i||u))return e}if(r>=s&&(!i||u))a=f(e,0,s);else{var d=i&&!u&&m?{maxByteLength:m(e)}:void 0;a=new C(s,d);for(var b=new Q(e),D=new Q(a),S=E(s,r),k=0;k<S;k++)w(D,k,y(b,k))}l||c(e);return a}},4644:(e,t,i)=>{var a,r,s,n=i(7811),o=i(3724),g=i(4576),c=i(4901),l=i(34),h=i(9297),C=i(6955),Q=i(6823),E=i(6699),u=i(6840),d=i(2106),f=i(1625),p=i(2787),m=i(2967),y=i(8227),w=i(3392),b=i(1181),D=b.enforce,S=b.get,k=g.Int8Array,R=k&&k.prototype,N=g.Uint8ClampedArray,G=N&&N.prototype,M=k&&p(k),U=R&&p(R),x=Object.prototype,L=g.TypeError,H=y("toStringTag"),J=w("TYPED_ARRAY_TAG"),v="TypedArrayConstructor",Y=n&&!!m&&"Opera"!==C(g.opera),K=!1,T={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},q={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(e){var t=p(e);if(l(t)){var i=S(t);return i&&h(i,v)?i[v]:getTypedArrayConstructor(t)}},isTypedArray=function(e){if(!l(e))return!1;var t=C(e);return h(T,t)||h(q,t)};for(a in T)(s=(r=g[a])&&r.prototype)?D(s)[v]=r:Y=!1;for(a in q)(s=(r=g[a])&&r.prototype)&&(D(s)[v]=r);if(!Y||!c(M)||M===Function.prototype){M=function TypedArray(){throw new L("Incorrect invocation")};if(Y)for(a in T)g[a]&&m(g[a],M)}if(!Y||!U||U===x){U=M.prototype;if(Y)for(a in T)g[a]&&m(g[a].prototype,U)}Y&&p(G)!==U&&m(G,U);if(o&&!h(U,H)){K=!0;d(U,H,{configurable:!0,get:function(){return l(this)?this[J]:void 0}});for(a in T)g[a]&&E(g[a],J,a)}e.exports={NATIVE_ARRAY_BUFFER_VIEWS:Y,TYPED_ARRAY_TAG:K&&J,aTypedArray:function(e){if(isTypedArray(e))return e;throw new L("Target is not a typed array")},aTypedArrayConstructor:function(e){if(c(e)&&(!m||f(M,e)))return e;throw new L(Q(e)+" is not a typed array constructor")},exportTypedArrayMethod:function(e,t,i,a){if(o){if(i)for(var r in T){var s=g[r];if(s&&h(s.prototype,e))try{delete s.prototype[e]}catch(i){try{s.prototype[e]=t}catch(e){}}}U[e]&&!i||u(U,e,i?t:Y&&R[e]||t,a)}},exportTypedArrayStaticMethod:function(e,t,i){var a,r;if(o){if(m){if(i)for(a in T)if((r=g[a])&&h(r,e))try{delete r[e]}catch(e){}if(M[e]&&!i)return;try{return u(M,e,i?t:Y&&M[e]||t)}catch(e){}}for(a in T)!(r=g[a])||r[e]&&!i||u(r,e,t)}},getTypedArrayConstructor,isView:function isView(e){if(!l(e))return!1;var t=C(e);return"DataView"===t||h(T,t)||h(q,t)},isTypedArray,TypedArray:M,TypedArrayPrototype:U}},5370:(e,t,i)=>{var a=i(6198);e.exports=function(e,t,i){for(var r=0,s=arguments.length>2?i:a(t),n=new e(s);s>r;)n[r]=t[r++];return n}},9617:(e,t,i)=>{var a=i(5397),r=i(5610),s=i(6198),createMethod=function(e){return function(t,i,n){var o=a(t),g=s(o);if(0===g)return!e&&-1;var c,l=r(n,g);if(e&&i!=i){for(;g>l;)if((c=o[l++])!=c)return!0}else for(;g>l;l++)if((e||l in o)&&o[l]===i)return e||l||0;return!e&&-1}};e.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(e,t,i)=>{var a=i(3724),r=i(4376),s=TypeError,n=Object.getOwnPropertyDescriptor,o=a&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(e){return e instanceof TypeError}}();e.exports=o?function(e,t){if(r(e)&&!n(e,"length").writable)throw new s("Cannot set read only .length");return e.length=t}:function(e,t){return e.length=t}},7680:(e,t,i)=>{var a=i(9504);e.exports=a([].slice)},7628:(e,t,i)=>{var a=i(6198);e.exports=function(e,t){for(var i=a(e),r=new t(i),s=0;s<i;s++)r[s]=e[i-s-1];return r}},9928:(e,t,i)=>{Line truncated
+160 -4
View File
@@ -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"]
);
+135 -20
View File
@@ -3,12 +3,18 @@
use guara_core::service::Engine;
use serde_json::json;
use std::io::Read;
use tiny_http::{Header, Method, Response, Server};
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
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");
pub fn run(port: u16, open: bool) -> Result<(), String> {
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?;
@@ -19,55 +25,166 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
.unwrap_or(port);
let mut tok = [0u8; 16];
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
let token: String = tok.iter().map(|b| format!("{b:02x}")).collect();
let token: Arc<str> = tok
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
.into();
let url = format!("http://127.0.0.1:{real_port}/?token={token}");
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
if open {
open_browser(&url);
}
let eng = Engine::new();
let hosts = [
let eng = Arc::new(Engine::new());
let hosts: Arc<[String; 2]> = Arc::new([
format!("127.0.0.1:{real_port}"),
format!("localhost:{real_port}"),
];
for mut req in server.incoming_requests() {
]);
for req in server.incoming_requests() {
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
}
Ok(())
}
fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
}
fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
let host_ok = req
.headers()
.iter()
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
if !host_ok {
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
continue;
return;
}
let path = req.url().split('?').next().unwrap_or("/").to_string();
let ct = |v: &str| Header::from_bytes("Content-Type", v).unwrap();
match (req.method().clone(), path.as_str()) {
let full = req.url().to_string();
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
let ct = |v: &str| header("Content-Type", v);
match (req.method().clone(), path) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
let _ = req.respond(
Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")),
);
let _ = req
.respond(Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")));
}
(Method::Get, "/app.css") => {
let _ = req
.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
let _ =
req.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
}
(Method::Get, "/app.js") => {
let _ = req.respond(
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/preview.js") => {
let _ = req.respond(
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(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")));
}
(Method::Get, "/vendor/pdf.min.mjs") => {
let _ = req.respond(
Response::from_string(PDF_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/vendor/pdf.worker.min.mjs") => {
let _ = req.respond(
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('&')
.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("/file/").split('/');
let (job, id) = (
it.next().unwrap_or(""),
it.next().and_then(|s| s.parse::<u64>().ok()),
);
let range = req
.headers()
.iter()
.find(|h| h.field.equiv("Range"))
.map(|h| h.value.as_str().to_string());
let Some(id) = id else {
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
return;
};
match eng.file_slice(job, id, range.as_deref()) {
Ok(slice) => {
let (status, len) = (slice.status(), slice.len() as usize);
let headers: Vec<Header> =
slice.headers().iter().map(|(k, v)| header(k, v)).collect();
let resp =
Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
let _ = req.respond(resp);
}
Err(code) => {
let _ =
req.respond(Response::from_string("indisponível").with_status_code(code));
}
}
}
(Method::Post, p) if p.starts_with("/rpc/") => {
let ok = req
.headers()
.iter()
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
if !ok {
let _ =
req.respond(Response::from_string("token inválido").with_status_code(401));
continue;
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
return;
}
let method = p.trim_start_matches("/rpc/").to_string();
let mut body = String::new();
@@ -88,8 +205,6 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
}
}
}
Ok(())
}
fn open_browser(url: &str) {
#[cfg(target_os = "linux")]
+6
View File
@@ -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]
+368
View File
@@ -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)
}
+10
View File
@@ -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,
},
+17 -3
View File
@@ -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,
};
+9
View File
@@ -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,
}
}
+10
View File
@@ -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,
+45
View File
@@ -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()
}
+10 -7
View File
@@ -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)
+8 -1
View File
@@ -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,14 +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};
+952
View File
@@ -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("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
/// "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)
}
+8 -9
View File
@@ -92,17 +92,16 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
let root_secs = (root_entries * 32).div_ceil(bps);
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
let clusters = data / spc;
let kind = if clusters < 4085 {
FsKind::Fat12
} else if clusters < 65525 {
FsKind::Fat16
} else {
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta).
let is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
let kind = if is32 {
FsKind::Fat32
} else if clusters < 4085 {
FsKind::Fat12
} else {
FsKind::Fat16
};
// Coerência: FAT32 tem root_entries == 0 e fat16 == 0.
if (kind == FsKind::Fat32) != (root_entries == 0 && fat16 == 0) {
return None;
}
Some((kind, Some(total * bps)))
}
+52 -11
View File
@@ -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,9 +210,44 @@ 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`
/// (usado para streaming/pré-visualização sem gravar em disco).
pub fn read_found_at(
src: &dyn Source,
f: &Found,
offset: u64,
buf: &mut [u8],
) -> std::io::Result<usize> {
if offset >= f.size || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((f.size - offset) as usize);
match &f.data {
DataLoc::Resident { bytes } => {
let o = offset as usize;
let n = want.min(bytes.len().saturating_sub(o));
buf[..n].copy_from_slice(&bytes[o..o + n]);
Ok(n)
}
DataLoc::Extents { extents } => {
let mut pos = 0u64;
for e in extents {
let elen = e.len.min(f.size.saturating_sub(pos));
if offset < pos + elen {
let within = offset - pos;
let n = want.min((elen - within) as usize);
if e.sparse {
buf[..n].fill(0);
return Ok(n);
}
return src.read_at(e.offset + within, &mut buf[..n]);
}
pos += elen;
}
Ok(0)
}
}
}
+306
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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
}
+7
View File
@@ -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 {
+194
View File
@@ -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
}
+506 -124
View File
@@ -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,84 +254,75 @@ 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 }))
}
pub fn preview(&self, id: &str, file: u64) -> Result<Value> {
/// 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)?;
let (f, src) = {
let s = j.st.lock().unwrap();
let i = *s
.index
.get(&file)
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
(
Ok((
s.found[i].clone(),
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
)
};
let mime = match f.ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
_ => return Ok(json!({ "mime": null })),
};
if f.size > 8 * 1024 * 1024 {
return Ok(json!({ "mime": null, "reason": "grande demais para pré-visualizar" }));
}
let tmp =
std::env::temp_dir().join(format!("guara-preview-{}-{}", std::process::id(), f.id));
let r = recover::write_file(src.as_ref(), &f, &tmp);
let bytes = std::fs::read(&tmp);
let _ = std::fs::remove_file(&tmp);
r?;
Ok(json!({ "mime": mime, "base64": base64(&bytes?) }))
))
}
// ---------------------------------------------------------------- recuperação
@@ -329,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(),
}
};
@@ -363,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| {
@@ -379,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 {
@@ -441,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<()> {
@@ -455,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(())
@@ -477,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
@@ -484,6 +636,9 @@ impl Engine {
Status::Paused
};
}
if data.done {
spawn_enrich(job.clone());
}
Ok(job.id.clone())
}
@@ -525,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")
@@ -534,12 +732,12 @@ impl Engine {
.unwrap_or_default();
self.results(&s("job")?, &q)?
}
"preview" => self.preview(
&s("job")?,
p.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| Error::msg("id ausente"))?,
)?,
"settings_get" => json!(crate::settings::load()),
"settings_set" => {
let st: crate::settings::Settings = serde_json::from_value(p.clone())?;
crate::settings::save(&st)?;
json!({})
}
"recover_start" => {
let ids: Option<Vec<u64>> = p
.get("ids")
@@ -575,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 {
@@ -618,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 {
@@ -672,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;
});
}
@@ -697,3 +907,175 @@ pub fn base64(data: &[u8]) -> String {
}
out
}
/// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar
/// script (HTML/SVG/XML) saem como texto puro.
pub fn mime_for(ext: &str) -> &'static str {
match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"webm" => "video/webm",
"mkv" => "video/x-matroska",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" | "opus" => "audio/ogg",
"m4a" => "audio/mp4",
"aac" => "audio/aac",
"flac" => "audio/flac",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
/// Trecho de um arquivo achado, pronto para responder a uma requisição HTTP
/// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri.
pub struct FileSlice {
found: Found,
src: Arc<dyn Source>,
pub start: u64,
/// Inclusivo.
pub end: u64,
pub total: u64,
pub partial: bool,
}
impl FileSlice {
pub fn len(&self) -> u64 {
if self.total == 0 {
0
} else {
self.end - self.start + 1
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn status(&self) -> u16 {
if self.partial {
206
} else {
200
}
}
pub fn headers(&self) -> Vec<(&'static str, String)> {
let mut h = vec![
("Content-Type", mime_for(&self.found.ext).to_string()),
("Accept-Ranges", "bytes".into()),
("Cache-Control", "no-store".into()),
("X-Content-Type-Options", "nosniff".into()),
// Conteúdo recuperado é não confiável: nunca executa script nem carrega nada.
(
"Content-Security-Policy",
"sandbox; default-src 'none'".into(),
),
("Access-Control-Allow-Origin", "*".into()),
("Content-Disposition", "inline".into()),
];
if self.partial {
h.push((
"Content-Range",
format!("bytes {}-{}/{}", self.start, self.end, self.total),
));
}
h
}
/// Lê o trecho inteiro em memória (limitado a `cap` bytes).
pub fn read_all(&self, cap: u64) -> std::io::Result<Vec<u8>> {
let n = self.len().min(cap) as usize;
let mut out = vec![0u8; n];
let mut got = 0;
while got < n {
let r = crate::recover::read_found_at(
self.src.as_ref(),
&self.found,
self.start + got as u64,
&mut out[got..],
)?;
if r == 0 {
break;
}
got += r;
}
out.truncate(got);
Ok(out)
}
pub fn reader(self) -> FileSliceReader {
FileSliceReader {
pos: self.start,
slice: self,
}
}
}
pub struct FileSliceReader {
slice: FileSlice,
pos: u64,
}
impl std::io::Read for FileSliceReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos > self.slice.end || self.slice.total == 0 {
return Ok(0);
}
let left = (self.slice.end - self.pos + 1) as usize;
let n = buf.len().min(left).min(1 << 20);
let r = crate::recover::read_found_at(
self.slice.src.as_ref(),
&self.slice.found,
self.pos,
&mut buf[..n],
)?;
self.pos += r as u64;
Ok(r)
}
}
impl Engine {
/// Resolve `/file/<job>/<id>` + cabeçalho Range. `Err(status)` em caso de falha.
pub fn file_slice(
&self,
job: &str,
id: u64,
range: Option<&str>,
) -> std::result::Result<FileSlice, u16> {
let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?;
let total = found.size;
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
let spec = spec.split(',').next().unwrap_or("");
if let Some((a, b)) = spec.split_once('-') {
let parsed = match (a.trim().parse::<u64>(), b.trim().parse::<u64>()) {
(Ok(a), Ok(b)) => Some((a, b.min(total.saturating_sub(1)))),
(Ok(a), Err(_)) if b.trim().is_empty() => Some((a, total.saturating_sub(1))),
(Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => {
Some((total.saturating_sub(n), total.saturating_sub(1)))
}
_ => None,
};
if let Some((a, b)) = parsed {
if a >= total || a > b {
return Err(416);
}
start = a;
end = b;
partial = true;
}
}
}
Ok(FileSlice {
found,
src,
start,
end,
total,
partial,
})
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Preferências do usuário (tema etc.), compartilhadas entre o app normal e o
//! reiniciado como administrador.
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// "auto", "light" ou "dark".
pub theme: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: "auto".into(),
}
}
}
/// Pasta de configuração do usuário *real*, mesmo quando rodando como root
/// via sudo/pkexec (para o tema não "mudar" ao elevar privilégios).
pub fn config_dir() -> PathBuf {
if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") {
if !d.is_empty() {
return PathBuf::from(d);
}
}
#[cfg(unix)]
{
if let Some(home) = invoking_user_home() {
return default_dir(home);
}
}
default_dir(PathBuf::from(
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default(),
))
}
fn default_dir(home: PathBuf) -> PathBuf {
#[cfg(windows)]
{
if let Ok(a) = std::env::var("APPDATA") {
return PathBuf::from(a).join("Guara");
}
home.join("AppData").join("Roaming").join("Guara")
}
#[cfg(target_os = "macos")]
{
home.join("Library")
.join("Application Support")
.join("Guara")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
match std::env::var("XDG_CONFIG_HOME") {
Ok(x)
if !x.is_empty()
&& std::env::var("SUDO_USER").is_err()
&& std::env::var("PKEXEC_UID").is_err() =>
{
PathBuf::from(x).join("guara")
}
_ => home.join(".config").join("guara"),
}
}
}
/// Se somos root por sudo/pkexec, a home de quem invocou.
#[cfg(unix)]
fn invoking_user_home() -> Option<PathBuf> {
// SAFETY: geteuid não tem pré-condições.
if unsafe { libc::geteuid() } != 0 {
return None;
}
let user = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
let uid = std::env::var("PKEXEC_UID").ok();
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
for l in passwd.lines() {
let f: Vec<&str> = l.split(':').collect();
if f.len() >= 6 && (user.as_deref() == Some(f[0]) || uid.as_deref() == Some(f[2])) {
return Some(PathBuf::from(f[5]));
}
}
None
}
fn file() -> PathBuf {
config_dir().join("settings.json")
}
pub fn load() -> Settings {
std::fs::read(file())
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
pub fn save(s: &Settings) -> Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir)?;
let path = file();
std::fs::write(&path, serde_json::to_vec_pretty(s)?)?;
#[cfg(unix)]
{
// Como root, devolve a posse ao dono da pasta para o app normal conseguir regravar.
use std::os::unix::fs::MetadataExt;
if unsafe { libc::geteuid() } == 0 {
if let Ok(m) = std::fs::metadata(&dir) {
let _ = std::os::unix::fs::chown(&path, Some(m.uid()), Some(m.gid()));
}
}
}
Ok(())
}
+126
View File
@@ -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)
}
+25
View File
@@ -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 {
+71
View File
@@ -231,3 +231,74 @@ fn pausa_e_retoma_encontra_o_mesmo() {
b.sort();
assert_eq!(a, b);
}
/// Streaming de pré-visualização: Range devolve exatamente os bytes do arquivo recuperado.
#[test]
fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
use std::io::Read;
let Some(dir) = fixtures() else { return };
let path = dir.join("ntfs.img");
let eng = service::Engine::new();
let job = eng
.start_scan(
path.to_str().unwrap(),
ScanOptions {
mode: ScanMode::Quick,
..Default::default()
},
)
.unwrap();
loop {
let st = eng.status(&job).unwrap();
if st["status"] != "running" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let res = eng
.results(
&job,
&service::Query {
query: Some("relatorio.pdf".into()),
..Default::default()
},
)
.unwrap();
let id = res["items"][0]["id"].as_u64().unwrap();
let size = res["items"][0]["size"].as_u64().unwrap();
let want = expected(&dir, "ntfs")
.into_iter()
.find(|(_, p)| p.ends_with("relatorio.pdf"))
.unwrap()
.0;
let full = eng.file_slice(&job, id, None).unwrap();
assert_eq!((full.status(), full.len()), (200, size));
let mut all = vec![];
full.reader().read_to_end(&mut all).unwrap();
assert_eq!(sha256(&all), want);
let part = eng.file_slice(&job, id, Some("bytes=100-199")).unwrap();
assert_eq!((part.status(), part.len()), (206, 100));
assert_eq!(part.read_all(1 << 20).unwrap(), all[100..200]);
let tail = eng.file_slice(&job, id, Some("bytes=-50")).unwrap();
assert_eq!(tail.read_all(1 << 20).unwrap(), all[all.len() - 50..]);
assert_eq!(
eng.file_slice(&job, id, Some(&format!("bytes={size}-")))
.err(),
Some(416)
);
assert_eq!(eng.file_slice(&job, 999_999, None).err(), Some(404));
}
#[test]
fn configuracoes_de_tema_persistem() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("GUARA_CONFIG_DIR", dir.path());
assert_eq!(settings::load().theme, "auto");
settings::save(&settings::Settings {
theme: "dark".into(),
})
.unwrap();
assert_eq!(settings::load().theme, "dark");
}
+507
View File
@@ -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 &amp; 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("&lt;script&gt;alert(1)&lt;/script&gt;.txt"));
assert!(html.contains("2026-001") && html.contains("Perita &lt;Ana&gt;"));
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
}
+64
View File
@@ -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!");
}
@@ -86,3 +95,58 @@ fn varredura_rapida_nao_faz_carving() {
7
);
}
/// Regressão: mkfs.vfat -F 32 em mídia pequena (< 65525 clusters) é FAT32 pelo BPB.
#[test]
fn fat32_pequeno_com_poucos_clusters_e_reconhecido() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let img = dir.path().join("p.img");
let ok = Command::new("truncate")
.args(["-s", "32M"])
.arg(&img)
.status()
.map(|s| s.success())
.unwrap_or(false)
&& Command::new("mkfs.vfat")
.args(["-F", "32"])
.arg(&img)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let file = dir.path().join("arquivo.txt");
std::fs::write(&file, b"conteudo de teste\n".repeat(500)).unwrap();
let ok = ok
&& Command::new("mcopy")
.arg("-i")
.arg(&img)
.arg(&file)
.arg("::/")
.status()
.map(|s| s.success())
.unwrap_or(false)
&& Command::new("mdel")
.arg("-i")
.arg(&img)
.arg("::/arquivo.txt")
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
eprintln!("SKIP: mtools/dosfstools ausentes");
return;
}
let res = scan_path(
&img,
&ScanOptions {
mode: ScanMode::Quick,
..Default::default()
},
);
let layout = res.layout.unwrap();
assert_eq!(layout.partitions[0].fs, FsKind::Fat32);
assert_eq!(res.found.len(), 1);
let out = tempfile::tempdir().unwrap();
recover_all(&img, &res.found, out.path());
assert!(hashes_in(out.path()).contains_key(&sha256(&b"conteudo de teste\n".repeat(500))));
}
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Gera a identidade visual do Guará.
Conceito: pixels de tamanhos distintos, soltos à esquerda, que se juntam numa
grade cada vez mais densa até formar a cabeça de um lobo-guará à direita.
- O lobo é uma silhueta VETORIAL suave (curvas), sutil, sem serrilhado.
- À esquerda a silhueta é recortada por uma máscara de quadrados de tamanhos
distintos que ficam cada vez mais densos até se fundirem no lobo inteiro.
- Partículas soltas (quadrados) completam a dissolução fora da silhueta.
Uso: python3 scripts/make-logo.py -> apps/gui/ui/guara-logo.svg e guara.svg
"""
import math
import random
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "apps/gui/ui"
W, H = 160.0, 100.0
CELL = 2.5 # módulo da grade de pixels
COLS, ROWS = int(W / CELL), int(H / CELL)
# --- silhueta -------------------------------------------------------------
# Cabeça de lobo-guará (3/4, olhando para a direita): orelhas enormes, focinho
# longo e fino, e a juba escorrendo para a esquerda (é ela que se desmancha em
# pixels). Coordenadas 0..100 na altura; x pode ser negativo (juba).
WOLF = [
(100, 57), (97.5, 54), (91, 51), (83, 48), (75, 45), (69, 41), (65, 36), (62, 29), (59, 20), (56, 11),
(52.5, 4), (49, 0.5), (46, 4), (43, 13), (41, 23), (40, 32), (38, 31), (35, 22), (31, 11), (27.5, 3),
(24.5, 0.5), (22, 5), (20, 15), (19, 27), (9, 35), (-6, 45), (-20, 58), (-30, 76), (-34, 100), (-10, 100), (20, 100), (44, 100),
(60, 100), (59.2, 95), (59.6, 89), (62, 82), (65, 78), (72, 74), (82, 71.5), (92, 69), (98.5, 66.5), (100, 63),
]
# Interior das orelhas (escuro, como no animal real).
EAR_IN = [
[(53, 9), (50, 3), (47, 10), (45, 20), (47, 27), (51, 24), (54, 15)],
[(27.5, 8), (25, 4), (23, 14), (22, 24), (25, 27), (28, 20)],
]
WX0, WSCALE = 64.0, 88.0 # nariz em x=152 (margem de 8 à direita)
Y0, YS = 6.0, 88.0 # topo das orelhas em y=6, base em y=94 # x do desenho onde wx=0 e largura de 100 unidades do lobo
def to_wolf(x, y):
return (x - WX0) * 100.0 / WSCALE, y * 100.0 / H
def wolf_at(x, y):
wx, wy = to_wolf(x, y)
if not _inside(WOLF, wx, wy):
return False
return math.hypot(wx - 71.0, wy - 49.0) >= 1.7 # olho
def ear_in(x, y):
wx, wy = to_wolf(x, y)
return any(_inside(p, wx, wy) for p in EAR_IN)
def _inside(poly, x, y):
c = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
c = not c
j = i
return c
def smooth(t):
t = max(0.0, min(1.0, t))
return t * t * (3 - 2 * t)
def mix(c1, c2, t):
t = max(0.0, min(1.0, t))
a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)]
b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(f"{round(p + (q - p) * t):02x}" for p, q in zip(a, b))
def smooth_path(pts, dup=()):
"""Curva fechada (Catmull-Rom -> Bézier); `dup` = índices de quinas vivas."""
P = []
for i, p in enumerate(pts):
P.append(p)
if i in dup:
P.append(p)
n = len(P)
d = f"M{P[0][0]:.2f},{P[0][1]:.2f}"
for i in range(n):
p0, p1, p2, p3 = P[(i - 1) % n], P[i], P[(i + 1) % n], P[(i + 2) % n]
c1 = (p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6)
c2 = (p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6)
d += f"C{c1[0]:.2f},{c1[1]:.2f} {c2[0]:.2f},{c2[1]:.2f} {p2[0]:.2f},{p2[1]:.2f}"
return d + "Z"
def tx(pts):
return [(WX0 + x * WSCALE / 100.0, Y0 + y * YS / 100.0) for x, y in pts]
def lerp(a, b, t):
return a + (b - a) * t
def build(pal, scatter=1.0, seed=5, uid="a", x_min=8.0):
rnd = random.Random(seed)
wolf = tx(WOLF)
corners = {i for i, (_, y) in enumerate(WOLF) if y >= 99.5 or y <= 0.6} # pontas e cantos vivos
body = smooth_path(wolf, dup=corners)
ears = [smooth_path(tx(e)) for e in EAR_IN]
cols, rows = int(W / CELL), int(H / CELL)
# gradiente da figura (o mesmo para a silhueta e para cada pixel => fusão sem emenda)
gx0, gy0, gx1, gy1 = 58.0, 0.0, 160.0, 100.0
stops = pal["stops"]
def grad(x, y):
dx, dy = gx1 - gx0, gy1 - gy0
t = max(0.0, min(1.0, ((x - gx0) * dx + (y - gy0) * dy) / (dx * dx + dy * dy)))
for (t0, c0), (t1, c1) in zip(stops, stops[1:]):
if t <= t1:
return mix(c0, c1, (t - t0) / (t1 - t0))
return stops[-1][1]
X_SOLID = 80.0 # múltiplo do módulo: a partir daqui a figura é inteira e lisa
def ramp(x):
return min(1.0, max(0.0, x / X_SOLID)) ** 2.0
# envelope da "cauda": nasce na largura do pescoço e se afunila para a esquerda
def envelope(x, y):
k = smooth((x + 6) / 80.0)
yc = lerp(70, 62, k)
hh = lerp(5, 31, k ** 1.15)
return math.exp(-(abs(y - yc) / hh) ** 3.0)
occ = set()
def free(cx, cy, n, m=0):
return all((cx + i, cy + j) not in occ for i in range(-m, n + m) for j in range(-m, n + m))
def take(cx, cy, n):
for i in range(n):
for j in range(n):
occ.add((cx + i, cy + j))
mask_sq, free_sq = [], []
def emit(cx, cy, n, sz_scale, op, gap):
x0, y0 = cx * CELL, cy * CELL
size = n * CELL
sz = size * sz_scale
off = (size - sz) / 2
cxm, cym = x0 + size / 2, y0 + size / 2
inside = _inside(wolf, cxm, cym)
if inside and x0 >= 56:
# faz parte da figura lisa: vira janela da máscara
e = 0.12
mask_sq.append(f'<rect x="{x0 - e:.2f}" y="{y0 - e:.2f}" width="{size + 2 * e:.2f}" height="{size + 2 * e:.2f}" fill="#fff"/>')
else:
free_sq.append(f'<rect x="{x0 + off:.2f}" y="{y0 + off:.2f}" width="{sz:.2f}" height="{sz:.2f}" fill="{grad(cxm, cym)}" fill-opacity="{op:.2f}"/>')
# 1) campo de pixels da esquerda até a figura: tamanhos em múltiplos do módulo
order = [(cx, cy) for cy in range(rows) for cx in range(int(x_min / CELL), int(X_SOLID / CELL))]
rnd.shuffle(order)
for cx, cy in order:
x, y = (cx + 0.5) * CELL, (cy + 0.5) * CELL
r, env = ramp(x), envelope(x, y)
p = r * env
if p < 0.01 or y > 94:
continue
# blocos grandes na faixa média; sempre 1×1 colados perto da figura
n = 1
if r < 0.7:
big = rnd.random() < 0.9 * (1 - r) * env ** 0.5 * min(1.0, r * 3.2)
if big:
n = rnd.choice([2, 2, 3]) if r < 0.45 else 2
if cx + n > cols or cy + n > rows or not free(cx, cy, n, 0):
n = 1
if not free(cx, cy, 1, 0):
continue
acc = p if n == 1 else min(1.0, p * 1.6)
if r > 0.88:
acc = 1.0 if env > 0.45 else acc
if rnd.random() > acc:
continue
take(cx, cy, n)
gap_scale = 1.0 if r > 0.86 else (0.9 - 0.45 * (1 - r)) if n > 1 else rnd.choice([0.5, 0.65, 0.8, 0.9]) * (0.7 + 0.3 * r)
op = 0.38 + 0.62 * smooth(r * 1.25)
emit(cx, cy, n, gap_scale if r <= 0.86 else 1.02, op, 0)
# 2) partículas esparsas bem à esquerda e acima/abaixo da cauda (profundidade)
placed, tries, target = 0, 0, int(34 * scatter)
while placed < target and tries < 6000:
tries += 1
x = x_min + rnd.random() ** 1.3 * (70 - x_min)
cy = rnd.randrange(3, rows - 4)
cx = int(x / CELL)
y = (cy + 0.5) * CELL
env = envelope(x, y)
if env > 0.5 or not free(cx, cy, 1, 1):
continue
r = ramp(x)
# sempre perto da cauda, nunca longe demais
near = math.exp(-((abs(y - 62) - 26) / 22.0) ** 2)
if rnd.random() > 0.55 * near * (0.35 + 0.65 * r):
continue
take(cx, cy, 1)
emit(cx, cy, 1, rnd.choice([0.45, 0.6, 0.75]), 0.22 + 0.38 * r, 0)
placed += 1
ex, ey = tx([(71.0, 49.0)])[0]
hx = " ".join(f'<stop offset="{t:g}" stop-color="{c}"/>' for t, c in stops)
svg = (
"<defs>"
f'<linearGradient id="{uid}h" gradientUnits="userSpaceOnUse" x1="{gx0:g}" y1="{gy0:g}" x2="{gx1:g}" y2="{gy1:g}">{hx}</linearGradient>'
f'<mask id="{uid}m" maskUnits="userSpaceOnUse" x="0" y="0" width="{W:g}" height="{H:g}"><g shape-rendering="crispEdges">'
f'<rect x="{X_SOLID:g}" y="0" width="{W - X_SOLID + 1:g}" height="{H:g}" fill="#fff"/>'
+ "".join(mask_sq)
+ "</g></mask></defs>"
f'<g mask="url(#{uid}m)"><path d="{body}" fill="url(#{uid}h)"/>'
+ "".join(f'<path d="{e}" fill="{pal["ear"]}" fill-opacity="{pal["ear_op"]}"/>' for e in ears)
+ "</g>"
+ '<g shape-rendering="crispEdges">' + "".join(free_sq) + "</g>"
+ f'<circle cx="{ex:.2f}" cy="{ey:.2f}" r="1.35" fill="{pal["eye"]}"/>'
)
return svg
def main():
# Marca larga: funciona em fundo claro e escuro. Verde-azulado -> azul petróleo.
pal = dict(stops=[(0.0, "#3BE3CC"), (0.45, "#12B5A8"), (1.0, "#0B6A86")], ear="#05323a", ear_op=0.5, eye="#ecfeff")
(OUT / "guara-logo.svg").write_text(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W:g} {H:g}" role="img" aria-label="Guará">' + build(pal, uid="w") + "</svg>\n"
)
# Ícone: fundo petróleo profundo, figura clara.
ipal = dict(stops=[(0.0, "#d9fff7"), (0.5, "#8df1e0"), (1.0, "#3fc8cf")], ear="#0b4a55", ear_op=0.45, eye="#083344")
(OUT / "guara.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">'
'<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#083344"/></linearGradient>'
'<clipPath id="ic"><rect width="128" height="128" rx="29"/></clipPath></defs>'
'<rect width="128" height="128" rx="29" fill="url(#bg)"/>'
'<g clip-path="url(#ic)"><g transform="translate(-13 18) scale(0.86)">' + build(ipal, scatter=0.5, seed=9, uid="i", x_min=30.0) + "</g></g></svg>\n"
)
if __name__ == "__main__":
main()
+112
View File
@@ -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)')