54 Commits
Author SHA1 Message Date
Lucas Rayan Guerra cd0e647922 Merge pull request #26 from lucasrguerra/fix-issues-new-release-3e3fedb1
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
Release 1.1.0: RustCrypto 0.11, mensagens do core em inglês e cobertura de i18n
2026-10-20 14:20:26 -03:00
Lucas Rayan b4a85722a6 release: 1.1.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
2026-10-20 13:36:18 -03:00
Lucas Rayan Guerra 5e43fa6d20 Merge pull request #25 from lucasrguerra/release/1.0.1
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
Release 1.0.1
2026-10-20 10:33:11 -03:00
Lucas RayanandClaude Sonnet 5.5 5e2f0e5172 Versão 1.0.1: changelog, roadmap e versões
Docker do terminal, telas da interface, documentação em inglês com
tradução em português, interface em inglês completa, correção das
listas de select no tema escuro e atualização de getrandom, windows-sys
e GitHub Actions. Sem mudança na lógica de varredura ou recuperação.

cargo fmt, clippy -D warnings e cargo audit limpos.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 10:29:48 -03:00
Lucas Rayan Guerra 785afb8e83 Merge pull request #18 from lucasrguerra/deps/windows-sys-0.61
Deps: windows-sys 0.61
2026-10-20 10:22:34 -03:00
Lucas Rayan Guerra a479593f5f Merge pull request #17 from lucasrguerra/deps/getrandom-0.4
Deps: getrandom 0.4
2026-10-20 10:22:25 -03:00
Lucas Rayan Guerra 6d5bc50c6a Merge pull request #16 from lucasrguerra/ci/update-github-actions
CI: update GitHub Actions (checkout 7, setup-node 7, github-script 9)
2026-10-20 10:22:16 -03:00
Lucas RayanandClaude Sonnet 5.5 8e2b5e3a1e Dependências: windows-sys 0.61
Substitui os PRs #2 e #6 do Dependabot, que foram fechados e não são
recriados. Só o guara-core usa a crate (acesso a dispositivos no
Windows); o Cargo.lock passa a ter menos versões duplicadas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 10:19:24 -03:00
Lucas RayanandClaude Sonnet 5.5 cf9323c2b5 Dependências: getrandom 0.4
getrandom::getrandom virou getrandom::fill na 0.3. Substitui o PR #3 do
Dependabot, que foi fechado e não é recriado. O uso é só a geração do
token do guara ui (16 bytes).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 10:17:20 -03:00
Lucas RayanandClaude Sonnet 5.5 b2ff93cb14 CI: atualiza actions/checkout para 7, setup-node para 7 e github-script para 9
Substitui os PRs do Dependabot #4, #7 e #9, que foram fechados e não são
recriados. O github-script só é usado no release.yml, apenas com
github.rest, context e require('fs'), que seguem valendo na versão 9.
tauri-action continua em v0 de propósito: a v1 passa a atualizar nome e
corpo de releases existentes e isso não dá para testar sem uma release.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 10:15:28 -03:00
Lucas Rayan Guerra 757ec65822 Merge pull request #15 from lucasrguerra/docs/screenshots
Docs: interface screenshots in the READMEs and docs/interface.md
2026-10-20 10:11:27 -03:00
Lucas Rayan Guerra a8acde30da Merge pull request #14 from lucasrguerra/fix/select-option-colors
UI: fix white text on white background in select dropdowns
2026-10-20 10:10:36 -03:00
Lucas Rayan Guerra 7e58a7f607 Merge pull request #13 from lucasrguerra/docker/cli-image
Docker: CLI image and usage docs
2026-10-20 10:10:29 -03:00
Lucas Rayan Guerra c49c9e36bd Merge pull request #12 from lucasrguerra/chore/superset-config
Superset: setup, teardown and run scripts for workspaces
2026-10-20 10:10:22 -03:00
Lucas Rayan Guerra 739d5fe9c1 Merge pull request #11 from lucasrguerra/ui/i18n-coverage
UI: fill in missing English translations and fix 'Format' label
2026-10-20 10:10:15 -03:00
Lucas Rayan Guerra bf1989bfc6 Merge pull request #10 from lucasrguerra/docs/english-primary
Docs: English as the main language, Portuguese in *.pt-BR.md
2026-10-20 10:09:59 -03:00
Lucas RayanandClaude Sonnet 5.5 5c3512a921 Documentação: telas da interface no README e em docs/interface.md
Seis imagens (explorador claro, escuro e Configurações, em inglês e
português) da interface real rodando sobre uma imagem sintética de 72
fotos geradas por scripts/make-photo-fixture.py. Página nova
docs/interface.md (en e pt-BR) e imagem na página de arquitetura.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 10:07:52 -03:00
Lucas RayanandClaude Sonnet 5.5 6012013b75 Interface: corrige texto branco sobre fundo branco nas listas de select
O .select tem fundo translúcido e as option herdavam só a cor do texto.
No tema escuro (ou Auto com o sistema em escuro) o texto fica claro e a
lista aberta do navegador vinha com fundo branco: o seletor de Idioma e
o de Ordenar ficavam ilegíveis. As option agora usam --surface e --text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 10:06:20 -03:00
Lucas RayanandClaude Sonnet 5.5 16eced75d9 Interface: traduz rótulos do núcleo e redesenha o explorador ao trocar de idioma
Achados ao rodar a interface real em inglês e varrer o DOM:
- campos de metadados (EXIF, PDF, MP4), tipos de arquivo e rótulos de
  saúde do disco que vêm do núcleo em Rust;
- textos que a interface divide em nós separados ("1 selecionado",
  "Sessão: nome", "Imagem · 27 KB", categorias com emoji);
- avisos do visualizador de SQLite.

Datas e títulos de mês são montados em JS no momento do desenho e
ficavam no idioma antigo depois de trocar em Configurações. O
explorador ganha relang() e a janela de Configurações o chama.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 10:04:10 -03:00
Lucas RayanandClaude Sonnet 5.5 f00ab2102b Docker: imagem do terminal e documentação de uso
Dockerfile em dois estágios (compila guara-cli e copia o binário para
debian-slim com smartmontools). Serve para hospedeiros Linux, servidores
sem desktop e arquivos de imagem forense. A interface gráfica fica de
fora de propósito: o guara ui só aceita 127.0.0.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 09:56:04 -03:00
Lucas RayanandClaude Sonnet 5.5 183d79a01e Superset: configura setup, teardown e run dos workspaces
- setup: copia .env, .env.* e CLAUDE.md da raiz (sem sobrescrever) e
  baixa as dependências com cargo fetch --locked. Pode rodar de novo.
- run: guara ui --no-open. A porta padrão é 0, então cada workspace
  recebe uma porta livre do sistema e a URL com o token sai no terminal.
- teardown: encerra o guara ui que ainda esteja vivo neste workspace.
- .gitignore passa a ignorar .env e .env.*.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 09:54:43 -03:00
Lucas RayanandClaude Sonnet 5.5 bb6f9cbde3 Interface: completa a tradução para inglês e corrige "Formato" no diálogo de imagem
Textos que apareciam em português no modo inglês: opções de volumes
cifrados e RAID, diálogo de criar imagem, resultados, partições,
campos de saúde do disco vindos do núcleo, pré-visualização e erros.

O rótulo "Formato" do diálogo de imagem era traduzido como "Form factor"
(ficha do disco). Agora é "Formato da imagem" ("Image format").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 09:52:41 -03:00
Lucas RayanandClaude Sonnet 5.5 b93ad02a0e Documentação: inglês como versão principal e português em *.pt-BR.md
README, ROADMAP, CHANGELOG, CONTRIBUTING, SECURITY e docs/ passam a ter
a versão principal em inglês. As versões em português ficam em
*.pt-BR.md e docs/pt-BR/, com seletor de idioma no topo de cada arquivo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RRZm8uTVN3WLTwcbrDQAUM
2026-10-20 09:48:51 -03:00
Lucas RayanandClaude Sonnet 5 3755fc7d2a Documentação: README, roadmap e docs/ (suporte, terminal, arquitetura)
README reescrito refletindo o estado real pós-1.0.0 (ext4, APFS, HFS+,
Btrfs, XFS, BitLocker, LUKS, FileVault 2, RAID, LVM, E01/Ex01, AFF4, JPEG
fragmentado, MP4 sem moov), com links para a documentação nova.

ROADMAP.md: histórico de versões e dez áreas com estado (feito, parcial,
planejado, ideia de longo prazo), incluindo cadeia de custódia, análise
forense, interpretação de artefatos e extração de dispositivos móveis.

docs/suporte.md, docs/cli.md e docs/arquitetura.md: matriz de suporte,
referência de comandos e mapa dos módulos do núcleo. Removido docs/ do
.gitignore, já que passa a ser documentação pública do projeto.

CONTRIBUTING.md: seções para adicionar sistema de arquivos, baixar dados
de referência e manter a documentação atualizada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 14:28:06 -03:00
Lucas RayanandClaude Opus 5.5 143e4ff083 Dependências: zip 9 e ruzstd 0.9 (RUSTSEC-2026-0368, RUSTSEC-2026-0344 e afins)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:57:14 -03:00
Lucas RayanandClaude Opus 5.5 beb83fef92 Formatação (cargo fmt) e ajustes do clippy para o CI da 1.0.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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:41:58 -03:00
Lucas RayanandClaude Sonnet 5.5 74218e7beb Versão 1.0.0: interface com senha, RAID, referência de vídeo e formatos de imagem; changelog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:37:07 -03:00
Lucas RayanandClaude Sonnet 5.5 6fcbe773a1 APFS: contêiner, mapa de objetos, árvore de arquivos, instantâneos e nós antigos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 01:33:14 -03:00
Lucas RayanandClaude Sonnet 5.5 465bb672bd XFS: espaço livre por AG e arquivos apagados (extents sobrando no inode livre + nome da entrada liberada)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:23:47 -03:00
Lucas RayanandClaude Sonnet 5.5 0ced7b725a Btrfs: árvores atuais, mapa livre e arquivos apagados em versões antigas de nós; extensões comprimidas (zlib, zstd, lzo)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:19:47 -03:00
Lucas RayanandClaude Sonnet 5.5 a017d79b00 24 formatos novos reconhecidos pela estrutura (gz, bz2, zst, mkv/webm, ogg, rar, elf, tiff/cr2, ico, regf, evtx, ts, cab, deb, swf, wmv, pst, flv, jp2, rtf, midi, aiff, pcap, pcapng)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:15:04 -03:00
Lucas RayanandClaude Sonnet 5.5 b89d5dd490 AFF4: leitura (snappy/deflate/LZ4, mapas esparsos) e gravação; CLI --format aff4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:11:36 -03:00
Lucas RayanandClaude Sonnet 5.5 b02bbcb7d6 CLI: senhas, chaves, membros de RAID, referências de vídeo, imagem E01 e verify
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:08:47 -03:00
Lucas RayanandClaude Sonnet 5.5 df194edb2f Reconstrução de vídeo MP4/MOV sem moov (H.264/HEVC) com referência de câmera e arquivos montados em partes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:06:25 -03:00
Lucas RayanandClaude Sonnet 5.5 8fdb931c49 Ajusta teste da varredura rápida: o ext4 agora lê metadados
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:48:45 -03:00
Lucas RayanandClaude Sonnet 5.5 7f00445d67 Carving de JPEG fragmentado: decodificador de entropia, índice de clusters e remontagem de até três fragmentos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:46:47 -03:00
Lucas RayanandClaude Sonnet 5.5 a9ac1dddba HFS+: catálogo, mapa livre, registros apagados em sobras de nós e no diário
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:31:49 -03:00
Lucas RayanandClaude Sonnet 5.5 b8dfe21fd7 Detecção automática de parâmetros de RAID sem metadados e montagem integrada
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:29:11 -03:00
Lucas RayanandClaude Sonnet 5.5 5d45273e0d RAID (0/1/4/5/6/10/linear, mdadm 0.90 e 1.x, degradado) e LVM2 remontados no disco virtual
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:24:41 -03:00
Lucas RayanandClaude Sonnet 5.5 c014526761 Montagem de discos virtuais: desbloqueio de LUKS, BitLocker e FileVault 2 integrado à varredura e à recuperação
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:18:04 -03:00
Lucas RayanandClaude Sonnet 5.5 a2ce82315e FileVault 2 (Core Storage): confere com o hash SHA-256 de referência do cryptsetup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:15:25 -03:00
Lucas RayanandClaude Sonnet 5.5 86344c0536 ext4 com diário e entradas apagadas; LUKS1/2 e BitLocker (todas as imagens de referência do cryptsetup)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:12:28 -03:00
Lucas RayanandClaude Sonnet 5.5 b1ac320193 Corrige sessões duplicadas ao reabrir uma sessão recente
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:09:26 -03:00
Lucas RayanandClaude Sonnet 5.5 627eae1b8b Imagens forenses E01 e Ex01: leitura (deflate, bzip2, padrão, multi-segmento) e gravação de E01
Validado contra o libewf (ewfverify) e imagens de referência geradas por ele.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:53:01 -03:00
Lucas RayanandClaude Opus 5.5 c3b5f79009 Versão 0.6.0: saúde do disco no estilo do CrystalDiskInfo e nova logo
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
SMART completo (estado com % de vida útil, temperatura, horas e vezes
ligado, total lido/gravado, ficha do dispositivo e tabela de atributos
ATA e NVMe), leitura pelo disco que contém a partição e comando
`guara health`. Nova logo: a cabeça do guará recortada das trilhas de um
disco, nas cores do app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:05:48 -03:00
Lucas RayanandClaude Opus 5.5 f65e7ae615 Nova logo em estilo geométrico facetado
Cabeça do lobo-guará contornada sobre foto de referência, com as regiões de
cor do animal real em tons de verde-azulado e o pescoço se desfazendo em
estilhaços.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:51:44 -03:00
Lucas RayanandClaude Sonnet 5.5 b9657ea856 Corrige aviso de variável não usada fora do Unix
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
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:05:24 -03:00
Lucas RayanandClaude Sonnet 5.5 bbcb42ae6e Versão 0.5.0: bancos SQLite, saúde do disco, notificações, retomada, mapa GPS, inglês
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:01:31 -03:00
Lucas RayanandClaude Sonnet 5.5 4bb2397e3b feat: reconexão do disco, pausa por desconexão, sessões automáticas e organização (0.4.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
- Reconhece o mesmo disco por tamanho + impressão digital quando volta com outro nome.
- Varredura pausa sozinha (sem perder nada) se o dispositivo parar de responder.
- Sessões salvas automaticamente (atômico) e listadas em Recentes.
- Organização ao recuperar (pastas, tipo, data, tudo junto) e checagem de espaço livre.
- Botão de escolher a origem também em sessões pausadas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 19:32:00 -03:00
Lucas RayanandClaude Sonnet 5.5 97f940f261 fix: vídeo no app Linux travava a janela; abrir no player do sistema (0.3.2)
Release / Cria a release (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 / CLI (linux-aarch64) (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
O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer; criar um <video>
derrubava o processo da página. O app Linux não cria mais <video>/<audio>: oferece
"Abrir no player do sistema". Clicar no preview abre a visualização grande.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:09:28 -03:00
Lucas RayanandClaude Sonnet 5.5 282161d297 test: sessão no formato da v0.1.0 gerada em tempo de teste
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
O arquivo estático dependia do conteúdo aleatório da imagem local e falhava no CI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:47:56 -03:00
Lucas RayanandClaude Sonnet 5.5 b9bef85bc7 fix: PNG truncado não vira arquivo gigante; filtros de tamanho e megapixels
Descoberto ao abrir uma sessão real: texto ASCII após um PNG truncado era lido
como tamanho de bloco (~1,9 GB). Também adiciona filtros para separar fotos de
ícones e falha cedo ao retomar sem a origem.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:44:03 -03:00
Lucas RayanandClaude Sonnet 5.5 354caaf8f0 fix: tudo que o Guará grava como root pertence ao usuário que o abriu
Sessões, CSV, relatórios, imagens e arquivos recuperados (e as pastas criadas)
passam ao dono do sudo/pkexec, e a pasta sugerida é a do usuário, não /root.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:40:13 -03:00
Lucas RayanandClaude Sonnet 5.5 682ba8b943 fix: sessões abrem mesmo sem a origem; mensagens claras de erro (0.3.1)
Antes, abrir uma sessão cuja origem não estava acessível (ou cujo arquivo não podia
ser lido, como em /root) falhava por inteiro com erro vago. Agora a sessão abre,
permite navegar/exportar e a origem pode ser reconectada. Testes cobrem sessões
gravadas pela v0.1.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:37:25 -03:00
183 changed files with 25780 additions and 875 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.git
target
**/node_modules
apps/gui/src-tauri
docs
scripts
*.img
*.guara-session.json
.claude
.wolf
.superpowers
+13
View File
@@ -3,6 +3,19 @@ updates:
- package-ecosystem: cargo
directory: /
schedule: { interval: weekly }
groups:
rustcrypto:
patterns:
- sha2
- sha1
- md-5
- hmac
- pbkdf2
- ripemd
- aes
- argon2
- cipher
- digest
- package-ecosystem: cargo
directory: /apps/gui/src-tauri
schedule: { interval: weekly }
+10 -5
View File
@@ -18,11 +18,16 @@ jobs:
name: Formatação e clippy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cobertura da tradução da interface
run: node scripts/check-ui-i18n.mjs
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
@@ -34,7 +39,7 @@ jobs:
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Ferramentas para imagens de teste (Linux)
@@ -52,7 +57,7 @@ jobs:
name: Auditoria de dependências
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -61,12 +66,12 @@ jobs:
name: Compila o app desktop (Linux)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/gui/src-tauri
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri
+20 -8
View File
@@ -18,14 +18,15 @@ jobs:
outputs:
release_id: ${{ steps.rel.outputs.result }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
id: notes
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v7
- uses: actions/github-script@v9
id: rel
with:
script: |
@@ -52,7 +53,7 @@ jobs:
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -95,14 +96,14 @@ jobs:
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
- { name: windows-x86_64, os: windows-latest, args: "" }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/gui/src-tauri
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri (Linux)
@@ -112,7 +113,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- uses: tauri-apps/tauri-action@v0
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -125,10 +126,21 @@ jobs:
needs: [create-release, cli, gui]
runs-on: ubuntu-24.04
steps:
- uses: actions/github-script@v7
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
await github.rest.repos.updateRelease({
owner: context.repo.owner, repo: context.repo.repo,
release_id: ${{ needs.create-release.outputs.release_id }}, draft: false,
release_id: ${{ needs.create-release.outputs.release_id }},
name: `Guará ${context.ref.replace('refs/tags/', '')}`,
body: fs.readFileSync('notes.md', 'utf8'),
draft: false,
});
+4 -1
View File
@@ -1,12 +1,15 @@
# Material de trabalho local, não versionado
.superpowers/
docs/
# Estado de ferramentas locais
.wolf/
.claude/
CLAUDE.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
.env
.env.*
# Build
target/
node_modules/
+5
View File
@@ -0,0 +1,5 @@
{
"setup": ["./.superset/setup.sh"],
"teardown": ["./.superset/teardown.sh"],
"run": ["cargo run -p guara-cli -- ui --no-open"]
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Runs once when a Superset workspace (git worktree) is created. Safe to run again.
set -euo pipefail
# Local files that are not tracked by git but that a new worktree needs.
# Nothing is overwritten if it already exists in the workspace.
if [ -n "${SUPERSET_ROOT_PATH:-}" ] && [ -d "$SUPERSET_ROOT_PATH" ]; then
for f in "$SUPERSET_ROOT_PATH"/.env "$SUPERSET_ROOT_PATH"/.env.* "$SUPERSET_ROOT_PATH"/CLAUDE.md; do
[ -f "$f" ] || continue
dest="./$(basename "$f")"
[ -e "$dest" ] || cp "$f" "$dest"
done
fi
# Download the crates now so the first build and the first test run do not wait on the network.
cargo fetch --locked
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Runs when a Superset workspace is deleted.
# Setup starts no background service. The only long-running process is the Run command
# (`guara ui`), which this workspace builds into its own target/ directory, so stop any
# copy that is still alive before the worktree is removed.
set -uo pipefail
pkill -f -- "$PWD/target/.*/guara ui" 2>/dev/null || true
exit 0
+151 -31
View File
@@ -1,44 +1,164 @@
# Changelog
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
[English](CHANGELOG.md) · [Português](CHANGELOG.pt-BR.md)
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.1.0] - 2026-10-20
### Added
- **Automated translation check** (`scripts/check-ui-i18n.mjs`): scans the interface JS, HTML and the labels emitted by the Rust core, fails on Portuguese text without an English entry and on duplicate dictionary keys. It runs in the CI lint job.
- **Dependabot group for RustCrypto:** `sha2`, `sha1`, `md-5`, `hmac`, `pbkdf2`, `ripemd`, `aes`, `argon2`, `cipher` and `digest` now arrive in one update, because their traits are only compatible when the versions move together.
### Changed
- **English interface for core messages:** the most common errors from the core now have `{n}` patterns in the dictionary, so paths and sizes are translated too.
- **Notifications in the chosen language:** the core formats scan/recovery notifications using the saved interface language (`ui_lang` follows `lang` or the system locale when unset).
- **ISO 8601 dates:** EXIF, PDF and MP4 metadata dates are emitted as `YYYY-MM-DDTHH:MM:SS` (MP4 with `Z`) and formatted by the interface in the selected locale, instead of a pre-formatted Brazilian date.
- **RustCrypto on digest 0.11 / cipher 0.5:** `aes` 0.9, `sha2` 0.11, `sha1` 0.11, `md-5` 0.11, `hmac` 0.13, `pbkdf2` 0.13, `ripemd` 0.2 and `argon2` 0.6. BitLocker, LUKS and FileVault 2 were checked against the reference images.
- **Release workflow:** `tauri-action` updated to v1; the publish job restores the release name and the CHANGELOG notes after the desktop builds, so v1 cannot overwrite them.
- **Docker documentation:** `--device=...:r` was verified with a loop device (`inspect`, `scan`, `recover` read; writes are refused). The SMART/`SYS_RAWIO` wording now states what was and was not verified.
- **Superset workspace:** setup, Run and teardown were verified in real workspaces; two interfaces get different ports, and deleting a workspace stops its `guara ui`.
### Fixed
- Eight duplicated keys in `apps/gui/ui/i18n-en.js` (the last one won silently).
- Untranslated fragments split by inline tags, metadata labels and preview messages that the previous manual pass missed.
### Notes
- No change to scanning or recovery logic. The encrypted-volume tests pass with `GUARA_REQUIRE_FIXTURES=1`.
- CLI output remains in Portuguese.
## [1.0.1] - 2026-10-20
### Added
- **Docker image of the command line tool** (`Dockerfile`, [docs/docker.md](https://github.com/lucasrguerra/guara/blob/main/docs/docker.md)): for Linux hosts, servers without a desktop and forensic image files. The graphical interface is not served from the container.
- **Interface screenshots** in the README and in the new [docs/interface.md](https://github.com/lucasrguerra/guara/blob/main/docs/interface.md), taken from the real interface on a synthetic test image.
- **Superset workspace configuration** (`.superset/`): setup, run and teardown scripts for parallel workspaces.
### Changed
- **Documentation:** English is now the main language. Portuguese versions live in `*.pt-BR.md` and `docs/pt-BR/`, with a language switch at the top of each file.
- **English interface completed:** scan options, the disk image dialog, metadata labels (EXIF, PDF, MP4), file kinds, disk health fields, selection counters and preview messages were still showing in Portuguese.
- **Language switch:** the explorer now redraws after changing the language in Settings, so dates and month headings follow it.
- Dependencies: `getrandom` 0.4 and `windows-sys` 0.61. GitHub Actions updated (`checkout` 7, `setup-node` 7, `github-script` 9).
### Fixed
- White text on a white background in the Language and Sort drop-down lists with the dark theme (including Auto with the system in dark mode).
- The "Format" field of the disk image dialog was translated as "Form factor" in English.
### Notes
- No change to scanning or recovery logic. The dependency updates touch the token of the local interface server (`getrandom`) and the Windows device bindings (`windows-sys`).
- Error messages from the core, notifications and the terminal output are still in Portuguese.
## [1.0.0] - 2026-10-19
### Added
- **File systems with metadata:** ext4 (deleted inodes and old copies in the journal), HFS+ (node leftovers and journal), Btrfs (old versions of the trees; zlib, zstd and lzo extents), XFS (freed inodes and names of freed entries) and APFS (snapshots and old nodes). ReFS is still only detected.
- **Encrypted volumes:** BitLocker (password, recovery key, .BEK, clear key), LUKS1/LUKS2 and FileVault 2 (Core Storage) open with the password or key and are scanned inside. Passwords are never written to the session file.
- **RAID and LVM:** RAID 0, 1, 4, 5, 6, 10 and linear from mdadm superblocks, including degraded; automatic discovery of level, stripe size, layout and order when there is no metadata; LVM2 (linear, striped, mirrored and RAID).
- **Forensic images:** reading of E01/Ex01 and AFF4; writing of E01 and AFF4; `guara verify` command.
- **Carving:** fragmented JPEG reassembled (up to three fragments), index reconstruction for MP4/MOV without moov (H.264/HEVC, with a reference video) and 24 formats recognized by structure.
- **Interface:** fields for password, RAID, reference video and image format.
### Fixed
- Reopening a recent session that is already open no longer creates identical copies in the sessions tab.
### Limitations
- Storage Spaces and ReFS (metadata) are not supported; XFS and APFS tested with simulated deletions; audio is not reconstructed in MP4 without moov.
## [0.6.0] - 2026-10-18
### Added
- **CrystalDiskInfo-style disk health:** the device screen shows the status (Good, Caution, Bad) with the % of remaining life on SSDs, temperature, power-on hours, power cycles, total read and total written. It also shows the device sheet (model, firmware, serial number, interface, transfer mode, capacity, rotation, form factor and features such as TRIM) and the full table of SMART attributes, with ID, current value, worst, threshold and raw value, and an indicator per attribute. Works with ATA/SATA and NVMe disks (on NVMe, the health log fields become the table).
- **Partitions:** when opening a partition (`/dev/sda1`, `/dev/nvme0n1p2`, `/dev/disk2s1`), SMART is read from the disk that contains it, and the screen says so.
- **`guara health` command** in the terminal, with readable output or `--json`.
### Changed
- A SMART attribute with a normalized value at or below the failure threshold now marks the disk as "Bad", even if the overall self-assessment still passes.
- **New logo:** the head of a maned wolf cut out of the tracks of a disk, whole on one side (recovered) and broken on the other (lost), in the app colors. In the interface it follows the chosen theme (light or dark).
## [0.5.0] - 2026-10-18
### Added
- **Database viewer:** SQLite files (`.db`, `.sqlite`, `.sqlite3`, `.db3`, `.sdb`, `.sqlitedb`, `.storedata`) open in the preview with schema, paginated tables, blobs and recognized dates. Own reader, read-only, tolerant of truncated or corrupted files. It also **recovers deleted rows** from the free space of pages.
- **Disk health (SMART):** before scanning, Guará queries `smartctl` (when installed) and the kernel error counter. If the disk looks faulty, it recommends making the image first and asks for confirmation before the deep scan.
- **System notification** when a long scan or a recovery finishes, or pauses because of a disconnection.
- **Resume an interrupted recovery:** a journal in the destination folder makes Guará skip what was already copied and checked; the result shows how many were skipped.
- **Map of photos with GPS** in the details panel (optional, uses the internet) and a button to open it in OpenStreetMap.
- **English interface**, with a language choice (Auto, Português, English) in the new Settings window, which also controls notifications and online maps.
- **Closing the window during a scan** asks whether you want to pause and quit, quit without pausing, or continue.
### Notes
- Error messages coming from the core and the terminal are still in Portuguese.
## [0.4.0] - 2026-10-18
### Added
- **Disk reconnection:** if the disk was disconnected and came back under another name (for example `/dev/sda` becoming `/dev/sdb`), Guará recognizes it by itself by its size and a fingerprint of the content (first and last MiB) and reattaches the session. If it cannot, the screen shows "Reconnect the disk" and "Choose the source...", with the list of connected disks and a "same disk" badge. A source with different content is refused, unless you tick "use anyway" (useful for copies).
- **Disconnection in the middle of a scan:** if reads fail several times in a row, the scan pauses by itself with the exact point saved and tells you to reconnect and continue, instead of skipping blocks and finishing with holes.
- **Sessions saved automatically** from time to time during the scan and when it finishes or pauses, in the user's configuration folder, and listed under "Recent" in the sidebar (`guara sessions` in the terminal). Atomic writes, with no risk of corrupting the session if the program closes midway.
- **Organization when recovering:** original folders, by type, by date (year and month) or everything in the same folder (`--organize` in the terminal).
- **Free space check** at the destination before copying, with a clear message if it will not fit.
### Fixed
- In paused sessions there was no way to choose another source; only "Continue", which failed without the disk.
## [0.3.2] - 2026-10-18
### Fixed
- **Freeze when clicking a video in the Linux app.** The AppImage ships WebKitGTK without the GStreamer media plugins: when a `<video>` or `<audio>` was created the page process died and the window froze. Now the Linux app never creates those elements; video and audio have an "Open in system player" button, which copies the file to a temporary folder of the user and opens it in the default player (even when Guará runs as administrator). In the browser (`guara ui`) and on other systems the video still plays inside the app, but only in the large view, never in the small panel.
### Changed
- Clicking the preview in the details panel opens the large view (image, PDF, document and other types), now larger (up to 98% of the window width and 95% of its height).
## [0.3.1] - 2026-10-18
### Added
- "Hide small files (under 30 KB)" and "Over 1 megapixel" filters in the explorer, to separate photos from icons and thumbnails when there are thousands of images.
### Fixed
- Opening a saved session failed entirely when the source (for example `/dev/sdb`) was not accessible, and the error message was vague. Now the session always opens: you can browse, export and generate a report, and the "Choose the source..." button reconnects the disk or an image to go back to previewing and recovering.
- Clear messages when the session file cannot be read (for example, created as administrator in `/root`) or is not a valid session.
- An automatic test ensures that sessions written by v0.1.0 still open.
- PNG carving: ASCII text right after a truncated PNG was read as a block of about 1.9 GB and produced giant low-confidence "photos". Now the block must be plausible (valid type, reasonable size, inside the disk) and the file ends at the last valid block.
- Resuming a scan of a session without the source available now warns immediately, instead of failing later.
- When running as administrator (sudo/pkexec), the suggested folders are now those of the user who launched Guará, and everything it writes (sessions, CSV, reports, images and recovered files, with the folders created) keeps the owner and permissions of that user, not root.
## [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.
### Added
- **File explorer:** thumbnail grid and list with virtual scrolling (thousands of items), grouping by day/month, sidebar navigation by type, photo date, folder, camera and confidence, selection with Ctrl/Shift/keyboard, details panel and full-screen quick look.
- **Fast thumbnails** generated in the core (JPEG with downscaling during decoding, PNG, GIF, BMP, WebP), with EXIF orientation and use of the embedded thumbnail when the image is damaged.
- **Metadata:** EXIF (camera, lens, dates, GPS), PDF, Office/OpenDocument and MP4/MOV; creation, modification and access dates from NTFS, FAT and exFAT.
- **Forensics:** MD5, SHA-1 and SHA-256 hashes, `guara hash`, `guara search` (text across the whole disk in UTF-8 and UTF-16), `guara export` (CSV), `guara report` (HTML report with chain of custody) and duplicate detection. All of it also in the graphical interface.
- New visual identity: a pixel logo that comes together into a maned wolf with a smooth silhouette.
### Alterado
- A barra inferior e o painel de progresso não são mais recriados a cada atualização.
- Textos sem travessões.
### Changed
- The bottom bar and the progress panel are no longer recreated on every update.
- Text without em dashes.
## [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á.
### Added
- **Full preview:** image (zoom), video (MP4 and others), audio, PDF (pdf.js included, works offline), text, Word/Excel/PowerPoint/OpenDocument, ZIP contents and hex; full screen with keyboard navigation (↑ ↓ ← → Space Esc). Content is read on demand with Range support, without writing to disk.
- **Auto / Light / Dark** theme selector; the choice is saved and also applies to the app restarted as administrator.
- New teal visual identity, with a pixel logo that comes together into a maned wolf.
### 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).
### Fixed
- Restarting as administrator on Linux: in the AppImage the executable sits on a mount point inaccessible to root; now it uses the `.AppImage` itself, shows the reason if it fails and closes the old app only when the new one opens.
- The bottom action bar flickered on every update; now it is created once and only the text updates. The progress panel also stopped being recreated on every cycle.
- FAT32 on small media (fewer than 65,525 clusters) was not recognized.
- The theme did not follow the choice when opened as root (root's WebView used another system theme).
## [0.1.0] - 2026-10-18
### Adicionado
- Núcleo em Rust, somente leitura na origem: acesso a dispositivos e imagens (Windows, Linux, macOS).
- Leitura de MBR/GPT, detecção de partições perdidas e de espaço não particionado.
- Metadados de NTFS, FAT12/16/32 e exFAT: arquivos apagados com nome, pasta e data.
- Carving por estrutura de JPEG, PNG, GIF, BMP, PDF, ZIP e derivados (DOCX, XLSX, PPTX, ODF, EPUB, APK, JAR), MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3 e PE.
- Mapa de blocos livres do ext2/3/4 para varrer apenas o espaço livre.
- Fusão sem duplicatas e confiança (alta/média/baixa) por arquivo.
- Detecção de volumes cifrados (BitLocker, LUKS) e aviso de TRIM em SSD.
- Imagem forense tolerante a setores ruins, pausar/retomar varredura e sessões salvas.
- Recuperação com SHA-256 e manifesto; recusa de destino no disco de origem.
- CLI `guara` e interface gráfica (Tauri) com tema claro/escuro e layout responsivo; modo `guara ui` no navegador.
- CI (Linux/Windows/macOS) e releases automáticas por tag.
### Added
- Rust core, read-only on the source: access to devices and images (Windows, Linux, macOS).
- Reading of MBR/GPT, detection of lost partitions and unpartitioned space.
- Metadata from NTFS, FAT12/16/32 and exFAT: deleted files with name, folder and date.
- Structure-aware carving of JPEG, PNG, GIF, BMP, PDF, ZIP and derivatives (DOCX, XLSX, PPTX, ODF, EPUB, APK, JAR), MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3 and PE.
- Free block map of ext2/3/4 to scan only the free space.
- Merging without duplicates and per-file confidence (high/medium/low).
- Detection of encrypted volumes (BitLocker, LUKS) and TRIM warning on SSDs.
- Forensic image tolerant of bad sectors, pause/resume of scans and saved sessions.
- Recovery with SHA-256 and manifest; refusal of a destination on the source disk.
- `guara` CLI and graphical interface (Tauri) with light/dark theme and responsive layout; `guara ui` mode in the browser.
- CI (Linux/Windows/macOS) and automatic releases by tag.
+164
View File
@@ -0,0 +1,164 @@
# Changelog
[English](CHANGELOG.md) · **Português**
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.1.0] - 2026-10-20
### Adicionado
- **Verificação automática da tradução** (`scripts/check-ui-i18n.mjs`): varre o JS e o HTML da interface e os rótulos emitidos pelo núcleo em Rust, falha quando há texto em português sem entrada em inglês e quando há chave duplicada no dicionário. Roda no job de lint do CI.
- **Grupo RustCrypto no Dependabot:** `sha2`, `sha1`, `md-5`, `hmac`, `pbkdf2`, `ripemd`, `aes`, `argon2`, `cipher` e `digest` passam a chegar numa única atualização, porque as traits só são compatíveis quando as versões andam juntas.
### Alterado
- **Interface em inglês para mensagens do núcleo:** os erros mais comuns do núcleo agora têm padrões com `{n}` no dicionário, então caminhos e tamanhos também são traduzidos.
- **Notificações no idioma escolhido:** o núcleo monta as notificações de varredura e recuperação usando o idioma salvo da interface (`ui_lang` segue `lang` ou o locale do sistema quando não definido).
- **Datas em ISO 8601:** as datas de metadados EXIF, PDF e MP4 saem como `YYYY-MM-DDTHH:MM:SS` (MP4 com `Z`) e são formatadas pela interface no locale escolhido, em vez de uma data brasileira já pronta.
- **RustCrypto em digest 0.11 / cipher 0.5:** `aes` 0.9, `sha2` 0.11, `sha1` 0.11, `md-5` 0.11, `hmac` 0.13, `pbkdf2` 0.13, `ripemd` 0.2 e `argon2` 0.6. BitLocker, LUKS e FileVault 2 foram conferidos contra as imagens de referência.
- **Fluxo de release:** `tauri-action` atualizado para v1; o job de publicação restaura o nome e as notas do CHANGELOG depois dos builds do desktop, para a v1 não sobrescrevê-las.
- **Documentação do Docker:** `--device=...:r` foi verificado com um dispositivo de loop (`inspect`, `scan`, `recover` leem; escritas são recusadas). O texto sobre SMART/`SYS_RAWIO` agora diz o que foi e o que não foi verificado.
- **Workspace do Superset:** setup, Run e teardown foram verificados em workspaces reais; duas interfaces recebem portas diferentes, e apagar um workspace encerra o `guara ui`.
### Corrigido
- Oito chaves duplicadas em `apps/gui/ui/i18n-en.js` (a última vencia em silêncio).
- Trechos sem tradução separados por tags inline, rótulos de metadados e mensagens da pré-visualização que a passada manual anterior não pegou.
### Notas
- Nenhuma mudança na lógica de varredura ou recuperação. Os testes de volumes cifrados passam com `GUARA_REQUIRE_FIXTURES=1`.
- A saída do terminal continua em português.
## [1.0.1] - 2026-10-20
### Adicionado
- **Imagem Docker do terminal** (`Dockerfile`, [docs/pt-BR/docker.md](https://github.com/lucasrguerra/guara/blob/main/docs/pt-BR/docker.md)): para hospedeiros Linux, servidores sem desktop e arquivos de imagem forense. A interface gráfica não é servida pelo container.
- **Telas da interface** no README e na nova [docs/pt-BR/interface.md](https://github.com/lucasrguerra/guara/blob/main/docs/pt-BR/interface.md), tiradas da interface real sobre uma imagem de teste sintética.
- **Configuração de workspaces do Superset** (`.superset/`): scripts de setup, run e teardown para workspaces em paralelo.
### Alterado
- **Documentação:** o inglês passa a ser o idioma principal. As versões em português ficam em `*.pt-BR.md` e `docs/pt-BR/`, com seletor de idioma no topo de cada arquivo.
- **Interface em inglês completada:** opções de varredura, diálogo de imagem de disco, rótulos de metadados (EXIF, PDF, MP4), tipos de arquivo, campos de saúde do disco, contadores de seleção e mensagens da pré-visualização ainda apareciam em português.
- **Troca de idioma:** o explorador agora se redesenha ao mudar o idioma em Configurações, e as datas e os títulos de mês acompanham.
- Dependências: `getrandom` 0.4 e `windows-sys` 0.61. GitHub Actions atualizadas (`checkout` 7, `setup-node` 7, `github-script` 9).
### Corrigido
- Texto branco sobre fundo branco nas listas de Idioma e Ordenar com o tema escuro (inclusive Auto com o sistema em escuro).
- O campo "Formato" do diálogo de imagem de disco era traduzido como "Form factor" em inglês.
### Notas
- Nenhuma mudança na lógica de varredura ou de recuperação. As atualizações de dependências tocam o token do servidor local da interface (`getrandom`) e as ligações de dispositivo do Windows (`windows-sys`).
- Mensagens de erro do núcleo, notificações e a saída do terminal continuam em português.
## [1.0.0] - 2026-10-19
### Adicionado
- **Sistemas de arquivos com metadados:** ext4 (inodes apagados e cópias antigas no diário), HFS+ (sobras de nós e diário), Btrfs (versões antigas das árvores; extensões zlib, zstd e lzo), XFS (inodes liberados e nomes de entradas liberadas) e APFS (instantâneos e nós antigos). ReFS ainda é só detectado.
- **Volumes cifrados:** BitLocker (senha, chave de recuperação, .BEK, chave limpa), LUKS1/LUKS2 e FileVault 2 (Core Storage) abrem com a senha ou a chave e são varridos por dentro. As senhas nunca vão para o arquivo de sessão.
- **RAID e LVM:** RAID 0, 1, 4, 5, 6, 10 e linear a partir de superblocos do mdadm, inclusive degradado; descoberta automática de nível, faixa, layout e ordem quando não há metadados; LVM2 (linear, em faixas, espelhado e RAID).
- **Imagens forenses:** leitura de E01/Ex01 e AFF4; gravação de E01 e AFF4; comando `guara verify`.
- **Carving:** JPEG fragmentado remontado (até três fragmentos), reconstrução do índice de MP4/MOV sem moov (H.264/HEVC, com vídeo de referência) e 24 formatos reconhecidos pela estrutura.
- **Interface:** campos de senha, RAID, vídeo de referência e formato da imagem.
### Corrigido
- Abrir de novo uma sessão recente já aberta não cria mais cópias iguais na aba de sessões.
### Limitações
- Storage Spaces e ReFS (metadados) não suportados; XFS e APFS testados com exclusões simuladas; áudio não é reconstruído em MP4 sem moov.
## [0.6.0] - 2026-10-18
### Adicionado
- **Saúde do disco no estilo do CrystalDiskInfo:** a tela do dispositivo mostra o estado (Bom, Atenção, Ruim) com a % de vida útil restante em SSD, temperatura, horas ligado, vezes ligado, total lido e total gravado. Também traz a ficha do dispositivo (modelo, firmware, nº de série, interface, modo de transferência, capacidade, rotação, formato e recursos como TRIM) e a tabela completa de atributos SMART, com ID, valor atual, pior, limite e bruto, e um indicador por atributo. Funciona com discos ATA/SATA e NVMe (nos NVMe, os campos do log de saúde viram a tabela).
- **Partições:** ao abrir uma partição (`/dev/sda1`, `/dev/nvme0n1p2`, `/dev/disk2s1`), o SMART é lido do disco que a contém, e a tela avisa isso.
- **Comando `guara health`** no terminal, com saída legível ou `--json`.
### Alterado
- Um atributo SMART com valor normalizado no limite de falha (ou abaixo dele) agora marca o disco como "Ruim", mesmo que a autoavaliação geral ainda passe.
- **Nova logo:** a cabeça do lobo-guará recortada das trilhas de um disco, inteiras de um lado (recuperado) e quebradas do outro (perdido), nas cores do app. Na interface ela acompanha o tema escolhido (claro ou escuro).
## [0.5.0] - 2026-10-18
### Adicionado
- **Visualizador de bancos de dados:** arquivos SQLite (`.db`, `.sqlite`, `.sqlite3`, `.db3`, `.sdb`, `.sqlitedb`, `.storedata`) abrem na pré-visualização com esquema, tabelas paginadas, blobs e datas reconhecidas. Leitor próprio, só leitura, tolerante a arquivos truncados ou corrompidos. Também **recupera linhas apagadas** a partir do espaço livre das páginas.
- **Saúde do disco (SMART):** antes de varrer, o Guará consulta o `smartctl` (quando instalado) e o contador de erros do kernel. Se o disco parece com defeito, recomenda criar a imagem primeiro e pede confirmação antes da varredura profunda.
- **Notificação do sistema** quando uma varredura longa ou uma recuperação termina, ou pausa por desconexão.
- **Retomar recuperação interrompida:** um diário na pasta de destino faz o Guará pular o que já foi copiado e conferido; o resultado mostra quantos foram pulados.
- **Mapa das fotos com GPS** no painel de detalhes (opcional, usa a internet) e botão para abrir no OpenStreetMap.
- **Interface em inglês**, com escolha de idioma (Auto, Português, English) na nova janela de Configurações, que também controla notificações e mapas online.
- **Fechar a janela durante a varredura** pergunta se você quer pausar e sair, sair sem pausar ou continuar.
### Notas
- Mensagens de erro vindas do núcleo e o terminal continuam em português.
## [0.4.0] - 2026-10-18
### Adicionado
- **Reconexão do disco:** se o disco foi desconectado e voltou com outro nome (por exemplo `/dev/sda` virando `/dev/sdb`), o Guará o reconhece sozinho pelo tamanho e pela impressão digital do conteúdo (primeiro e último MiB) e religa a sessão. Se não conseguir, a tela mostra "Reconectar o disco" e "Escolher a origem…", com a lista dos discos conectados e o selo "mesmo disco". Uma origem com conteúdo diferente é recusada, a menos que você marque "usar mesmo assim" (útil para cópias).
- **Desconexão no meio da varredura:** se as leituras falharem várias vezes seguidas, a varredura pausa sozinha com o ponto exato salvo e avisa para reconectar e continuar, em vez de seguir pulando blocos e terminar com buracos.
- **Sessões salvas automaticamente** de tempos em tempos durante a varredura e ao terminar ou pausar, na pasta de configuração do usuário, e listadas em "Recentes" na barra lateral (`guara sessions` no terminal). Gravação atômica, sem risco de corromper a sessão se o programa fechar no meio.
- **Organização ao recuperar:** pastas originais, por tipo, por data (ano e mês) ou tudo na mesma pasta (`--organize` no terminal).
- **Verificação de espaço livre** no destino antes de copiar, com mensagem clara se não couber.
### Corrigido
- Em sessões pausadas não havia como escolher outra origem; só "Continuar", que falhava sem o disco.
## [0.3.2] - 2026-10-18
### Corrigido
- **Travamento ao clicar em um vídeo no app para Linux.** O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer: ao criar um `<video>` ou `<audio>` o processo da página morria e a janela ficava congelada. Agora o app para Linux nunca cria esses elementos; vídeo e áudio têm o botão "Abrir no player do sistema", que copia o arquivo para uma pasta temporária do usuário e o abre no player padrão (inclusive quando o Guará roda como administrador). No navegador (`guara ui`) e nos demais sistemas o vídeo continua tocando dentro do app, mas só na tela grande, nunca no painel pequeno.
### Alterado
- Clicar na pré-visualização do painel de detalhes abre a visualização grande (imagem, PDF, documento e demais tipos), agora maior (até 98% da largura e 95% da altura da janela).
## [0.3.1] - 2026-10-18
### Adicionado
- Filtros "Ocultar arquivos pequenos (menos de 30 KB)" e "Mais de 1 megapixel" no explorador, para separar fotos de ícones e miniaturas quando há milhares de imagens.
### Corrigido
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
- Carving de PNG: texto ASCII logo após um PNG truncado era lido como um bloco de ~1,9 GB e gerava "fotos" gigantes de confiança baixa. Agora o bloco precisa ser plausível (tipo válido, tamanho razoável, dentro do disco) e o arquivo termina no último bloco válido.
- Retomar uma varredura de sessão sem a origem disponível agora avisa na hora, em vez de falhar depois.
- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root.
## [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
- Núcleo em Rust, somente leitura na origem: acesso a dispositivos e imagens (Windows, Linux, macOS).
- Leitura de MBR/GPT, detecção de partições perdidas e de espaço não particionado.
- Metadados de NTFS, FAT12/16/32 e exFAT: arquivos apagados com nome, pasta e data.
- Carving por estrutura de JPEG, PNG, GIF, BMP, PDF, ZIP e derivados (DOCX, XLSX, PPTX, ODF, EPUB, APK, JAR), MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3 e PE.
- Mapa de blocos livres do ext2/3/4 para varrer apenas o espaço livre.
- Fusão sem duplicatas e confiança (alta/média/baixa) por arquivo.
- Detecção de volumes cifrados (BitLocker, LUKS) e aviso de TRIM em SSD.
- Imagem forense tolerante a setores ruins, pausar/retomar varredura e sessões salvas.
- Recuperação com SHA-256 e manifesto; recusa de destino no disco de origem.
- CLI `guara` e interface gráfica (Tauri) com tema claro/escuro e layout responsivo; modo `guara ui` no navegador.
- CI (Linux/Windows/macOS) e releases automáticas por tag.
+26 -10
View File
@@ -1,12 +1,14 @@
# Contribuindo com o Guará
# Contributing to Guará
Obrigado por ajudar! Regras de ouro:
[English](CONTRIBUTING.md) · [Português](CONTRIBUTING.pt-BR.md)
1. **Nunca escreva na origem.** Qualquer código que abra um dispositivo deve usar `source::open_path` (somente leitura). PRs que escrevem no disco de origem são recusados.
2. **Teste com dados reais.** Novos detectores/leitores precisam de teste que recupera um arquivo conhecido bit a bit (veja `crates/guara-core/tests/` e `scripts/make-fixtures.sh`).
3. **Seja honesto sobre confiança.** Se o conteúdo não puder ser validado, a confiança é no máximo média.
Thanks for helping! Golden rules:
## Fluxo
1. **Never write to the source.** Any code that opens a device must use `source::open_path` (read-only). PRs that write to the source disk are rejected.
2. **Test with real data.** New detectors/readers need a test that recovers a known file bit for bit (see `crates/guara-core/tests/` and `scripts/make-fixtures.sh`).
3. **Be honest about confidence.** If the content cannot be validated, confidence is at most medium.
## Workflow
```bash
cargo fmt --all
@@ -14,12 +16,26 @@ cargo clippy --workspace --all-targets
cargo test --workspace
```
No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` e `python3`. Sem essas ferramentas os testes com imagem são ignorados (defina `GUARA_REQUIRE_FIXTURES=1` para falhar).
On Linux the tests build images with `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` and `python3`. Without these tools the image tests are skipped (set `GUARA_REQUIRE_FIXTURES=1` to make them fail).
## Adicionando um formato de arquivo
For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `scripts/fetch-testdata.sh`; without them those tests are skipped. The BitLocker test takes about 75 s.
Edite `crates/guara-core/src/signatures.rs`: acrescente o detector em `detect`, o primeiro byte em `might_start`, a extensão em `category_for_ext`/`matches_ext` e um arquivo real ao `scripts/make-fixtures.sh` com teste de bit-exatidão.
## Where to make changes
A map of the modules is in [docs/architecture.md](docs/architecture.md). What is missing and what is welcome is in the [ROADMAP](ROADMAP.md).
## Adding a file format
Simple detectors live in `crates/guara-core/src/signatures.rs`; the newer ones in `formats.rs`. Add the detector to `detect`, the first byte to `might_start`, the extension to `category_for_ext`/`matches_ext` and a real file to `scripts/make-fixtures.sh` with a bit-exactness test.
## Adding a file system
Create the reader in `crates/guara-core/src/fs/`, register it in `fs/mod.rs` and bring a test with a real or reference image. **We do not ship a reader without validation**: that is why ReFS and Storage Spaces do not exist yet. Small images without personal data are the best gift you can give the project.
## Documentation
The main version is in English and every file has a Portuguese translation (`*.pt-BR.md`, `docs/pt-BR/`). No em dashes or en dashes. When you change a feature, update both versions (English and Portuguese) of [docs/support.md](docs/support.md), the [CHANGELOG](CHANGELOG.md) and, if applicable, the [ROADMAP](ROADMAP.md).
## Commits
Mensagens curtas no imperativo (`corrige leitura de runs esparsos no NTFS`). Uma mudança lógica por PR.
Short messages in the imperative (`fix reading of sparse runs in NTFS`). One logical change per PR.
+41
View File
@@ -0,0 +1,41 @@
# Contribuindo com o Guará
[English](CONTRIBUTING.md) · **Português**
Obrigado por ajudar! Regras de ouro:
1. **Nunca escreva na origem.** Qualquer código que abra um dispositivo deve usar `source::open_path` (somente leitura). PRs que escrevem no disco de origem são recusados.
2. **Teste com dados reais.** Novos detectores/leitores precisam de teste que recupera um arquivo conhecido bit a bit (veja `crates/guara-core/tests/` e `scripts/make-fixtures.sh`).
3. **Seja honesto sobre confiança.** Se o conteúdo não puder ser validado, a confiança é no máximo média.
## Fluxo
```bash
cargo fmt --all
cargo clippy --workspace --all-targets
cargo test --workspace
```
No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` e `python3`. Sem essas ferramentas os testes com imagem são ignorados (defina `GUARA_REQUIRE_FIXTURES=1` para falhar).
Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), rode `scripts/fetch-testdata.sh`; sem elas esses testes são pulados. O teste de BitLocker leva cerca de 75 s.
## Onde mexer
Mapa dos módulos em [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). O que falta e o que é bem-vindo está no [ROADMAP](ROADMAP.pt-BR.md).
## Adicionando um formato de arquivo
Detectores simples ficam em `crates/guara-core/src/signatures.rs`; os mais novos, em `formats.rs`. Acrescente o detector em `detect`, o primeiro byte em `might_start`, a extensão em `category_for_ext`/`matches_ext` e um arquivo real ao `scripts/make-fixtures.sh` com teste de bit-exatidão.
## Adicionando um sistema de arquivos
Crie o leitor em `crates/guara-core/src/fs/`, registre-o em `fs/mod.rs` e traga um teste com imagem real ou de referência. **Não entregamos leitor sem validação**: é por isso que ReFS e Storage Spaces ainda não existem. Imagens pequenas e sem dados pessoais são o melhor presente que se pode dar ao projeto.
## Documentação
A versão principal é em inglês e cada arquivo tem uma tradução em português (`*.pt-BR.md`, `docs/pt-BR/`). Sem travessões. Ao mudar um recurso, atualize as duas versões (inglês e português) de [docs/support.md](docs/support.md), do [CHANGELOG](CHANGELOG.md) e, se for o caso, do [ROADMAP](ROADMAP.md).
## Commits
Mensagens curtas no imperativo (`corrige leitura de runs esparsos no NTFS`). Uma mudança lógica por PR.
Generated
+401 -139
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
dependencies = [
"cipher",
"cpubits",
"cpufeatures",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -44,7 +55,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -55,7 +66,19 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "argon2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
@@ -70,6 +93,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -77,12 +106,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
name = "blake2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"generic-array",
"digest",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
@@ -94,18 +132,45 @@ dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -124,6 +189,16 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -155,7 +230,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -164,6 +239,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -177,31 +258,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cpufeatures"
version = "0.2.17"
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"generic-array",
"typenum",
"hybrid-array",
]
[[package]]
@@ -212,17 +304,28 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "ctutils"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
[[package]]
name = "digest"
version = "0.10.7"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"ctutils",
]
[[package]]
@@ -237,6 +340,12 @@ dependencies = [
"objc2",
]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
version = "0.3.14"
@@ -244,7 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -273,27 +382,6 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -317,47 +405,84 @@ dependencies = [
[[package]]
name = "guara-cli"
version = "0.3.0"
version = "1.1.0"
dependencies = [
"clap",
"ctrlc",
"getrandom 0.2.17",
"getrandom",
"guara-core",
"rpassword",
"serde_json",
"tiny_http",
]
[[package]]
name = "guara-core"
version = "0.3.0"
version = "1.1.0"
dependencies = [
"aes",
"argon2",
"bzip2",
"flate2",
"hmac",
"image",
"jpeg-decoder",
"jpeg-encoder",
"libc",
"lz4_flex",
"md-5",
"mp4",
"pbkdf2",
"ripemd",
"ruzstd",
"serde",
"serde_json",
"sha1",
"sha2",
"snap",
"tempfile",
"thiserror",
"windows-sys 0.59.0",
"thiserror 2.0.21",
"tiny_http",
"windows-sys",
"zip",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -384,6 +509,25 @@ dependencies = [
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "inout"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [
"hybrid-array",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -408,6 +552,12 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.191"
@@ -427,10 +577,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "md-5"
version = "0.10.6"
name = "lz4_flex"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest",
@@ -472,6 +628,20 @@ dependencies = [
"pxfm",
]
[[package]]
name = "mp4"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3"
dependencies = [
"byteorder",
"bytes",
"num-rational",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -484,6 +654,37 @@ dependencies = [
"libc",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -520,6 +721,37 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "password-hash"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"getrandom",
"phc",
]
[[package]]
name = "pbkdf2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "phc"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
dependencies = [
"base64ct",
"ctutils",
"getrandom",
]
[[package]]
name = "png"
version = "0.18.1"
@@ -569,6 +801,36 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ripemd"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159"
dependencies = [
"digest",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys",
]
[[package]]
name = "rtoolbox"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "rustix"
version = "1.1.5"
@@ -579,7 +841,16 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "ruzstd"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
[[package]]
@@ -609,7 +880,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -627,9 +898,9 @@ dependencies = [
[[package]]
name = "sha1"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -638,9 +909,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.10.9"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -653,12 +924,29 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
@@ -677,10 +965,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
@@ -689,7 +986,18 @@ version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -700,7 +1008,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -715,6 +1023,18 @@ dependencies = [
"log",
]
[[package]]
name = "twox-hash"
version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "typed-path"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -733,18 +1053,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
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"
@@ -757,15 +1065,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -776,69 +1075,20 @@ dependencies = [
]
[[package]]
name = "windows-targets"
version = "0.52.6"
name = "zip"
version = "9.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"nix",
"typed-path",
"zopfli",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -850,3 +1100,15 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+5 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "0.3.0"
version = "1.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
@@ -16,3 +16,7 @@ codegen-units = 1
opt-level = 3
strip = true
panic = "abort"
# Dependências otimizadas também em debug: os testes de cifra fazem milhões de SHA-256.
[profile.dev.package."*"]
opt-level = 3
+25
View File
@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
# Guará CLI image. Intended for Linux hosts, headless servers and forensic image files.
# The graphical interface is not served from the container (see docs/docker.md).
FROM rust:1-slim-bookworm AS build
WORKDIR /src
# The CLI embeds the web interface at compile time (include_str!), so it needs apps/gui/ui.
COPY Cargo.toml Cargo.lock ./
COPY crates crates
COPY apps/gui/ui apps/gui/ui
RUN cargo build --release --locked -p guara-cli
FROM debian:bookworm-slim
# smartmontools gives `guara health` access to SMART data.
RUN apt-get update \
&& apt-get install -y --no-install-recommends smartmontools ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/guara /usr/local/bin/guara
LABEL org.opencontainers.image.title="Guará" \
org.opencontainers.image.description="File recovery for hard drives, SSDs and USB sticks (CLI)" \
org.opencontainers.image.source="https://github.com/lucasrguerra/guara" \
org.opencontainers.image.licenses="MIT OR Apache-2.0"
WORKDIR /data
ENTRYPOINT ["guara"]
CMD ["--help"]
+96 -72
View File
@@ -1,116 +1,140 @@
<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>
<p align="center">Free file recovery for hard drives, SSDs and USB sticks.<br>Terminal and graphical interface · Windows, Linux and macOS · read-only on the source.</p>
<p align="center"><a href="docs/support.md">What it supports</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/cli.md">Terminal</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
<p align="center"><img src="docs/img/explorer-light-en.png" width="900" alt="The Guará explorer showing recovered photos grouped by month, with a sidebar to filter and a details panel on the right"><br><sub>The real interface running on a synthetic test image (generated photos, no real data).</sub></p>
---
O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec, TestDisk e Disk Drill fazem separadamente:
In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately:
- **Metadados do sistema de arquivos** (NTFS, FAT12/16/32, exFAT): acha arquivos apagados **com nome, pasta e data**.
- **Carving por assinatura/estrutura** no espaço livre, em partições danificadas e no **espaço não particionado**: acha arquivos mesmo quando o sistema de arquivos sumiu. Os detectores leem a estrutura real (JPEG, PNG, GIF, BMP, PDF, ZIP/DOCX/XLSX/PPTX/ODF/EPUB, MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE (DOC/XLS/PPT), MP3, EXE/DLL) para saber o **tamanho exato** e a **confiança**.
- **Partições perdidas:** lê MBR/GPT e procura volumes (NTFS, FAT, exFAT, ext, APFS, HFS+, BitLocker, LUKS) por assinatura onde a tabela falhou.
- **Fusão sem duplicatas:** um arquivo achado pelos dois métodos aparece uma vez, com a melhor evidência.
- **Confiança por arquivo** (alta / média / baixa): estrutura íntegra, clusters reutilizados, conteúdo zerado (TRIM) ou que não confere com a extensão.
- **File system metadata:** finds deleted files **with name, folder and date** on NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs, XFS and APFS.
- **Structure-aware carving** in free space, damaged partitions and unpartitioned space. The detectors read the real structure of dozens of formats to learn the **exact size** and a **confidence** level.
- **Encrypted volumes:** opens BitLocker, LUKS and FileVault 2 with your password or key and scans inside.
- **RAID and LVM:** reassembles arrays from loose disks (RAID 0, 1, 4, 5, 6, 10, LVM2), working out level, stripe size and order by itself when there is no metadata.
- **Fragmented files:** reassembles JPEGs stored in pieces and rebuilds the index of MP4/MOV videos that lost their `moov`.
- **Forensic images:** reads and writes raw images, **E01/Ex01** and **AFF4**.
- **Lost partitions:** reads MBR/GPT and searches for volumes by signature where the table failed.
- **Merge without duplicates:** a file found by more than one method appears once, with the best evidence.
- **Per-file confidence** (high, medium, low): intact structure, reused clusters, zeroed content (TRIM) or content that does not match the extension.
## Segurança dos dados
The full matrix, with the limits of each item, is in [docs/support.md](docs/support.md).
- 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 **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.
## Data safety
## Instalação
- The source is opened **read-only**. Nothing in the core writes to the device being recovered.
- Recovery **refuses destinations on the same disk** as the source (Linux and macOS).
- **Forensic imaging** tolerates bad sectors, with an error map and hash: always work on the copy of a failing disk.
- Every recovery writes `guara-manifesto.json` with the **MD5, SHA-1 and SHA-256** of every file.
- **Passwords are never written to the session file.**
Baixe o pacote do seu sistema em **[Releases](../../releases)**:
## Installation
| | CLI | App gráfico |
Download the package for your system from **[Releases](../../releases)**:
| | CLI | Graphical app |
|---|---|---|
| Windows | `guara-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
| Windows | `guara-*-windows-x86_64.zip` | `.msi` / `.exe` installer |
| macOS | `guara-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+).
Every file has a `.sha256` next to it. To build from source: `cargo build --release -p guara-cli` (Rust 1.80+).
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256.
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS).
Reading raw disks requires privileges: **administrator** (Windows), `sudo` (Linux and macOS). Even as root, Guará writes sessions, reports and recovered files with the owner and permissions of the user who launched it.
## Uso (terminal)
There is also a Docker image of the command line tool, for Linux hosts and for working on image files: see [docs/docker.md](docs/docker.md).
## Getting started
```bash
sudo guara devices # discos e pendrives (avisa TRIM e volumes montados)
sudo guara inspect /dev/sdb # partições, espaço não particionado, cifragem
sudo guara scan /dev/sdb --session s.json --list # varredura profunda
sudo guara scan /dev/sdb --quick # só metadados (segundos)
guara scan disco.img --category image,document --min-confidence medium
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)
sudo guara devices # which disks exist
sudo guara scan /dev/sdb --session s.json --list # deep scan
guara recover s.json /mnt/other-disk/Recovered # recover, onto ANOTHER disk
```
`--json` está disponível em `devices`, `inspect` e `scan` para automação.
Failing disk? Make the image first and work on the copy:
## Interface gráfica
```bash
sudo guara image /dev/sdb /mnt/other-disk/sdb.img
guara scan /mnt/other-disk/sdb.img --session s.json
```
App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando o Guará acha milhares de itens:
Other commands (`inspect`, `health`, `hash`, `search`, `export`, `report`, `verify`, `ui`, encrypted volumes, RAID) are in **[docs/cli.md](docs/cli.md)**.
- **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`.
## Graphical interface
**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`).
A desktop app (Tauri) in the style of a file manager, designed for when Guará finds thousands of items. The same front end runs in the browser with `guara ui`. More screenshots and details in **[docs/interface.md](docs/interface.md)**.
## Recursos periciais
<p align="center"><img src="docs/img/explorer-dark-en.png" width="640" alt="The explorer in the dark theme"> <img src="docs/img/settings-language-en.png" width="250" alt="Settings with the language selector"></p>
Pensados para quem precisa documentar o que fez:
- **Thumbnail grid** or **list**, grouping by day or month and virtual scrolling for tens of thousands of files.
- **Sidebar navigation** by type, real photo date (EXIF), folder, camera, confidence and GPS, with an option to hide duplicates.
- **Finder or Explorer style selection** and full-screen **quick look** (Space, Enter or double click).
- **Details panel** with preview, dates and metadata.
- Fields for password, RAID, reference video and image format; disk health (SMART); pause and resume; Auto, Light and Dark themes; interface in English and Portuguese, chosen in Settings.
- **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.
**Preview before recovering**, read straight from the disk without writing anything: images, video, audio, PDF, text, Word/Excel/PowerPoint/OpenDocument, ZIP contents, **SQLite databases** (with deleted rows) and hex. On Linux, video playback depends on the `gstreamer1.0-libav` and `gstreamer1.0-plugins-good` plugins; without them, the "Open in system player" button covers the case.
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.
## Forensic features
## Limites conhecidos (v0.1)
- **Hashes** MD5, SHA-1 and SHA-256 of every recovered file, of the image and of the device.
- **Metadata:** EXIF (camera, lens, serial number, GPS), PDF, Office and OpenDocument, MP4/MOV.
- **Timeline:** creation, modification and access dates from NTFS, FAT and exFAT.
- **Text search across the whole disk**, including free space, in UTF-8 and UTF-16.
- **CSV** (opens in Excel) and a printable **HTML report** with case, examiner, source, partitions, summary and hashes.
- **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name.
- **Duplicate detection** by content signature.
Seja realista sobre o que dá para esperar:
> **Warning:** Guará has **not been externally validated** yet (for example, in the NIST CFTT tests) and has no audit log or signed report. It is well suited to recovery and triage; for use in court, check the results by other means. The path to get there is in the [ROADMAP](ROADMAP.md).
- **Dados sobrescritos não voltam.** Em **SSD com TRIM** o próprio disco pode zerar blocos apagados; o Guará avisa, mas não faz milagre.
- **Carving só acha arquivos contíguos.** Arquivos fragmentados sem metadados podem sair truncados (marcados com confiança baixa).
- **ext2/3/4, APFS e HFS+:** só varredura por assinatura (no ext, apenas no espaço livre). Não há leitura de inodes/journal ainda.
- **NTFS:** arquivos comprimidos/EFS e registros com lista de atributos (arquivos muito fragmentados) são ignorados ou marcados como incompletos.
- **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).
## Known limits
## Desenvolvimento
- **Overwritten data does not come back.** On an SSD with TRIM the disk may zero deleted blocks; Guará warns you, but it cannot work miracles.
- **Fragments:** only JPEG (up to three pieces) and MP4 without an index are reassembled. Other formats, if fragmented, come out truncated with low confidence.
- **ReFS and Storage Spaces** are not supported (ReFS is only detected).
- **APFS:** no free-space map. **XFS and APFS:** deletion recovery tested only in simulation.
- **NTFS:** compressed files, EFS and heavily fragmented records are skipped or marked as incomplete.
- **exFAT:** validated with a synthetic image; real media are still missing.
- **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords.
- **Windows and macOS:** the code builds and is tested in CI, but the tests with real images run only on Linux. Reports from real media are very welcome.
- **GUI:** on Linux and macOS the app restarts entirely as administrator (`pkexec` or `osascript`) instead of using a helper process.
## Where it is going
Summary of the [ROADMAP](ROADMAP.md):
| | |
|---|---|
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART |
| **Next** | Audit log, signed and PDF report, APFS free map, tests on Windows and macOS |
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
## Development
```
crates/guara-core núcleo: leitura, partições, sistemas de arquivos, carving, fusão, recuperação
crates/guara-cli binário `guara` (CLI + servidor local da UI)
apps/gui interface (ui/ = HTML/CSS/JS; src-tauri/ = app desktop)
scripts/ geração de imagens de teste (FAT32, NTFS, ext4, exFAT)
crates/guara-core core: reading, partitions, file systems, encryption, RAID, carving, recovery
crates/guara-cli `guara` binary (CLI + local UI server)
apps/gui interface (ui/ = HTML, CSS and JS; src-tauri/ = desktop app)
scripts/ test images and reference data
docs/ support, terminal and architecture
```
```bash
cargo test --workspace # no Linux gera imagens reais com mtools/ntfs-3g/e2fsprogs (veja scripts/)
cargo test --workspace # on Linux this builds real images with mtools, ntfs-3g, e2fsprogs
scripts/fetch-testdata.sh # optional: reference images (BitLocker, FileVault 2, dfvfs, AFF4)
cargo clippy --workspace --all-targets
```
Veja [CONTRIBUTING.md](CONTRIBUTING.md). Versões seguem [SemVer](https://semver.org/lang/pt-BR/); mudanças em [CHANGELOG.md](CHANGELOG.md). Para publicar: `git tag vX.Y.Z && git push --tags` (o workflow `release` compila e publica tudo).
See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/architecture.md](docs/architecture.md). Versions follow [SemVer](https://semver.org/); changes are in [CHANGELOG.md](CHANGELOG.md). To publish: `git tag vX.Y.Z && git push --tags` (the `release` workflow builds and publishes everything). Vulnerabilities: [SECURITY.md](SECURITY.md).
## Licença
## License
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
[MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
+140
View File
@@ -0,0 +1,140 @@
<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>
<p align="center"><a href="docs/pt-BR/suporte.md">O que suporta</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
<p align="center"><img src="docs/img/explorer-light-pt.png" width="900" alt="O explorador do Guará mostrando fotos recuperadas agrupadas por mês, com a barra lateral para filtrar e o painel de detalhes à direita"><br><sub>A interface de verdade rodando sobre uma imagem de teste sintética (fotos geradas, sem dados reais).</sub></p>
---
O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec, TestDisk e Disk Drill fazem separadamente:
- **Metadados do sistema de arquivos:** acha arquivos apagados **com nome, pasta e data** em NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs, XFS e APFS.
- **Carving por estrutura** no espaço livre, em partições danificadas e no espaço não particionado. Os detectores leem a estrutura real de dezenas de formatos para saber o **tamanho exato** e a **confiança**.
- **Volumes cifrados:** abre BitLocker, LUKS e FileVault 2 com a sua senha ou chave e varre por dentro.
- **RAID e LVM:** remonta arrays a partir de discos soltos (RAID 0, 1, 4, 5, 6, 10, LVM2), descobrindo nível, faixa e ordem sozinho quando não há metadados.
- **Arquivos fragmentados:** remonta JPEG em pedaços e refaz o índice de vídeos MP4/MOV sem `moov`.
- **Imagens forenses:** lê e grava imagem bruta, **E01/Ex01** e **AFF4**.
- **Partições perdidas:** lê MBR/GPT e procura volumes por assinatura onde a tabela falhou.
- **Fusão sem duplicatas:** um arquivo achado por mais de um método aparece uma vez, com a melhor evidência.
- **Confiança por arquivo** (alta, média, baixa): estrutura íntegra, clusters reutilizados, conteúdo zerado (TRIM) ou que não confere com a extensão.
Matriz completa, com limites de cada item, em [docs/pt-BR/suporte.md](docs/pt-BR/suporte.md).
## Segurança dos dados
- 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 e macOS).
- **Imagem forense** tolerante a setores ruins, com mapa de erros e hash: trabalhe sempre na cópia de um disco com defeito.
- Cada recuperação grava `guara-manifesto.json` com **MD5, SHA-1 e SHA-256** de todos os arquivos.
- **Senhas nunca vão para o arquivo de sessão.**
## Instalação
Baixe o pacote do seu sistema em **[Releases](../../releases)**:
| | CLI | App gráfico |
|---|---|---|
| Windows | `guara-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
| macOS | `guara-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+).
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux e macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
Há também uma imagem Docker do terminal, para hospedeiros Linux e para trabalhar com arquivos de imagem: veja [docs/pt-BR/docker.md](docs/pt-BR/docker.md).
## Começando
```bash
sudo guara devices # que discos existem
sudo guara scan /dev/sdb --session s.json --list # varredura profunda
guara recover s.json /mnt/outro-disco/Recuperados # recupera, em OUTRO disco
```
Disco com defeito? Faça a imagem primeiro e trabalhe na cópia:
```bash
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img
guara scan /mnt/outro-disco/sdb.img --session s.json
```
Outros comandos (`inspect`, `health`, `hash`, `search`, `export`, `report`, `verify`, `ui`, volumes cifrados, RAID) estão em **[docs/pt-BR/cli.md](docs/pt-BR/cli.md)**.
## Interface gráfica
App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando o Guará acha milhares de itens. O mesmo front-end roda no navegador com `guara ui`. Mais telas e detalhes em **[docs/pt-BR/interface.md](docs/pt-BR/interface.md)**.
<p align="center"><img src="docs/img/explorer-dark-pt.png" width="640" alt="O explorador no tema escuro"> <img src="docs/img/settings-language-pt.png" width="250" alt="Configurações com o seletor de idioma"></p>
- **Grade de miniaturas** ou **lista**, agrupamento por dia ou mês e rolagem virtual para dezenas de milhares de arquivos.
- **Navegação lateral** por tipo, data real da foto (EXIF), pasta, câmera, confiança e GPS, com opção de ocultar duplicadas.
- **Seleção como no Finder ou Explorer** e **visualização rápida** em tela cheia (Espaço, Enter ou duplo clique).
- **Painel de detalhes** com pré-visualização, datas e metadados.
- Campos para senha, RAID, vídeo de referência e formato da imagem; saúde do disco (SMART); pausar e continuar; temas Auto, Claro e Escuro; interface em português e inglês.
**Pré-visualização antes de recuperar**, lida direto do disco sem gravar nada: imagens, vídeo, áudio, PDF, texto, Word/Excel/PowerPoint/OpenDocument, conteúdo de ZIP, **bancos SQLite** (com linhas apagadas) e hexadecimal. No Linux, a reprodução de vídeo depende dos plugins `gstreamer1.0-libav` e `gstreamer1.0-plugins-good`; sem eles, o botão "Abrir no player do sistema" cobre o caso.
## Recursos periciais
- **Hashes** MD5, SHA-1 e SHA-256 de cada arquivo recuperado, da imagem e do dispositivo.
- **Metadados:** EXIF (câmera, lente, número de série, GPS), PDF, Office e OpenDocument, MP4/MOV.
- **Linha do tempo:** datas de criação, modificação e acesso do NTFS, FAT e exFAT.
- **Busca de texto no disco inteiro**, inclusive espaço livre, em UTF-8 e UTF-16.
- **CSV** (abre no Excel) e **relatório HTML** imprimível com caso, examinador, origem, partições, resumo e hashes.
- **Sessões automáticas** e reconexão: o Guará reconhece o mesmo disco que volta com outro nome.
- **Detecção de duplicadas** por assinatura de conteúdo.
> **Atenção:** o Guará ainda **não foi validado externamente** (por exemplo, nos testes do NIST CFTT) e não tem registro de auditoria nem relatório assinado. Serve bem para recuperação e triagem; para uso em juízo, confira os resultados por outro meio. O caminho para isso está no [ROADMAP](ROADMAP.pt-BR.md).
## Limites conhecidos
- **Dados sobrescritos não voltam.** Em SSD com TRIM o disco pode zerar blocos apagados; o Guará avisa, mas não faz milagre.
- **Fragmentos:** só JPEG (até três pedaços) e MP4 sem índice são remontados. Os demais formatos, se fragmentados, saem truncados com confiança baixa.
- **ReFS e Storage Spaces** não são suportados (ReFS só é detectado).
- **APFS:** sem mapa de espaço livre. **XFS e APFS:** recuperação de exclusão testada só em simulação.
- **NTFS:** arquivos comprimidos, EFS e registros muito fragmentados são ignorados ou marcados como incompletos.
- **exFAT:** validado com imagem sintética; faltam mídias reais.
- **Celulares:** o Guará trabalha com mídias de armazenamento e **não** desbloqueia aparelhos nem contorna senhas.
- **Windows e 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 e macOS o app reinicia inteiro como administrador (`pkexec` ou `osascript`) em vez de usar um processo auxiliar.
## Para onde vai
Resumo do [ROADMAP](ROADMAP.pt-BR.md):
| | |
|---|---|
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART |
| **Próximo** | Registro de auditoria, relatório assinado e em PDF, mapa livre do APFS, testes em Windows e macOS |
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
## Desenvolvimento
```
crates/guara-core núcleo: leitura, partições, sistemas de arquivos, cifra, RAID, carving, recuperação
crates/guara-cli binário `guara` (CLI + servidor local da UI)
apps/gui interface (ui/ = HTML, CSS e JS; src-tauri/ = app desktop)
scripts/ imagens de teste e dados de referência
docs/ suporte, terminal e arquitetura
```
```bash
cargo test --workspace # no Linux gera imagens reais com mtools, ntfs-3g, e2fsprogs
scripts/fetch-testdata.sh # opcional: imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
cargo clippy --workspace --all-targets
```
Veja [CONTRIBUTING.md](CONTRIBUTING.pt-BR.md) e [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). Versões seguem [SemVer](https://semver.org/lang/pt-BR/); mudanças em [CHANGELOG.md](CHANGELOG.pt-BR.md). Para publicar: `git tag vX.Y.Z && git push --tags` (o workflow `release` compila e publica tudo). Vulnerabilidades: [SECURITY.md](SECURITY.pt-BR.md).
## Licença
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
+182
View File
@@ -0,0 +1,182 @@
# Roadmap
[English](ROADMAP.md) · [Português](ROADMAP.pt-BR.md)
Where Guará has been, where it is and where it is going. What is marked as done exists in the code and has a test; what is planned is intent, not a promise, and has no date.
Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ out of scope
## Principles that do not change
1. **The source is read-only.** Nothing in the core writes to the device being recovered.
2. **Do not ship without validating.** A feature only lands with a test against a real or reference image. That is why ReFS and Storage Spaces do not exist yet: there is no reference image to check against.
3. **Honest confidence.** If the content could not be checked, confidence is low or medium, never high.
4. **Free and auditable.** Open source (MIT or Apache-2.0), no telemetry, and every result can be reproduced.
## History
| Version | Date | Milestone |
|---|---|---|
| 0.1.0 | 2026-10-18 | Read-only core, MBR/GPT, NTFS, FAT, exFAT, structure-aware carving, forensic image, sessions, CLI and interface |
| 0.2.0 | 2026-10-18 | Full preview (image, video, audio, PDF, Office, ZIP, hex) and themes |
| 0.3.0 | 2026-10-18 | Explorer with thumbnails, metadata (EXIF, GPS), hashes, disk search, CSV, HTML report and duplicates |
| 0.4.0 | 2026-10-18 | Disk reconnection, automatic sessions, organization when recovering |
| 0.5.0 | 2026-10-18 | SQLite viewer with deleted rows, SMART, English interface, recovery resume |
| 0.6.0 | 2026-10-18 | Full disk health (ATA and NVMe SMART), `guara health`, new logo |
| 1.0.0 | 2026-10-19 | ext4, HFS+, Btrfs, XFS, APFS; BitLocker, LUKS, FileVault 2; RAID and LVM; E01, Ex01, AFF4; fragmented JPEG; MP4 without moov |
| 1.0.1 | 2026-10-20 | Documentation in English with Portuguese translations, complete English interface, screenshots, Docker image of the CLI |
| 1.1.0 | 2026-10-20 | Core messages and dates in English, automated translation check, RustCrypto on digest 0.11, verified Docker device access and Superset workspaces |
Details of each version in the [CHANGELOG](CHANGELOG.md).
## Current status (1.1.0) against what is expected from a forensic suite
### 1. File systems with metadata
| Item | Status | Note |
|---|---|---|
| NTFS, FAT12/16/32, exFAT | ✅ | Since 0.1.0. NTFS skips compressed and EFS files |
| ext2/3/4 | ✅ | Deleted inodes and old copies in the journal |
| HFS+ | ✅ | Tree node leftovers and journal |
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents |
| XFS | ✅ | Freed inodes and names of freed entries. Tested only with simulated deletion |
| APFS | 🟡 | Container, object map, snapshots and old nodes. **Free-space map is missing**; deletion tested only in simulation |
| ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
| Storage Spaces | 🔜 | Not done. Reference image missing |
| NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today |
| XFS and APFS validation with real deletion | 🔜 | Replace the simulation with images from real devices |
### 2. Encrypted volumes
| Item | Status |
|---|---|
| BitLocker (password, recovery key, .BEK, clear key) | ✅ |
| LUKS1 and LUKS2 | ✅ |
| FileVault 2 (Core Storage) | ✅ |
| FileVault 2 on APFS with an encrypted volume | 🔜 |
| Key from memory (hibernation, RAM dump) | 🔭 |
| Dictionary and brute-force attacks (BitLocker, LUKS, FileVault, RAR, ZIP and other encrypted volumes/files) | 🔜 |
Today Guará opens volumes when you **already have** the password or key. Password recovery by dictionary and brute force (with a GPU, like Hashcat) is a planned feature, central to forensic use.
### 3. Forensic images
| Item | Status |
|---|---|
| Raw image (dd) tolerant of bad sectors, with an error map | ✅ |
| E01 and Ex01: read, write and `guara verify` | ✅ |
| AFF4: read and write | ✅ |
| Other containers (VMDK, VHD/VHDX, QCOW2, DMG) | 🔜 |
| Mobile device image (see item 7) | 🔭 |
### 4. Assembling loose disks
| Item | Status |
|---|---|
| RAID 0, 1, 4, 5, 6, 10 and linear (mdadm), including degraded | ✅ |
| Automatic discovery of level, stripe size, layout and order without metadata | ✅ |
| LVM2 (linear, striped, mirrored and RAID) | ✅ |
| Storage Spaces | 🔜 |
| Multi-disk Btrfs and ZFS (the file system's own RAID) | 🔭 |
| Proprietary controller RAID (Dell PERC, HP Smart Array) | 🔭 |
### 5. Carving
| Item | Status |
|---|---|
| Detection by real structure, with exact size and confidence | ✅ Since 0.1.0 (JPEG, PNG, GIF, BMP, PDF, ZIP and derivatives, MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3, PE) and, in 1.0.0, 24 more (gzip, zstd, Matroska/WebM, Ogg, MPEG-TS, FLV, ASF, MIDI, RAR, ar, SWF, ELF, TIFF, ICO, Windows registry, EVTX, PST, RTF, pcap and others) |
| Fragmented JPEG reassembled (up to three fragments) | ✅ |
| MP4/MOV without `moov` rebuilt (H.264 and HEVC) from a reference video | ✅ |
| Fragments: more than three, and formats beyond JPEG (PNG, DOC, ZIP, SQLite) | 🔜 |
| Video reconstruction without a reference, audio in MP4 without `moov`, drone and action camera videos in other containers | 🔜 |
| More formats validated by structure | 🔜 ongoing |
### 6. Artifact interpretation
Guará **identifies and recovers** these files, but only interprets some of them.
| Item | Status |
|---|---|
| SQLite database: schema, tables, blobs, dates and deleted rows | ✅ |
| Metadata: EXIF and GPS, PDF, Office and OpenDocument, MP4/MOV | ✅ |
| Carving of Windows registry (`regf`), EVTX, PST, pcap | 🟡 only recognizes and recovers, does not interpret |
| Interpret the Windows registry (users, programs, USB, execution history) | 🔜 |
| Browser history, cookies and downloads (Chrome, Firefox, Safari) | 🔜 built on the SQLite reader |
| Messages, contacts and location from apps (WhatsApp, Telegram, Signal and others) | 🔜 built on the SQLite reader |
| System logs and Windows events (EVTX) | 🔜 |
| **Unified timeline** of all artifacts | 🔜 |
### 7. Mobile device extraction
Here the leap is one of kind, not of feature. Today Guará works with **storage media**.
| Item | Status |
|---|---|
| Android through ADB and backups | 🔭 |
| iOS through iTunes or Finder backups (including password-encrypted ones) | 🔭 |
| Physical access through bootloader and chip exploits | 🔭 |
Physical access is the differentiator of commercial tools like Cellebrite. It depends on full-time vulnerability research teams, and many of those techniques cannot be published as open source, which makes this the most distant item on the roadmap. The closest direction is to read what the user or examiner can legitimately export (backups and ADB) and interpret that data with item 6; physical access by exploit stays as a long-term idea, to pursue as the project grows.
### 8. Chain of custody and validity in court
| Item | Status |
|---|---|
| MD5, SHA-1 and SHA-256 of every file, of the image and of the device | ✅ |
| `guara-manifesto.json` manifest in every recovery | ✅ |
| HTML report with case, examiner, source, partitions, summary and hashes | ✅ |
| Verification of an E01/Ex01 image against the stored hash | ✅ |
| **Audit log** of every examiner action (who, when, what) | 🔜 |
| **Digitally signed reports** and a timestamped manifest | 🔜 |
| **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
| PDF report | 🔜 |
| **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 |
| Signed binaries (Windows and macOS) | 🔜 |
External validation is what makes a report accepted. Without it, Guará is good for triage, recovery and support, and the examiner must check the results by other means.
### 9. Analysis
| Item | Status |
|---|---|
| Text search across the whole disk, UTF-8 and UTF-16, including free space | ✅ `guara search` |
| Duplicate detection | ✅ |
| Regular expression and keyword search in **file content** (Office, PDF, e-mail) | 🔜 |
| Known hashes: **NSRL** to ignore system files | 🔜 |
| Hash lists of illicit material (imported by the examiner) | 🔜 |
| Image detection by content (similarity, classification) | 🔭 |
| **OCR** on scanned images and PDFs | 🔭 |
### 10. Platform and distribution
| Item | Status |
|---|---|
| CLI and interface for Windows, Linux and macOS | ✅ |
| CI on three systems, automatic release by tag | ✅ |
| Tests with real images on Windows and macOS (Linux only today) | 🔜 |
| GUI: privileged helper process instead of restarting the whole app as administrator | 🔜 |
| Signed and notarized binaries | 🔜 |
| Packages in package managers (winget, Homebrew, Flatpak) | 🔭 |
## Suggested next milestones
No dates, in the order that makes the most sense.
**1.1: trust and custody**
Audit log, PDF report, signed manifest, APFS free-space map, XFS and APFS validation with real deletions, tests with real images on Windows and macOS.
**1.2: analysis**
NSRL and hash lists, search in file content, unified timeline.
**1.3: artifacts**
Windows registry, browsers, EVTX and the most common messaging apps, built on the SQLite reader.
**1.4: more structure**
ReFS and Storage Spaces (once there is a reference image), other image containers (VMDK, VHD, QCOW2), expanded fragmented carving.
**2.0: mobile devices and validation**
iTunes backups, ADB and Android backups; submission to the NIST CFTT tests.
## How to help
The most common bottleneck is a **reference image**: a ReFS volume, a Storage Spaces pool or an XFS with real deletion, preferably small and without personal data. If you have one, open an issue. See also [CONTRIBUTING](CONTRIBUTING.md).
+182
View File
@@ -0,0 +1,182 @@
# Roadmap
[English](ROADMAP.md) · **Português**
Onde o Guará esteve, onde está e para onde vai. O que está marcado como feito existe no código e tem teste; o que está planejado é intenção, não promessa, e não tem data.
Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo · ⛔ fora do escopo
## Princípios que não mudam
1. **A origem é somente leitura.** Nada no núcleo escreve no dispositivo recuperado.
2. **Não entregar sem validar.** Um recurso só entra com teste contra imagem real ou de referência. Por isso ReFS e Storage Spaces ainda não existem: não há imagem de referência para conferir.
3. **Confiança honesta.** Se o conteúdo não pôde ser conferido, a confiança é baixa ou média, nunca alta.
4. **Livre e auditável.** Código aberto (MIT ou Apache-2.0), sem telemetria, e cada resultado pode ser reproduzido.
## Histórico
| Versão | Data | Marco |
|---|---|---|
| 0.1.0 | 2026-10-18 | Núcleo somente leitura, MBR/GPT, NTFS, FAT, exFAT, carving por estrutura, imagem forense, sessões, CLI e interface |
| 0.2.0 | 2026-10-18 | Pré-visualização completa (imagem, vídeo, áudio, PDF, Office, ZIP, hexadecimal) e temas |
| 0.3.0 | 2026-10-18 | Explorador com miniaturas, metadados (EXIF, GPS), hashes, busca no disco, CSV, relatório HTML e duplicadas |
| 0.4.0 | 2026-10-18 | Reconexão de disco, sessões automáticas, organização ao recuperar |
| 0.5.0 | 2026-10-18 | Visualizador de SQLite com linhas apagadas, SMART, interface em inglês, retomada de recuperação |
| 0.6.0 | 2026-10-18 | Saúde do disco completa (SMART ATA e NVMe), `guara health`, nova logo |
| 1.0.0 | 2026-10-19 | ext4, HFS+, Btrfs, XFS, APFS; BitLocker, LUKS, FileVault 2; RAID e LVM; E01, Ex01, AFF4; JPEG fragmentado; MP4 sem moov |
| 1.0.1 | 2026-10-20 | Documentação em inglês com tradução em português, interface completa em inglês, telas, imagem Docker do terminal |
| 1.1.0 | 2026-10-20 | Mensagens do núcleo e datas em inglês, verificação automática da tradução, RustCrypto em digest 0.11, acesso a dispositivo no Docker e workspaces do Superset verificados |
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
## Situação atual (1.1.0) contra o que se espera de uma suíte forense
### 1. Sistemas de arquivos com metadados
| Item | Estado | Observação |
|---|---|---|
| NTFS, FAT12/16/32, exFAT | ✅ | Desde a 0.1.0. NTFS ignora arquivos comprimidos e EFS |
| ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário |
| HFS+ | ✅ | Sobras de nós da árvore e diário |
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo |
| XFS | ✅ | Inodes liberados e nomes de entradas liberadas. Testado só com exclusão simulada |
| APFS | 🟡 | Contêiner, mapa de objetos, instantâneos e nós antigos. **Falta o mapa de espaço livre**; exclusão testada só em simulação |
| ReFS | 🔜 | Hoje só é detectado; a recuperação depende do carving. Falta imagem de referência |
| Storage Spaces | 🔜 | Não feito. Falta imagem de referência |
| NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos |
| Validação de XFS e APFS com exclusão real | 🔜 | Trocar a simulação por imagens de dispositivos reais |
### 2. Volumes cifrados
| Item | Estado |
|---|---|
| BitLocker (senha, chave de recuperação, .BEK, chave limpa) | ✅ |
| LUKS1 e LUKS2 | ✅ |
| FileVault 2 (Core Storage) | ✅ |
| FileVault 2 em APFS com volume cifrado | 🔜 |
| Chave a partir de memória (hibernação, despejo de RAM) | 🔭 |
| Ataque de dicionário e força bruta (BitLocker, LUKS, FileVault, RAR, ZIP e demais volumes/arquivos cifrados) | 🔜 |
Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recuperação de senha por dicionário e força bruta (com GPU, como Hashcat) é um recurso planejado, central no uso pericial.
### 3. Imagens forenses
| Item | Estado |
|---|---|
| Imagem bruta (dd) tolerante a setores ruins, com mapa de erros | ✅ |
| E01 e Ex01: leitura, gravação e `guara verify` | ✅ |
| AFF4: leitura e gravação | ✅ |
| Outros contêineres (VMDK, VHD/VHDX, QCOW2, DMG) | 🔜 |
| Imagem de dispositivo móvel (ver item 7) | 🔭 |
### 4. Montagem de discos soltos
| Item | Estado |
|---|---|
| RAID 0, 1, 4, 5, 6, 10 e linear (mdadm), inclusive degradado | ✅ |
| Descoberta automática de nível, faixa, layout e ordem sem metadados | ✅ |
| LVM2 (linear, em faixas, espelhado e RAID) | ✅ |
| Storage Spaces | 🔜 |
| Btrfs e ZFS multidisco (RAID do próprio sistema de arquivos) | 🔭 |
| RAID de controladoras proprietárias (Dell PERC, HP Smart Array) | 🔭 |
### 5. Carving
| Item | Estado |
|---|---|
| Detecção pela estrutura real, com tamanho exato e confiança | ✅ Desde a 0.1.0 (JPEG, PNG, GIF, BMP, PDF, ZIP e derivados, MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3, PE) e, na 1.0.0, mais 24 (gzip, zstd, Matroska/WebM, Ogg, MPEG-TS, FLV, ASF, MIDI, RAR, ar, SWF, ELF, TIFF, ICO, registro do Windows, EVTX, PST, RTF, pcap e outros) |
| JPEG fragmentado remontado (até três fragmentos) | ✅ |
| MP4/MOV sem `moov` reconstruído (H.264 e HEVC) a partir de um vídeo de referência | ✅ |
| Fragmentos: mais de três, e formatos além de JPEG (PNG, DOC, ZIP, SQLite) | 🔜 |
| Reconstrução de vídeo sem referência, áudio em MP4 sem `moov`, vídeos de drone e câmeras de ação em outros contêineres | 🔜 |
| Mais formatos validados por estrutura | 🔜 contínuo |
### 6. Interpretação de artefatos
O Guará **identifica e recupera** esses arquivos, mas só interpreta alguns.
| Item | Estado |
|---|---|
| Banco SQLite: esquema, tabelas, blobs, datas e linhas apagadas | ✅ |
| Metadados: EXIF e GPS, PDF, Office e OpenDocument, MP4/MOV | ✅ |
| Carving de registro do Windows (`regf`), EVTX, PST, pcap | 🟡 só reconhece e recupera, não interpreta |
| Interpretar o registro do Windows (usuários, programas, USB, histórico de execução) | 🔜 |
| Histórico, cookies e downloads de navegadores (Chrome, Firefox, Safari) | 🔜 base no leitor de SQLite |
| Mensagens, contatos e localização de apps (WhatsApp, Telegram, Signal e outros) | 🔜 base no leitor de SQLite |
| Logs do sistema e eventos do Windows (EVTX) | 🔜 |
| **Linha do tempo unificada** de todos os artefatos | 🔜 |
### 7. Extração de dispositivos móveis
Aqui o salto é de natureza, não de recurso. Hoje o Guará trabalha com **mídias de armazenamento**.
| Item | Estado |
|---|---|
| Android por ADB e backups | 🔭 |
| iOS por backups do iTunes ou Finder (inclusive cifrados com senha) | 🔭 |
| Acesso físico por exploits de bootloader e de chip | 🔭 |
O acesso físico é o diferencial de ferramentas comerciais como a Cellebrite. Depende de equipes de pesquisa de vulnerabilidades em tempo integral e muitas dessas técnicas não são publicáveis em código aberto, o que torna esse item o mais distante do roadmap. A direção mais próxima é ler o que o usuário ou o perito consegue exportar de forma legítima (backups e ADB) e interpretar esses dados com o item 6; o acesso físico por exploit fica como ideia de longo prazo, a perseguir conforme o projeto crescer.
### 8. Cadeia de custódia e validade em tribunal
| Item | Estado |
|---|---|
| MD5, SHA-1 e SHA-256 de cada arquivo, da imagem e do dispositivo | ✅ |
| Manifesto `guara-manifesto.json` em toda recuperação | ✅ |
| Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ |
| Verificação de imagem E01/Ex01 contra o hash gravado | ✅ |
| **Registro de auditoria** de cada ação do perito (quem, quando, o quê) | 🔜 |
| **Relatórios assinados** digitalmente e manifesto com carimbo de tempo | 🔜 |
| **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
| Relatório em PDF | 🔜 |
| **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 |
| Binários assinados (Windows e macOS) | 🔜 |
A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve para triagem, recuperação e apoio, e o perito deve conferir os resultados por outro meio.
### 9. Análise
| Item | Estado |
|---|---|
| Busca por texto em todo o disco, UTF-8 e UTF-16, inclusive espaço livre | ✅ `guara search` |
| Detecção de duplicadas | ✅ |
| Busca por expressão regular e por palavra-chave em **conteúdo de arquivos** (Office, PDF, e-mail) | 🔜 |
| Hashes conhecidos: **NSRL** para ignorar arquivos de sistema | 🔜 |
| Listas de hashes de material ilícito (importadas pelo perito) | 🔜 |
| Detecção de imagens por conteúdo (similaridade, classificação) | 🔭 |
| **OCR** em imagens e PDF digitalizados | 🔭 |
### 10. Plataforma e distribuição
| Item | Estado |
|---|---|
| CLI e interface para Windows, Linux e macOS | ✅ |
| CI em três sistemas, release automática por tag | ✅ |
| Testes com imagens reais em Windows e macOS (hoje só Linux) | 🔜 |
| GUI: processo auxiliar privilegiado em vez de reiniciar o app inteiro como administrador | 🔜 |
| Binários assinados e notarizados | 🔜 |
| Pacotes em gerenciadores (winget, Homebrew, Flatpak) | 🔭 |
## Próximos marcos sugeridos
Sem datas, na ordem em que fazem mais sentido.
**1.1: confiança e custódia**
Registro de auditoria, relatório em PDF, manifesto assinado, mapa de espaço livre do APFS, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS.
**1.2: análise**
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
**1.3: artefatos**
Registro do Windows, navegadores, EVTX e os apps de mensagem mais comuns, apoiados no leitor de SQLite.
**1.4: mais estrutura**
ReFS e Storage Spaces (quando houver imagem de referência), outros contêineres de imagem (VMDK, VHD, QCOW2), carving fragmentado ampliado.
**2.0: dispositivos móveis e validação**
Backups do iTunes, ADB e backups do Android; submissão aos testes do NIST CFTT.
## Como ajudar
O gargalo mais comum é **imagem de referência**: um volume ReFS, um pool de Storage Spaces ou um XFS com exclusão real, de preferência pequeno e sem dados pessoais. Se você tem um, abra uma issue. Veja também o [CONTRIBUTING](CONTRIBUTING.pt-BR.md).
+4 -2
View File
@@ -1,3 +1,5 @@
# Segurança
# Security
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.
[English](SECURITY.md) · [Português](SECURITY.pt-BR.md)
Guará opens devices in read-only mode and processes potentially corrupted or malicious data (forged file systems and files). Vulnerabilities (memory corruption in parsers, improper writes to the source, path escape during recovery) must be reported privately through the **Security → Report a vulnerability** tab of the repository on GitHub. Do not open a public issue before the fix.
+5
View File
@@ -0,0 +1,5 @@
# Segurança
[English](SECURITY.md) · **Português**
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.
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
version = "0.3.0"
version = "1.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "default",
"description": "Permissões da janela principal",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guara",
"version": "0.3.0",
"version": "1.1.0",
"identifier": "dev.guara.app",
"build": {
"frontendDist": "../ui"
@@ -19,7 +19,7 @@
}
],
"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'"
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost https://tile.openstreetmap.org; 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": {
+65 -1
View File
@@ -212,6 +212,8 @@ details.adv[open] summary { margin-bottom: 10px; }
.chip[aria-pressed="true"] { background: var(--text); color: var(--bg); }
.chip small { opacity: .6; margin-left: 4px; font-variant-numeric: tabular-nums; }
.input, .select { border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; min-width: 0; }
/* O fundo do select é translúcido; a lista aberta não herda isso e vinha branca com texto claro no tema escuro. */
.select option, .select optgroup { background-color: var(--surface); color: var(--text); }
.input { flex: 1 1 200px; }
.input::placeholder { color: var(--text-3); }
.split { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 18px; align-items: start; }
@@ -323,7 +325,7 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
.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 { width: min(1760px, 98vw); height: min(95vh, 1100px); 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; }
@@ -454,3 +456,65 @@ dialog.lightbox[open] { display: grid; }
.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; } }
.xp-insp #xpPv { cursor: zoom-in; }
.pv-card { width: 100%; min-height: 220px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 24px; text-align: center; }
.pv-card b { overflow-wrap: anywhere; font-size: 15px; }
.pv-bigicon { font-size: 52px; line-height: 1; }
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); }
.cand { display: grid; gap: 4px; margin: 6px 0 12px; max-height: 240px; overflow: auto; }
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
.cand .s { white-space: normal; }
/* ------------------------------------------------------------ visualizador de bancos de dados */
.db-sum { width: 100%; padding: 16px; display: grid; gap: 8px; text-align: left; }
.db-sum ul { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 2px; font-size: 13px; }
.db-sum li { display: flex; justify-content: space-between; gap: 10px; } .db-sum li small { color: var(--text-3); }
.viewer.db-large { display: block; overflow: hidden; }
.db-wrap { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: 100%; min-height: 0; width: 100%; text-align: left; }
.db-tables { overflow: auto; padding: 10px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
.db-t { display: flex; justify-content: space-between; gap: 8px; border: 0; background: transparent; cursor: pointer; padding: 7px 10px; border-radius: 9px; color: var(--text); font-size: 13.5px; text-align: left; }
.db-t:hover { background: color-mix(in srgb, var(--text) 7%, transparent); } .db-t[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.db-t small { color: var(--text-3); font-weight: 400; }
.db-meta { margin-top: 12px; display: grid; gap: 2px; font-size: 12.5px; color: var(--text-2); } .db-meta b { margin-top: 6px; color: var(--text-3); font-size: 11.5px; }
.db-main { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; }
.db-bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); }
.db-bar .input { flex: 0 1 220px; }
.db-grid { overflow: auto; }
.db-foot { display: flex; justify-content: space-between; align-items: center; padding: 8px 14px; border-top: 1px solid var(--line); }
.db-table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
.db-table th, .db-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
.db-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; color: var(--text-3); font-weight: 600; text-align: left; }
.db-table tbody th { color: var(--text-3); font-weight: 400; text-align: right; }
.db-table tr.del td { background: var(--warn-soft); } .db-table tr.del th { color: var(--warn); }
.db-null { color: var(--text-3); font-style: italic; } .db-blob { color: var(--accent); }
.db-ts { margin-left: 6px; font-style: normal; font-size: 11px; color: var(--accent); background: var(--accent-soft); padding: 0 5px; border-radius: 6px; }
@media (max-width: 860px) { .db-wrap { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } .db-tables { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); } }
.xp-map { position: relative; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 10px; margin-top: 8px; background: var(--surface-2, #e6eeee); }
.xp-tiles { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; height: 100%; }
.xp-tiles img { width: 100%; height: 100%; display: block; }
.xp-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #0f8f8f; border: 3px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
/* saúde do disco (SMART) */
.h-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }
.h-tile { background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; }
.h-tile small { color: var(--text-3); font-size: 12px; }
.h-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
.h-tile.warning b { color: var(--warn); } .h-tile.failing b { color: var(--bad); }
.h-status.ok { background: var(--ok-soft); } .h-status.ok b { color: var(--ok); }
.h-status.warning { background: var(--warn-soft); } .h-status.failing { background: var(--bad-soft); }
.h-status b span { font-weight: 500; opacity: .8; }
.h-info { margin-top: 14px; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 14px 16px; }
.h-scroll { overflow-x: auto; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); }
.h-table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }
.h-table th { text-align: left; color: var(--text-3); font-weight: 500; padding: 8px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.2)); white-space: nowrap; }
.h-table td { padding: 6px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.12)); white-space: nowrap; }
.h-table tr:last-child td { border-bottom: 0; }
.h-table .dim { color: var(--text-3); }
.h-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); }
.h-dot.warning { background: var(--warn); } .h-dot.failing { background: var(--bad); }
/* logo inline: segue o tema escolhido no app, não o do sistema */
.logo .a { stroke: var(--accent); } .logo .b { stroke: var(--accent-2); } .logo .t { fill: var(--text); }
+285 -28
View File
@@ -2,6 +2,7 @@
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
import { createExplorer } from './explorer.js';
import { setLang, LOC, currentLang } from './i18n.js';
const tauri = window.__TAURI__;
const params = new URLSearchParams(location.search);
@@ -47,9 +48,17 @@ 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 */ }
saveSettings({ theme: t });
}));
// ------------------------------------------------------------------ configurações
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false };
async function saveSettings(patch) {
Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } 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]));
@@ -64,7 +73,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' }) : 'sem data');
const date = (t) => (t ? new Date(t * 1000).toLocaleString(LOC(), { 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'],
@@ -108,8 +117,16 @@ 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('')}
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${
f.options ? `<select class="select" name="${f.name}">${f.options.map(([v, l]) => `<option value="${esc(v)}" ${v === f.value ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`
: f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>`
: `<span class="row" style="flex-wrap:nowrap"><input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">${f.pick && tauri?.dialog ? `<button type="button" class="btn" data-pick="${f.name}">Escolher…</button>` : ''}</span>`}</label>`).join('')}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
d.querySelectorAll('[data-pick]').forEach((b) => (b.onclick = async () => {
const f = fields.find((x) => x.name === b.dataset.pick);
const r = await tauri.dialog.open(f.pick);
if (r) d.querySelector(`[name="${f.name}"]`).value = Array.isArray(r) ? r[0] : r;
}));
d.onclose = () => {
if (d.returnValue !== 'ok') return resolve(null);
const out = {};
@@ -180,6 +197,31 @@ function renderSide() {
<li><button class="dev" data-j="${i}" aria-current="${S.job === s.job}"><span class="ico">${icon('default')}</span>
<span><span class="t">${esc(s.label)}</span><span class="s">${esc(s.target)}</span></span></button></li>`).join('');
sl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openJob(S.sessions[+b.dataset.j]); setMenu(false); }));
const rl = $('#recentList');
rl.dataset.empty = 'Nada salvo ainda';
rl.innerHTML = (S.recent || []).map((r, i) => `
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join('');
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
}
async function loadRecent() {
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
renderSide();
}
async function openRecent(r) {
setMenu(false);
try {
const { job } = await rpc('session_load', { path: r.path });
const st = await rpc('status', { job });
let s = S.sessions.find((x) => x.job === job);
if (!s) {
s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
S.sessions.unshift(s);
}
openJob(s);
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
}
$('#openImageBtn').onclick = async () => {
@@ -207,10 +249,13 @@ $('#loadSessionBtn').onclick = async () => {
try {
const { job } = await rpc('session_load', { path });
const st = await rpc('status', { job });
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
let s = S.sessions.find((x) => x.job === job);
if (!s) {
s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
S.sessions.unshift(s);
}
openJob(s);
} catch (e) { toast(e.message); }
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
};
// ------------------------------------------------------------------ início
@@ -240,9 +285,67 @@ async function openDevice(d) {
main.innerHTML = `<div class="page"><p class="sub">Lendo ${esc(d.name)}…</p></div>`;
main.scrollTop = 0;
let layout = null, err = null;
S.health = null;
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
S.layout = layout;
renderDevice(err);
// saúde (SMART) em segundo plano: não atrasa a tela
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
}
const HEALTH_WORD = { ok: 'Bom', warning: 'Atenção', failing: 'Ruim' };
function paintHealth() {
const el = $('#healthArea');
const h = S.health;
if (!el || !h) { if (el) el.innerHTML = ''; return; }
if (!h.available) {
el.innerHTML = `<div class="section"><h2>Saúde do disco</h2><p class="sub">${esc(h.summary)}</p></div>`;
return;
}
const bad = h.status === 'failing' || h.status === 'warning';
const fmtN = (n) => Number(n).toLocaleString(LOC());
const tile = (label, value, cls = '') => value == null ? '' : `<div class="h-tile ${cls}"><small>${label}</small><b>${value}</b></div>`;
const temp = h.temperature_c;
const tiles = [
`<div class="h-tile h-status ${esc(h.status)}"><small>Estado</small><b>${HEALTH_WORD[h.status] || '?'}${h.health_pct != null ? ` <span>${h.health_pct}%</span>` : ''}</b></div>`,
tile('Temperatura', temp == null ? null : `${temp} °C`, temp >= 60 ? 'failing' : temp >= 50 ? 'warning' : ''),
tile('Horas ligado', h.power_on_hours == null ? null : `${fmtN(h.power_on_hours)} h`),
tile('Vezes ligado', h.power_cycles == null ? null : fmtN(h.power_cycles)),
tile('Total lido', h.host_reads == null ? null : human(h.host_reads)),
tile('Total gravado', h.host_writes == null ? null : human(h.host_writes)),
].join('');
const info = (h.info || []).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
const dash = (x) => x == null ? '<span class="dim">-</span>' : x;
const attrs = h.attributes || [];
const table = attrs.length ? `<details class="adv h-attrs"${bad ? ' open' : ''}><summary>Atributos SMART (${attrs.length})</summary>
<div class="h-scroll"><table class="h-table"><thead><tr><th></th><th>ID</th><th>Atributo</th><th>Atual</th><th>Pior</th><th>Limite</th><th>Bruto</th></tr></thead><tbody>
${attrs.map((a) => `<tr><td><span class="h-dot ${esc(a.status)}" title="${HEALTH_WORD[a.status] || ''}"></span></td><td class="mono">${esc(a.id)}</td><td class="mono">${esc(a.name)}</td><td>${dash(a.value)}</td><td>${dash(a.worst)}</td><td>${dash(a.thresh)}</td><td class="mono">${esc(a.raw)}</td></tr>`).join('')}
</tbody></table></div></details>` : '';
const scope = h.partition ? `<p class="sub">O SMART é do disco inteiro: os dados abaixo são de <span class="mono">${esc(h.disk)}</span>, que contém <span class="mono">${esc(h.partition)}</span>.</p>` : '';
// o que motivou o alerta (os campos da ficha já aparecem abaixo)
const known = new Set(['Modelo', 'Nº de série', 'Horas ligado', 'Temperatura', 'Autoavaliação SMART', 'Vida útil usada']);
const why = (h.details || []).filter(([k]) => !known.has(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
el.innerHTML = `${bad ? `<div class="notice ${h.status === 'failing' ? 'bad' : ''}">${warnSvg}<div><b>${esc(h.summary)}</b>
${why ? `<dl class="kv" style="margin-top:6px">${why}</dl>` : ''}
${(h.advice || []).map((a) => `<div style="margin-top:6px">${esc(a)}</div>`).join('')}
<div class="row" style="margin-top:8px"><button class="btn sm primary" id="healthImg">Criar a imagem primeiro</button></div></div></div>` : ''}
<div class="section"><h2>Saúde do disco</h2>${scope}
<div class="h-tiles">${tiles}</div>
${info ? `<dl class="kv h-info">${info}</dl>` : ''}${table}</div>`;
$('#healthImg')?.addEventListener('click', startImage);
}
/** Disco com sinais de defeito: oferece criar a imagem antes de varrer. */
function confirmRisky() {
return new Promise((resolve) => {
const h = S.health;
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Este disco parece estar com defeito</h2><p>${esc(h.summary)}</p>${(h.advice || []).map((a) => `<p class="sub">${esc(a)}</p>`).join('')}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn" value="scan">Varrer mesmo assim</button><button class="btn primary" value="image">Criar a imagem primeiro</button></menu></form>`;
d.onclose = () => resolve(d.returnValue === 'image' ? 'image' : d.returnValue === 'scan' ? 'scan' : null);
d.showModal();
});
}
function fsClass(p) {
@@ -279,6 +382,7 @@ function renderDevice(err) {
main.innerHTML = `<div class="page">
<div class="dev-head"><div><h1>${esc(d.name)}</h1><div class="sub">${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · <span class="mono">${esc(d.path)}</span></div></div></div>
${notes.map(([k, t]) => `<div class="notice ${k === 'bad' ? 'bad' : ''}">${warnSvg}<div>${t}</div></div>`).join('')}
<div id="healthArea"></div>
${l ? `<div class="section"><h2>Mapa do disco</h2>${diskMap(l)}
<div class="legend">${l.partitions.map((p) => `<div><span class="dot ${fsClass(p)}"></span><b>Partição ${p.index + 1}</b> ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''}
${p.lost ? '<span class="pill accent">encontrada por varredura</span>' : ''}${p.encrypted ? `<span class="pill warn">cifrada (${esc(p.encrypted)}) · só detecção</span>` : ''}</div>`).join('')}
@@ -305,22 +409,52 @@ function renderDevice(err) {
<div class="row" id="optCats">${CATS.map(([k, n]) => `<label class="check"><input type="checkbox" value="${k}"> ${n}</label>`).join('')}</div>
<div class="sub">Sem nenhuma categoria marcada, todos os tipos são procurados.</div>
</details>
<details class="adv"><summary>Volumes cifrados, RAID e vídeos</summary>
<label class="fld"><span>Senha do volume (BitLocker, LUKS, FileVault 2)</span><input class="input" id="optPw" type="password" autocomplete="off"></label>
<label class="fld"><span>Chave de recuperação do BitLocker</span><input class="input" id="optRk" placeholder="000000-000000-... (8 grupos)" autocomplete="off"></label>
<label class="fld"><span>Arquivo de chave (.BEK ou keyfile)</span><input class="input" id="optKf" placeholder="/caminho/chave.BEK"></label>
<label class="fld"><span>Outros discos do mesmo conjunto (RAID/LVM), um caminho por linha</span><textarea class="input" id="optMembers" rows="3"></textarea></label>
<label class="check"><input type="checkbox" id="optRaid"><span><b>Estes discos formam um RAID sem metadados</b><br><span class="sub">O Guará descobre sozinho o nível, a faixa, o layout e a ordem dos discos.</span></span></label>
<label class="fld"><span>Vídeo íntegro da mesma câmera/drone (refaz o índice de MP4 sem moov)</span><input class="input" id="optRef" placeholder="/caminho/video-bom.mp4"></label>
<label class="check"><input type="checkbox" id="optRemember"><span><b>Lembrar as chaves dos volumes abertos na sessão</b><br><span class="sub">A senha nunca é gravada; só a chave derivada.</span></span></label>
</details>
</div></div>`;
$('#elevate')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
const opts = (mode) => ({
mode,
assembly: (() => {
const v = (id) => ($(id)?.value || '').trim();
const secrets = [];
if (v('#optPw')) secrets.push({ kind: 'password', value: v('#optPw') });
if (v('#optRk')) secrets.push({ kind: 'recovery', value: v('#optRk') });
if (v('#optKf')) secrets.push({ kind: 'keyfile', path: v('#optKf') });
return {
secrets,
members: v('#optMembers').split('\n').map((x) => x.trim()).filter(Boolean),
raid: $('#optRaid')?.checked ? {} : null,
remember_keys: $('#optRemember')?.checked || false,
};
})(),
references: $('#optRef')?.value.trim() ? [$('#optRef').value.trim()] : [],
include_allocated: $('#optAlloc')?.checked || false,
categories: [...document.querySelectorAll('#optCats input:checked')].map((i) => i.value),
});
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
$('#goImage')?.addEventListener('click', startImage);
paintHealth();
$('#goSearch')?.addEventListener('click', startSearch);
$('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); });
$('#goHash')?.addEventListener('click', startHash);
}
async function startScan(options) {
if (S.health && (S.health.status === 'failing' || S.health.status === 'warning') && options.mode === 'deep' && !S.healthAck?.[S.device.path]) {
const c = await confirmRisky();
if (c === 'image') return startImage();
if (!c) return;
(S.healthAck ??= {})[S.device.path] = true;
}
try {
const { job } = await rpc('scan_start', { path: S.device.path, options });
const s = { job, target: S.device.path, label: S.device.name };
@@ -331,10 +465,18 @@ async function startScan(options) {
async function startImage() {
const base = (S.defaults.home || '') + (S.defaults.sep || '/');
const dest = await ask({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.', value: base + 'disco-guara.img', ok: 'Criar imagem' });
if (!dest) return;
const f = await askForm({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.', ok: 'Criar imagem', fields: [
{ name: 'dest', label: 'Arquivo de destino', value: base + 'disco-guara.img' },
{ name: 'format', label: 'Formato da imagem', value: 'raw', options: [['raw', 'Bruta (.img)'], ['e01', 'Expert Witness (.E01, com hash e metadados)'], ['aff4', 'AFF4 (.aff4)']] },
{ name: 'case', label: 'Número do caso (E01/AFF4)', value: '' },
{ name: 'examiner', label: 'Examinador (E01/AFF4)', value: '' },
] });
if (!f || !f.dest) return;
const dest = f.dest;
const format = f.format === 'e01' ? { kind: 'e01', case_number: f.case, examiner: f.examiner }
: f.format === 'aff4' ? { kind: 'aff4', case_number: f.case, examiner: f.examiner } : { kind: 'raw' };
try {
const { job } = await rpc('image_start', { path: S.device.path, dest });
const { job } = await rpc('image_start', { path: S.device.path, dest, format });
watchAux(job, 'Criando imagem do disco', (st) => {
const r = st.report || {};
const bad = (r.bad_ranges || []).length;
@@ -379,9 +521,9 @@ function openSearch(job, terms) {
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>'));
$('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString(LOC())} 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(LOC())} 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(LOC())}</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 }); };
@@ -415,6 +557,7 @@ async function tick() {
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
if (st.status !== 'running' && !st.enriching) {
stopPoll();
loadRecent();
if (st.status === 'error') toast(st.error);
} else if (!S.poll) {
S.poll = setInterval(tick, 700);
@@ -431,7 +574,7 @@ function renderScan() {
</div>`;
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
$('#progressArea').onclick = onProgressClick;
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast });
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent), getSettings: () => SETTINGS });
}
function paintStatus() {
@@ -444,38 +587,85 @@ 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;
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 enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</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>' : '';
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
if (running || st.status === 'paused') {
setHTML($('#progressArea'), `<div class="progress-card slim">
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
<div class="stats compact">
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos</span></div>
<div class="stat"><b>${st.found.toLocaleString(LOC())}</b><span>arquivos</span></div>
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div>
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
</div></div>`);
} else {
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString('pt-BR')}</b> arquivos em ${dur(st.elapsed)}</span>
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString(LOC())}</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>` : ''));
}
async function chooseSource() {
let cands = [];
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
const st = S.status || {};
const size = st.layout?.size;
const d = $('#dlg');
const badge = (c) => (c.same_content === true ? '<span class="pill ok">mesmo disco</span>'
: c.same_content === false ? '<span class="pill warn">tamanho igual, conteúdo diferente</span>'
: c.same_size ? '<span class="pill accent">mesmo tamanho (precisa de administrador para conferir)</span>' : '');
d.innerHTML = `<form method="dialog"><h2>Escolher a origem</h2>
<p>Esta sessão foi feita sobre <code>${esc(st.target || '')}</code>${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.</p>
<div class="cand">${cands.map((c, i) => `<button type="button" class="dev" data-c="${i}"><span class="ico">${icon(c.device.kind)}</span><span><span class="t">${esc(c.device.name)}</span><span class="s">${esc(c.device.path)} · ${human(c.device.size)} ${badge(c)}</span></span></button>`).join('') || '<p class="sub">Nenhum disco encontrado. Informe o caminho abaixo.</p>'}</div>
<label class="fld"><span>Ou o caminho de um dispositivo ou imagem</span><input class="input" id="srcPath" name="path" placeholder="/dev/sdb ou /caminho/imagem.img" value="${esc(st.source_path || st.target || '')}"></label>
<label class="check"><input type="checkbox" name="force"><span>Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)</span></label>
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Usar esta origem</button></menu></form>`;
d.querySelectorAll('.cand .dev').forEach((b) => (b.onclick = () => {
d.querySelectorAll('.cand .dev').forEach((x) => x.setAttribute('aria-current', 'false'));
b.setAttribute('aria-current', 'true');
$('#srcPath', d).value = cands[+b.dataset.c].device.path;
}));
d.onclose = async () => {
if (d.returnValue !== 'ok') return;
const path = $('#srcPath', d).value.trim();
const force = d.querySelector('[name=force]').checked;
if (!path) return;
try {
await rpc('session_relink', { job: S.job, path, force });
toast('Origem reconectada');
const cur = { job: S.job, target: st.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
openJob(cur);
} catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
};
d.showModal();
}
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') {
else if (act === 'resume') {
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
catch (err) { toast(err.message); chooseSource(); }
} else if (act === 'reconnect') {
try {
const r = await rpc('session_reconnect', { job: S.job });
toast(r.renamed ? `Disco encontrado como ${r.renamed}` : 'Disco reconectado');
const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
openJob(cur);
} catch { chooseSource(); }
}
else if (act === 'relink') { chooseSource();
} 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); } }
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} 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' },
@@ -495,22 +685,26 @@ async function onProgressClick(e) {
}
async function recover(sel) {
const dest = await ask({
const f = await askForm({
title: 'Recuperar para…',
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.',
value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados',
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>',
ok: 'Recuperar',
pick: { directory: true, multiple: false },
fields: [
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
],
});
if (!dest) return;
if (!f || !f.dest) return;
const dest = f.dest;
try {
const { job } = await rpc('recover_start', { job: S.job, dest, ...sel });
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
watchAux(job, 'Recuperando arquivos', (st) => {
const r = st.report || {};
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
${r.skipped ? `<p class="sub">${r.skipped} já estavam recuperados e conferidos (pulados).</p>` : ''}
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''}
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista o SHA-256 de cada arquivo para você conferir a integridade.</p>`;
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.</p>`;
});
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
}
@@ -544,18 +738,81 @@ function watchAux(job, title, doneHtml) {
async function openJobFromList(s) { openJob(s); }
// ------------------------------------------------------------------ configurações (janela)
$('#settingsBtn')?.addEventListener('click', () => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
d.querySelector('[name=lang]').value = SETTINGS.lang;
d.onclose = () => {
if (d.returnValue !== 'ok') return;
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
setLang(patch.lang);
patch.ui_lang = currentLang();
saveSettings(patch);
EX?.relang?.();
toast('Salvo');
};
d.showModal();
});
// ------------------------------------------------------------------ fechar com varredura em andamento
function askClose() {
return new Promise((resolve) => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Há uma varredura em andamento</h2><p>A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.</p>
<menu><button class="btn" value="stay">Continuar varrendo</button><button class="btn" value="quit">Sair sem pausar</button><button class="btn primary" value="pause">Pausar e sair</button></menu></form>`;
d.onclose = () => resolve(d.returnValue);
d.showModal();
});
}
function watchClose() {
window.addEventListener('beforeunload', (e) => {
if (S.status?.status === 'running' && !tauri) { e.preventDefault(); e.returnValue = ''; }
});
const win = tauri?.window?.getCurrentWindow?.();
if (!win?.onCloseRequested) return;
win.onCloseRequested(async (ev) => {
if (S.status?.status !== 'running') return;
ev.preventDefault();
const c = await askClose();
if (c === 'stay' || !c) return;
if (c === 'pause') {
try {
await rpc('scan_pause', { job: S.job });
for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); }
await new Promise((r) => setTimeout(r, 600)); // dá tempo do salvamento automático
} catch { /* sai mesmo assim */ }
}
win.destroy();
});
}
// ------------------------------------------------------------------ início do app
(async function init() {
try {
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 */ }
try {
const st = await rpc('settings_get');
Object.assign(SETTINGS, st);
applyTheme(st.theme);
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
} catch { /* usa o local */ }
setLang(SETTINGS.lang);
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
watchClose();
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;
}
await loadDevices();
await loadRecent();
renderHome();
})();
+67 -18
View File
@@ -4,6 +4,7 @@
// visualização rápida em tela cheia.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js';
const CAT = {
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
@@ -31,8 +32,17 @@ const I = {
export function createExplorer(deps) {
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
const inlineMedia = !deps.noInlineMedia;
const dbApi = (it) => ({
info: () => rpc('db_info', { job, id: it.id }),
rows: (table, offset, limit, deleted) => rpc('db_rows', { job, id: it.id, table, offset, limit, deleted }),
});
const openExternal = (it) => {
toast('Preparando para abrir no player do sistema…');
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
};
const E = {
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' },
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
sort: 'date',
view: 'grid',
tile: 150,
@@ -95,6 +105,8 @@ export function createExplorer(deps) {
if (f.minConf) q.min_confidence = f.minConf;
if (f.gps) q.has_gps = true;
if (f.dedupe) q.dedupe = true;
if (f.bigOnly) q.min_size = 30 * 1024;
if (f.mp1) q.min_mp = 1;
if (f.query) q.query = f.query;
return q;
};
@@ -134,11 +146,14 @@ export function createExplorer(deps) {
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' });
? new Date(k + 'T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { 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' }));
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
// Metadados do núcleo trazem datas em ISO 8601; o idioma decide a ordem.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
const fmtMeta = (v) => ISO_DATE.test(v) ? new Date(v.endsWith('Z') ? v : v + 'Z').toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'medium' }) : v;
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
function geometry() {
const W = Math.max(200, scroll.clientWidth - PAD * 2);
@@ -279,7 +294,10 @@ export function createExplorer(deps) {
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>`);
const offline = E.status && E.status.source === false;
wrap?.insertAdjacentHTML('afterbegin', offline
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
: `<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
@@ -373,11 +391,11 @@ export function createExplorer(deps) {
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>
? `<span class="info"><b>${n.toLocaleString(LOC())}</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>` : ''}
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString(LOC())}</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>
: `<span class="info">${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString(LOC())})` : ''}</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; }
}
@@ -395,7 +413,7 @@ export function createExplorer(deps) {
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>`;
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString(LOC())}</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('');
@@ -421,6 +439,8 @@ export function createExplorer(deps) {
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.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
+ (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>`
@@ -447,6 +467,8 @@ export function createExplorer(deps) {
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 if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
else if (f === 'mp1') F.mp1 = !F.mp1;
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;
@@ -455,7 +477,7 @@ export function createExplorer(deps) {
});
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: '' };
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
$('#xpQ').value = '';
if (refresh) changed(); else changed();
}
@@ -475,7 +497,7 @@ export function createExplorer(deps) {
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>`;
$('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString(LOC())}</small>`;
}
function paintChips() {
@@ -490,6 +512,8 @@ export function createExplorer(deps) {
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.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
if (F.mp1) add('Mais de 1 MP', 'mp1');
if (F.query) add(`“${esc(F.query)}”`, 'query');
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
}
@@ -497,7 +521,7 @@ export function createExplorer(deps) {
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;
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
if (k === 'year') E.filters.month = null;
if (k === 'query') $('#xpQ').value = '';
nav._h = null;
@@ -542,7 +566,7 @@ export function createExplorer(deps) {
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>` : ''}
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString(LOC())} 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;
}
@@ -559,16 +583,39 @@ export function createExplorer(deps) {
<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' });
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
// clicar na miniatura abre o modal grande
$('#xpPv').addEventListener('click', (ev) => {
if (ev.target.closest('button, a, .pv-pdfbar')) return;
openLightbox(idxOf(it.id));
});
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>` : '');
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(fmtMeta(v))}</dd>`).join('')}</dl>`
+ (m.gps ? gpsHtml(m.gps) : '');
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
}).catch(() => {});
}
/** Coordenadas e, se o usuário permitiu, um mapa (blocos do OpenStreetMap, 3x3 ao redor do ponto). */
function gpsHtml([lat, lon]) {
let map = '';
if (deps.getSettings?.().maps) {
const z = 15, n = 2 ** z;
const fx = (lon + 180) / 360 * n;
const fy = (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n;
const tx = Math.floor(fx), ty = Math.floor(fy);
let imgs = '';
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) imgs += `<img src="https://tile.openstreetmap.org/${z}/${tx + dx}/${ty + dy}.png" alt="" loading="lazy" draggable="false">`;
const px = (fx - tx + 1) / 3 * 100, py = (fy - ty + 1) / 3 * 100;
map = `<div class="xp-map"><div class="xp-tiles">${imgs}</div><span class="xp-pin" style="left:${px}%;top:${py}%"></span></div>`;
}
return `<div class="sub" style="font-size:12.5px;margin-top:8px">Coordenadas: ${lat.toFixed(5)}, ${lon.toFixed(5)}</div>${map}
<div class="row" style="margin-top:6px"><button class="btn sm" id="xpOsm">Abrir no OpenStreetMap</button></div>
${map ? '<div class="sub" style="font-size:11px;margin-top:4px">© colaboradores do OpenStreetMap</div>' : ''}`;
}
insp.addEventListener('click', (e) => {
const a = e.target.closest('[data-i]')?.dataset.i;
if (a === 'full') openLightbox(idxOf(E.curId));
@@ -591,7 +638,7 @@ export function createExplorer(deps) {
<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 });
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, db: dbApi(it), onExternal: () => openExternal(it) });
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);
@@ -625,6 +672,8 @@ export function createExplorer(deps) {
},
query: () => toQuery(),
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
/** Idioma trocado em Configurações: refaz o que foi montado em JS (datas, meses, rótulos). */
relang() { paintCrumb(); paintChips(); paintNav(); paintActions(); paintInspector(); relayout(); },
focus() { scroll.focus({ preventScroll: true }); },
};
}
+1 -1
View File
@@ -1 +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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará"><style>.a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}</style><defs><clipPath id="g-head"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+1 -1
View File
@@ -1 +1 @@
<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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará"><style>.a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}</style><defs><clipPath id="g-head"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+578
View File
@@ -0,0 +1,578 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
export const EN = {
// saúde do disco (SMART)
'O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco.': 'SMART could not be read. Install the smartmontools package (smartctl command) and run as administrator to see the disk health.',
'Setores realocados': 'Reallocated sectors',
'Setores pendentes de realocação': 'Pending sectors',
'Setores incorrigíveis offline': 'Offline uncorrectable sectors',
'Erros incorrigíveis reportados': 'Reported uncorrectable errors',
'Erros de CRC na interface (cabo ou porta)': 'Interface CRC errors (cable or port)',
'Erros registrados no log do disco': 'Errors in the disk log',
'Aviso crítico NVMe': 'NVMe critical warning',
'Erros de mídia': 'Media errors',
'Reserva de blocos abaixo do limite': 'Spare blocks below threshold',
'Erros de leitura/gravação vistos pelo sistema': 'Read/write errors seen by the system',
'Estado': 'Status',
'Bom': 'Good',
'Atenção': 'Caution',
'Ruim': 'Bad',
'Temperatura': 'Temperature',
'Horas ligado': 'Power on hours',
'Vezes ligado': 'Power on count',
'Total lido': 'Host reads',
'Total gravado': 'Host writes',
'Atributos SMART ({n})': 'SMART attributes ({n})',
'Atributo': 'Attribute',
'Atual': 'Current',
'Pior': 'Worst',
'Limite': 'Threshold',
'Bruto': 'Raw',
'Modelo': 'Model',
'Nº de série': 'Serial number',
'Família': 'Family',
'Modo de transferência': 'Transfer mode',
'Capacidade': 'Capacity',
'Rotação': 'Rotation rate',
'Formato': 'Form factor',
'Recursos': 'Features',
'O SMART é do disco inteiro: os dados abaixo são de': 'SMART belongs to the whole disk: the data below is for',
', que contém': ', which contains',
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.',
// barra lateral e estrutura
'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices',
'Dispositivos e sessões': 'Devices and sessions',
'Atualizar lista': 'Refresh list',
'Lista atualizada': 'List refreshed',
'Abrir arquivo de imagem…': 'Open disk image file…',
'Abrir sessão salva…': 'Open saved session…',
'Sessões': 'Sessions',
'Sessão': 'Session',
'Sessão:': 'Session:',
'Recentes': 'Recent',
'(salvas sozinhas)': '(saved automatically)',
'Nenhuma ainda': 'None yet',
'Nada salvo ainda': 'Nothing saved yet',
'Nenhum disco encontrado': 'No disks found',
'Somente leitura na origem': 'Read-only on the source',
'Abrir menu': 'Open menu',
'Tema': 'Theme',
'Auto': 'Auto',
'Claro': 'Light',
'Escuro': 'Dark',
'Configurações': 'Settings',
'· requer admin': '· needs admin',
'requer admin': 'needs admin',
'{n} · requer admin': '{n} · needs admin',
// início
'Encontre o que você achou que tinha perdido.': 'Find what you thought was lost.',
'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.': 'Pick a disk, SSD or USB drive on the left. Guará reads everything: deleted files, lost partitions and unpartitioned space, without writing anything to the source disk.',
'Uma varredura, tudo junto': 'One scan, everything together',
'Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.': 'File system metadata and signature search in the same pass, without duplicates.',
'Confiança por arquivo': 'Confidence per file',
'Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.': 'Each finding shows the chance of opening correctly: high, medium or low.',
'Origem protegida': 'Protected source',
'Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.': 'Read-only. Recovery always goes to another folder, with SHA-256 of everything.',
'Nenhum disco apareceu.': 'No disks showed up.',
// dispositivo
'Mapa do disco': 'Disk map',
'Como procurar': 'How to search',
'Recomendado': 'Recommended',
'Varredura profunda': 'Deep scan',
'Varredura rápida': 'Quick scan',
'Lê os metadados e examina todo o espaço livre, partições danificadas e áreas não particionadas por assinatura. Encontra mais, leva mais tempo.': 'Reads the metadata and examines all free space, damaged partitions and unpartitioned areas by signature. Finds more, takes longer.',
'Procurar tudo': 'Search everything',
'Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.': 'Only the file system records. In seconds it shows recently deleted files, with name and folder.',
'Criar imagem do disco': 'Create disk image',
'Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.': 'Copies the whole disk to a file, tolerating bad sectors. Ideal for failing disks: you work on the copy.',
'Criar imagem…': 'Create image…',
'Ferramentas de perícia': 'Forensic tools',
'Pesquisar texto no disco': 'Search text on the disk',
'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.': 'Searches words across the whole disk, including free space and outside partitions (UTF-8 and UTF-16). Useful when there is no file structure.',
'termos separados por vírgula': 'terms separated by commas',
'Termos a pesquisar': 'Terms to search',
'Pesquisar': 'Search',
'Hashes da origem': 'Source hashes',
'Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.': 'Computes MD5, SHA-1 and SHA-256 of the whole disk to record integrity and chain of custody in the report.',
'Calcular hashes': 'Compute hashes',
'Opções avançadas': 'Advanced options',
'Incluir áreas ainda em uso': 'Include areas still in use',
'Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.': 'Also looks for old copies inside existing files. Much slower.',
'Sem nenhuma categoria marcada, todos os tipos são procurados.': 'With no category checked, all types are searched.',
'Este disco exige permissão de administrador.': 'This disk requires administrator permission.',
'Reiniciar como administrador': 'Restart as administrator',
'Feche e rode de novo com': 'Close and run again with',
'ou como administrador (Windows).': 'or as administrator (Windows).',
'Espaço não particionado': 'Unpartitioned space',
'Não particionado': 'Unpartitioned',
'encontrada por varredura': 'found by scanning',
'Desconhecido': 'Unknown',
'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.': 'This SSD uses TRIM: the system tells the disk which blocks were deleted and the SSD itself may zero them. Files deleted a long time ago tend to be unrecoverable.',
// saúde do disco
'Saúde do disco': 'Disk health',
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
'Nenhum sinal de defeito encontrado pelo SMART.': 'No signs of failure found by SMART.',
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
'Crie a imagem do disco primeiro (botão "Criar imagem" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.': 'Create the disk image first ("Create image" button or `guara image`) and scan the copy. Every extra read can make a failing disk worse.',
'Criar a imagem primeiro': 'Create the image first',
'Varrer mesmo assim': 'Scan anyway',
'Cancelar': 'Cancel',
'Este disco parece estar com defeito': 'This disk seems to be failing',
// varredura
'‹ Dispositivos': '‹ Devices',
'Procurando…': 'Searching…',
'Varredura concluída': 'Scan complete',
'Varredura pausada': 'Scan paused',
'Varredura interrompida': 'Scan stopped',
'Algo deu errado': 'Something went wrong',
'Iniciando': 'Starting',
'Pausar': 'Pause',
'Parar': 'Stop',
'Continuar': 'Continue',
'Pausando…': 'Pausing…',
'Reconectar o disco': 'Reconnect the disk',
'Escolher a origem…': 'Choose the source…',
'Escolher a origem': 'Choose the source',
'Salvar sessão…': 'Save session…',
'Exportar CSV': 'Export CSV',
'Relatório': 'Report',
'arquivos encontrados': 'files found',
'arquivos': 'files',
'decorrido': 'elapsed',
'confiança alta': 'high confidence',
'média': 'medium',
'baixa': 'low',
'alta': 'high',
'· faltam ~{n}': '· ~{n} left',
'· {n}%': '· {n}%',
'{n} arquivos em {n}': '{n} files in {n}',
'confiança alta {n} · média {n} · baixa {n}': 'high confidence {n} · medium {n} · low {n}',
'Lendo datas e metadados das fotos… {n}/{n}': 'Reading dates and metadata from photos… {n}/{n}',
'Lendo tabela de partições': 'Reading partition table',
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
'Varredura profunda por assinatura': 'Deep signature scan',
// explorador
'Navegação': 'Navigation',
'Filtros': 'Filters',
'Todos os arquivos': 'All files',
'Tudo': 'All',
'Tipos': 'Types',
'Datas': 'Dates',
'Pastas': 'Folders',
'Câmeras': 'Cameras',
'Confiança': 'Confidence',
'Outros': 'Other',
'Fotos': 'Photos',
'Documentos': 'Documents',
'Vídeos': 'Videos',
'Áudio': 'Audio',
'Compactados': 'Archives',
'Bancos de dados': 'Databases',
'Raiz': 'Root',
'/ (raiz)': '/ (root)',
'Por assinatura': 'By signature',
'Sem data': 'No date',
'sem data': 'no date',
'Com localização (GPS)': 'With location (GPS)',
'Com GPS': 'With GPS',
'Ocultar duplicadas': 'Hide duplicates',
'Sem duplicadas': 'Without duplicates',
'Ocultar arquivos pequenos (menos de 30 KB)': 'Hide small files (under 30 KB)',
'Sem arquivos pequenos': 'Without small files',
'Mais de 1 megapixel': 'Over 1 megapixel',
'Mais de 1 MP': 'Over 1 MP',
'Limpar filtros': 'Clear filters',
'Buscar por nome, pasta ou câmera': 'Search by name, folder or camera',
'Buscar': 'Search',
'Ordenar': 'Sort',
'Data (mais recentes)': 'Date (newest)',
'Data (mais antigas)': 'Date (oldest)',
'Nome': 'Name',
'Tamanho': 'Size',
'Modo de exibição': 'View mode',
'Grade': 'Grid',
'Lista': 'List',
'Grade de miniaturas': 'Thumbnail grid',
'Tamanho das miniaturas': 'Thumbnail size',
'Arquivos encontrados': 'Files found',
'Detalhes': 'Details',
'janeiro': 'January', 'fevereiro': 'February', 'março': 'March', 'abril': 'April', 'maio': 'May', 'junho': 'June',
'julho': 'July', 'agosto': 'August', 'setembro': 'September', 'outubro': 'October', 'novembro': 'November', 'dezembro': 'December',
'{n} item': '{n} item',
'{n} itens': '{n} items',
'{n} itens nesta vista': '{n} items in this view',
'{n} item nesta vista': '{n} item in this view',
'{n} itens nesta vista (mostrando {n})': '{n} items in this view (showing {n})',
'{n} selecionado': '{n} selected',
'{n} selecionados': '{n} selected',
'{n} selecionado (todos desta vista)': '{n} selected (all in this view)',
'{n} selecionados (todos desta vista)': '{n} selected (all in this view)',
'{n} itens selecionados': '{n} items selected',
'{n} no total': '{n} in total',
'Selecionar grupo': 'Select group',
'Selecionar este grupo': 'Select this group',
'Selecionar tudo': 'Select all',
'Selecionar todos os {n}': 'Select all {n}',
'Limpar seleção': 'Clear selection',
'Recuperar tudo desta vista': 'Recover everything in this view',
'Recuperar selecionado': 'Recover selected',
'Recuperar selecionados': 'Recover selected',
'Recuperar este arquivo': 'Recover this file',
'Tela cheia': 'Full screen',
'Fechar': 'Close',
'Anterior': 'Previous',
'Próximo': 'Next',
'← → para navegar · Esc para fechar': '← → to navigate · Esc to close',
'Selecione um arquivo para pré-visualizar.': 'Select a file to preview.',
'Selecione um arquivo para pré-visualizar': 'Select a file to preview',
'Setas navegam, Espaço abre em tela cheia.': 'Arrows navigate, Space opens full screen.',
'Pasta': 'Folder',
'Modificado': 'Modified',
'Criado': 'Created',
'Acessado': 'Accessed',
'Tirada em': 'Taken on',
'Câmera': 'Camera',
'Resolução': 'Resolution',
'Metadados': 'Metadata',
'Metadados · {n}': 'Metadata · {n}',
'Coordenadas:': 'Coordinates:',
'Aviso': 'Warning',
'Encontrado por assinatura': 'Found by signature',
'Assinatura de formato': 'Format signature',
'Origem': 'Source',
'Origem desconectada: sem miniatura': 'Source disconnected: no thumbnail',
'Não foi possível gerar a miniatura (arquivo possivelmente danificado)': 'Could not generate the thumbnail (file possibly damaged)',
'Tem localização GPS': 'Has GPS location',
'Confiança {n}': 'Confidence {n}',
'Nada por aqui': 'Nothing here',
// pré-visualização
'Imagem': 'Image',
'Vídeo': 'Video',
'PDF': 'PDF',
'Texto': 'Text',
'Documento (texto extraído)': 'Document (extracted text)',
'Conteúdo do arquivo compactado': 'Archive contents',
'Dados brutos (hexadecimal)': 'Raw data (hexadecimal)',
'Banco de dados SQLite': 'SQLite database',
'Banco SQLite': 'SQLite database',
'Arquivo vazio.': 'Empty file.',
'Não foi possível pré-visualizar.': 'Could not preview.',
'O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.': 'The file may be damaged, incomplete or partially overwritten, which also means the chance of recovery is lower.',
'A imagem não pôde ser decodificada.': 'The image could not be decoded.',
'Este arquivo não parece ser texto.': 'This file does not look like text.',
'Mostrando o início ({n} de {n}).': 'Showing the beginning ({n} of {n}).',
'Mostrando os primeiros {n} de {n}.': 'Showing the first {n} of {n}.',
'{n} × {n} px. Clique para alternar entre ajustar e tamanho real.': '{n} × {n} px. Click to toggle between fit and actual size.',
'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.': 'Text extracted from the document. The original formatting, images and charts are not shown.',
'{n} item(ns) no arquivo compactado.': '{n} item(s) in the archive.',
'Sem texto.': 'No text.',
'Página {n} de {n}': 'Page {n} of {n}',
'Página anterior': 'Previous page',
'Próxima página': 'Next page',
'Reproduzir em tela grande': 'Play full size',
'Abrir no player do sistema': 'Open in the system player',
'Preparando para abrir no player do sistema…': 'Preparing to open in the system player…',
'Aberto no player do sistema': 'Opened in the system player',
'Neste app o vídeo abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, video opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'Neste app o áudio abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, audio opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'pasta': 'folder',
// banco de dados
'Abrir o visualizador': 'Open the viewer',
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
'{n} linhas · {n} colunas': '{n} rows · {n} columns',
'Tabelas': 'Tables',
'Views': 'Views',
'Índices': 'Indexes',
'Filtrar esta página': 'Filter this page',
'Filtrar': 'Filter',
'Registros apagados (prováveis)': 'Deleted records (likely)',
'Ver esquema': 'View schema',
'Ver dados': 'View data',
'apagado': 'deleted',
'Nenhuma linha.': 'No rows.',
'Nenhuma tabela.': 'No tables.',
'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.': 'No deleted records recognized in this table. That does not guarantee there are none: the content may have been overwritten.',
'{n} registro(s) provável(is) apagado(s)': '{n} likely deleted record(s)',
'{n} a {n} de {n}': '{n} to {n} of {n}',
'‹ Anterior': '‹ Previous',
'Próximas ›': 'Next ›',
// recuperar
'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover',
'Pasta de destino': 'Destination folder',
'Escolher…': 'Choose…',
'Organizar os arquivos': 'Organize the files',
'Pastas originais (e por tipo quando não há pasta)': 'Original folders (and by type when there is no folder)',
'Por tipo (Fotos, Documentos, Vídeos...)': 'By type (Photos, Documents, Videos...)',
'Por data (ano e mês)': 'By date (year and month)',
'Tudo na mesma pasta': 'Everything in the same folder',
'Recuperando arquivos': 'Recovering files',
'Pronto': 'Done',
'Interrompido': 'Stopped',
'Salvar sessão': 'Save session',
'Guarda os resultados para recuperar depois, sem varrer de novo.': 'Saves the results so you can recover later without scanning again.',
'Sessão salva': 'Session saved',
'Salvar': 'Save',
'Exportar para CSV': 'Export to CSV',
'Exportar': 'Export',
'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.': 'Writes the files in the current view (with filters applied). The file opens directly in Excel.',
'{n} linhas gravadas': '{n} rows written',
'Relatório de recuperação': 'Recovery report',
'Gerar relatório': 'Generate report',
'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.': 'Generates a print-ready HTML report with the examined source, the findings and the hashes of recovered files.',
'Salvar em': 'Save to',
'Número do caso': 'Case number',
'Responsável pelo exame': 'Examiner',
'Organização': 'Organization',
'Observações': 'Notes',
'Relatório gravado': 'Report saved',
'lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.': 'lists the MD5, SHA-1 and SHA-256 of each file so you can verify integrity.',
'O arquivo': 'The file',
'Não foi possível recuperar': 'Could not recover',
'Não foi possível iniciar': 'Could not start',
'Entendi': 'Got it',
'{n} arquivo(s) salvos em {n} ({n}).': '{n} file(s) saved to {n} ({n}).',
'{n} arquivo(s) não puderam ser gravados.': '{n} file(s) could not be written.',
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
// imagem, hash, busca
'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes',
'Eles serão incluídos nos relatórios gerados nesta sessão.': 'They will be included in reports generated in this session.',
'Digite pelo menos um termo.': 'Type at least one term.',
'Posição': 'Position',
'Partição': 'Partition',
'Codificação': 'Encoding',
'Contexto': 'Context',
'fora': 'outside',
'Nada encontrado': 'Nothing found',
'Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).': 'These terms do not appear anywhere on the disk (neither in UTF-8 nor UTF-16).',
'{n} ocorrência': '{n} occurrence',
'{n} ocorrências': '{n} occurrences',
'{n} ocorrência{n}': '{n} occurrence{n}',
'Termos: {n}': 'Terms: {n}',
'{n}% · {n} até agora': '{n}% · {n} so far',
// sessões e origem
'Abrir arquivo de imagem': 'Open disk image file',
'Caminho completo de uma imagem de disco (.img, .dd, .iso…).': 'Full path of a disk image (.img, .dd, .iso…).',
'Caminho do arquivo de sessão (.json) criado pelo Guará.': 'Path of the session file (.json) created by Guará.',
'Abrir': 'Open',
'Abrir sessão salva': 'Open saved session',
'Não foi possível abrir a sessão': 'Could not open the session',
'Disco encontrado como {n}': 'Disk found as {n}',
'Disco reconectado': 'Disk reconnected',
'Origem reconectada': 'Source reconnected',
'Não foi possível usar essa origem': 'Could not use this source',
'Esta sessão foi feita sobre': 'This session was made on',
'Escolha o disco que voltou ou uma imagem dele.': 'Choose the disk that came back or an image of it.',
'Nenhum disco encontrado. Informe o caminho abaixo.': 'No disks found. Enter the path below.',
'Ou o caminho de um dispositivo ou imagem': 'Or the path to a device or image',
'Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)': 'Use it anyway, even if the initial content differs (for example, an edited copy)',
'Usar esta origem': 'Use this source',
'mesmo disco': 'same disk',
'tamanho igual, conteúdo diferente': 'same size, different content',
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
// configurações e fechamento
'Idioma': 'Language',
'Notificações': 'Notifications',
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
'Mapas online': 'Online maps',
'Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)': 'Show maps for photos with GPS (uses the internet: downloads OpenStreetMap tiles)',
'Salvo': 'Saved',
'Mostrar mapa': 'Show map',
'Mostrar mapa (usa a internet)': 'Show map (uses the internet)',
'Abrir no OpenStreetMap': 'Open in OpenStreetMap',
'Há uma varredura em andamento': 'A scan is in progress',
'Pausar e sair': 'Pause and quit',
'Sair sem pausar': 'Quit without pausing',
'Continuar varrendo': 'Keep scanning',
'A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.': 'The session is saved automatically. If you pause now, you resume exactly where you stopped.',
// erros comuns vindos do núcleo
'Não foi possível falar com o núcleo do Guará: {n}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando': 'Could not talk to the Guará core: {n}. If you opened this page manually, use the full address shown by the command',
'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
'Autorização cancelada.': 'Authorization canceled.',
'Origem desconectada': 'Source disconnected',
'Este disco está em uso (montado em': 'This disk is in use (mounted at',
'). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.': '). Guará only reads, but avoid writing to it while recovering, because any write can overwrite what you want to save.',
// formulário de opções de varredura
'Volumes cifrados, RAID e vídeos': 'Encrypted volumes, RAID and videos',
'Senha do volume (BitLocker, LUKS, FileVault 2)': 'Volume password (BitLocker, LUKS, FileVault 2)',
'Chave de recuperação do BitLocker': 'BitLocker recovery key',
'Arquivo de chave (.BEK ou keyfile)': 'Key file (.BEK or keyfile)',
'Outros discos do mesmo conjunto (RAID/LVM), um caminho por linha': 'Other disks of the same set (RAID/LVM), one path per line',
'Estes discos formam um RAID sem metadados': 'These disks form a RAID without metadata',
'O Guará descobre sozinho o nível, a faixa, o layout e a ordem dos discos.': 'Guará works out the level, stripe size, layout and order of the disks by itself.',
'Vídeo íntegro da mesma câmera/drone (refaz o índice de MP4 sem moov)': 'Intact video from the same camera/drone (rebuilds the index of an MP4 without moov)',
'Lembrar as chaves dos volumes abertos na sessão': 'Remember the keys of opened volumes in the session',
'A senha nunca é gravada; só a chave derivada.': 'The password is never saved; only the derived key.',
// criar imagem do disco
'Arquivo de destino': 'Destination file',
'Número do caso (E01/AFF4)': 'Case number (E01/AFF4)',
'Formato da imagem': 'Image format',
'Bruta (.img)': 'Raw (.img)',
'Expert Witness (.E01, com hash e metadados)': 'Expert Witness (.E01, with hash and metadata)',
'Examinador (E01/AFF4)': 'Examiner (E01/AFF4)',
'Imagem salva em': 'Image saved to',
'Hashes do conteúdo inteiro de': 'Hashes of the entire content of',
// resultados e partições
'arquivo(s) salvos em': 'file(s) saved to',
'arquivos em {n}': 'files in {n}',
'Espaço não particionado: {n}': 'Unpartitioned space: {n}',
'Partição {n}': 'Partition {n}',
'erro {n}': 'error {n}',
// saúde do disco (campos vindos do núcleo)
'Autoavaliação SMART': 'SMART self-assessment',
'Vida útil usada': 'Life used',
'aprovado': 'passed',
'REPROVADO: o próprio disco prevê falha': 'FAILED: the disk itself predicts failure',
// pré-visualização
'Pré-visualização': 'Preview',
'Pré-visualização de {n}': 'Preview of {n}',
'vídeo': 'video',
'áudio': 'audio',
'Não foi possível reproduzir este {n}.': 'This {n} could not be played.',
'compressão não suportada': 'compression not supported',
'diretório do ZIP inconsistente': 'inconsistent ZIP directory',
'diretório do ZIP não encontrado (arquivo possivelmente incompleto)': 'ZIP directory not found (file possibly incomplete)',
'visualizador indisponível': 'viewer unavailable',
'© colaboradores do OpenStreetMap': '© OpenStreetMap contributors',
// explorador: textos que a interface divide em nós separados
'selecionado': 'selected',
'selecionados': 'selected',
'selecionado (todos desta vista)': 'selected (all in this view)',
'selecionados (todos desta vista)': 'selected (all in this view)',
'Sessão: {n}': 'Session: {n}',
'🖼 Fotos': '🖼 Photos',
'📄 Documentos': '📄 Documents',
'🎞 Vídeos': '🎞 Videos',
'🎵 Áudio': '🎵 Audio',
'🗜 Compactados': '🗜 Archives',
'🗄 Bancos de dados': '🗄 Databases',
'📦 Outros': '📦 Other',
'Imagem · {n}': 'Image · {n}',
'Vídeo · {n}': 'Video · {n}',
'Áudio · {n}': 'Audio · {n}',
'Texto · {n}': 'Text · {n}',
'Documento (texto extraído) · {n}': 'Document (extracted text) · {n}',
'Conteúdo do arquivo compactado · {n}': 'Archive contents · {n}',
'Banco de dados SQLite · {n}': 'SQLite database · {n}',
'Dados brutos (hexadecimal) · {n}': 'Raw data (hexadecimal) · {n}',
// tipos e campos de metadados vindos do núcleo
'Imagem JPEG': 'JPEG image',
'Imagem PNG': 'PNG image',
'Imagem GIF': 'GIF image',
'Imagem BMP': 'BMP image',
'Mídia MP4/MOV': 'MP4/MOV media',
'Fabricante da câmera': 'Camera make',
'Modelo da câmera': 'Camera model',
'Data (EXIF)': 'Date (EXIF)',
'Foto tirada em': 'Photo taken on',
'Nº de série do corpo': 'Body serial number',
'Proprietário da câmera': 'Camera owner',
'Exposição': 'Exposure',
'Distância focal': 'Focal length',
'Data do GPS': 'GPS date',
'Localização (GPS)': 'Location (GPS)',
'Versão do PDF': 'PDF version',
'Título': 'Title',
'Criado com': 'Created with',
'Páginas (aprox.)': 'Pages (approx.)',
'Última modificação por': 'Last modified by',
'Páginas': 'Pages',
'Duração': 'Duration',
// saúde do disco e confiança vindas do núcleo
'SSD (sem rotação)': 'SSD (no rotation)',
'Cache de gravação': 'Write cache',
'Segurança ATA': 'ATA security',
'{n} (máx. {n})': '{n} (max. {n})',
'membro de RAID': 'RAID member',
'sem rodapé/estrutura completa: pode estar truncado': 'no footer/complete structure: may be truncated',
'vídeo sem índice (moov)': 'video without index (moov)',
// visualizador de SQLite
'Nenhuma tabela encontrada no esquema. A primeira página pode estar danificada.': 'No table found in the schema. The first page may be damaged.',
'Tabela virtual: o conteúdo não fica armazenado como linhas comuns.': 'Virtual table: the content is not stored as ordinary rows.',
'Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida.': 'WITHOUT ROWID table: the rows live in an index tree, not shown yet.',
'Arquivo truncado: o cabeçalho indica {n} páginas, mas só há {n}. Parte dos dados pode estar faltando.': 'Truncated file: the header says {n} pages, but there are only {n}. Part of the data may be missing.',
// mensagens de erro e avisos do núcleo (valores variáveis usam {n})
'job {n} não existe': 'job {n} does not exist',
'a varredura não está pausada': 'the scan is not paused',
'A origem desta sessão não está disponível ({n}). Reconecte o disco ou escolha a origem para continuar.': 'The source of this session is unavailable ({n}). Reconnect the disk or choose the source to continue.',
'arquivo não encontrado': 'file not found',
'nenhum arquivo selecionado': 'no file selected',
'informe pelo menos um termo': 'enter at least one term',
'Espaço insuficiente no destino: os arquivos somam {n} e há {n} livres. Escolha outro disco ou recupere menos arquivos.': 'Not enough space at the destination: the files total {n} and {n} are free. Choose another disk or recover fewer files.',
'Sem permissão para ler a sessão {n}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta.': 'Permission denied reading the session {n}. If it was created as administrator (for example in /root), open Guará as administrator or copy the file to your folder.',
'Não foi possível ler a sessão {n}: {n}': 'Could not read the session {n}: {n}',
'O arquivo {n} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {n}': 'The file {n} is not a valid Guará session (or was created by an incompatible version): {n}',
'disco não encontrado': 'disk not found',
'Essa origem tem {n} bytes, mas a sessão foi feita sobre um disco de {n} bytes. Escolha o mesmo disco ou uma imagem dele.': 'This source has {n} bytes, but the session was made on a {n}-byte disk. Choose the same disk or an image of it.',
'O conteúdo inicial dessa origem é diferente do disco da sessão (outra tabela de partições ou outro disco). Se tem certeza, use a opção de usar assim mesmo.': 'The initial content of this source differs from the session disk (another partition table or another disk). If you are sure, use the \'use anyway\' option.',
'só sessões automáticas podem ser apagadas por aqui': 'only automatic sessions can be deleted here',
'parâmetro \'{n}\' ausente': 'missing parameter \'{n}\'',
'endereço não permitido': 'address not allowed',
'id ausente': 'missing id',
'método desconhecido: {n}': 'unknown method: {n}',
'A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.': 'The source of this session is unavailable. Reconnect the disk or choose the source to preview and recover.',
'Sem permissão para ler {n}. Discos exigem privilégios de administrador: no Linux/macOS rode com sudo; no Windows, execute como administrador.': 'Permission denied reading {n}. Disks need administrator privileges: on Linux/macOS run with sudo; on Windows, run as administrator.',
'Não foi possível abrir {n}: {n}': 'Could not open {n}: {n}',
'O destino {n} fica em {n} (montado a partir da origem {n}). Escolha outro disco para não sobrescrever dados recuperáveis.': 'The destination {n} is on {n} (mounted from the source {n}). Choose another disk so you do not overwrite recoverable data.',
'A origem {n} contém o sistema (/). Recupere para um disco externo ou use uma imagem.': 'The source {n} contains the system (/). Recover to an external disk or use an image.',
'leitura além do fim da origem': 'read beyond the end of the source',
'senha ou chave incorreta': 'wrong password or key',
'não suportado: {n}': 'not supported: {n}',
'cabeçalho inválido: {n}': 'invalid header: {n}',
'Não é um banco SQLite (cabeçalho ausente). Se o arquivo é um banco, ele pode estar criptografado (SQLCipher) ou ter o início sobrescrito.': 'Not an SQLite database (missing header). If the file is a database, it may be encrypted (SQLCipher) or have its beginning overwritten.',
'Tamanho de página inválido no cabeçalho do banco.': 'Invalid page size in the database header.',
'página {n} fora do arquivo (banco truncado)': 'page {n} outside the file (truncated database)',
'página {n} incompleta': 'page {n} incomplete',
'página 0 inválida': 'invalid page 0',
'tabela {n} não encontrada': 'table {n} not found',
'célula ilegível na página {n}': 'unreadable cell on page {n}',
'página {n} de tipo inesperado (0x{n})': 'page {n} of unexpected type (0x{n})',
'Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos.': 'File too large to open directly. Recover it and open it with your file manager.',
'não foi possível criar {n}: {n} (o arquivo não pode existir)': 'could not create {n}: {n} (the file must not exist)',
'não foi possível ler o arquivo de chave {n}: {n}': 'could not read the key file {n}: {n}',
'chave do volume em hexadecimal inválida': 'invalid hexadecimal volume key',
'não foi possível descobrir os parâmetros do RAID (nenhuma combinação produziu um sistema de arquivos coerente)': 'could not discover the RAID parameters (no combination produced a coherent file system)',
'não é uma imagem EWF (E01/Ex01)': 'not an EWF (E01/Ex01) image',
'imagem EWF sem tabela de chunks': 'EWF image without a chunk table',
'tabela EWF inconsistente': 'inconsistent EWF table',
'cabeçalho de volume EWF ausente': 'missing EWF volume header',
'segmento Ex01 ausente: sequência de chunks com lacuna': 'missing Ex01 segment: chunk sequence has a gap',
'tamanho de chunk EWF inválido': 'invalid EWF chunk size',
'imagem grande demais para o formato E01': 'image too large for the E01 format',
'não foi possível criar {n}: {n}': 'could not create {n}: {n}',
'AFF4 inválido (ZIP): {n}': 'invalid AFF4 (ZIP): {n}',
'AFF4: entrada sem início de dados: {n}': 'AFF4: entry without a data start: {n}',
'AFF4 sem information.turtle': 'AFF4 without information.turtle',
'AFF4 sem objeto de imagem (aff4:Image com aff4:dataStream)': 'AFF4 without an image object (aff4:Image with aff4:dataStream)',
'AFF4: fluxo de dados da imagem não encontrado': 'AFF4: image data stream not found',
'AFF4: fluxo simbólico inválido': 'AFF4: invalid symbolic stream',
'AFF4: fluxo {n} não descrito': 'AFF4: stream {n} not described',
'AFF4: entrada {n} ausente': 'AFF4: entry {n} missing',
'AFF4/ZIP: {n}': 'AFF4/ZIP: {n}',
'cifra {n} (apenas AES)': 'cipher {n} (only AES)',
'chave inválida para {n}': 'invalid key for {n}',
'IV {n}': 'IV {n}',
'{n} arquivos · {n} · {n}': '{n} files · {n} · {n}',
'Discos brutos exigem administrador. Rode': 'Raw disks require administrator. Run',
'(Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.': '(Linux/macOS) or open Guará as administrator (Windows). You can still open an image file from the menu.',
'(Linux/macOS) ou como administrador (Windows).': '(Linux/macOS) or as administrator (Windows).',
'cifrada ({n}) · só detecção': 'encrypted ({n}) · detection only',
'Onde salvar o arquivo de imagem? Use': 'Where to save the image file? Use',
'outro disco': 'another disk',
'com espaço suficiente e um nome que ainda não exista.': 'with enough space and a name that does not exist yet.',
'Criar imagem': 'Create image',
'{n} trecho(s) ilegível(is) foram preenchidos com zeros. Veja o mapa de erros ao lado da imagem.': '{n} unreadable chunk(s) were filled with zeros. See the error map next to the image.',
'{n}. Escolha o disco que voltou ou uma imagem dele.': '{n}. Choose the disk that came back or an image of it.',
'Escolha uma pasta em': 'Choose a folder on',
'(todos desta vista)': '(all in this view)',
'Recuperar {n}': 'Recover {n}',
'Dimensões': 'Dimensions',
'{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}',
'e mais {n}…': 'and {n} more…',
'abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'opens in your player: the embedded Linux browser does not include the codecs and would freeze the window.',
};
+122
View File
@@ -0,0 +1,122 @@
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
// continua observando a página para traduzir o que for desenhado depois.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files").
import { EN } from './i18n-en.js';
let lang = 'pt';
export const isEn = () => lang === 'en';
export const currentLang = () => lang;
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
export function resolveLang(pref) {
if (pref === 'pt' || pref === 'en') return pref;
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
// Dados do usuário (nomes, caminhos, conteúdo) nunca são traduzidos.
const SKIP = '.xp-cap, .xp-lnm, .db-table, .pv-zip, .hit, pre, code, .mono, .xp-ititle b, dd, textarea, input, script, style';
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
const patterns = Object.entries(EN)
.filter(([k]) => k.includes('{n}'))
.map(([k, v]) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
})
// padrões mais específicos (mais texto fixo) primeiro
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
export function tr(text) {
if (!isEn()) return text;
const lead = text.match(/^\s*/)[0];
const trail = text.match(/\s*$/)[0];
const core = text.trim();
if (!core) return text;
if (cache.has(core)) return lead + cache.get(core) + trail;
let out = exact.get(core);
if (out === undefined) {
for (const p of patterns) {
const m = core.match(p.re);
if (m) {
let i = 0;
// os valores capturados também podem ter partes traduzíveis (ex.: nomes de tipos)
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break;
}
}
}
if (out === undefined) out = core;
if (cache.size > 5000) cache.clear();
cache.set(core, out);
return lead + out + trail;
}
const orig = new WeakMap(); // nó -> texto original em português
let observer = null;
function translateNode(n) {
if (n.nodeType === 3) {
if (n.parentElement?.closest(SKIP)) return;
const o = orig.get(n) ?? n.nodeValue;
const t = tr(o);
if (t !== n.nodeValue) {
if (!orig.has(n)) orig.set(n, o);
n.nodeValue = t;
}
} else if (n.nodeType === 1) {
if (n.matches?.(SKIP) && n.tagName !== 'INPUT') return;
for (const a of ATTRS) {
if (n.hasAttribute?.(a)) {
const key = `i18n-${a}`;
const o = n[key] ?? n.getAttribute(a);
const t = tr(o);
if (t !== n.getAttribute(a)) { n[key] = o; n.setAttribute(a, t); }
}
}
for (const c of n.childNodes) translateNode(c);
}
}
function restoreAll(root) {
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) { if (orig.has(n)) { n.nodeValue = orig.get(n); orig.delete(n); } }
root.querySelectorAll('*').forEach((el) => {
for (const a of ATTRS) { const key = `i18n-${a}`; if (el[key] !== undefined) { el.setAttribute(a, el[key]); delete el[key]; } }
});
}
function start() {
translateNode(document.body);
document.title = tr(document.title);
if (observer) return;
observer = new MutationObserver((muts) => {
observer.disconnect();
for (const m of muts) {
if (m.type === 'childList') m.addedNodes.forEach(translateNode);
else if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
else if (m.type === 'attributes') { delete m.target[`i18n-${m.attributeName}`]; translateNode(m.target); }
}
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
}
function stop() {
observer?.disconnect();
observer = null;
restoreAll(document.body);
}
export function setLang(pref) {
const next = resolveLang(pref);
const changed = next !== lang;
lang = next;
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
if (lang === 'en') start();
else if (changed || observer) stop();
}
+5 -3
View File
@@ -14,19 +14,21 @@
<button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
</button>
<img src="guara.svg" alt="" width="24" height="24"><strong>Guará</strong>
<svg class="logo" width="24" height="24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-top"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-top)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><strong>Guará</strong>
</header>
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
<div class="brand"><img src="guara.svg" alt="" width="30" height="30"><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<div class="brand"><svg class="logo" width="30" height="30" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-side"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-side)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<nav>
<div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
<ul id="deviceList" class="list"></ul>
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
<div class="side-title">Sessões</div>
<ul id="sessionList" class="list"></ul>
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
<ul id="recentList" 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"><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>
<footer class="side-foot"><button class="icon-btn small" id="settingsBtn" title="Configurações" aria-label="Configurações"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button><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>
+116 -2
View File
@@ -1,3 +1,4 @@
import { LOC } from './i18n.js';
// 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
@@ -9,6 +10,7 @@ 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']);
const DBS = new Set(['db', 'sqlite', 'sqlite3', 'db3', 'sdb', 'sqlitedb', 'storedata']);
export function kindOf(item) {
const e = (item.ext || '').toLowerCase();
@@ -16,6 +18,7 @@ export function kindOf(item) {
if (VID.has(e)) return 'video';
if (AUD.has(e)) return 'audio';
if (e === 'pdf') return 'pdf';
if (DBS.has(e)) return 'database';
if (OFFICE.has(e)) return 'office';
if (ZIPS.has(e)) return 'zip';
if (TXT.has(e)) return 'text';
@@ -24,7 +27,7 @@ export function kindOf(item) {
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)',
zip: 'Conteúdo do arquivo compactado', database: 'Banco de dados SQLite', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
};
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -131,8 +134,10 @@ async function image(host, item, url, state, thumb) {
if (state.dead) return resolve();
host.innerHTML = '';
host.appendChild(img);
if (!thumb) {
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();
};
@@ -270,14 +275,122 @@ async function zip(host, item, url, state) {
note(host, `${ents.length} item(ns) no arquivo compactado.`);
}
// ------------------------------------------------------------------ bancos de dados (SQLite)
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
function tsHint(v) {
if (!Number.isInteger(v)) return null;
let ms = null, kind = '';
if (v > 9.4e8 && v < 4.2e9) { ms = v * 1000; kind = 'Unix (s)'; }
else if (v > 9.4e11 && v < 4.2e12) { ms = v; kind = 'Unix (ms)'; }
else if (v > 9.4e14 && v < 4.2e15) { ms = v / 1000; kind = 'Unix (µs)'; }
else if (v > 1.2e16 && v < 1.4e16) { ms = v / 1000 - 11644473600000; kind = 'Chrome/WebKit'; }
else if (v > 3e8 && v < 9.4e8) { ms = (v + 978307200) * 1000; kind = 'macOS/CoreData'; }
if (ms == null) return null;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : { kind, text: d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' };
}
function cellHtml(c) {
switch (c.t) {
case 'null': return '<span class="db-null">NULL</span>';
case 'int': { const h = tsHint(c.v); return h ? `<span title="${esc(h.kind)}: ${esc(h.text)}">${c.v}<i class="db-ts">${esc(h.text.slice(0, 10))}</i></span>` : String(c.v); }
case 'real': return String(c.v);
case 'text': return `<span title="${esc(c.v.slice(0, 600))}">${esc(c.v.length > 240 ? c.v.slice(0, 240) + '…' : c.v)}</span>`;
default: return `<span class="db-blob" title="${esc(c.v.head)}">BLOB ${c.v.mime ? esc(c.v.mime) + ' ' : ''}${human(c.v.len)}</span>`;
}
}
async function database(host, item, state, large, db, onOpen) {
if (!db) throw new Error('visualizador indisponível');
const info = await db.info();
if (state.dead) return;
const tables = info.tables;
const warn = (info.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
if (!large) {
const total = tables.reduce((a, t) => a + (t.rows || 0), 0);
host.innerHTML = `<div class="db-sum"><b>Banco SQLite</b><span class="sub">${tables.length} tabela(s) · ${total.toLocaleString(LOC())} linhas · páginas de ${human(info.page_size)}</span>
<ul>${tables.slice(0, 10).map((t) => `<li><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></li>`).join('')}</ul>
${tables.length > 10 ? `<span class="sub">e mais ${tables.length - 10}…</span>` : ''}${warn}
<button class="btn primary sm" data-pv="open">Abrir o visualizador</button></div>`;
host.onclick = (e) => { if (e.target.closest('[data-pv]')) onOpen?.(); };
return;
}
const S = { table: tables.find((t) => t.rows > 0)?.name || tables[0]?.name, offset: 0, limit: 100, deleted: false, q: '', schema: false };
host.classList.add('db-large');
host.innerHTML = `<div class="db-wrap"><nav class="db-tables" aria-label="Tabelas"></nav><section class="db-main"><div class="db-bar"></div><div class="db-grid"></div><div class="db-foot"></div></section></div>`;
const $ = (sel) => host.querySelector(sel);
const nav = $('.db-tables'), bar = $('.db-bar'), grid = $('.db-grid'), foot = $('.db-foot');
nav.innerHTML = tables.map((t) => `<button class="db-t" data-t="${esc(t.name)}"><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></button>`).join('')
+ (info.indexes.length || info.views.length ? `<div class="db-meta">${info.views.length ? `<b>Views</b>${info.views.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}${info.indexes.length ? `<b>Índices</b>${info.indexes.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}</div>` : '')
+ warn;
async function load() {
if (state.dead) return;
nav.querySelectorAll('.db-t').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.t === S.table)));
const t = tables.find((x) => x.name === S.table);
if (!t) { grid.innerHTML = '<div class="pv-note">Nenhuma tabela.</div>'; return; }
bar.innerHTML = `<b>${esc(t.name)}</b><span class="sub">${(t.rows || 0).toLocaleString(LOC())} linhas · ${t.columns.length} colunas</span>
<input class="input" id="dbq" type="search" placeholder="Filtrar esta página" value="${esc(S.q)}" aria-label="Filtrar">
<label class="check" style="padding:0"><input type="checkbox" id="dbdel" ${S.deleted ? 'checked' : ''}><span>Registros apagados (prováveis)</span></label>
<button class="btn sm" id="dbsch">${S.schema ? 'Ver dados' : 'Ver esquema'}</button>`;
if (S.schema) { grid.innerHTML = `<pre class="pv-text">${esc(t.sql)}</pre>`; foot.innerHTML = ''; wire(); return; }
grid.innerHTML = '<div class="pv-loading"><i></i></div>';
let page;
try { page = await db.rows(S.table, S.offset, S.limit, S.deleted); } catch (e) { grid.innerHTML = `<div class="pv-note bad">${esc(e.message || e)}</div>`; wire(); return; }
if (state.dead) return;
const q = S.q.toLowerCase();
const rows = q ? page.rows.filter((r) => r.cells.some((c) => String(c.t === 'blob' ? '' : c.v).toLowerCase().includes(q))) : page.rows;
const notes = (page.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
grid.innerHTML = notes + (rows.length ? `<table class="db-table"><thead><tr><th>#</th>${page.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r, i) => `<tr class="${r.deleted ? 'del' : ''}"><th>${r.deleted ? 'apagado' : (r.rowid ?? S.offset + i + 1)}</th>${r.cells.map((c) => `<td>${cellHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`
: `<div class="pv-note">${S.deleted ? 'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.' : 'Nenhuma linha.'}</div>`);
const total = S.deleted ? page.total : (t.rows || page.total);
foot.innerHTML = `<span class="sub">${S.deleted ? `${total.toLocaleString(LOC())} registro(s) provável(is) apagado(s)` : `${total ? S.offset + 1 : 0} a ${Math.min(S.offset + S.limit, total)} de ${total.toLocaleString(LOC())}`}</span>
<span class="row"><button class="btn sm" id="dbprev" ${S.offset <= 0 ? 'disabled' : ''}>‹ Anterior</button><button class="btn sm" id="dbnext" ${S.offset + S.limit >= total ? 'disabled' : ''}>Próximas ›</button></span>`;
wire();
}
function wire() {
$('#dbq')?.addEventListener('input', (e) => { S.q = e.target.value; clearTimeout(S.t); S.t = setTimeout(load, 200); });
$('#dbdel')?.addEventListener('change', (e) => { S.deleted = e.target.checked; S.offset = 0; load(); });
$('#dbsch')?.addEventListener('click', () => { S.schema = !S.schema; load(); });
$('#dbprev')?.addEventListener('click', () => { S.offset = Math.max(0, S.offset - S.limit); load(); });
$('#dbnext')?.addEventListener('click', () => { S.offset += S.limit; load(); });
}
nav.addEventListener('click', (e) => { const b = e.target.closest('.db-t'); if (b) { S.table = b.dataset.t; S.offset = 0; S.q = ''; S.deleted = false; S.schema = false; load(); } });
load();
}
// ------------------------------------------------------------------ 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 }) {
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
const ico = kind === 'video' ? '🎞' : '🎵';
const canInline = inlineMedia && large;
host.innerHTML = `<div class="pv-card"><div class="pv-bigicon" aria-hidden="true">${ico}</div>
<b>${esc(item.name)}</b><span class="sub">${esc(KIND_LABEL[kind])} · ${human(item.size)}</span>
${inlineMedia
? '<button class="btn primary" data-pv="open">Reproduzir em tela grande</button>'
: '<button class="btn primary" data-pv="ext">Abrir no player do sistema</button><span class="pv-why">Neste app o ' + (kind === 'video' ? 'vídeo' : 'áudio') + ' abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.</span>'}
</div>`;
host.onclick = (e) => {
const a = e.target.closest('[data-pv]')?.dataset.pv;
if (a === 'open') onOpen?.();
else if (a === 'ext') onExternal?.();
};
void canInline;
}
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null, db = null }) {
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);
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
return () => { state.dead = true; };
}
(async () => {
try {
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
@@ -285,6 +398,7 @@ export function renderPreview(host, item, { url, large = false, thumb = false })
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 === 'database') await database(host, item, state, large, db, onOpen);
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); }
+2 -1
View File
@@ -16,5 +16,6 @@ guara-core = { path = "../guara-core" }
clap = { version = "4", features = ["derive"] }
serde_json = "1"
tiny_http = "0.12"
getrandom = "0.2"
getrandom = "0.4"
ctrlc = "3"
rpassword = "7"
+381 -6
View File
@@ -31,6 +31,15 @@ enum Cmd {
path: String,
#[arg(long)]
json: bool,
#[command(flatten)]
asm: AsmArgs,
},
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos
Health {
/// Dispositivo ou partição (o SMART lido é o do disco que a contém)
path: String,
#[arg(long)]
json: bool,
},
/// Procura arquivos apagados/perdidos (metadados + assinatura)
Scan(ScanArgs),
@@ -49,7 +58,36 @@ enum Cmd {
filter: FilterArgs,
},
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
Image { path: String, dest: String },
Image {
path: String,
dest: String,
/// Formato da imagem: raw (padrão), e01 (Expert Witness) ou aff4 (AFF4 v1.0), com hash e metadados
#[arg(long, default_value = "raw")]
format: String,
/// E01: número do caso
#[arg(long, default_value = "")]
case: String,
/// E01: examinador
#[arg(long, default_value = "")]
examiner: String,
/// E01: descrição da evidência
#[arg(long, default_value = "")]
description: String,
/// E01: número da evidência
#[arg(long, default_value = "")]
evidence: String,
/// E01: observações
#[arg(long, default_value = "")]
notes: String,
/// E01: tamanho máximo de cada segmento em MiB (0 = um arquivo só)
#[arg(long, default_value_t = 1500)]
segment_mib: u64,
/// E01: compressão 0 (nenhuma) a 9
#[arg(long, default_value_t = 1)]
compression: u32,
},
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
Verify { path: String },
/// Exporta os achados de uma sessão para CSV (abre no Excel)
Export {
session: String,
@@ -94,6 +132,8 @@ enum Cmd {
},
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
Hash { path: String },
/// Lista as sessões salvas automaticamente
Sessions,
/// Abre a interface gráfica no navegador (modo local)
Ui {
#[arg(long, default_value_t = 0)]
@@ -114,6 +154,9 @@ struct FilterArgs {
/// Só nomes/caminhos que contenham este texto
#[arg(long)]
name: Option<String>,
/// Como organizar o que for recuperado: folders (pastas originais), type, date ou flat
#[arg(long, default_value = "folders")]
organize: String,
}
#[derive(clap::Args, Clone)]
@@ -128,6 +171,120 @@ struct OutArgs {
json: bool,
}
/// Montagem do disco: volumes cifrados, RAID e LVM.
#[derive(clap::Args, Clone, Default)]
struct AsmArgs {
/// Senha de volume cifrado (BitLocker, LUKS, FileVault 2). Prefira --ask-password ou GUARA_PASSWORD
#[arg(long = "password")]
passwords: Vec<String>,
/// Pergunta a senha no terminal, sem mostrar o que é digitado
#[arg(long)]
ask_password: bool,
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos)
#[arg(long = "recovery-key")]
recovery_keys: Vec<String>,
/// Arquivo de chave (.BEK do BitLocker ou keyfile do LUKS)
#[arg(long = "key-file")]
key_files: Vec<String>,
/// Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume)
#[arg(long = "volume-key")]
volume_keys: Vec<String>,
/// Outro disco ou imagem do mesmo conjunto (RAID/LVM). Pode repetir; "-" marca um disco ausente
#[arg(long = "member")]
members: Vec<String>,
/// Os discos (alvo + --member, nesta ordem) formam um RAID sem metadados: descobre sozinho nível, faixa e layout
#[arg(long)]
raid: bool,
/// Fixa o nível do RAID: 0, 5, 6, 1
#[arg(long)]
raid_level: Option<String>,
/// Fixa o tamanho da faixa do RAID em KiB
#[arg(long)]
raid_chunk_kb: Option<u64>,
/// Fixa o layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
#[arg(long)]
raid_layout: Option<String>,
/// Ordem dos discos no RAID (índices; 0 = alvo)
#[arg(long, value_delimiter = ',')]
raid_order: Vec<usize>,
/// Guarda na sessão as chaves dos volumes abertos (a senha nunca é guardada)
#[arg(long)]
remember_keys: bool,
/// Vídeo íntegro da mesma câmera/drone para refazer o índice de MP4 sem moov (pode repetir)
#[arg(long = "reference")]
references: Vec<String>,
}
impl AsmArgs {
fn assembly(&self) -> Result<guara_core::assemble::Assembly, String> {
use guara_core::assemble::{Assembly, RaidSpec, SecretInput};
let mut secrets = vec![];
for p in &self.passwords {
secrets.push(SecretInput::Password { value: p.clone() });
}
if let Ok(p) = std::env::var("GUARA_PASSWORD") {
if !p.is_empty() {
secrets.push(SecretInput::Password { value: p });
}
}
if self.ask_password {
let p = rpassword::prompt_password("Senha do volume: ").map_err(s)?;
secrets.push(SecretInput::Password { value: p });
}
for r in &self.recovery_keys {
secrets.push(SecretInput::Recovery { value: r.clone() });
}
for k in &self.key_files {
secrets.push(SecretInput::Keyfile { path: k.clone() });
}
for k in &self.volume_keys {
secrets.push(SecretInput::Key { hex: k.clone() });
}
let raid = if self.raid
|| self.raid_level.is_some()
|| self.raid_chunk_kb.is_some()
|| self.raid_layout.is_some()
|| !self.raid_order.is_empty()
{
let level = match self.raid_level.as_deref() {
None => None,
Some("0") => Some(guara_core::raid::Level::R0),
Some("1") => Some(guara_core::raid::Level::R1),
Some("5") => Some(guara_core::raid::Level::R5),
Some("6") => Some(guara_core::raid::Level::R6),
Some(x) => {
return Err(format!(
"nível de RAID não suportado: {x} (use 0, 1, 5 ou 6)"
))
}
};
let layout = match self.raid_layout.as_deref() {
None => None,
Some("left-symmetric") => Some(guara_core::raid::Layout::LeftSymmetric),
Some("left-asymmetric") => Some(guara_core::raid::Layout::LeftAsymmetric),
Some("right-symmetric") => Some(guara_core::raid::Layout::RightSymmetric),
Some("right-asymmetric") => Some(guara_core::raid::Layout::RightAsymmetric),
Some(x) => return Err(format!("layout desconhecido: {x}")),
};
Some(RaidSpec {
level,
chunk_kb: self.raid_chunk_kb,
layout,
order: (!self.raid_order.is_empty()).then(|| self.raid_order.clone()),
})
} else {
None
};
Ok(Assembly {
raid,
members: self.members.iter().filter(|m| *m != "-").cloned().collect(),
secrets,
remember_keys: self.remember_keys,
keys: vec![],
})
}
}
#[derive(clap::Args)]
struct ScanArgs {
/// Dispositivo ou imagem
@@ -142,6 +299,8 @@ struct ScanArgs {
#[arg(long)]
recover: Option<String>,
#[command(flatten)]
asm: AsmArgs,
#[command(flatten)]
filter: FilterArgs,
#[command(flatten)]
out: OutArgs,
@@ -192,8 +351,14 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Inspect { path, json } => {
let v = eng.rpc("inspect", json!({ "path": path })).map_err(s)?;
Cmd::Inspect { path, json, asm } => {
let a = asm.assembly()?;
let v = eng
.rpc(
"inspect",
json!({ "path": path, "assembly": serde_json::to_value(&a).map_err(s)?}),
)
.map_err(s)?;
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap());
} else {
@@ -201,6 +366,15 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Health { path, json } => {
let v = eng.rpc("health", json!({ "path": path })).map_err(s)?;
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap());
} else {
print_health(&v);
}
Ok(())
}
Cmd::Scan(a) => {
let opts = ScanOptions {
mode: if a.quick {
@@ -210,6 +384,8 @@ fn run(cli: Cli) -> Result<(), String> {
},
include_allocated: a.include_allocated,
categories: parse_cats(&a.filter.category)?,
assembly: a.asm.assembly()?,
references: a.asm.references.clone(),
..Default::default()
};
let job = eng.start_scan(&a.path, opts).map_err(s)?;
@@ -230,6 +406,7 @@ fn run(cli: Cli) -> Result<(), String> {
category: vec![],
min_confidence: "low".into(),
name: None,
organize: "folders".into(),
},
None,
)
@@ -250,8 +427,88 @@ fn run(cli: Cli) -> Result<(), String> {
eng.save_session(&job, &session).map_err(s)?;
Ok(())
}
Cmd::Image { path, dest } => {
let job = eng.start_image(&path, &dest).map_err(s)?;
Cmd::Verify { path } if path.to_ascii_lowercase().ends_with(".aff4") => {
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(&path)).map_err(s)?;
println!("Formato: AFF4");
for (k, v) in &a.info {
println!("{k}: {v}");
}
eprintln!("Recalculando o hash...");
match a.verify_stream_md5().map_err(s)? {
Some(true) => {
println!("MD5 gravado e recalculado conferem.");
Ok(())
}
Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()),
None => {
println!("Esta imagem não traz MD5 para conferir.");
Ok(())
}
}
}
Cmd::Verify { path } => {
let e = guara_core::ewf::EwfSource::open(std::path::Path::new(&path)).map_err(s)?;
println!(
"Formato: {}",
if e.info.version == 2 {
"Ex01 (EnCase 7)"
} else {
"E01 (EnCase)"
}
);
println!("Segmentos: {}", e.info.segments);
for (k, v) in &e.info.fields {
println!("{k}: {v}");
}
eprintln!("Recalculando o hash...");
match e.verify().map_err(s)? {
Some(true) => {
println!("MD5 gravado e recalculado conferem: {}", e.info.md5.clone().unwrap_or_default());
Ok(())
}
Some(false) => Err("O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada.".into()),
None => {
println!("Esta imagem não traz hash gravado para conferir.");
Ok(())
}
}
}
Cmd::Image {
path,
dest,
format,
case,
examiner,
description,
evidence,
notes,
segment_mib,
compression,
} => {
let fmt = match format.as_str() {
"raw" => guara_core::image::ImageFormat::Raw,
"e01" => guara_core::image::ImageFormat::E01 {
case_number: case,
description,
evidence_number: evidence,
examiner,
notes,
segment_mib,
compression,
},
"aff4" => guara_core::image::ImageFormat::Aff4 {
case_number: case,
examiner,
evidence_number: evidence,
notes,
},
x => {
return Err(format!(
"formato de imagem desconhecido: {x} (use raw, e01 ou aff4)"
))
}
};
let job = eng.start_image_as(&path, &dest, &fmt).map_err(s)?;
let st = wait(&eng, &job, None)?;
if st["status"] == "error" {
return Err(st["error"].as_str().unwrap_or("falha").to_string());
@@ -367,6 +624,23 @@ fn run(cli: Cli) -> Result<(), String> {
);
Ok(())
}
Cmd::Sessions => {
let list = eng.rpc("sessions_list", json!({})).map_err(s)?;
let items = list.as_array().cloned().unwrap_or_default();
if items.is_empty() {
println!("Nenhuma sessão salva automaticamente ainda.");
}
for i in items {
println!(
"{:<10} {:>8} arquivos {:<22} {}",
i["status"].as_str().unwrap_or(""),
i["found"],
i["target"].as_str().unwrap_or(""),
i["path"].as_str().unwrap_or("")
);
}
Ok(())
}
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
}
}
@@ -483,7 +757,13 @@ fn recover_job(
}
ids = Some(all);
}
let rj = eng.start_recover(scan_job, ids, Some(q), dest).map_err(s)?;
let layout = guara_core::recover::RecoverLayout::parse(&filter.organize).ok_or(format!(
"organização inválida: {} (use folders, type, date ou flat)",
filter.organize
))?;
let rj = eng
.start_recover(scan_job, ids, Some(q), dest, layout)
.map_err(s)?;
let st = wait(eng, &rj, None)?;
eprintln!();
if st["status"] == "error" {
@@ -673,3 +953,98 @@ fn paint(code: &str, s: &str) -> String {
fn s<E: std::fmt::Display>(e: E) -> String {
e.to_string()
}
fn print_health(v: &Value) {
let st = v["status"].as_str().unwrap_or("unknown");
if !v["available"].as_bool().unwrap_or(false) {
println!("{}", v["summary"].as_str().unwrap_or(""));
return;
}
let word = match st {
"ok" => paint("32", "Bom"),
"warning" => paint("33", "Atenção"),
"failing" => paint("31", "Ruim"),
_ => "?".into(),
};
let pct = v["health_pct"]
.as_u64()
.map(|p| format!(" ({p}%)"))
.unwrap_or_default();
println!("Disco: {}", v["disk"].as_str().unwrap_or(""));
if let Some(p) = v["partition"].as_str() {
println!(" (o SMART é do disco inteiro que contém {p})");
}
println!(
"Estado: {word}{pct} {}",
v["summary"].as_str().unwrap_or("")
);
let num = |k: &str| v[k].as_i64();
let bytes = |k: &str| v[k].as_u64().map(human);
let mut m = Vec::new();
if let Some(t) = num("temperature_c") {
m.push(format!("Temperatura: {t} °C"));
}
if let Some(h) = num("power_on_hours") {
m.push(format!("Horas ligado: {h}"));
}
if let Some(c) = num("power_cycles") {
m.push(format!("Vezes ligado: {c}"));
}
if let Some(r) = bytes("host_reads") {
m.push(format!("Total lido: {r}"));
}
if let Some(w) = bytes("host_writes") {
m.push(format!("Total gravado: {w}"));
}
if !m.is_empty() {
println!("{}", m.join(" · "));
}
if st != "ok" {
for kv in v["details"].as_array().into_iter().flatten() {
println!(
" ! {}: {}",
kv[0].as_str().unwrap_or(""),
kv[1].as_str().unwrap_or("")
);
}
}
println!();
for kv in v["info"].as_array().into_iter().flatten() {
println!(
" {:<24}{}",
kv[0].as_str().unwrap_or(""),
kv[1].as_str().unwrap_or("")
);
}
let attrs = v["attributes"].as_array().cloned().unwrap_or_default();
if !attrs.is_empty() {
let o = |x: &Value| {
x.as_i64()
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into())
};
println!(
"\n ID {:<34}{:>6}{:>6}{:>7} Bruto",
"Atributo", "Atual", "Pior", "Limite"
);
for a in attrs {
let dot = match a["status"].as_str() {
Some("failing") => paint("31", "●"),
Some("warning") => paint("33", "●"),
_ => paint("32", "●"),
};
println!(
" {dot} {:<3} {:<34}{:>6}{:>6}{:>7} {}",
a["id"].as_str().unwrap_or(""),
a["name"].as_str().unwrap_or(""),
o(&a["value"]),
o(&a["worst"]),
o(&a["thresh"]),
a["raw"].as_str().unwrap_or("")
);
}
}
for adv in v["advice"].as_array().into_iter().flatten() {
println!("\n{}", adv.as_str().unwrap_or(""));
}
}
+19 -76
View File
@@ -4,12 +4,14 @@ use guara_core::service::Engine;
use serde_json::json;
use std::io::Read;
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use tiny_http::{Header, Method, Request, Response, Server};
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 I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.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");
@@ -24,7 +26,7 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
.map(|a| a.port())
.unwrap_or(port);
let mut tok = [0u8; 16];
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
getrandom::fill(&mut tok).map_err(|e| e.to_string())?;
let token: Arc<str> = tok
.iter()
.map(|b| format!("{b:02x}"))
@@ -52,7 +54,7 @@ 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]) {
fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
let host_ok = req
.headers()
.iter()
@@ -61,8 +63,11 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
return;
}
let Some(mut req) = guara_core::http::handle_media(eng, token, req) else {
return;
};
let full = req.url().to_string();
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
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") => {
@@ -83,6 +88,16 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/i18n.js") => {
let _ = req.respond(
Response::from_string(I18N_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/i18n-en.js") => {
let _ = req.respond(
Response::from_string(I18N_EN_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")));
}
@@ -105,78 +120,6 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, p) if p.starts_with("/thumb/") => {
let tok_ok = query
.split('&')
.any(|kv| kv.strip_prefix("t=") == Some(token));
if !tok_ok {
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
return;
}
let mut it = p.trim_start_matches("/thumb/").split('/');
let (job, id) = (
it.next().unwrap_or(""),
it.next().and_then(|s| s.parse::<u64>().ok()),
);
let size = query
.split('&')
.find_map(|kv| kv.strip_prefix("s="))
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(256);
let Some(id) = id else {
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
return;
};
match eng.thumb(job, id, size) {
Ok(bytes) => {
let resp = Response::from_data(bytes.as_ref().clone())
.with_header(ct("image/jpeg"))
.with_header(header("Cache-Control", "private, max-age=3600"));
let _ = req.respond(resp);
}
Err(code) => {
let _ =
req.respond(Response::from_string("sem miniatura").with_status_code(code));
}
}
}
(Method::Get, p) if p.starts_with("/file/") => {
let tok_ok = query
.split('&')
.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()
+16 -4
View File
@@ -10,20 +10,32 @@ rust-version.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sha1 = "0.10"
md-5 = "0.10"
sha2 = "0.11"
sha1 = "0.11"
md-5 = "0.11"
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"
tiny_http = "0.12"
bzip2 = "0.6"
zip = { version = "9", default-features = false, features = ["deflate"] }
snap = "1"
ruzstd = "0.9"
lz4_flex = { version = "0.11", default-features = false, features = ["safe-decode", "safe-encode"] }
aes = "0.9"
hmac = "0.13"
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
ripemd = "0.2"
argon2 = "0.6"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Ioctl", "Win32_Security"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Ioctl", "Win32_Security"] }
[dev-dependencies]
tempfile = "3"
mp4 = "0.14"
+850
View File
@@ -0,0 +1,850 @@
//! AFF4 (Advanced Forensic Format 4, padrão v1.0): contêiner ZIP com metadados em Turtle.
//!
//! Leitura de imagens lineares e mapeadas (inclusive esparsas), com blocos comprimidos por
//! snappy, deflate, LZ4 ou sem compressão. Gravação de uma imagem linear com blocos snappy.
//! Conjuntos em vários volumes (spanned/striped) não são suportados.
use crate::error::{Error, Result};
use crate::hashing::MultiHasher;
use crate::image::ImageReport;
use crate::source::{read_exact_at, Source};
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, Read, Write};
use std::path::Path;
use std::sync::Mutex;
pub fn is_aff4(head: &[u8]) -> bool {
// ZIP local file header + primeiro nome "container.description" ou "version.txt".
head.len() > 50
&& head.starts_with(b"PK\x03\x04")
&& (find(&head[30..head.len().min(80)], b"container.description")
|| find(&head[30..head.len().min(80)], b"version.txt"))
}
fn find(h: &[u8], n: &[u8]) -> bool {
h.windows(n.len()).any(|w| w == n)
}
const NS: &str = "http://aff4.org/Schema#";
// ------------------------------------------------------------------ Turtle
#[derive(Debug, Default, Clone)]
struct Resource {
types: Vec<String>,
props: HashMap<String, Vec<String>>,
}
impl Resource {
fn get(&self, k: &str) -> Option<&str> {
self.props
.get(k)
.and_then(|v| v.first())
.map(|s| s.as_str())
}
fn num(&self, k: &str) -> Option<u64> {
self.get(k)?.parse().ok()
}
fn is(&self, t: &str) -> bool {
self.types.iter().any(|x| x == t)
}
}
struct Turtle<'a> {
b: &'a [u8],
i: usize,
prefixes: HashMap<String, String>,
}
impl Turtle<'_> {
fn ws(&mut self) {
loop {
while self.i < self.b.len() && self.b[self.i].is_ascii_whitespace() {
self.i += 1;
}
if self.i < self.b.len() && self.b[self.i] == b'#' {
while self.i < self.b.len() && self.b[self.i] != b'\n' {
self.i += 1;
}
} else {
break;
}
}
}
fn expand(&self, tok: &str) -> String {
if let Some((p, l)) = tok.split_once(':') {
if let Some(base) = self.prefixes.get(p) {
return format!("{base}{l}");
}
}
tok.to_string()
}
/// Um termo: <iri>, "literal"[^^tipo], nome:prefixado ou `a`.
fn term(&mut self) -> Option<String> {
self.ws();
let c = *self.b.get(self.i)?;
match c {
b'<' => {
let s = self.i + 1;
while self.i < self.b.len() && self.b[self.i] != b'>' {
self.i += 1;
}
let v = String::from_utf8_lossy(&self.b[s..self.i]).to_string();
self.i += 1;
Some(v)
}
b'"' => {
self.i += 1;
let mut out = Vec::new();
while self.i < self.b.len() && self.b[self.i] != b'"' {
if self.b[self.i] == b'\\' && self.i + 1 < self.b.len() {
self.i += 1;
}
out.push(self.b[self.i]);
self.i += 1;
}
self.i += 1;
if self.b[self.i..].starts_with(b"^^") {
self.i += 2;
let _ = self.term();
} else if self.b.get(self.i) == Some(&b'@') {
while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() {
self.i += 1;
}
}
Some(String::from_utf8_lossy(&out).to_string())
}
_ => {
let s = self.i;
while self.i < self.b.len()
&& !self.b[self.i].is_ascii_whitespace()
&& !matches!(self.b[self.i], b';' | b',')
{
self.i += 1;
}
// ponto final de sentença colado ao termo
let mut e = self.i;
if e > s && self.b[e - 1] == b'.' {
e -= 1;
self.i = e;
}
let tok = String::from_utf8_lossy(&self.b[s..e]).to_string();
Some(if tok == "a" { tok } else { self.expand(&tok) })
}
}
}
}
fn parse_turtle(text: &str) -> HashMap<String, Resource> {
let mut t = Turtle {
b: text.as_bytes(),
i: 0,
prefixes: HashMap::new(),
};
let mut out: HashMap<String, Resource> = HashMap::new();
loop {
t.ws();
if t.i >= t.b.len() {
break;
}
if t.b[t.i..].starts_with(b"@prefix") {
t.i += 7;
t.ws();
let s = t.i;
while t.i < t.b.len() && t.b[t.i] != b':' {
t.i += 1;
}
let name = String::from_utf8_lossy(&t.b[s..t.i]).trim().to_string();
t.i += 1;
let iri = t.term().unwrap_or_default();
t.ws();
if t.b.get(t.i) == Some(&b'.') {
t.i += 1;
}
t.prefixes.insert(name, iri);
continue;
}
// sujeito
let Some(subj) =
(if t.b[t.i] == b':' && t.b.get(t.i + 1).map_or(true, |c| c.is_ascii_whitespace()) {
t.i += 1;
Some(t.prefixes.get("").cloned().unwrap_or_default())
} else {
t.term()
})
else {
break;
};
let res = out.entry(subj).or_default();
loop {
let Some(pred) = t.term() else { return out };
let mut objs = vec![];
loop {
let Some(o) = t.term() else { return out };
objs.push(o);
t.ws();
if t.b.get(t.i) == Some(&b',') {
t.i += 1;
continue;
}
break;
}
if pred == "a" {
res.types.extend(objs);
} else {
res.props.entry(pred).or_default().extend(objs);
}
t.ws();
match t.b.get(t.i) {
Some(b';') => {
t.i += 1;
t.ws();
// ";" final antes de "."
if t.b.get(t.i) == Some(&b'.') {
t.i += 1;
break;
}
}
Some(b'.') => {
t.i += 1;
break;
}
_ => break,
}
}
}
out
}
// ------------------------------------------------------------------ leitor
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Method {
Stored,
Deflate,
Zlib,
Snappy,
Lz4,
}
#[derive(Clone)]
struct ImageStream {
urn: String,
chunk: u64,
per_bevy: u64,
method: Method,
size: u64,
}
#[derive(Clone, Copy)]
struct MapEntry {
start: u64,
len: u64,
target_off: u64,
target: u32,
}
#[derive(Clone)]
enum Target {
Stream(usize),
Zero,
Fill(u8),
}
#[derive(Clone, Copy)]
struct Loc {
data_start: u64,
size: u64,
}
pub struct Aff4Source {
file: File,
entries: HashMap<String, Loc>,
streams: Vec<ImageStream>,
targets: Vec<Target>,
map: Vec<MapEntry>,
size: u64,
/// Metadados legíveis: caso, examinador, notas, hashes.
pub info: HashMap<String, String>,
cache: Mutex<Option<(usize, u64, Vec<u8>)>>,
index_cache: Mutex<HashMap<(usize, u64), std::sync::Arc<Vec<u8>>>>,
}
fn enc(urn: &str) -> String {
urn.replace(':', "%3A").replace('/', "%2F")
}
fn method_of(uri: &str) -> Method {
let u = uri.to_ascii_lowercase();
if u.contains("snappy") {
Method::Snappy
} else if u.contains("lz4") {
Method::Lz4
} else if u.contains("rfc1951") {
Method::Deflate
} else if u.contains("rfc1950") {
Method::Zlib
} else {
Method::Stored
}
}
impl Aff4Source {
pub fn open(path: &Path) -> Result<Self> {
let file = File::open(path)?;
let mut zip = zip::ZipArchive::new(file.try_clone()?)
.map_err(|e| Error::msg(format!("AFF4 inválido (ZIP): {e}")))?;
let mut entries = HashMap::new();
for i in 0..zip.len() {
let f = zip.by_index_raw(i).map_err(|e| Error::msg(e.to_string()))?;
let name = f.name().map_err(zerr)?.into_owned();
let data_start = f
.data_start()
.ok_or_else(|| Error::msg(format!("AFF4: entrada sem início de dados: {name}")))?;
entries.insert(
name,
Loc {
data_start,
size: f.compressed_size(),
},
);
}
let turtle = {
let mut f = zip
.by_name("information.turtle")
.map_err(|_| Error::msg("AFF4 sem information.turtle"))?;
let mut s = String::new();
f.read_to_string(&mut s)?;
s
};
let res = parse_turtle(&turtle);
let img_t = format!("{NS}Image");
let disk_t = format!("{NS}DiskImage");
let ds = format!("{NS}dataStream");
let image = res
.values()
.find(|r| (r.is(&img_t) || r.is(&disk_t)) && r.get(&ds).is_some())
.ok_or_else(|| {
Error::msg("AFF4 sem objeto de imagem (aff4:Image com aff4:dataStream)")
})?;
let mut info = HashMap::new();
if let Some(v) = image.get(&format!("{NS}diskModel")) {
info.insert("Modelo do disco".to_string(), v.to_string());
}
for (k, label) in [
("caseNumber", "Número do caso"),
("examiner", "Examinador"),
("notes", "Notas"),
("evidenceNumber", "Número da evidência"),
("caseName", "Nome do caso"),
] {
if let Some(v) = res.values().find_map(|r| r.get(&format!("{NS}{k}"))) {
info.insert(label.to_string(), v.to_string());
}
}
let data_urn = image.get(&ds).unwrap().to_string();
// Hashes gravados no fluxo de dados (ou no mapa).
for r in res
.values()
.filter(|r| r.get(&format!("{NS}hash")).is_some())
{
for h in r.props.get(&format!("{NS}hash")).into_iter().flatten() {
match h.len() {
32 if h.chars().all(|c| c.is_ascii_hexdigit()) => {
info.entry("md5".to_string()).or_insert_with(|| h.clone());
}
40 if h.chars().all(|c| c.is_ascii_hexdigit()) => {
info.entry("sha1".to_string()).or_insert_with(|| h.clone());
}
_ => {}
}
}
}
let mut me = Self {
file,
entries,
streams: vec![],
targets: vec![],
map: vec![],
size: 0,
info,
cache: Mutex::new(None),
index_cache: Mutex::new(HashMap::new()),
};
let top = res
.get(&data_urn)
.ok_or_else(|| Error::msg("AFF4: fluxo de dados da imagem não encontrado"))?;
let map_t = format!("{NS}Map");
if top.is(&map_t) {
me.size = top.num(&format!("{NS}size")).unwrap_or(0);
let mp = enc(&data_urn);
let idx_txt = me.read_entry(&format!("{mp}/idx"))?;
for line in String::from_utf8_lossy(&idx_txt).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(sym) = line.strip_prefix(&format!("{NS}SymbolicStream")) {
let b = u8::from_str_radix(sym, 16)
.map_err(|_| Error::msg("AFF4: fluxo simbólico inválido"))?;
me.targets.push(Target::Fill(b));
} else if line == format!("{NS}Zero") {
me.targets.push(Target::Zero);
} else if line.starts_with(NS) {
me.targets.push(Target::Zero);
} else {
let s = me.load_stream(&res, line)?;
me.streams.push(s);
me.targets.push(Target::Stream(me.streams.len() - 1));
}
}
let m = me.read_entry(&format!("{mp}/map"))?;
for c in m.chunks_exact(28) {
me.map.push(MapEntry {
start: u64::from_le_bytes(c[0..8].try_into().unwrap()),
len: u64::from_le_bytes(c[8..16].try_into().unwrap()),
target_off: u64::from_le_bytes(c[16..24].try_into().unwrap()),
target: u32::from_le_bytes(c[24..28].try_into().unwrap()),
});
}
me.map.sort_by_key(|e| e.start);
} else {
let s = me.load_stream(&res, &data_urn)?;
me.size = s.size;
me.streams.push(s);
me.targets.push(Target::Stream(0));
me.map = vec![MapEntry {
start: 0,
len: me.size,
target_off: 0,
target: 0,
}];
}
if me.size == 0 {
me.size = image.num(&format!("{NS}size")).unwrap_or(0);
}
Ok(me)
}
fn load_stream(&self, res: &HashMap<String, Resource>, urn: &str) -> Result<ImageStream> {
let r = res
.get(urn)
.ok_or_else(|| Error::msg(format!("AFF4: fluxo {urn} não descrito")))?;
Ok(ImageStream {
urn: urn.to_string(),
chunk: r.num(&format!("{NS}chunkSize")).unwrap_or(32768),
per_bevy: r.num(&format!("{NS}chunksInSegment")).unwrap_or(2048),
method: r
.get(&format!("{NS}compressionMethod"))
.map(method_of)
.unwrap_or(Method::Stored),
size: r.num(&format!("{NS}size")).unwrap_or(0),
})
}
/// MD5 do primeiro fluxo de blocos (o que o AFF4 grava em `aff4:hash`), para conferir a integridade.
pub fn verify_stream_md5(&self) -> Result<Option<bool>> {
let Some(want) = self.info.get("md5").cloned() else {
return Ok(None);
};
let Some(st) = self.streams.first() else {
return Ok(None);
};
let mut h = MultiHasher::new();
let mut buf = vec![0u8; st.chunk as usize];
let mut pos = 0u64;
while pos < st.size {
let n = (st.size - pos).min(st.chunk) as usize;
self.read_stream(0, pos, &mut buf[..n])?;
h.update(&buf[..n]);
pos += n as u64;
}
Ok(Some(h.finalize().md5 == want))
}
fn read_entry(&self, name: &str) -> Result<Vec<u8>> {
let loc = self
.entries
.get(name)
.ok_or_else(|| Error::msg(format!("AFF4: entrada {name} ausente")))?;
let mut v = vec![0u8; loc.size as usize];
read_exact_at_file(&self.file, loc.data_start, &mut v)?;
Ok(v)
}
fn chunk(&self, si: usize, chunk_no: u64) -> io::Result<Vec<u8>> {
{
let c = self.cache.lock().unwrap();
if let Some((s, n, d)) = c.as_ref() {
if *s == si && *n == chunk_no {
return Ok(d.clone());
}
}
}
let st = &self.streams[si];
let (bevy, within) = (chunk_no / st.per_bevy, chunk_no % st.per_bevy);
let base = format!("{}/{:08}", enc(&st.urn), bevy);
let index = {
let mut ic = self.index_cache.lock().unwrap();
if let Some(i) = ic.get(&(si, bevy)) {
i.clone()
} else {
let loc = self.entries.get(&format!("{base}.index")).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "índice do bevy ausente")
})?;
let mut v = vec![0u8; loc.size as usize];
read_exact_at_file(&self.file, loc.data_start, &mut v)?;
let a = std::sync::Arc::new(v);
ic.insert((si, bevy), a.clone());
a
}
};
let e = index
.get(within as usize * 12..within as usize * 12 + 12)
.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "bloco fora do índice"))?;
let (off, len) = (
u64::from_le_bytes(e[0..8].try_into().unwrap()),
u32::from_le_bytes(e[8..12].try_into().unwrap()) as usize,
);
let loc = self
.entries
.get(&base)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "bevy ausente"))?;
let mut raw = vec![0u8; len];
read_exact_at_file(&self.file, loc.data_start + off, &mut raw)?;
let want = st.chunk.min(st.size.saturating_sub(chunk_no * st.chunk)) as usize;
let data = if len == st.chunk as usize || st.method == Method::Stored {
raw
} else {
let bad = |m: String| io::Error::new(io::ErrorKind::InvalidData, m);
match st.method {
Method::Snappy => snap::raw::Decoder::new()
.decompress_vec(&raw)
.map_err(|e| bad(format!("snappy: {e}")))?,
Method::Lz4 => lz4_flex::block::decompress(&raw, st.chunk as usize)
.map_err(|e| bad(format!("lz4: {e}")))?,
Method::Deflate => {
let mut v = Vec::with_capacity(st.chunk as usize);
flate2::read::DeflateDecoder::new(&raw[..])
.read_to_end(&mut v)
.map_err(|e| bad(format!("deflate: {e}")))?;
v
}
Method::Zlib => {
let mut v = Vec::with_capacity(st.chunk as usize);
flate2::read::ZlibDecoder::new(&raw[..])
.read_to_end(&mut v)
.map_err(|e| bad(format!("zlib: {e}")))?;
v
}
Method::Stored => unreachable!(),
}
};
let mut data = data;
data.resize(want.max(data.len().min(st.chunk as usize)), 0);
*self.cache.lock().unwrap() = Some((si, chunk_no, data.clone()));
Ok(data)
}
fn read_stream(&self, si: usize, off: u64, buf: &mut [u8]) -> io::Result<()> {
let cs = self.streams[si].chunk;
let mut done = 0;
while done < buf.len() {
let pos = off + done as u64;
let (cn, within) = (pos / cs, (pos % cs) as usize);
let d = self.chunk(si, cn)?;
if within >= d.len() {
buf[done..].fill(0);
return Ok(());
}
let n = (d.len() - within).min(buf.len() - done);
buf[done..done + n].copy_from_slice(&d[within..within + n]);
done += n;
}
Ok(())
}
}
fn read_exact_at_file(f: &File, off: u64, buf: &mut [u8]) -> io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::FileExt::read_exact_at(f, buf, off)
}
#[cfg(windows)]
{
let mut done = 0;
while done < buf.len() {
let n =
std::os::windows::fs::FileExt::seek_read(f, &mut buf[done..], off + done as u64)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"AFF4 truncado",
));
}
done += n;
}
Ok(())
}
}
impl Source for Aff4Source {
fn size(&self) -> u64 {
self.size
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.size || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((self.size - offset) as usize);
let mut done = 0usize;
while done < want {
let pos = offset + done as u64;
let i = self.map.partition_point(|e| e.start + e.len <= pos);
let Some(e) = self.map.get(i).filter(|e| e.start <= pos) else {
// vão do mapa: zeros até a próxima entrada
let next = self.map.get(i).map(|e| e.start).unwrap_or(self.size);
let n = ((next - pos) as usize).min(want - done);
buf[done..done + n].fill(0);
done += n;
continue;
};
let n = ((e.start + e.len - pos) as usize).min(want - done);
let toff = e.target_off + (pos - e.start);
match self.targets.get(e.target as usize) {
Some(Target::Stream(si)) => {
self.read_stream(*si, toff, &mut buf[done..done + n])?
}
Some(Target::Fill(b)) => buf[done..done + n].fill(*b),
_ => buf[done..done + n].fill(0),
}
done += n;
}
Ok(done)
}
}
// ------------------------------------------------------------------ gravação
#[derive(Debug, Clone)]
pub struct Aff4Options {
pub case_name: String,
pub examiner: String,
pub notes: String,
pub evidence_number: String,
pub chunk_size: u32,
}
impl Default for Aff4Options {
fn default() -> Self {
Self {
case_name: String::new(),
examiner: String::new(),
notes: String::new(),
evidence_number: String::new(),
chunk_size: 32768,
}
}
}
fn new_uuid() -> String {
let mut b = [0u8; 16];
let _ = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut b));
if b == [0u8; 16] {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(1);
b = (t ^ (std::process::id() as u128) << 64).to_le_bytes();
}
b[6] = (b[6] & 0x0F) | 0x40;
b[8] = (b[8] & 0x3F) | 0x80;
let h: Vec<String> = b.iter().map(|x| format!("{x:02x}")).collect();
format!(
"{}-{}-{}-{}-{}",
h[0..4].concat(),
h[4..6].concat(),
h[6..8].concat(),
h[8..10].concat(),
h[10..16].concat()
)
}
fn iso_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(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 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
fn esc(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
/// Grava uma imagem AFF4 linear (um fluxo de blocos snappy) de `src`.
pub fn write_aff4(
src: &dyn Source,
dest: &Path,
opts: &Aff4Options,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(u64, u64),
) -> Result<ImageReport> {
let out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dest)
.map_err(|e| {
Error::msg(format!(
"não foi possível criar {}: {e} (o arquivo não pode existir)",
dest.display()
))
})?;
crate::owner::give_to_user(dest);
let total = src.size();
let chunk = opts.chunk_size as u64;
let per_bevy = 2048u64;
let volume = format!("aff4://{}", new_uuid());
let stream = format!("aff4://{}", new_uuid());
let image = format!("aff4://{}", new_uuid());
let mut zip = zip::ZipWriter::new(out);
let stored = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.large_file(true);
let result = (|| -> Result<ImageReport> {
zip.start_file("container.description", stored)
.map_err(zerr)?;
zip.write_all(volume.as_bytes())?;
zip.start_file("version.txt", stored).map_err(zerr)?;
zip.write_all(format!("major=1\nminor=2\ntool=Guara {}\n", crate::VERSION).as_bytes())?;
let mut rep = ImageReport::default();
let mut hasher = MultiHasher::new();
let mut buf = vec![0u8; chunk as usize];
let mut pos = 0u64;
let mut chunk_no = 0u64;
let mut enc_snap = snap::raw::Encoder::new();
while pos < total {
let bevy = chunk_no / per_bevy;
let name = format!("{}/{:08}", enc(&stream), bevy);
zip.start_file(name.as_str(), stored).map_err(zerr)?;
let mut index: Vec<u8> = vec![];
let mut boff = 0u64;
for _ in 0..per_bevy {
if pos >= total {
break;
}
if cancelled() {
rep.cancelled = true;
break;
}
let want = (total - pos).min(chunk) as usize;
let b = &mut buf[..want];
if read_exact_at(src, pos, b).is_err() {
let mut o = 0;
while o < want {
let n = 512.min(want - o);
if read_exact_at(src, pos + o as u64, &mut b[o..o + n]).is_err() {
b[o..o + n].fill(0);
match rep.bad_ranges.last_mut() {
Some(l) if l.0 + l.1 == pos + o as u64 => l.1 += n as u64,
_ => rep.bad_ranges.push((pos + o as u64, n as u64)),
}
}
o += n;
}
}
hasher.update(b);
// bloco curto (último) é guardado como está; comprimido só se ficar menor
let comp = enc_snap
.compress_vec(b)
.map_err(|e| Error::msg(e.to_string()))?;
// Só um bloco cheio pode ficar sem compressão (comprimento == tamanho do bloco).
let data: &[u8] = if want as u64 == chunk && comp.len() >= want {
b
} else {
&comp
};
zip.write_all(data)?;
index.extend_from_slice(&boff.to_le_bytes());
index.extend_from_slice(&(data.len() as u32).to_le_bytes());
boff += data.len() as u64;
pos += want as u64;
chunk_no += 1;
progress(pos, total);
}
zip.start_file(format!("{name}.index").as_str(), stored)
.map_err(zerr)?;
zip.write_all(&index)?;
if rep.cancelled {
break;
}
}
let h = hasher.finalize();
rep.bytes = pos;
rep.md5 = h.md5.clone();
rep.sha1 = h.sha1.clone();
rep.sha256 = h.sha256.clone();
let now = iso_now();
let sectors = total.div_ceil(512);
let ttl = format!(
"@prefix aff4: <http://aff4.org/Schema#> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n\n\
<{stream}>\n a aff4:ImageStream ;\n aff4:chunkSize \"{chunk}\"^^xsd:int ;\n aff4:chunksInSegment \"{per_bevy}\"^^xsd:int ;\n aff4:compressionMethod <http://code.google.com/p/snappy/> ;\n aff4:hash \"{sha1}\"^^aff4:SHA1 , \"{md5}\"^^aff4:MD5 ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:version \"1\"^^xsd:int .\n\n\
<{image}>\n a aff4:ContiguousImage , aff4:DiskImage , aff4:Image ;\n aff4:blockSize \"512\"^^xsd:int ;\n aff4:dataStream <{stream}> ;\n aff4:sectorCount \"{sectors}\"^^xsd:long ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> .\n\n\
<aff4://{notes_id}>\n a aff4:CaseNotes ;\n aff4:caseNumber \"{case}\" ;\n aff4:evidenceNumber \"{ev}\" ;\n aff4:examiner \"{ex}\" ;\n aff4:notes \"{notes}\" ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:timestamp \"{now}\"^^xsd:dateTime .\n\n\
<{volume}>\n a aff4:ZipVolume ;\n aff4:contains <{stream}> , <{image}> ;\n aff4:creationTime \"{now}\"^^xsd:dateTime ;\n aff4:interface aff4:Volume ;\n aff4:stored \"{fname}\" .\n",
sha1 = h.sha1,
md5 = h.md5,
notes_id = new_uuid(),
case = esc(&opts.case_name),
ev = esc(&opts.evidence_number),
ex = esc(&opts.examiner),
notes = esc(&opts.notes),
fname = esc(&dest.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()),
);
zip.start_file("information.turtle", stored).map_err(zerr)?;
zip.write_all(ttl.as_bytes())?;
Ok(rep)
})();
match result {
Ok(rep) => {
zip.finish().map_err(zerr)?;
crate::owner::write_owned(
&dest.with_extension("map.json"),
serde_json::to_vec_pretty(&rep)?,
)?;
Ok(rep)
}
Err(e) => {
drop(zip);
let _ = std::fs::remove_file(dest);
Err(e)
}
}
}
fn zerr(e: zip::result::ZipError) -> Error {
Error::msg(format!("AFF4/ZIP: {e}"))
}
+541
View File
@@ -0,0 +1,541 @@
//! Monta o disco virtual a partir do que o usuário informou: membros extras (RAID/LVM) e
//! senhas ou chaves para volumes cifrados. É a única porta de entrada para varrer e recuperar
//! discos que não são "simples".
use crate::crypt::{self, Secret, UnlockError};
use crate::error::{Error, Result};
use crate::partition;
use crate::source::{open_path, Source, SubSource};
use crate::types::{FsKind, PartInfo};
use crate::volume::{Overlay, Region};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SecretInput {
Password {
value: String,
},
Recovery {
value: String,
},
/// Caminho de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
Keyfile {
path: String,
},
/// Chave do volume em hexadecimal.
Key {
hex: String,
},
}
/// Chave de volume guardada na sessão (só quando o usuário pede para lembrar).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SavedKey {
pub start: u64,
pub kind: String,
pub key_hex: String,
}
/// RAID sem metadados: o que o usuário sabe; o resto é descoberto.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RaidSpec {
pub level: Option<crate::raid::Level>,
/// Tamanho da faixa em KiB.
pub chunk_kb: Option<u64>,
pub layout: Option<crate::raid::Layout>,
/// Ordem dos discos: índice 0 é o alvo principal, depois `members` na ordem dada.
pub order: Option<Vec<usize>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Assembly {
/// Trata o alvo e `members` como discos de um RAID sem metadados (parâmetros descobertos).
#[serde(default)]
pub raid: Option<RaidSpec>,
/// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal.
#[serde(default)]
pub members: Vec<String>,
/// Senhas e chaves: o `snapshot` da sessão as descarta antes de gravar.
#[serde(default)]
pub secrets: Vec<SecretInput>,
/// Guarda as chaves de volume derivadas na sessão, para reabrir sem pedir a senha de novo.
#[serde(default)]
pub remember_keys: bool,
#[serde(default)]
pub keys: Vec<SavedKey>,
}
impl Assembly {
pub fn is_plain(&self) -> bool {
self.members.is_empty()
&& self.secrets.is_empty()
&& self.keys.is_empty()
&& self.raid.is_none()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct UnlockInfo {
pub start: u64,
pub len: u64,
pub kind: String,
pub description: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct LockedInfo {
pub start: u64,
pub kind: String,
pub reason: String,
}
pub struct Assembled {
pub source: Arc<dyn Source>,
pub unlocked: Vec<UnlockInfo>,
pub locked: Vec<LockedInfo>,
/// Chaves derivadas, para guardar na sessão se o usuário permitir.
pub keys: Vec<SavedKey>,
/// Volumes lógicos / partições extras que não estão na tabela do disco.
pub extra_parts: Vec<PartInfo>,
/// Partições reais que são só contêineres (membros de RAID/LVM já remontados).
pub covered: Vec<u64>,
/// O disco principal é só um membro de RAID: não varrer o disco cru.
pub skip_primary: bool,
pub notes: Vec<String>,
}
fn to_secrets(a: &Assembly) -> Result<Vec<Secret>> {
let mut v = vec![];
for s in &a.secrets {
v.push(match s {
SecretInput::Password { value } => Secret::Password(value.clone()),
SecretInput::Recovery { value } => Secret::RecoveryKey(value.clone()),
SecretInput::Keyfile { path } => Secret::KeyFile(std::fs::read(path).map_err(|e| {
Error::msg(format!(
"não foi possível ler o arquivo de chave {path}: {e}"
))
})?),
SecretInput::Key { hex } => Secret::VolumeKey(
crypt::from_hex(hex)
.ok_or_else(|| Error::msg("chave do volume em hexadecimal inválida"))?,
),
});
}
Ok(v)
}
fn kind_name(fs: FsKind) -> &'static str {
match fs {
FsKind::Bitlocker => "bitlocker",
FsKind::Luks => "luks",
FsKind::Corestorage => "filevault",
_ => "?",
}
}
fn try_unlock(window: Arc<dyn Source>, fs: FsKind, secrets: &[Secret]) -> crypt::UnlockResult {
match fs {
FsKind::Bitlocker => crypt::bitlocker::unlock(window, secrets),
FsKind::Luks => crypt::luks::unlock(window, secrets),
FsKind::Corestorage => crypt::filevault::unlock(window, secrets),
_ => Err(UnlockError::Unsupported("tipo".into())),
}
}
/// Abre `path` e aplica a montagem pedida.
pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
let base = open_path(Path::new(path)).map_err(|e| Error::msg(format!("{path}: {e}")))?;
assemble_source(base, asm)
}
const MIB: u64 = 1 << 20;
/// Coloca `src` em `start`; se cair dentro de uma região virtual, a substitui por dentro.
fn patch(regions: &mut Vec<Region>, start: u64, src: Arc<dyn Source>, label: String) {
let len = src.size();
if let Some(r) = regions
.iter_mut()
.find(|r| r.start <= start && start < r.start + r.len)
{
let inner = Region {
start: start - r.start,
len,
src,
label: label.clone(),
};
let wrapped = Overlay::new(r.src.clone(), vec![inner]);
if start == r.start {
r.len = r.len.max(len);
}
r.src = Arc::new(wrapped);
return;
}
regions.push(Region {
start,
len,
src,
label,
});
}
/// Candidato a membro de RAID/LVM: uma janela de disco (inteiro ou partição).
struct Window {
src: Arc<dyn Source>,
/// Início no espaço do disco virtual, se a janela faz parte dele.
primary_start: Option<u64>,
}
fn windows_of(src: &Arc<dyn Source>, primary: bool) -> Vec<Window> {
let mut v = vec![Window {
src: src.clone(),
primary_start: primary.then_some(0),
}];
let layout = partition::analyze(src.as_ref(), false);
for p in &layout.partitions {
let len = if p.len == 0 {
src.size().saturating_sub(p.start)
} else {
p.len
};
v.push(Window {
src: Arc::new(SubSource::new(src.clone(), p.start, len)),
primary_start: primary.then_some(p.start),
});
}
v
}
pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assembled> {
let secrets = to_secrets(asm)?;
let mut regions: Vec<Region> = vec![];
let mut out = Assembled {
source: base.clone(),
unlocked: vec![],
locked: vec![],
keys: vec![],
extra_parts: vec![],
covered: vec![],
skip_primary: false,
notes: vec![],
};
let mut members: Vec<Arc<dyn Source>> = vec![];
for m in &asm.members {
members.push(open_path(Path::new(m)).map_err(|e| Error::msg(format!("{m}: {e}")))?);
}
let mut next_virtual = base.size().div_ceil(MIB) * MIB + MIB;
if let Some(spec) = &asm.raid {
let mut disks: Vec<Arc<dyn Source>> = vec![base.clone()];
disks.extend(members.iter().cloned());
let params = pick_raid(&disks, spec, &mut out.notes)?;
let src: Arc<dyn Source> =
Arc::new(crate::raid::RaidSource::new(params).map_err(Error::msg)?);
out.notes.push(format!(
"RAID montado a partir de {} disco(s) sem metadados.",
disks.len()
));
out.skip_primary = true;
members.clear();
add_virtual(
&mut regions,
&mut next_virtual,
&mut out,
src,
"RAID".into(),
);
}
let mut done_containers: std::collections::BTreeSet<String> = Default::default();
let mut tried_pool = false;
// Rodadas: o que foi aberto/montado pode conter outro volume cifrado, RAID ou LVM.
for round in 0..4 {
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false);
let mut all_parts: Vec<PartInfo> = layout.partitions.clone();
for p in &out.extra_parts {
if !all_parts.iter().any(|q| q.start == p.start) {
all_parts.push(p.clone());
}
}
let mut progress = false;
// 1) RAID e LVM: reúne janelas do disco principal e dos membros extras.
if round == 0 || !tried_pool || !out.extra_parts.is_empty() {
let mut pool: Vec<Window> = vec![];
pool.push(Window {
src: current.clone(),
primary_start: Some(0),
});
for p in &all_parts {
if matches!(p.fs, FsKind::Raid | FsKind::Lvm) {
let len = if p.len == 0 {
current.size().saturating_sub(p.start)
} else {
p.len
};
pool.push(Window {
src: Arc::new(SubSource::new(current.clone(), p.start, len)),
primary_start: Some(p.start),
});
}
}
for m in &members {
pool.extend(windows_of(m, false));
}
tried_pool = true;
// RAID
let mut md: Vec<(Arc<dyn Source>, crate::raid::MdSuper)> = vec![];
let mut md_primary: Vec<u64> = vec![];
for w in &pool {
if let Some(sup) = crate::raid::read_md_super(w.src.as_ref()) {
if let Some(st) = w.primary_start {
md_primary.push(st);
}
md.push((w.src.clone(), sup));
}
}
let new_md: Vec<_> = md
.into_iter()
.filter(|(_, s)| {
!done_containers.contains(&format!("md{}", crypt::to_hex(&s.uuid)))
})
.collect();
if !new_md.is_empty() {
for (_, s) in &new_md {
done_containers.insert(format!("md{}", crypt::to_hex(&s.uuid)));
}
for res in crate::raid::assemble_md(new_md) {
match res {
Ok(a) => {
let label = format!(
"{} {}",
a.info.level.name(),
if a.name.is_empty() {
a.uuid.clone()
} else {
a.name.clone()
}
);
if a.missing > 0 {
out.notes.push(format!(
"{label}: montado sem {} membro(s) (modo degradado).",
a.missing
));
} else {
out.notes.push(format!("{label} montado."));
}
add_virtual(
&mut regions,
&mut next_virtual,
&mut out,
a.source.clone(),
label,
);
progress = true;
}
Err(e) => out.notes.push(format!("RAID não montado: {e}")),
}
}
out.covered.extend(md_primary);
}
// LVM
let mut pvs: Vec<(Arc<dyn Source>, crate::lvm::PvInfo)> = vec![];
let mut pv_primary: Vec<u64> = vec![];
let md_srcs: Vec<Arc<dyn Source>> = regions.iter().map(|r| r.src.clone()).collect();
let mut lvm_pool: Vec<(Arc<dyn Source>, Option<u64>)> = pool
.iter()
.map(|w| (w.src.clone(), w.primary_start))
.collect();
for (r, s) in regions.iter().zip(md_srcs) {
lvm_pool.push((s, Some(r.start)));
}
for (src, st) in lvm_pool {
if let Some(pv) = crate::lvm::read_pv(src.as_ref()) {
if !done_containers.insert(format!("pv{}", pv.uuid)) {
continue;
}
if let Some(st) = st {
pv_primary.push(st);
}
pvs.push((src, pv));
}
}
if !pvs.is_empty() {
for vg in crate::lvm::assemble_vgs(pvs) {
for w in &vg.warnings {
out.notes.push(format!("LVM {}: {w}", vg.name));
}
for (name, lv) in vg.lvs {
add_virtual(
&mut regions,
&mut next_virtual,
&mut out,
lv,
format!("LVM {}/{}", vg.name, name),
);
progress = true;
}
}
out.covered.extend(pv_primary);
}
}
// 2) Desbloqueio de volumes cifrados.
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false);
let mut all_parts: Vec<PartInfo> = layout.partitions.clone();
for p in &out.extra_parts {
if !all_parts.iter().any(|q| q.start == p.start) {
all_parts.push(p.clone());
}
}
out.locked.clear();
for p in &all_parts {
if p.encrypted.is_none() || out.unlocked.iter().any(|u| u.start == p.start) {
continue;
}
let len = if p.len == 0 {
current.size().saturating_sub(p.start)
} else {
p.len
};
let window: Arc<dyn Source> = Arc::new(SubSource::new(current.clone(), p.start, len));
let mut all = secrets.clone();
for k in asm.keys.iter().filter(|k| k.start == p.start) {
if let Some(b) = crypt::from_hex(&k.key_hex) {
all.push(Secret::VolumeKey(b));
}
}
match try_unlock(window, p.fs, &all) {
Ok(u) => {
let ulen = u.source.size();
out.unlocked.push(UnlockInfo {
start: p.start,
len: ulen,
kind: kind_name(p.fs).into(),
description: u.description.clone(),
});
if let Some(k) = &u.volume_key {
out.keys.push(SavedKey { start: p.start, kind: kind_name(p.fs).into(), key_hex: crypt::to_hex(k) });
}
patch(&mut regions, p.start, u.source, u.description);
progress = true;
}
Err(e) => out.locked.push(LockedInfo {
start: p.start,
kind: kind_name(p.fs).into(),
reason: match e {
UnlockError::WrongSecret if secrets.is_empty() && asm.keys.is_empty() => "volume cifrado: informe a senha, a chave de recuperação ou o arquivo de chave".into(),
other => other.to_string(),
},
}),
}
}
if !progress {
break;
}
}
if !regions.is_empty() {
out.source = Arc::new(Overlay::new(base, regions));
// Atualiza o que se sabe sobre as partições virtuais (algumas foram desbloqueadas depois).
for p in &mut out.extra_parts {
if let Some((fs, len)) = partition::detect_fs(out.source.as_ref(), p.start) {
p.fs = fs;
p.encrypted = fs.encrypted().map(String::from);
if let Some(l) = len {
p.len = p.len.min(l).max(1);
}
}
}
}
out.covered.sort_unstable();
out.covered.dedup();
Ok(out)
}
/// Anexa um volume virtual depois do fim do disco e registra as partições que ele contém.
fn add_virtual(
regions: &mut Vec<Region>,
next: &mut u64,
out: &mut Assembled,
src: Arc<dyn Source>,
label: String,
) {
let start = *next;
let len = src.size();
if len == 0 {
return;
}
*next = (start + len).div_ceil(MIB) * MIB + MIB;
let inner = partition::analyze(src.as_ref(), false);
if inner.partitions.is_empty() {
out.extra_parts.push(PartInfo {
index: 0,
start,
len,
fs: FsKind::Unknown,
label: Some(label.clone()),
lost: false,
encrypted: None,
table_type: Some(label.clone()),
});
} else {
for p in inner.partitions {
let mut q = p.clone();
q.start += start;
q.label = q.label.or_else(|| Some(label.clone()));
out.extra_parts.push(q);
}
}
regions.push(Region {
start,
len,
src,
label,
});
}
/// Escolhe os parâmetros do RAID: o que o usuário deu manda; o resto vem da detecção.
fn pick_raid(
disks: &[Arc<dyn Source>],
spec: &RaidSpec,
notes: &mut Vec<String>,
) -> Result<crate::raid::RaidParams> {
use crate::raid::{Member, RaidParams};
if let (Some(level), Some(chunk), Some(layout), Some(order)) =
(spec.level, spec.chunk_kb, spec.layout, spec.order.clone())
{
if order.len() == disks.len() && order.iter().all(|&i| i < disks.len()) {
let members = order
.iter()
.map(|&i| {
Some(Member {
src: disks[i].clone(),
data_offset: 0,
})
})
.collect();
return Ok(RaidParams::simple(level, chunk * 1024, layout, members));
}
}
let cands = crate::raid_detect::detect(disks, &|| false);
let c = cands
.into_iter()
.find(|c| {
spec.level.map_or(true, |l| c.level == l)
&& spec.chunk_kb.map_or(true, |k| c.chunk == k * 1024)
&& spec.layout.map_or(true, |l| c.layout == l)
&& spec.order.as_ref().map_or(true, |o| *o == c.order)
})
.ok_or_else(|| Error::msg("não foi possível descobrir os parâmetros do RAID (nenhuma combinação produziu um sistema de arquivos coerente)"))?;
notes.push(format!(
"Parâmetros do RAID descobertos: {}, faixa de {} KiB, layout {:?}, ordem dos discos {:?} (pontuação {:.1}).",
c.level.name(),
c.chunk / 1024,
c.layout,
c.order,
c.score
));
Ok(c.params(disks))
}
+34 -8
View File
@@ -25,23 +25,33 @@ pub fn carve_range(
cfg: &CarveCfg,
cancelled: &(dyn Fn() -> bool + Sync),
ev: &mut dyn CarveEvents,
) -> bool {
) -> CarveEnd {
let end = end.min(src.size());
let mut pos = (*next).max(start);
// Alinha ao setor.
pos = pos.div_ceil(SECTOR) * SECTOR;
let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
let mut skip_until = 0u64;
let mut fails = 0u32;
while pos < end {
if cancelled() {
*next = pos;
return false;
return CarveEnd::Cancelled;
}
let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD);
let got = match read_full(src, pos, &mut buf[..want]) {
Ok(n) => n,
Ok(n) => {
fails = 0;
n
}
Err(_) => {
// Setor ruim: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
// Várias falhas seguidas = dispositivo sumiu: para com o ponto de retomada salvo.
fails += 1;
if fails >= 4 {
*next = pos - (fails as u64 - 1) * CHUNK as u64;
return CarveEnd::IoLost;
}
// Setor ruim isolado: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
pos += CHUNK as u64;
ev.advance(CHUNK as u64);
*next = pos;
@@ -52,6 +62,7 @@ pub fn carve_range(
break;
}
let scan_len = got.min(CHUNK).min((end - pos) as usize);
ev.chunk(pos, &buf[..scan_len]);
let mut i = 0usize;
while i < scan_len {
let abs = pos + i as u64;
@@ -68,11 +79,13 @@ pub fn carve_range(
|| cfg.categories.contains(&category_for_ext(hit.ext));
if hit.len >= MIN_LEN && allowed {
let action = ev.hit(abs, &hit);
if matches!(action, HitAction::Skip)
&& hit.confidence != crate::types::Confidence::Low
{
match action {
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
skip_until = abs + hit.len;
}
HitAction::SkipLen(n) => skip_until = abs + n,
_ => {}
}
}
}
}
@@ -84,12 +97,23 @@ pub fn carve_range(
*next = pos;
}
*next = end;
true
CarveEnd::Done
}
/// Como o carving de um intervalo terminou.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CarveEnd {
Done,
Cancelled,
/// Várias leituras seguidas falharam: o dispositivo provavelmente foi desconectado.
IoLost,
}
/// Receptor de eventos do carving.
pub trait CarveEvents {
fn advance(&mut self, bytes: u64);
/// Cada trecho lido, antes de procurar assinaturas (para indexar o que serve a recomposição).
fn chunk(&mut self, _abs: u64, _data: &[u8]) {}
fn hit(&mut self, offset: u64, hit: &Hit) -> HitAction;
}
@@ -97,6 +121,8 @@ pub trait CarveEvents {
pub enum HitAction {
/// Pula o conteúdo do arquivo reconhecido (comportamento padrão).
Skip,
/// Pula só estes bytes (arquivo cujo resto não é confiável).
SkipLen(u64),
/// Segue setor a setor (acerto descartado).
Continue,
}
+529
View File
@@ -0,0 +1,529 @@
//! BitLocker (Windows 7 em diante, inclusive BitLocker To Go): AES-CBC com ou sem difusor
//! Elephant, e AES-XTS. Protetores: senha, chave de recuperação, chave de inicialização (.BEK),
//! chave limpa (proteção suspensa) e chave do volume já conhecida. TPM não é suportado.
use super::*;
use crate::source::{read_vec, SubSource};
use sha2::{Digest, Sha256};
fn le16(b: &[u8], o: usize) -> usize {
u16::from_le_bytes([b[o], b[o + 1]]) as usize
}
fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
const GUID_NORMAL: [u8; 16] = [
0x3b, 0xd6, 0x67, 0x49, 0x29, 0x2e, 0xd8, 0x4a, 0x83, 0x99, 0xf6, 0xa3, 0x39, 0xe3, 0xd0, 0x01,
];
const GUID_EOW: [u8; 16] = [
0x3b, 0x4d, 0xa8, 0x92, 0x80, 0xdd, 0x0e, 0x4d, 0x9e, 0x4e, 0xb1, 0xe3, 0x28, 0x4e, 0xae, 0xd8,
];
/// Posição da tabela de deslocamentos FVE, se for BitLocker (normal ou To Go).
fn offsets_pos(vbr: &[u8]) -> Option<usize> {
if vbr.len() >= 11 && &vbr[3..11] == b"-FVE-FS-" {
return Some(176);
}
// BitLocker To Go: parece FAT ("MSWIN4.1"), mas guarda o GUID do BitLocker no setor de boot.
if vbr.len() >= 464 && (vbr[424..440] == GUID_NORMAL || vbr[424..440] == GUID_EOW) {
return Some(440);
}
None
}
pub fn detect(vbr: &[u8]) -> bool {
offsets_pos(vbr).is_some()
}
// ------------------------------------------------------------- Elephant
fn rotl(x: u32, n: u32) -> u32 {
x.rotate_left(n)
}
const RA: [u32; 4] = [9, 0, 13, 0];
const RB: [u32; 4] = [0, 10, 0, 25];
fn words(buf: &[u8]) -> Vec<u32> {
buf.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect()
}
// Decifragem (ordem do dm-crypt do Linux): somas em ordem crescente.
fn diffuser_a_decrypt(d: &mut [u32]) {
let n = d.len();
for _ in 0..5 {
for i in 0..n {
let a = d[(i + n - 2) % n];
let b = rotl(d[(i + n - 5) % n], RA[i % 4]);
d[i] = d[i].wrapping_add(a ^ b);
}
}
}
fn diffuser_b_decrypt(d: &mut [u32]) {
let n = d.len();
for _ in 0..3 {
for i in 0..n {
let a = d[(i + 2) % n];
let b = rotl(d[(i + 5) % n], RB[i % 4]);
d[i] = d[i].wrapping_add(a ^ b);
}
}
}
/// Decifra um setor AES-CBC + difusor Elephant (BitLocker Vista/7 sem XTS).
pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off: u64) {
let mut iv = [0u8; 16];
iv[..8].copy_from_slice(&byte_off.to_le_bytes());
key.encrypt(&mut iv);
cbc_decrypt(key, &iv, buf);
let mut d = words(buf);
diffuser_b_decrypt(&mut d);
diffuser_a_decrypt(&mut d);
for (i, w) in d.iter().enumerate() {
buf[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes());
}
// Chave do setor: duas cifragens do deslocamento com a chave de ajuste.
let mut k0 = [0u8; 16];
k0[..8].copy_from_slice(&byte_off.to_le_bytes());
let mut k1 = k0;
k1[15] = 0x80;
tweak.encrypt(&mut k0);
tweak.encrypt(&mut k1);
let mut sk = [0u8; 32];
sk[..16].copy_from_slice(&k0);
sk[16..].copy_from_slice(&k1);
for (i, b) in buf.iter_mut().enumerate() {
*b ^= sk[i % 32];
}
}
// ------------------------------------------------------------------ CCM
/// AES-CCM (nonce de 12 bytes, tag de 16): `blob` = tag cifrada (16) + dados cifrados.
fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
if nonce.len() != 12 || blob.len() < 16 {
return None;
}
let ctr = |i: u32| -> [u8; 16] {
let mut a = [0u8; 16];
a[0] = 2;
a[1..13].copy_from_slice(nonce);
a[13..16].copy_from_slice(&i.to_be_bytes()[1..]);
key.encrypt(&mut a);
a
};
let s0 = ctr(0);
let mut tag = [0u8; 16];
for i in 0..16 {
tag[i] = blob[i] ^ s0[i];
}
let ct = &blob[16..];
let mut pt = Vec::with_capacity(ct.len());
for (bi, ch) in ct.chunks(16).enumerate() {
let s = ctr(bi as u32 + 1);
pt.extend(ch.iter().zip(s.iter()).map(|(a, b)| a ^ b));
}
// CBC-MAC sobre B0 + dados.
let mut b0 = [0u8; 16];
b0[0] = 0x3A;
b0[1..13].copy_from_slice(nonce);
b0[13..16].copy_from_slice(&(pt.len() as u32).to_be_bytes()[1..]);
key.encrypt(&mut b0);
let mut x = b0;
for ch in pt.chunks(16) {
for i in 0..ch.len() {
x[i] ^= ch[i];
}
key.encrypt(&mut x);
}
(x == tag).then_some(pt)
}
// --------------------------------------------------------------- chaves
fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] {
let mut last = [0u8; 32];
let mut buf = [0u8; 88];
buf[32..64].copy_from_slice(initial);
buf[64..80].copy_from_slice(salt);
for i in 0u64..0x10_0000 {
buf[..32].copy_from_slice(&last);
buf[80..88].copy_from_slice(&i.to_le_bytes());
last.copy_from_slice(&Sha256::digest(buf));
}
last
}
fn password_key(pw: &str, salt: &[u8]) -> [u8; 32] {
let utf16: Vec<u8> = pw.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let h1 = Sha256::digest(&utf16);
let mut init = [0u8; 32];
init.copy_from_slice(&Sha256::digest(h1));
stretch(&init, salt)
}
/// Chave de recuperação: 8 blocos de 6 dígitos, cada um múltiplo de 11.
fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
let digits: String = rp.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 48 {
return None;
}
let mut raw = [0u8; 16];
for i in 0..8 {
let n: u32 = digits[i * 6..i * 6 + 6].parse().ok()?;
if n % 11 != 0 || n / 11 > 0xFFFF {
return None;
}
raw[i * 2..i * 2 + 2].copy_from_slice(&((n / 11) as u16).to_le_bytes());
}
let mut init = [0u8; 32];
init.copy_from_slice(&Sha256::digest(raw));
Some(stretch(&init, salt))
}
// ----------------------------------------------------------- metadados
#[derive(Debug, Clone)]
struct Entry {
etype: usize,
vtype: usize,
data: Vec<u8>,
}
fn parse_entries(b: &[u8]) -> Vec<Entry> {
let mut v = vec![];
let mut o = 0;
while o + 8 <= b.len() {
let size = le16(b, o);
if size < 8 || o + size > b.len() {
break;
}
v.push(Entry {
etype: le16(b, o + 2),
vtype: le16(b, o + 4),
data: b[o + 8..o + size].to_vec(),
});
o += size;
}
v
}
struct Vmk {
protection: usize,
nested: Vec<Entry>,
}
impl Vmk {
fn ccm(&self) -> Option<&Entry> {
self.nested.iter().find(|e| e.vtype == 5)
}
fn stretch_salt(&self) -> Option<&[u8]> {
self.nested
.iter()
.find(|e| e.vtype == 3)
.filter(|e| e.data.len() >= 20)
.map(|e| &e.data[4..20])
}
}
struct Fve {
method: usize,
volume_header_offset: u64,
volume_header_size: u64,
encrypted_size: u64,
meta_size: u64,
vmks: Vec<Vmk>,
fvek: Option<Entry>,
}
fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
let h = read_vec(src, offset, 64 + 48).ok()?;
if h.len() < 112 || &h[..8] != b"-FVE-FS-" {
return None;
}
let version = le16(&h, 10);
if version != 2 {
return None; // Vista (versão 1) não suportado
}
let encrypted_size = le64(&h, 16);
let hdr_sectors = le32(&h, 28) as u64;
let volume_header_offset = le64(&h, 56);
let meta_size = le32(&h, 64) as usize;
if !(48..=1 << 20).contains(&meta_size) {
return None;
}
let method = le16(&h, 64 + 36);
let all = read_vec(src, offset + 64, meta_size).ok()?;
if all.len() < meta_size {
return None;
}
let entries = parse_entries(&all[48..]);
let mut vmks = vec![];
let mut fvek = None;
let (mut vh_off, mut vh_size) = (volume_header_offset, hdr_sectors * 512);
for e in entries {
match (e.etype, e.vtype) {
(15, 15) if e.data.len() >= 16 => {
vh_off = le64(&e.data, 0);
vh_size = le64(&e.data, 8);
}
(2, 8) if e.data.len() > 28 => vmks.push(Vmk {
protection: le16(&e.data, 26),
nested: parse_entries(&e.data[28..]),
}),
(3, 5) => fvek = Some(e),
_ => {}
}
}
Some(Fve {
method,
volume_header_offset: vh_off,
volume_header_size: vh_size,
encrypted_size,
meta_size: 0x10000,
vmks,
fvek,
})
}
/// Chave de um arquivo .BEK (chave de inicialização).
fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
if file.len() < 48 {
return None;
}
let es = parse_entries(&file[48..]);
for e in &es {
if e.vtype == 9 && e.data.len() > 24 {
for n in parse_entries(&e.data[24..]) {
if n.vtype == 1 && n.data.len() >= 36 {
return Some(n.data[4..36].to_vec());
}
}
}
}
None
}
fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = vec![];
// Chave limpa: o próprio protetor guarda a chave em claro.
if v.protection == 0 {
for n in &v.nested {
if n.vtype == 1 && n.data.len() >= 36 {
out.push(n.data[4..36].to_vec());
}
}
}
for s in secrets {
match s {
Secret::Password(p) if v.protection == 0x2000 => {
if let Some(salt) = v.stretch_salt() {
out.push(password_key(p, salt).to_vec());
}
}
Secret::RecoveryKey(r) if v.protection == 0x0800 => {
if let Some(k) = v.stretch_salt().and_then(|salt| recovery_key(r, salt)) {
out.push(k.to_vec());
}
}
Secret::KeyFile(f) if v.protection == 0x0200 => {
if let Some(k) = bek_key(f) {
out.push(k);
}
}
_ => {}
}
}
out
}
fn decrypt_key_entry(key: &[u8], e: &Entry) -> Option<Vec<u8>> {
let aes = AesAny::new(key)?;
if e.data.len() < 12 + 16 + 8 {
return None;
}
let pt = ccm_decrypt(&aes, &e.data[..12], &e.data[12..])?;
// payload = cabeçalho de entrada (8) + método (4) + chave
(pt.len() > 12).then(|| pt[12..].to_vec())
}
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let vbr = read_vec(src.as_ref(), 0, 512).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if !detect(&vbr) || vbr.len() < 512 {
return Err(UnlockError::Corrupt("não é BitLocker".into()));
}
let bps = le16(&vbr, 11).max(512) as u64;
let pos = offsets_pos(&vbr).unwrap_or(176);
// Três cópias dos metadados; usa a primeira legível.
let mut fve = None;
let mut meta_offsets = vec![];
for i in 0..3 {
let off = le64(&vbr, pos + i * 8);
if off != 0 {
meta_offsets.push(off);
}
if off != 0 && off < src.size() {
if let Some(f) = read_fve(src.as_ref(), off) {
fve = Some(f);
break;
}
}
}
let fve = fve.ok_or_else(|| {
UnlockError::Unsupported("metadados FVE ilegíveis ou do Windows Vista".into())
})?;
let fvek_entry = fve
.fvek
.as_ref()
.ok_or_else(|| UnlockError::Corrupt("sem FVEK".into()))?;
let mut volume_key: Option<Vec<u8>> = None;
for s in secrets {
if let Secret::VolumeKey(k) = s {
volume_key = Some(k.clone());
}
}
let mut vmk_found: Option<Vec<u8>> = None;
if volume_key.is_none() {
'outer: for v in &fve.vmks {
let Some(ccm) = v.ccm() else { continue };
for k in candidate_keys(v, secrets) {
if let Some(vmk) = decrypt_key_entry(&k, ccm) {
if vmk.len() >= 32 {
vmk_found = Some(vmk[..32].to_vec());
break 'outer;
}
}
}
}
}
let fvek_bytes = if let Some(k) = volume_key {
k
} else {
let vmk = vmk_found.ok_or(UnlockError::WrongSecret)?;
decrypt_key_entry(&vmk, fvek_entry)
.ok_or_else(|| UnlockError::Corrupt("FVEK não decifra com a VMK".into()))?
};
let k = &fvek_bytes;
let bad = || UnlockError::Corrupt("tamanho de chave incompatível com o método".into());
let (cipher, name) = match fve.method {
0x8000 | 0x8001 => {
let n = if fve.method == 0x8000 { 16 } else { 32 };
if k.len() < 32 + n {
return Err(bad());
}
(
SectorCipher::Elephant {
key: AesAny::new(&k[..n]).ok_or_else(bad)?,
tweak: AesAny::new(&k[32..32 + n]).ok_or_else(bad)?,
},
if n == 16 {
"AES-128-CBC + difusor"
} else {
"AES-256-CBC + difusor"
},
)
}
0x8002 | 0x8003 => {
let n = if fve.method == 0x8002 { 16 } else { 32 };
if k.len() < n {
return Err(bad());
}
let key = AesAny::new(&k[..n]).ok_or_else(bad)?;
(
SectorCipher::Cbc {
key: key.clone(),
iv: Iv::Eboiv(key),
},
if n == 16 {
"AES-128-CBC"
} else {
"AES-256-CBC"
},
)
}
0x8004 | 0x8005 => {
let n = if fve.method == 0x8004 { 16 } else { 32 };
if k.len() < 2 * n {
return Err(bad());
}
let iv = if bps > 512 {
Iv::Unit(bps)
} else {
Iv::Plain64
};
(
SectorCipher::Xts {
k1: AesAny::new(&k[..n]).ok_or_else(bad)?,
k2: AesAny::new(&k[n..2 * n]).ok_or_else(bad)?,
iv,
},
if n == 16 {
"AES-128-XTS"
} else {
"AES-256-XTS"
},
)
}
m => {
return Err(UnlockError::Unsupported(format!(
"método de cifragem BitLocker {m:#x}"
)))
}
};
let len = src.size();
let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps);
let mut patches = vec![];
// Áreas de metadados FVE: o Windows não as expõe como dados.
for off in meta_offsets {
patches.push(Patch {
at: off,
len: fve.meta_size,
from: 0,
decrypt_from: None,
zero: true,
});
}
if fve.volume_header_size > 0 && fve.volume_header_offset > 0 {
// O início do volume original foi movido para outro lugar e cifrado lá.
patches.push(Patch {
at: fve.volume_header_offset,
len: fve.volume_header_size,
from: 0,
decrypt_from: None,
zero: true,
});
patches.push(Patch {
at: 0,
len: fve.volume_header_size,
from: fve.volume_header_offset,
decrypt_from: Some(fve.volume_header_offset),
zero: false,
});
}
cs = cs.with_patches(patches);
if fve.encrypted_size > 0 && fve.encrypted_size < len {
cs = cs.with_plain_from(fve.encrypted_size);
}
Ok(Unlocked {
source: Arc::new(cs),
description: format!("BitLocker ({name})"),
volume_key: Some(fvek_bytes),
})
}
pub fn unlock_window(
base: Arc<dyn Source>,
start: u64,
len: u64,
secrets: &[Secret],
) -> UnlockResult {
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
}
+261
View File
@@ -0,0 +1,261 @@
//! FileVault 2 sobre Core Storage (volumes HFS+ cifrados do macOS 10.7 a 10.12 e Fusion Drives).
//! Senha ou chave do volume; chave de recuperação institucional não é suportada.
use super::*;
use crate::source::{read_vec, SubSource};
use sha2::{Digest, Sha256};
const MD_BLOCK: usize = 8192;
fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
pub fn detect(head: &[u8]) -> bool {
head.len() >= 512 && head[88] == 0x43 && head[89] == 0x53 && head[10] == 0x10
}
/// Desembrulha uma chave AES-128 de 24 bytes (RFC 3394).
fn unwrap_key(kek: &[u8], wrapped: &[u8]) -> Option<[u8; 16]> {
let aes = AesAny::new(kek)?;
if wrapped.len() != 24 {
return None;
}
let mut a: [u8; 8] = wrapped[..8].try_into().unwrap();
let mut r2: [u8; 8] = wrapped[8..16].try_into().unwrap();
let mut r3: [u8; 8] = wrapped[16..24].try_into().unwrap();
for t in (1..=12u64).rev() {
let prev_r2 = r2;
let tb = t.to_be_bytes();
let mut blk = [0u8; 16];
for i in 0..8 {
blk[i] = a[i] ^ tb[i];
}
blk[8..].copy_from_slice(&r3);
aes.decrypt(&mut blk);
a.copy_from_slice(&blk[..8]);
r2.copy_from_slice(&blk[8..]);
r3 = prev_r2;
}
if a != [0xA6; 8] {
return None;
}
let mut out = [0u8; 16];
out[..8].copy_from_slice(&r2);
out[8..].copy_from_slice(&r3);
Some(out)
}
fn b64(s: &str) -> Option<Vec<u8>> {
let mut out = vec![];
let (mut acc, mut bits) = (0u32, 0);
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
_ => return None,
} as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
/// Valor de `<key>NOME</key><tipo ...>VALOR</tipo>` num plist simples (primeira ocorrência com valor).
fn plist_value(xml: &str, key: &str) -> Option<String> {
let k = format!("<key>{key}</key>");
let mut from = 0;
while let Some(p) = xml[from..].find(&k) {
let rest = &xml[from + p + k.len()..];
from += p + k.len();
let Some(open) = rest.find('<').filter(|&o| o == 0) else {
continue;
};
let Some(gt) = rest[open..].find('>') else {
continue;
};
if rest[open..open + gt].ends_with('/') {
continue; // <reference .../>
}
let Some(te) = rest[open + 1..].find([' ', '>']) else {
continue;
};
let tag = &rest[open + 1..open + 1 + te];
let vstart = open + gt + 1;
if let Some(e) = rest[vstart..].find(&format!("</{tag}>")) {
let v = rest[vstart..vstart + e].trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
fn xml_of(blk: &[u8], off_pos: usize) -> Option<String> {
let off = le32(blk, off_pos) as usize;
let size = le32(blk, off_pos + 4) as usize;
if off >= MD_BLOCK || size > MD_BLOCK - off {
return None;
}
Some(String::from_utf8_lossy(&blk[off..off + size]).to_string())
}
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let corrupt = |s: &str| UnlockError::Corrupt(s.to_string());
let h = read_vec(src.as_ref(), 0, 512).map_err(|e| corrupt(&e.to_string()))?;
if !detect(&h) {
return Err(corrupt("não é um volume Core Storage"));
}
let block_size = le32(&h, 96) as u64;
let disklbl = le64(&h, 104);
if block_size == 0 || block_size > 1 << 20 || le32(&h, 168) != 16 {
return Err(UnlockError::Unsupported(
"Core Storage com chave de metadados que não é AES-128".into(),
));
}
let mut md_key = [0u8; 32];
md_key[..16].copy_from_slice(&h[176..192]);
md_key[16..].copy_from_slice(&h[304..320]);
let (k1, k2) = (
AesAny::new(&md_key[..16]).unwrap(),
AesAny::new(&md_key[16..]).unwrap(),
);
let md_cipher = SectorCipher::Xts {
k1,
k2,
iv: Iv::Plain64,
};
let label = read_vec(src.as_ref(), disklbl * block_size, MD_BLOCK)
.map_err(|e| corrupt(&e.to_string()))?;
if label.len() < MD_BLOCK {
return Err(corrupt("rótulo do disco truncado"));
}
let vgd_off = le32(&label, 220) as u64;
let vgd = read_vec(src.as_ref(), disklbl * block_size + vgd_off, 40)
.map_err(|e| corrupt(&e.to_string()))?;
if vgd.len() < 40 {
return Err(corrupt("descritor de grupos truncado"));
}
let md_blocks = le64(&vgd, 8);
let md_start = le64(&vgd, 32) * block_size;
let (mut iters, mut salt, mut wkek, mut wvk) = (0u32, vec![], vec![], vec![]);
let (mut lv_size, mut lv_off) = (0u64, 0u64);
let mut family = [0u8; 16];
let mut got = 0u8;
for i in 0..md_blocks.min(64) {
let mut b = read_vec(src.as_ref(), md_start + i * MD_BLOCK as u64, MD_BLOCK)
.map_err(|e| corrupt(&e.to_string()))?;
if b.len() < MD_BLOCK || b.iter().all(|&x| x == 0) {
break;
}
// XTS sobre o bloco inteiro, com o número do bloco como ajuste inicial.
md_cipher.decrypt_sector(&mut b, i, 0);
match u16::from_le_bytes([b[10], b[11]]) {
0x0019 => {
if let Some(xml) = xml_of(&b, 112) {
let pwk = plist_value(&xml, "PassphraseWrappedKEKStruct").and_then(|s| b64(&s));
let kw = plist_value(&xml, "KEKWrappedVolumeKeyStruct").and_then(|s| b64(&s));
if let (Some(p), Some(k)) = (pwk, kw) {
if p.len() >= 172 && k.len() >= 32 {
salt = p[8..24].to_vec();
wkek = p[32..56].to_vec();
iters = le32(&p, 168);
wvk = k[8..32].to_vec();
got |= 1;
}
}
}
}
0x001a => {
if let Some(xml) = xml_of(&b, 128) {
if let Some(v) = plist_value(&xml, "com.apple.corestorage.lv.size") {
lv_size = u64::from_str_radix(v.trim_start_matches("0x"), 16).unwrap_or(0);
got |= 8;
if let Some(u) = plist_value(&xml, "com.apple.corestorage.lv.familyUUID") {
let hex: String = u.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if let Some(b) = super::from_hex(&hex).filter(|b| b.len() == 16) {
family.copy_from_slice(&b);
got |= 2;
}
}
}
}
}
0x0305 => {
lv_off = le32(&b, 104) as u64 * block_size;
got |= 4;
}
_ => {}
}
}
if got != 7 | 8 {
return Err(corrupt("metadados Core Storage incompletos"));
}
let finish = |vk16: [u8; 16]| -> UnlockResult {
let mut full = [0u8; 32];
full[..16].copy_from_slice(&vk16);
full[16..].copy_from_slice(
&Sha256::new()
.chain_update(vk16)
.chain_update(family)
.finalize()[..16],
);
let cipher = SectorCipher::Xts {
k1: AesAny::new(&full[..16]).unwrap(),
k2: AesAny::new(&full[16..]).unwrap(),
iv: Iv::Plain64,
};
let cs = CryptSource::new(src.clone(), cipher, lv_off, lv_size, 512);
Ok(Unlocked {
source: Arc::new(cs),
description: "FileVault 2 (Core Storage, AES-128-XTS)".into(),
volume_key: Some(vk16.to_vec()),
})
};
for s in secrets {
if let Secret::VolumeKey(k) = s {
if k.len() >= 16 {
let mut a = [0u8; 16];
a.copy_from_slice(&k[..16]);
return finish(a);
}
}
}
for s in secrets {
if let Secret::Password(p) = s {
let mut pk = [0u8; 16];
pbkdf2::pbkdf2_hmac::<Sha256>(p.as_bytes(), &salt, iters.max(1), &mut pk);
if let Some(kek) = unwrap_key(&pk, &wkek) {
if let Some(vk) = unwrap_key(&kek, &wvk) {
return finish(vk);
}
}
}
}
Err(UnlockError::WrongSecret)
}
pub fn unlock_window(
base: Arc<dyn Source>,
start: u64,
len: u64,
secrets: &[Secret],
) -> UnlockResult {
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
}
+474
View File
@@ -0,0 +1,474 @@
//! LUKS1 e LUKS2 (cifras AES; PBKDF2 e Argon2; hashes SHA-1/256/512 e RIPEMD-160).
use super::*;
use crate::source::{read_vec, SubSource};
use sha1::Sha1;
use sha2::{Digest, Sha256, Sha512};
pub const MAGIC: &[u8; 6] = b"LUKS\xBA\xBE";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hash {
Sha1,
Sha256,
Sha512,
Ripemd160,
}
impl Hash {
pub fn parse(s: &str) -> Option<Hash> {
Some(match s.to_ascii_lowercase().as_str() {
"sha1" => Hash::Sha1,
"sha256" => Hash::Sha256,
"sha512" => Hash::Sha512,
"ripemd160" | "rmd160" => Hash::Ripemd160,
_ => return None,
})
}
pub fn size(self) -> usize {
match self {
Hash::Sha1 | Hash::Ripemd160 => 20,
Hash::Sha256 => 32,
Hash::Sha512 => 64,
}
}
pub fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
let mut d = D::new();
for p in parts {
d.update(p);
}
d.finalize().to_vec()
}
match self {
Hash::Sha1 => run::<Sha1>(parts),
Hash::Sha256 => run::<Sha256>(parts),
Hash::Sha512 => run::<Sha512>(parts),
Hash::Ripemd160 => run::<ripemd::Ripemd160>(parts),
}
}
pub fn pbkdf2(self, pass: &[u8], salt: &[u8], iterations: u32, len: usize) -> Vec<u8> {
let mut out = vec![0u8; len];
let it = iterations.max(1);
match self {
Hash::Sha1 => pbkdf2::pbkdf2_hmac::<Sha1>(pass, salt, it, &mut out),
Hash::Sha256 => pbkdf2::pbkdf2_hmac::<Sha256>(pass, salt, it, &mut out),
Hash::Sha512 => pbkdf2::pbkdf2_hmac::<Sha512>(pass, salt, it, &mut out),
Hash::Ripemd160 => pbkdf2::pbkdf2_hmac::<ripemd::Ripemd160>(pass, salt, it, &mut out),
}
out
}
}
/// Difusão do "anti-forensic splitter".
fn diffuse(block: &mut [u8], h: Hash) {
let ds = h.size();
let mut i = 0u32;
let mut off = 0;
while off < block.len() {
let n = ds.min(block.len() - off);
let d = h.digest(&[&i.to_be_bytes(), &block[off..off + n]]);
block[off..off + n].copy_from_slice(&d[..n]);
off += n;
i += 1;
}
}
/// Recompõe a chave a partir das `stripes` faixas (AFmerge).
pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec<u8> {
let mut buf = vec![0u8; block];
for i in 0..stripes.saturating_sub(1) {
for (b, s) in buf.iter_mut().zip(&src[i * block..(i + 1) * block]) {
*b ^= *s;
}
diffuse(&mut buf, h);
}
let last = &src[(stripes - 1) * block..stripes * block];
buf.iter().zip(last).map(|(a, b)| a ^ b).collect()
}
/// Cifra descrita por texto como "aes-xts-plain64" ou "aes-cbc-essiv:sha256".
pub fn parse_cipher(spec: &str, key: &[u8]) -> std::result::Result<SectorCipher, UnlockError> {
let (alg, rest) = spec
.split_once('-')
.ok_or_else(|| UnlockError::Unsupported(spec.into()))?;
if !alg.eq_ignore_ascii_case("aes") {
return Err(UnlockError::Unsupported(format!(
"cifra {alg} (apenas AES)"
)));
}
let (mode, ivs) = rest.split_once('-').unwrap_or((rest, "plain"));
let bad = || UnlockError::Unsupported(format!("chave inválida para {spec}"));
let mk_iv = |k: &[u8]| -> std::result::Result<Iv, UnlockError> {
let ivl = ivs.to_ascii_lowercase();
Ok(match ivl.as_str() {
"plain64" => Iv::Plain64,
"plain" | "plain32" => Iv::Plain32,
"plain64be" => Iv::Plain64Be,
s if s.starts_with("essiv:") => {
let h =
Hash::parse(&s[6..]).ok_or_else(|| UnlockError::Unsupported(spec.into()))?;
Iv::Essiv(AesAny::new(&h.digest(&[k])).ok_or_else(bad)?)
}
_ => return Err(UnlockError::Unsupported(format!("IV {ivs}"))),
})
};
match mode.to_ascii_lowercase().as_str() {
"xts" => {
if key.len() % 2 != 0 {
return Err(bad());
}
let h = key.len() / 2;
Ok(SectorCipher::Xts {
k1: AesAny::new(&key[..h]).ok_or_else(bad)?,
k2: AesAny::new(&key[h..]).ok_or_else(bad)?,
iv: mk_iv(&key[..h])?,
})
}
"cbc" => Ok(SectorCipher::Cbc {
key: AesAny::new(key).ok_or_else(bad)?,
iv: mk_iv(key)?,
}),
m => Err(UnlockError::Unsupported(format!("modo {m}"))),
}
}
fn passwords(secrets: &[Secret]) -> Vec<Vec<u8>> {
secrets
.iter()
.filter_map(|s| match s {
Secret::Password(p) => Some(p.as_bytes().to_vec()),
Secret::KeyFile(k) => Some(k.clone()),
_ => None,
})
.collect()
}
fn volume_keys(secrets: &[Secret]) -> Vec<Vec<u8>> {
secrets
.iter()
.filter_map(|s| match s {
Secret::VolumeKey(k) => Some(k.clone()),
_ => None,
})
.collect()
}
pub fn detect(head: &[u8]) -> bool {
head.len() >= 8 && &head[..6] == MAGIC
}
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let head = read_vec(src.as_ref(), 0, 8).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if !detect(&head) {
return Err(UnlockError::Corrupt("não é LUKS".into()));
}
match u16::from_be_bytes([head[6], head[7]]) {
1 => unlock_v1(src, secrets),
2 => unlock_v2(src, secrets),
v => Err(UnlockError::Unsupported(format!("LUKS versão {v}"))),
}
}
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
}
fn cstr(b: &[u8]) -> String {
let n = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(&b[..n]).to_string()
}
fn unlock_v1(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let h = read_vec(src.as_ref(), 0, 592).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if h.len() < 592 {
return Err(UnlockError::Corrupt("cabeçalho LUKS1 truncado".into()));
}
let cipher_name = cstr(&h[8..40]);
let cipher_mode = cstr(&h[40..72]);
let hash = Hash::parse(&cstr(&h[72..104]))
.ok_or_else(|| UnlockError::Unsupported(format!("hash {}", cstr(&h[72..104]))))?;
let payload = be32(&h, 104) as u64 * 512;
let key_bytes = be32(&h, 108) as usize;
let mk_digest = &h[112..132];
let mk_salt = &h[132..164];
let mk_iter = be32(&h, 164);
let spec = format!("{cipher_name}-{cipher_mode}");
if key_bytes == 0 || key_bytes > 128 {
return Err(UnlockError::Corrupt("tamanho de chave inválido".into()));
}
let check = |mk: &[u8]| hash.pbkdf2(mk, mk_salt, mk_iter, 20) == mk_digest;
let finish = |mk: Vec<u8>| -> UnlockResult {
let cipher = parse_cipher(&spec, &mk)?;
let len = src.size().saturating_sub(payload);
let cs = CryptSource::new(src.clone(), cipher, payload, len, 512);
Ok(Unlocked {
source: Arc::new(cs),
description: format!("LUKS1 ({spec})"),
volume_key: Some(mk),
})
};
for vk in volume_keys(secrets) {
if vk.len() == key_bytes && check(&vk) {
return finish(vk);
}
}
let pws = passwords(secrets);
for slot in 0..8 {
let o = 208 + slot * 48;
if be32(&h, o) != 0x00AC_71F3 {
continue;
}
let iter = be32(&h, o + 4);
let salt = &h[o + 8..o + 40];
let km_off = be32(&h, o + 40) as u64 * 512;
let stripes = be32(&h, o + 44) as usize;
if stripes == 0 || stripes > 100_000 {
continue;
}
let km_len = (key_bytes * stripes).div_ceil(512) * 512;
let Ok(mut km) = read_vec(src.as_ref(), km_off, km_len) else {
continue;
};
if km.len() < km_len {
continue;
}
for pw in &pws {
let dk = hash.pbkdf2(pw, salt, iter, key_bytes);
let cipher = parse_cipher(&spec, &dk)?;
let mut m = km.clone();
for (i, s) in m.chunks_exact_mut(512).enumerate() {
cipher.decrypt_sector(s, i as u64, i as u64 * 512);
}
let mk = af_merge(&m, key_bytes, stripes, hash);
if check(&mk) {
return finish(mk);
}
}
km.clear();
}
Err(UnlockError::WrongSecret)
}
fn b64(s: &str) -> Option<Vec<u8>> {
let mut out = vec![];
let mut acc = 0u32;
let mut bits = 0;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' | b'\n' | b'\r' => continue,
_ => return None,
} as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
fn jstr<'a>(v: &'a serde_json::Value, k: &str) -> Option<&'a str> {
v.get(k)?.as_str()
}
/// Números no JSON do LUKS2 vêm como texto (inteiros grandes) ou número.
fn jnum(v: &serde_json::Value, k: &str) -> Option<u64> {
let x = v.get(k)?;
x.as_u64().or_else(|| x.as_str()?.parse().ok())
}
fn unlock_v2(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let h = read_vec(src.as_ref(), 0, 4096).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if h.len() < 4096 {
return Err(UnlockError::Corrupt("cabeçalho LUKS2 truncado".into()));
}
let hdr_size = u64::from_be_bytes(h[8..16].try_into().unwrap());
if !(16384..=(4 << 20)).contains(&hdr_size) {
return Err(UnlockError::Corrupt(
"tamanho de cabeçalho LUKS2 inválido".into(),
));
}
let area = read_vec(src.as_ref(), 4096, (hdr_size - 4096) as usize)
.map_err(|e| UnlockError::Corrupt(e.to_string()))?;
let end = area.iter().position(|&c| c == 0).unwrap_or(area.len());
let json: serde_json::Value = serde_json::from_slice(&area[..end])
.map_err(|e| UnlockError::Corrupt(format!("JSON: {e}")))?;
let segments = json
.get("segments")
.and_then(|s| s.as_object())
.ok_or_else(|| UnlockError::Corrupt("sem segments".into()))?;
let (_, seg) = segments
.iter()
.find(|(_, s)| jstr(s, "type") == Some("crypt"))
.ok_or_else(|| UnlockError::Unsupported("sem segmento cifrado".into()))?;
let seg_off =
jnum(seg, "offset").ok_or_else(|| UnlockError::Corrupt("offset do segmento".into()))?;
let seg_len = match seg.get("size").and_then(|s| s.as_str()) {
Some("dynamic") | None => src.size().saturating_sub(seg_off),
Some(n) => n.parse().unwrap_or(0),
};
let seg_enc = jstr(seg, "encryption")
.unwrap_or("aes-xts-plain64")
.to_string();
let sector = jnum(seg, "sector_size").unwrap_or(512);
let iv_tweak = jnum(seg, "iv_tweak").unwrap_or(0);
let digests = json
.get("digests")
.and_then(|d| d.as_object())
.ok_or_else(|| UnlockError::Corrupt("sem digests".into()))?;
let check = |vk: &[u8]| -> bool {
for (_, d) in digests {
if jstr(d, "type") != Some("pbkdf2") {
continue;
}
let (Some(hash), Some(salt), Some(want), Some(it)) = (
jstr(d, "hash").and_then(Hash::parse),
jstr(d, "salt").and_then(b64),
jstr(d, "digest").and_then(b64),
jnum(d, "iterations"),
) else {
continue;
};
if hash.pbkdf2(vk, &salt, it as u32, want.len()) == want {
return true;
}
}
false
};
let finish = |vk: Vec<u8>| -> UnlockResult {
let cipher = parse_cipher(&seg_enc, &vk)?;
let cs =
CryptSource::new(src.clone(), cipher, seg_off, seg_len, sector).with_iv_tweak(iv_tweak);
Ok(Unlocked {
source: Arc::new(cs),
description: format!("LUKS2 ({seg_enc})"),
volume_key: Some(vk),
})
};
for vk in volume_keys(secrets) {
if check(&vk) {
return finish(vk);
}
}
let pws = passwords(secrets);
let slots = json
.get("keyslots")
.and_then(|d| d.as_object())
.ok_or_else(|| UnlockError::Corrupt("sem keyslots".into()))?;
let mut unsupported: Option<String> = None;
for (_, slot) in slots {
if jstr(slot, "type") != Some("luks2") {
continue;
}
let key_size = jnum(slot, "key_size").unwrap_or(0) as usize;
let af = slot.get("af");
let stripes = af.and_then(|a| jnum(a, "stripes")).unwrap_or(4000) as usize;
let af_hash = af
.and_then(|a| jstr(a, "hash"))
.and_then(Hash::parse)
.unwrap_or(Hash::Sha256);
let ar = slot.get("area");
let (Some(a_off), Some(a_size)) = (
ar.and_then(|a| jnum(a, "offset")),
ar.and_then(|a| jnum(a, "size")),
) else {
continue;
};
let a_enc = ar
.and_then(|a| jstr(a, "encryption"))
.unwrap_or("aes-xts-plain64");
let a_ksize = ar.and_then(|a| jnum(a, "key_size")).unwrap_or(64) as usize;
let kdf = slot.get("kdf");
let Some(kdf) = kdf else { continue };
let salt = jstr(kdf, "salt").and_then(b64).unwrap_or_default();
let km_len = (key_size * stripes).div_ceil(512) * 512;
if key_size == 0 || stripes == 0 || km_len as u64 > a_size {
continue;
}
let Ok(km) = read_vec(src.as_ref(), a_off, km_len) else {
continue;
};
if km.len() < km_len {
continue;
}
for pw in &pws {
let dk: Vec<u8> = match jstr(kdf, "type") {
Some("pbkdf2") => {
let hh = jstr(kdf, "hash")
.and_then(Hash::parse)
.unwrap_or(Hash::Sha256);
hh.pbkdf2(
pw,
&salt,
jnum(kdf, "iterations").unwrap_or(1000) as u32,
a_ksize,
)
}
Some(t @ ("argon2i" | "argon2id")) => {
let alg = if t == "argon2i" {
argon2::Algorithm::Argon2i
} else {
argon2::Algorithm::Argon2id
};
let params = match argon2::Params::new(
jnum(kdf, "memory").unwrap_or(65536) as u32,
jnum(kdf, "time").unwrap_or(4) as u32,
jnum(kdf, "cpus").unwrap_or(1) as u32,
Some(a_ksize),
) {
Ok(p) => p,
Err(e) => {
unsupported = Some(format!("parâmetros Argon2: {e}"));
continue;
}
};
let mut out = vec![0u8; a_ksize];
if argon2::Argon2::new(alg, argon2::Version::V0x13, params)
.hash_password_into(pw, &salt, &mut out)
.is_err()
{
continue;
}
out
}
Some(t) => {
unsupported = Some(format!("KDF {t}"));
continue;
}
None => continue,
};
let cipher = parse_cipher(a_enc, &dk)?;
let mut m = km.clone();
for (i, s) in m.chunks_exact_mut(512).enumerate() {
cipher.decrypt_sector(s, i as u64, i as u64 * 512);
}
let vk = af_merge(&m, key_size, stripes, af_hash);
if check(&vk) {
return finish(vk);
}
}
}
match unsupported {
Some(u) => Err(UnlockError::Unsupported(u)),
None => Err(UnlockError::WrongSecret),
}
}
/// Atalho: abre uma janela da origem como LUKS.
pub fn unlock_window(
base: Arc<dyn Source>,
start: u64,
len: u64,
secrets: &[Secret],
) -> UnlockResult {
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
}
+364
View File
@@ -0,0 +1,364 @@
//! Cifras de disco (somente leitura) e desbloqueio de volumes cifrados.
//!
//! O volume desbloqueado é exposto como uma `Source` comum: o restante do Guará
//! (varredura, recuperação) enxerga o sistema de arquivos em claro, sem saber
//! que existe cifra por baixo.
pub mod bitlocker;
pub mod filevault;
pub mod luks;
use crate::source::Source;
use aes::cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use aes::{Aes128, Aes192, Aes256};
use std::io;
use std::sync::Arc;
/// AES com tamanho de chave escolhido em tempo de execução.
#[derive(Clone)]
pub enum AesAny {
A128(Aes128),
A192(Aes192),
A256(Aes256),
}
impl AesAny {
pub fn new(key: &[u8]) -> Option<Self> {
Some(match key.len() {
16 => AesAny::A128(Aes128::new_from_slice(key).ok()?),
24 => AesAny::A192(Aes192::new_from_slice(key).ok()?),
32 => AesAny::A256(Aes256::new_from_slice(key).ok()?),
_ => return None,
})
}
pub fn encrypt(&self, b: &mut [u8; 16]) {
let g: &mut Block<Aes128> = (&mut b[..]).try_into().expect("bloco AES de 16 bytes");
match self {
AesAny::A128(c) => c.encrypt_block(g),
AesAny::A192(c) => c.encrypt_block(g),
AesAny::A256(c) => c.encrypt_block(g),
}
}
pub fn decrypt(&self, b: &mut [u8; 16]) {
let g: &mut Block<Aes128> = (&mut b[..]).try_into().expect("bloco AES de 16 bytes");
match self {
AesAny::A128(c) => c.decrypt_block(g),
AesAny::A192(c) => c.decrypt_block(g),
AesAny::A256(c) => c.decrypt_block(g),
}
}
}
fn xor16(a: &mut [u8; 16], b: &[u8]) {
for i in 0..16 {
a[i] ^= b[i];
}
}
/// Como o vetor de inicialização de cada setor é calculado.
#[derive(Clone)]
pub enum Iv {
/// Número do setor (de 512 bytes) em 64 bits little-endian.
Plain64,
/// Número do setor em 32 bits little-endian.
Plain32,
Plain64Be,
/// ESSIV: AES(SHA-256(chave), setor).
Essiv(AesAny),
/// Número da unidade de dados (setor de `usize` bytes) em 64 bits little-endian.
Unit(u64),
/// BitLocker (Windows 10 1511+): AES(chave, offset em bytes).
Eboiv(AesAny),
/// BitLocker CBC com difusor Elephant: IV = AES(chave, offset em bytes).
Bitlocker(AesAny),
}
impl Iv {
fn make(&self, sector_512: u64, byte_off: u64) -> [u8; 16] {
let mut iv = [0u8; 16];
match self {
Iv::Plain64 => iv[..8].copy_from_slice(&sector_512.to_le_bytes()),
Iv::Plain32 => iv[..4].copy_from_slice(&(sector_512 as u32).to_le_bytes()),
Iv::Plain64Be => iv[8..].copy_from_slice(&sector_512.to_be_bytes()),
Iv::Unit(u) => iv[..8].copy_from_slice(&(byte_off / u).to_le_bytes()),
Iv::Essiv(k) => {
iv[..8].copy_from_slice(&sector_512.to_le_bytes());
k.encrypt(&mut iv);
}
Iv::Eboiv(k) | Iv::Bitlocker(k) => {
iv[..8].copy_from_slice(&byte_off.to_le_bytes());
k.encrypt(&mut iv);
}
}
iv
}
}
/// Cifra aplicada setor a setor.
#[derive(Clone)]
pub enum SectorCipher {
Xts {
k1: AesAny,
k2: AesAny,
iv: Iv,
},
Cbc {
key: AesAny,
iv: Iv,
},
/// BitLocker AES-CBC com difusor Elephant (chave de dados + chave de ajuste).
Elephant {
key: AesAny,
tweak: AesAny,
},
}
fn gf_double(t: &mut [u8; 16]) {
let mut carry = 0u8;
for b in t.iter_mut() {
let next = *b >> 7;
*b = (*b << 1) | carry;
carry = next;
}
if carry != 0 {
t[0] ^= 0x87;
}
}
impl SectorCipher {
/// Decifra um setor no lugar. `sector_512` conta setores de 512 bytes; `byte_off` é a posição
/// em bytes relativa ao início dos dados cifrados.
pub fn decrypt_sector(&self, buf: &mut [u8], sector_512: u64, byte_off: u64) {
match self {
SectorCipher::Xts { k1, k2, iv } => {
let mut t = iv.make(sector_512, byte_off);
k2.encrypt(&mut t);
for ch in buf.chunks_exact_mut(16) {
let mut b = [0u8; 16];
b.copy_from_slice(ch);
xor16(&mut b, &t);
k1.decrypt(&mut b);
xor16(&mut b, &t);
ch.copy_from_slice(&b);
gf_double(&mut t);
}
}
SectorCipher::Cbc { key, iv } => {
cbc_decrypt(key, &iv.make(sector_512, byte_off), buf);
}
SectorCipher::Elephant { key, tweak } => {
bitlocker::elephant_decrypt(key, tweak, buf, byte_off);
}
}
}
}
pub fn cbc_decrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
let mut prev = *iv;
for ch in buf.chunks_exact_mut(16) {
let mut c = [0u8; 16];
c.copy_from_slice(ch);
let mut b = c;
key.decrypt(&mut b);
xor16(&mut b, &prev);
ch.copy_from_slice(&b);
prev = c;
}
}
pub fn cbc_encrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
let mut prev = *iv;
for ch in buf.chunks_exact_mut(16) {
let mut b = [0u8; 16];
b.copy_from_slice(ch);
xor16(&mut b, &prev);
key.encrypt(&mut b);
ch.copy_from_slice(&b);
prev = b;
}
}
/// Volume cifrado visto em claro: `len` bytes que começam em `data_offset` na origem.
pub struct CryptSource {
inner: Arc<dyn Source>,
cipher: SectorCipher,
data_offset: u64,
len: u64,
sector: u64,
/// Setores antes deste limite estão em claro (BitLocker parcialmente cifrado) ou
/// são cobertos por uma cópia (os primeiros setores do BitLocker).
patches: Vec<Patch>,
/// A partir deste byte (relativo aos dados) o conteúdo ainda não foi cifrado.
plain_from: u64,
/// Somado ao número do setor no cálculo do IV (iv_tweak do LUKS2).
iv_tweak: u64,
}
/// Trecho do volume lido de outro lugar da origem (BitLocker move o início do volume).
#[derive(Clone)]
pub struct Patch {
/// Início no volume em claro.
pub at: u64,
pub len: u64,
/// Onde estão os dados, na origem, já em claro.
pub from: u64,
/// Se os bytes de origem precisam ser decifrados (usando a posição `from_crypt`).
pub decrypt_from: Option<u64>,
/// Devolve zeros (áreas de metadados do BitLocker).
pub zero: bool,
}
impl CryptSource {
pub fn new(
inner: Arc<dyn Source>,
cipher: SectorCipher,
data_offset: u64,
len: u64,
sector: u64,
) -> Self {
let sector = sector.max(512);
Self {
inner,
cipher,
data_offset,
len: len / sector * sector,
sector,
patches: vec![],
plain_from: u64::MAX,
iv_tweak: 0,
}
}
pub fn with_patches(mut self, p: Vec<Patch>) -> Self {
self.patches = p;
self
}
pub fn with_iv_tweak(mut self, t: u64) -> Self {
self.iv_tweak = t;
self
}
pub fn with_plain_from(mut self, off: u64) -> Self {
self.plain_from = off;
self
}
fn read_sector_plain(&self, rel: u64, out: &mut [u8]) -> io::Result<()> {
// `rel` alinhado ao setor; `out.len() == sector`.
for p in &self.patches {
if rel >= p.at && rel < p.at + p.len {
if p.zero {
out.fill(0);
return Ok(());
}
let src_off = p.from + (rel - p.at);
crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + src_off, out)?;
if let Some(pos) = p.decrypt_from {
let r = pos + (rel - p.at);
self.cipher.decrypt_sector(out, r / 512, r);
}
return Ok(());
}
}
crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + rel, out)?;
if rel < self.plain_from {
self.cipher
.decrypt_sector(out, rel / 512 + self.iv_tweak, rel);
}
Ok(())
}
}
impl Source for CryptSource {
fn size(&self) -> u64 {
self.len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.len || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((self.len - offset) as usize);
let s = self.sector;
let mut done = 0usize;
let mut tmp = vec![0u8; s as usize];
while done < want {
let pos = offset + done as u64;
let rel = pos / s * s;
let within = (pos - rel) as usize;
let n = (s as usize - within).min(want - done);
self.read_sector_plain(rel, &mut tmp)?;
buf[done..done + n].copy_from_slice(&tmp[within..within + n]);
done += n;
}
Ok(done)
}
}
/// Algo que o usuário fornece para abrir um volume.
#[derive(Clone)]
pub enum Secret {
Password(String),
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos).
RecoveryKey(String),
/// Conteúdo de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
KeyFile(Vec<u8>),
/// Chave do volume já conhecida (FVEK/master key).
VolumeKey(Vec<u8>),
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Secret::Password(_) => "Secret::Password(..)",
Secret::RecoveryKey(_) => "Secret::RecoveryKey(..)",
Secret::KeyFile(_) => "Secret::KeyFile(..)",
Secret::VolumeKey(_) => "Secret::VolumeKey(..)",
})
}
}
#[derive(Debug)]
pub enum UnlockError {
/// Nenhuma das senhas/chaves abriu o volume.
WrongSecret,
Unsupported(String),
Corrupt(String),
}
impl std::fmt::Display for UnlockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UnlockError::WrongSecret => write!(f, "senha ou chave incorreta"),
UnlockError::Unsupported(s) => write!(f, "não suportado: {s}"),
UnlockError::Corrupt(s) => write!(f, "cabeçalho inválido: {s}"),
}
}
}
impl From<UnlockError> for crate::Error {
fn from(e: UnlockError) -> Self {
crate::Error::msg(e.to_string())
}
}
/// Volume aberto.
pub struct Unlocked {
pub source: Arc<dyn Source>,
pub description: String,
/// Chave do volume (para reabrir sem a senha, se o usuário permitir guardá-la).
pub volume_key: Option<Vec<u8>>,
}
pub type UnlockResult = std::result::Result<Unlocked, UnlockError>;
pub fn to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub fn from_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim();
if s.len() % 2 != 0 {
return None;
}
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok())
.collect()
}
+398
View File
@@ -0,0 +1,398 @@
//! Recomposição de arquivos fragmentados no carving.
//!
//! Para JPEG: valida os bytes contíguos com o decodificador de entropia; se a imagem quebra,
//! procura entre os clusters "com cara de JPEG" próximos a continuação cujo DC de luminância
//! emenda sem salto, até três fragmentos. É o que dá para fazer sem tabela de alocação, e só
//! vale quando o sistema de arquivos não deixou metadados para o arquivo.
use crate::jpegscan::{self, Header, State, Stop};
use crate::source::{read_vec, Source};
use crate::types::Extent;
const MAX_FILE: usize = 48 << 20;
/// Janela de busca da continuação, em bytes, depois e antes do primeiro fragmento.
const WINDOW_AFTER: u64 = 512 << 20;
const WINDOW_BEFORE: u64 = 64 << 20;
const PROBE: usize = 8192;
pub const CLUSTER: u64 = 4096;
const MAX_PROBES: usize = 20_000;
/// Marca os clusters (de 4 KiB, alinhados ao início da origem) que parecem dados entropy-coded
/// de JPEG: sem marcadores no meio e com bytes bem distribuídos.
#[derive(Default, Clone)]
pub struct ClusterIndex {
bits: Vec<u64>,
}
impl ClusterIndex {
pub fn new(size: u64) -> Self {
Self {
bits: vec![0; (size / CLUSTER / 64 + 1) as usize],
}
}
pub fn set(&mut self, off: u64) {
let c = (off / CLUSTER) as usize;
if let Some(w) = self.bits.get_mut(c / 64) {
*w |= 1 << (c % 64);
}
}
pub fn get(&self, off: u64) -> bool {
let c = (off / CLUSTER) as usize;
self.bits
.get(c / 64)
.map(|w| w >> (c % 64) & 1 == 1)
.unwrap_or(false)
}
/// Examina um trecho lido e marca os clusters candidatos.
pub fn scan_chunk(&mut self, abs: u64, buf: &[u8]) {
let first = abs.div_ceil(CLUSTER) * CLUSTER;
let mut o = first;
while o + CLUSTER <= abs + buf.len() as u64 {
let c = &buf[(o - abs) as usize..(o - abs + CLUSTER) as usize];
if jpeg_like(c) {
self.set(o);
}
o += CLUSTER;
}
}
pub fn iter_range(&self, from: u64, to: u64) -> impl Iterator<Item = u64> + '_ {
let (a, b) = (from.div_ceil(CLUSTER), to / CLUSTER);
(a..b)
.filter(move |&c| {
self.bits
.get(c as usize / 64)
.map(|w| w >> (c % 64) & 1 == 1)
.unwrap_or(false)
})
.map(|c| c * CLUSTER)
}
}
pub fn jpeg_like(c: &[u8]) -> bool {
let mut seen = [false; 256];
let mut distinct = 0;
for (i, &b) in c.iter().enumerate() {
if !seen[b as usize] {
seen[b as usize] = true;
distinct += 1;
}
if b == 0xFF {
match c.get(i + 1) {
Some(&n) if n == 0x00 || (0xD0..=0xD7).contains(&n) => {}
Some(0xD9) if i + 2 >= c.len() - 16 => {}
None => {}
_ => return false,
}
}
}
distinct >= 190
}
pub struct Pool<'a> {
pub index: &'a ClusterIndex,
/// Extents já atribuídos a outros arquivos.
pub used: &'a [Extent],
}
impl Pool<'_> {
fn free_at(&self, off: u64, len: u64) -> bool {
!self
.used
.iter()
.any(|u| off < u.end() && off + len > u.offset)
}
}
#[derive(Debug)]
pub enum Analysis {
/// Contíguo e consistente; `len` inclui o EOI.
Intact { len: u64 },
/// Remontado a partir de fragmentos.
Rebuilt { extents: Vec<Extent>, size: u64 },
/// Só o início é confiável.
Broken { good_len: u64 },
/// Formato que não sabemos validar (progressivo etc.).
Unsupported,
}
/// Procura FF D9 logo depois do último MCU (até 64 bytes de preenchimento).
fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
let end = (pos + 64).min(data.len().saturating_sub(1));
(pos.saturating_sub(2)..end)
.find(|&i| data[i] == 0xFF && data[i + 1] == 0xD9)
.map(|i| i + 2)
}
/// Valida o JPEG que começa em `off` e, se estiver quebrado, tenta recompô-lo.
pub fn analyze_jpeg(
src: &dyn Source,
off: u64,
hit_len: u64,
pool: &Pool,
allow_rebuild: bool,
) -> Analysis {
let read_len = (hit_len as usize).clamp(4096, MAX_FILE);
let Ok(buf) = read_vec(src, off, read_len) else {
return Analysis::Unsupported;
};
let Some(h) = jpegscan::parse_header(&buf) else {
return Analysis::Unsupported;
};
let d = jpegscan::decode(&h, &buf, State::start(&h), u32::MAX, 512);
let disc = jpegscan::first_discontinuity(&h, &d.dc);
if d.stop == Stop::Done && disc.is_none() {
if let Some(end) = eoi_after(&buf, d.state.pos) {
return Analysis::Intact { len: end as u64 };
}
}
let mut stop_pos = match d.stop {
Stop::Marker(_, p) | Stop::Error(p) => p,
Stop::Truncated | Stop::Done => buf.len(),
};
if let Some(m) = disc {
let bad = d
.snaps
.iter()
.rev()
.find(|s| (s.mcu as usize) <= m)
.map(|s| s.pos)
.unwrap_or(h.scan_start);
stop_pos = stop_pos.min(bad);
}
let cl = CLUSTER as usize;
if off % CLUSTER != 0 || !allow_rebuild {
return Analysis::Broken {
good_len: (stop_pos / 512 * 512).max(512) as u64,
};
}
let mut pieces: Vec<(u64, usize)> = vec![];
let mut joined = buf;
let mut cur_base = off; // início absoluto do fragmento atual
let mut frag_start = 0usize; // posição em `joined` onde o fragmento atual começa
let mut snaps = d.snaps;
let mut suspect = stop_pos;
for _ in 0..3 {
let top = suspect.saturating_sub(frag_start) / cl * cl;
let mut best: Option<(usize, u64, State, f64)> = None;
let mut probes = 0usize;
for back in 0..4usize {
let Some(bc_rel) = top.checked_sub(back * cl).filter(|&b| b > 0) else {
break;
};
let bc = frag_start + bc_rel;
let Some(snap) = snaps
.iter()
.rev()
.find(|s| s.pos <= bc)
.cloned()
.or_else(|| (frag_start == 0).then(|| State::start(&h)))
else {
continue;
};
let mut plane = jpegscan::Plane::new(&h);
let _ =
jpegscan::decode_plane(&h, &joined[..bc], State::start(&h), u32::MAX, &mut plane);
let lo = off.saturating_sub(WINDOW_BEFORE);
let hi = cur_base + bc_rel as u64 + WINDOW_AFTER;
let pre = jpegscan::decode(&h, &joined[..bc], State::start(&h), u32::MAX, 0);
let bpm_prefix =
(pre.state.pos.saturating_sub(h.scan_start)) as f64 / pre.dc.len().max(1) as f64;
let mut ranked: Vec<(f64, u64)> = vec![];
for q in pool.index.iter_range(lo, hi) {
if q >= cur_base && q < cur_base + bc_rel as u64 + CLUSTER {
continue;
}
if !pool.free_at(q, CLUSTER)
|| pieces
.iter()
.any(|&(po, pl)| q < po + pl as u64 && q + CLUSTER > po)
{
continue;
}
probes += 1;
if probes > MAX_PROBES {
break;
}
if let Some(sc) =
probe_candidate(src, &h, &joined[..bc], &plane, &snap, q, bpm_prefix)
{
ranked.push((sc, q));
}
}
ranked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
ranked.truncate(120);
// Etapa 2: a continuação certa fecha a imagem exatamente no último MCU.
let mut longest: Option<(usize, u64, f64)> = None;
for (sc, q) in ranked {
let avail = (src.size() - q) as usize;
let more = read_vec(src, q, MAX_FILE.min(avail)).unwrap_or_default();
let mut j2 = joined[..bc].to_vec();
j2.extend_from_slice(&more);
let d2 = jpegscan::decode(&h, &j2, snap.clone(), u32::MAX, 0);
if d2.stop == Stop::Done && eoi_after(&j2, d2.state.pos).is_some() {
best = Some((bc, q, snap.clone(), 0.0));
break;
}
let reach = match d2.stop {
Stop::Marker(_, p) | Stop::Error(p) => p.saturating_sub(bc),
_ => j2.len() - bc,
};
if reach >= 3 * cl && longest.map_or(true, |l| sc < l.2) {
longest = Some((reach, q, sc));
}
}
if best.is_none() {
if let Some((_, q, sc)) = longest {
if sc < 25.0 {
let cand = (bc, q, snap.clone(), sc);
if best.as_ref().map_or(true, |b| sc < b.3) {
best = Some(cand);
}
}
}
}
if best.as_ref().is_some_and(|b| b.3 == 0.0) {
break;
}
}
let Some((bc, q, snap, _)) = best else { break };
pieces.push((cur_base, bc - frag_start));
let more = read_vec(src, q, MAX_FILE.min((src.size() - q) as usize)).unwrap_or_default();
joined.truncate(bc);
joined.extend_from_slice(&more);
let d2 = jpegscan::decode(&h, &joined, snap, u32::MAX, 512);
if d2.stop == Stop::Done {
if let Some(end) = eoi_after(&joined, d2.state.pos) {
pieces.push((q, end - bc));
return finish(pieces);
}
}
// Quebrou de novo: onde?
let mut sp = match d2.stop {
Stop::Marker(_, p) | Stop::Error(p) => p,
_ => joined.len(),
};
let all = jpegscan::decode(&h, &joined, State::start(&h), u32::MAX, 0);
if let Some(m) = jpegscan::first_discontinuity(&h, &all.dc) {
let bad = d2
.snaps
.iter()
.rev()
.find(|s| (s.mcu as usize) <= m)
.map(|s| s.pos)
.unwrap_or(bc);
if bad > bc {
sp = sp.min(bad);
}
}
snaps = d2.snaps;
cur_base = q;
frag_start = bc;
suspect = sp.max(bc + cl);
}
let good = pieces.iter().map(|p| p.1).sum::<usize>()
+ suspect.saturating_sub(frag_start).min(cl * 4) / 512 * 512;
Analysis::Broken {
good_len: (stop_pos.min(good.max(512)) / 512 * 512).max(512) as u64,
}
}
fn finish(pieces: Vec<(u64, usize)>) -> Analysis {
let mut extents: Vec<Extent> = vec![];
for (o, l) in &pieces {
match extents.last_mut() {
Some(last) if last.end() == *o => last.len += *l as u64,
_ => extents.push(Extent::new(*o, *l as u64)),
}
}
let size = pieces.iter().map(|p| p.1 as u64).sum();
if extents.len() == 1 {
return Analysis::Intact { len: size };
}
Analysis::Rebuilt { extents, size }
}
/// Pontuação (menor é melhor) de `q` como continuação de `prefix`: diferença média de pixels
/// nas bordas entre os MCUs novos e seus vizinhos. Natural: poucos níveis de cinza; lixo: dezenas.
fn probe_candidate(
src: &dyn Source,
h: &Header,
prefix: &[u8],
plane: &jpegscan::Plane,
snap: &State,
q: u64,
bpm_prefix: f64,
) -> Option<f64> {
let more = read_vec(src, q, PROBE).ok()?;
if more.len() < 1024 {
return None;
}
let mut joined = Vec::with_capacity(prefix.len() + more.len());
joined.extend_from_slice(prefix);
joined.extend_from_slice(&more);
let seam = plane.have;
const K: u32 = 36;
let mut news: std::collections::HashMap<u32, Vec<u8>> = std::collections::HashMap::new();
let mut hook = |mcu: u32, blocks: &[[i32; 64]]| {
if mcu >= seam {
news.insert(mcu, jpegscan::mcu_luma(h, blocks));
}
};
let want = seam.saturating_sub(snap.mcu) + K + 1;
let d = jpegscan::decode_hook(h, &joined, snap.clone(), want, 0, Some(&mut hook));
match d.stop {
Stop::Error(_) => return None,
Stop::Marker(m, _) if m != 0xD9 => return None,
_ => {}
}
let (mw, mh) = (plane.mw, plane.mh);
let mx = h.mcus_x;
let get = |idx: u32| -> Option<Vec<u8>> {
if idx < seam {
let (x, y) = ((idx % mx) as usize, (idx / mx) as usize);
let mut v = vec![0u8; mw * mh];
for r in 0..mh {
let o = (y * mh + r) * plane.w + x * mw;
v[r * mw..(r + 1) * mw].copy_from_slice(&plane.px[o..o + mw]);
}
Some(v)
} else {
news.get(&idx).cloned()
}
};
let (mut sum, mut n) = (0f64, 0u32);
for idx in seam + 1..seam + 1 + K {
let Some(cur) = news.get(&idx) else { break };
if idx % mx > 0 {
if let Some(l) = get(idx - 1) {
for y in 0..mh {
sum += (cur[y * mw] as i32 - l[y * mw + mw - 1] as i32).abs() as f64;
n += 1;
}
}
}
if idx >= mx {
if let Some(t) = get(idx - mx) {
for x in 0..mw {
sum += (cur[x] as i32 - t[(mh - 1) * mw + x] as i32).abs() as f64;
n += 1;
}
}
}
}
if n < 40 {
return None;
}
// Bytes por MCU depois da emenda, comparados com os do trecho anterior.
let after = d.dc.len() as f64 - (seam.saturating_sub(snap.mcu)) as f64;
let used = d.state.pos.saturating_sub(prefix.len()) as f64;
let ratio = if after > 4.0 && used > 0.0 {
(used / after) / bpm_prefix.max(0.5)
} else {
1.0
};
let sc = sum / n as f64 + 20.0 * ratio.ln().abs();
Some(sc)
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -180,7 +180,14 @@ pub fn facets(found: &[Found]) -> Value {
let mut conf = [0usize; 3];
let mut seen_sig = std::collections::HashSet::new();
let mut dups = 0usize;
let (mut small, mut mp1) = (0usize, 0usize);
for f in found {
if f.size < 30 * 1024 {
small += 1;
}
if f.px.is_some_and(|(w, h)| w as u64 * h as u64 >= 1_000_000) {
mp1 += 1;
}
if let Some(g) = f.sig {
if !seen_sig.insert((g, f.size)) {
dups += 1;
@@ -242,6 +249,8 @@ pub fn facets(found: &[Found]) -> Value {
"root": root,
"gps": gps,
"duplicates": dups,
"small": small,
"mp1": mp1,
})
}
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more