68 Commits
Author SHA1 Message Date
Lucas Rayan Guerra 165f6a24f2 Merge pull request #47 from lucasrguerra/release/1.3.0
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
release: 1.3.0
2026-10-23 11:15:52 -03:00
Lucas RayanandClaude Sonnet 5.5 b431f01874 release: 1.3.0
Versões, changelog e roadmap: atualização pelo próprio app, assinaturas Ed25519, QCOW2 e VMDK
diferenciais, DMG com LZFSE, imagens de referência no CI dos três sistemas e o teste de exclusão
real do XFS (que rebaixa o XFS para 🟡).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:12:24 -03:00
Lucas Rayan Guerra afcb0e9bf1 Merge pull request #45 from lucasrguerra/feat/updater
Atualização pelo próprio app (cartão Atualizar agora)
2026-10-23 11:11:08 -03:00
Lucas Rayan Guerra 6451bf9aee Merge pull request #46 from lucasrguerra/ci/apt-cache-all-installs
CI: cache, tempo limite e nova tentativa em todos os passos de apt
2026-10-23 11:10:36 -03:00
Lucas RayanandClaude Sonnet 5.5 5b6346d575 CI: cache, tempo limite e nova tentativa em todos os passos de apt
O passo de ferramentas das imagens de teste levou 13 minutos num dia de espelho lento (803 s
no último run da main), como o do Tauri. scripts/apt-install.sh vira genérico (pasta de cache e
pacotes por argumento) e passa a servir aos dois.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 10:54:00 -03:00
Lucas RayanandClaude Sonnet 5.5 9d1a8c727f Teste de configurações conhece o campo updates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 10:53:22 -03:00
Lucas RayanandClaude Sonnet 5.5 daa12ba117 Atualização pelo próprio app (Tauri updater) com cartão de aviso
Ao abrir, o app consulta o latest.json da release no GitHub; havendo versão nova, um cartão
oferece Atualizar agora: baixa, confere a assinatura com a chave pública embutida, instala e
reabre (AppImage substituído no lugar; Windows e macOS pelo instalador). Instalação por .deb ou
.rpm só avisa. Configurações ganha a opção de desligar a consulta e o botão Verificar agora.

A release assina os pacotes com a chave guardada no ambiente 'release' do GitHub (só tags v*) e
o latest.json é montado de uma vez por scripts/make-latest-json.py, depois que todos os jobs
de empacotamento terminam.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 10:51:30 -03:00
Lucas Rayan Guerra 17b0e89e03 Merge pull request #43 from lucasrguerra/chore/untrack-superset
Tira o .superset do repositório (configuração local)
2026-10-23 10:50:27 -03:00
Lucas Rayan fdd7bf900b Mescla a main (CI do Tauri com cache e nova tentativa) 2026-10-23 09:51:16 -03:00
Lucas Rayan Guerra 472ad1107b Merge pull request #44 from lucasrguerra/ci/tauri-deps-cache-and-retry
CI: dependências do Tauri com cache de .deb, tempo limite e nova tentativa
2026-10-23 09:51:01 -03:00
Lucas RayanandClaude Sonnet 5.5 e755f440ae CI: dependências do Tauri com cache de .deb, tempo limite e nova tentativa
O passo do apt levou 22 e 28 minutos em duas execuções de um dia de espelho lento (o normal é
uns 35 s). O script usa os .deb em cache sem nem atualizar o índice e, sem cache, tenta até 3 vezes
com tempo limite por tentativa. Vale para o CI e para o job do app desktop da release.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:40:11 -03:00
Lucas RayanandClaude Sonnet 5.5 06478dfd75 Tira o .superset do repositório (é configuração local)
Os arquivos continuam na máquina, agora ignorados pelo git.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:25:27 -03:00
Lucas Rayan Guerra 26099eaacf Merge pull request #42 from lucasrguerra/test/xfs-real-deletion
XFS: teste com exclusão real do kernel mostra o que funciona (carving sim, nomes não)
2026-10-23 09:25:09 -03:00
Lucas RayanandClaude Sonnet 5.5 ccbaa2d7e6 Mescla a main (CI com imagens de referência e o passo do XFS)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:55:36 -03:00
Lucas Rayan Guerra a6ce38a173 Merge pull request #41 from lucasrguerra/ci/real-reference-images
CI: testes com imagens reais de referência em Linux, macOS e Windows
2026-10-23 08:54:58 -03:00
Lucas Rayan Guerra 26469de29e Merge pull request #40 from lucasrguerra/feat/signatures
Assinatura digital Ed25519 (guara sign)
2026-10-23 08:54:33 -03:00
Lucas RayanandClaude Sonnet 5.5 27d4cab2e6 Mescla a main (lzfse_rust e ed25519-compact lado a lado)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:51:40 -03:00
Lucas Rayan Guerra f45ded139e Merge pull request #39 from lucasrguerra/feat/vdisk-chains
Discos virtuais: apoio do QCOW2, snapshots de VMDK e DMG com LZFSE
2026-10-23 08:50:46 -03:00
Lucas Rayan Guerra 9c9b7879be Merge pull request #38 from lucasrguerra/deps/ctutils-0.4.4
Dependências: ctutils 0.4.5 (RUSTSEC-2026-0385)
2026-10-23 08:50:24 -03:00
Lucas RayanandClaude Sonnet 5.5 bbb251aacb Roadmap: XFS 🟡 depois do teste com exclusão real
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:36:06 -03:00
Lucas RayanandClaude Sonnet 5.5 4d93d9d512 XFS: o teste com exclusão real mostra o que funciona de verdade
No CI, com um XFS apagado pelo kernel, a recuperação por metadados não acha nada (o kernel zera
o inode e as extensões); o carving recupera o conteúdo de arquivos com estrutura reconhecível.
O teste de nome e pasta fica ignorado até o Guará ler o diário, e o roadmap e a documentação
passam a dizer isso em vez de marcar o XFS como validado.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:35:55 -03:00
Lucas RayanandClaude Sonnet 5.5 e5ae3ea570 XFS real: imagem de 320 MiB (o mkfs.xfs recente recusa menos de 300 MiB)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:31:42 -03:00
Lucas RayanandClaude Sonnet 5.5 418d0b9b90 Dependências: ctutils 0.4.5 (RUSTSEC-2026-0385)
Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:31:42 -03:00
Lucas RayanandClaude Sonnet 5.5 21d5fc4c7f CI: nome do passo de testes reflete as imagens de referência nos três sistemas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:08:10 -03:00
Lucas RayanandClaude Sonnet 5.5 239f96263d Teste de XFS com exclusão feita pelo kernel
scripts/make-xfs-real-deletion.sh (precisa de root) monta uma imagem XFS por loop, grava
arquivos, apaga alguns e desmonta, deixando o hash de cada um em expected.tsv. O teste confere
que cada arquivo apagado volta pelo nome, na pasta certa e com o conteúdo original, e que os que
ficaram não aparecem como apagados. O CI do Linux gera a imagem com sudo e exige o teste.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:07:33 -03:00
Lucas RayanandClaude Sonnet 5.5 4b18acc792 CI: baixa as imagens de referência em Linux, macOS e Windows
Os testes com imagens reais (BitLocker, FileVault 2, dfvfs, AFF4, discos virtuais) só rodavam
na máquina de quem baixava as imagens; no CI eram pulados em silêncio. Agora o script de
download roda nos três sistemas, com cache pela versão do script.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:00:24 -03:00
Lucas RayanandClaude Sonnet 5.5 6ae4dbe9b0 Dependências: ctutils 0.4.5 (RUSTSEC-2026-0385)
Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:00:16 -03:00
Lucas RayanandClaude Sonnet 5.5 34fb72bf24 Assinatura digital Ed25519 de manifestos, relatórios e recuperações
guara sign keygen, file, verify e recovery. Com chave configurada, recover e report
assinam sozinhos o manifesto e o relatório. A assinatura cobre hash, tamanho, nome, hora e
assinante e é Ed25519 comum (conferida com o openssl). A conferência de uma pasta de
recuperação refaz o hash de cada arquivo contra o manifesto e não deixa o manifesto apontar
para fora da pasta. A hora é a do relógio local, sem carimbo de terceiros (RFC 3161).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:00:02 -03:00
Lucas RayanandClaude Sonnet 5.5 447e57bc1b Dependências: ctutils 0.4.5 (RUSTSEC-2026-0385)
Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:56:12 -03:00
Lucas RayanandClaude Sonnet 5.5 4bec3e971a Discos virtuais: arquivo de apoio do QCOW2, snapshots de VMDK e DMG com LZFSE
QCOW2 e VMDK filhos leem o pai no que não alocaram (o QCOW2 respeita o formato do apoio;
o VMDK acha o pai por nome ou, no VirtualBox, por UUID, e recusa o filho se o CID do pai
mudou). Chunks LZFSE do DMG usam lzfse_rust, conferidos com dados do compressor de
referência da Apple. Snapshot real do VirtualBox entra como fixture.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:55:58 -03:00
Lucas RayanandClaude Sonnet 5.5 3a6b0c7b74 Dependências: ctutils 0.4.5 (RUSTSEC-2026-0385)
Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:55:27 -03:00
Lucas RayanandClaude Sonnet 5.5 1c8cf30761 Dependências: ctutils 0.4.5 (RUSTSEC-2026-0385)
Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:55:18 -03:00
Lucas Rayan Guerra 2d210fa240 Merge pull request #37 from lucasrguerra/docs/roadmap-next-milestones
Roadmap: renumera os próximos marcos depois do 1.2.0
2026-10-22 22:23:47 -03:00
Lucas RayanandClaude Sonnet 5.5 32785df672 Roadmap: renumera os próximos marcos depois do 1.2.0
Os marcos 1.1 e 1.2 já foram lançados com outro conteúdo, e os contêineres de
disco virtual deixaram de ser pendência (restam só os casos recusados).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:04:12 -03:00
Lucas Rayan Guerra cb38ad401a Merge pull request #36 from lucasrguerra/release/1.2.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
Guará 1.2.0: discos virtuais, trilha de auditoria, relatório em PDF
2026-10-22 16:58:29 -03:00
Lucas Rayan Guerra 3caa9d805a Merge pull request #31 from lucasrguerra/feat/apfs-free-map
APFS: lê o mapa de espaço livre do spaceman
2026-10-22 16:53:17 -03:00
Lucas RayanandClaude Sonnet 5.5 cde0dd45f8 release: 1.2.0
Versões, changelog e roadmap: discos virtuais (QCOW2, VMDK, VHD, VHDX, DMG), trilha
de auditoria encadeada por hash, relatório em PDF e mapa de espaço livre do APFS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:10:23 -03:00
Lucas RayanandClaude Sonnet 5.5 58940965bb Relatório em PDF (A4) com gerador próprio
guara report relatorio.pdf (e o botão de relatório da interface, pela extensão) gera
as mesmas seções do HTML em PDF com fontes padrão, sem dependência nova. O relatório,
em PDF e HTML, passa a trazer a situação da trilha de auditoria e o hash da última
entrada. Conferido com pdfinfo e pdftotext.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:09:46 -03:00
Lucas RayanandClaude Sonnet 5.5 26e5ebb592 Trilha de auditoria encadeada por hash e comando guara audit
Cada ação sobre a evidência (inspect, scan, recover, image, hash, search, verify,
exportações e sessões) vira uma linha JSON com usuário, máquina, hora UTC e detalhes,
encadeada por SHA-256. guara audit lista e --verify aponta a entrada onde a cadeia
quebra. Senhas e chaves nunca entram no registro.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:04:43 -03:00
Lucas RayanandClaude Sonnet 5.5 c1c4f442d7 APFS: lê o mapa de espaço livre do spaceman
Percorre o spaceman, os chunk-info blocks e os bitmaps para entregar à varredura
profunda só os blocos não alocados. Contêineres com CAB voltam a varrer o volume
inteiro, com aviso. Conferido com a imagem de referência do dfvfs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:00:48 -03:00
Lucas RayanandClaude Sonnet 5.5 213a5a3df5 Discos virtuais: leitura de QCOW2, VMDK, VHD, VHDX e DMG
Cada contêiner é aberto como o disco que contém, somente leitura, pela mesma
open_path das imagens E01 e AFF4. VHD e VHDX diferenciais seguem a cadeia de pais.
QCOW2 com arquivo de apoio, VMDK com pai, DMG cifrado e LZFSE são recusados com o
motivo. Conferido por hash contra o 7-Zip em imagens reais do dfvfs e do VirtualBox.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:00:48 -03:00
Lucas RayanandClaude Sonnet 5.5 c6db7c7d2a chore: remove Dependabot configuration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 19:40:28 -03:00
Lucas Rayan a75cc6aeba Dependências: jpeg-encoder 0.7 e lz4_flex 0.14
Consolida os PRs #27-#30 do Dependabot. O lz4_flex 0.14 exige a feature alloc quando default-features = false, caso contrário a API decompress que retorna Vec não é compilada.
2026-10-20 15:58:16 -03:00
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
95 changed files with 8619 additions and 585 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
-11
View File
@@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: cargo
directory: /
schedule: { interval: weekly }
- package-ecosystem: cargo
directory: /apps/gui/src-tauri
schedule: { interval: weekly }
- package-ecosystem: github-actions
directory: /
schedule: { interval: weekly }
+39 -12
View File
@@ -18,11 +18,18 @@ 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
- name: Script do latest.json do atualizador
run: python3 scripts/test_make_latest_json.py
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
@@ -34,15 +41,31 @@ 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: Cache dos pacotes das ferramentas de teste (.deb)
if: runner.os == 'Linux'
uses: actions/cache@v6
with:
path: ~/apt-cache-tools
key: apt-tools-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Ferramentas para imagens de teste (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs imagemagick zip
- name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux)
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
- name: Cache das imagens de referência
uses: actions/cache@v6
with:
path: target/testdata
key: testdata-${{ runner.os }}-${{ hashFiles('scripts/fetch-testdata.sh') }}
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
shell: bash
run: scripts/fetch-testdata.sh
- name: Imagem XFS com exclusão feita pelo kernel (Linux)
if: runner.os == 'Linux'
run: sudo scripts/make-xfs-real-deletion.sh
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux)
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
@@ -52,7 +75,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,18 +84,22 @@ 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: Cache dos pacotes do Tauri (.deb)
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache 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
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
+44 -11
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 }}
@@ -86,6 +87,8 @@ jobs:
name: App desktop (${{ matrix.name }})
needs: create-release
runs-on: ${{ matrix.os }}
# Os segredos de assinatura do atualizador ficam no ambiente "release", que só aceita tags v*.
environment: release
strategy:
fail-fast: false
matrix:
@@ -95,40 +98,70 @@ 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: Cache dos pacotes do Tauri (.deb)
if: runner.os == 'Linux'
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache 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
# A chave privada de atualização fica só nos segredos do repositório. Ela assina os pacotes
# que o app baixa sozinho; a chave pública correspondente está em tauri.conf.json.
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: apps/gui/src-tauri
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.args }}
# o latest.json é montado no job de publicação, de uma vez só (scripts/make-latest-json.py)
uploadUpdaterJson: false
publish:
name: Publica a release
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,
});
# Depois de publicada (rascunho não é visível por tag), monta e sobe o latest.json.
- name: latest.json do atualizador do app
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
python3 scripts/make-latest-json.py "$GITHUB_REF_NAME" notes.md latest.json
gh release upload "$GITHUB_REF_NAME" latest.json --clobber
+5 -1
View File
@@ -1,12 +1,16 @@
# Material de trabalho local, não versionado
.superpowers/
docs/
# Estado de ferramentas locais
.wolf/
.claude/
.superset/
CLAUDE.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
.env
.env.*
# Build
target/
node_modules/
+164 -80
View File
@@ -1,118 +1,202 @@
# 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.3.0] - 2026-10-23
### Added
- **Update from inside the desktop app.** On startup the app asks GitHub for the release's `latest.json`; if there is a newer version, a card offers **Update now**. It downloads the package, verifies its minisign signature against the public key built into the app, installs and reopens (the AppImage is replaced in place; Windows and macOS use the installer). Installs from `.deb` or `.rpm` only show the notice. **Settings** can turn the startup check off and has a **Check now** button. The check is one HTTP request and sends nothing of yours.
- **Digital signatures (Ed25519):** `guara sign keygen | file | verify | recovery`. With a key configured, `recover` and `report` sign the manifest and the report by themselves. `guara sign recovery` checks the manifest signature and the hash of every recovered file (and refuses manifest paths that leave the folder). The signature is plain Ed25519 over a documented message, so `openssl` verifies it too. Comparing the key fingerprint (`--trust`) says whose key it is; the signed time is the local clock, not a third-party timestamp.
- **Virtual disks:** QCOW2 with a backing file, VMDK snapshots (parent by name or, in VirtualBox, by UUID; the child is refused if the parent's CID changed) and DMG with LZFSE chunks.
- **CI with real reference images** on Linux, macOS and Windows (they were skipped before), and an XFS image deleted by the real kernel generated with `sudo` on the Linux runner.
- Release pipeline: update packages are signed with a key kept in the repository's `release` environment (tags `v*` only), and `latest.json` is built in one step by `scripts/make-latest-json.py`.
### Changed
- **XFS is now 🟡.** On a real XFS (v5, kernel deletion) the inode and extent list are wiped, so the metadata path finds nothing; the content of files with a recognizable structure comes back by carving, without name or folder. The earlier path only worked on simulated deletions. The name-and-folder test is kept `ignore`d until the journal is read.
- CI `apt` steps use cached `.deb`s, a time limit per attempt and retries (they took up to 28 minutes on a slow mirror day).
- Dependencies: `ctutils` 0.4.5 (RUSTSEC-2026-0385); new `ed25519-compact`, `lzfse_rust`, `tauri-plugin-updater`.
- The README and ROADMAP now say the update check is the only network use by default.
### Notes
- Versions before 1.3.0 do not have the updater: from 1.2.0, replace the AppImage by hand one last time. The first update inside the app is 1.3.0 to the next release.
- Still missing: password-protected DMG, an identity check for VHD, VHDX and QCOW2 parents, third-party timestamps (RFC 3161), XFS journal reading, and APFS validation with real deletion (needs an image from a real Mac).
- CLI output remains in Portuguese.
## [1.2.0] - 2026-10-22
### Added
- **Virtual disk containers (read-only):** QCOW2 (v2 and v3, compressed clusters with deflate or zstd), VMDK (sparse, `streamOptimized`, and text descriptors with flat and zero extents), VHD and VHDX (fixed, dynamic and differencing, following the parent chain) and DMG (UDIF with zero, raw, ADC, zlib and bzip2 chunks). They open through the same path as E01 and AFF4, so partitions, file systems and encrypted volumes work inside them. Images that would read wrong are refused with the reason: QCOW2 with a backing file or encryption, VMDK with a parent or in SEsparse, password-protected DMG, DMG with LZFSE chunks. The VHDX log is not replayed.
- **Audit trail** (`auditoria.jsonl`, `guara audit`): every action on evidence (`inspect`, `scan`, `recover`, `image`, `hash`, `search`, `verify`, exports and sessions) is recorded with UTC time, system user, host, version and details, chained by SHA-256. `guara audit --verify` reports the entry where the chain breaks. Set `GUARA_EXAMINER` to record the examiner; passwords and keys are never written.
- **PDF report:** `guara report out.pdf` (and the interface's report button, by file extension) writes an A4 PDF with the same sections as the HTML, using a built-in writer with the standard fonts and no new dependency. Both formats now show the audit trail status and the hash of its last entry.
- **APFS free-space map:** the container's space manager (spaceman), chunk-info blocks and bitmaps are read, so deep scans of an APFS volume cover only unallocated blocks. Containers that use chunk-info address blocks fall back to the whole volume, with a warning.
- `scripts/fetch-testdata.sh` also downloads the dfvfs virtual disk images used by the container tests.
### Changed
- The APFS partition warning no longer says metadata is unsupported; it says the free-space map is read and that encrypted (FileVault) volumes need the key.
- The interface's report dialog explains that the file extension chooses PDF or HTML.
### Notes
- Containers were checked by hash against an independent reader (7-Zip) on real images from dfvfs and VirtualBox, including differencing VHD and VHDX; the small fixtures in `tests/fixtures/vdisk` run in CI.
- APFS deletion is still tested only in simulation, so APFS stays 🟡 in the roadmap.
- CLI output remains in Portuguese.
## [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
### 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.
### 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.
### Corrigido
- Abrir de novo uma sessão recente já aberta não cria mais cópias iguais na aba de sessões.
### Fixed
- Reopening a recent session that is already open no longer creates identical copies in the sessions tab.
### 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.
### 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
### 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`.
### 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`.
### 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).
### 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
### 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.
### 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.
### Notas
- Mensagens de erro vindas do núcleo e o terminal continuam em português.
### Notes
- Error messages coming from the core and the terminal are still in Portuguese.
## [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.
### 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.
### Corrigido
- Em sessões pausadas não havia como escolher outra origem; só "Continuar", que falhava sem o disco.
### 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
### 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.
### 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.
### 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).
### 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
### 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.
### 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.
### 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.
### 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.
+202
View File
@@ -0,0 +1,202 @@
# 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.3.0] - 2026-10-23
### Adicionado
- **Atualização dentro do app desktop.** Ao abrir, o app pede ao GitHub o `latest.json` da release; havendo versão nova, um cartão oferece **Atualizar agora**. Ele baixa o pacote, confere a assinatura minisign com a chave pública embutida no app, instala e reabre (o AppImage é substituído no lugar; Windows e macOS usam o instalador). Instalações por `.deb` ou `.rpm` só mostram o aviso. **Configurações** desliga a consulta ao abrir e tem o botão **Verificar agora**. A consulta é uma requisição HTTP e não envia nada seu.
- **Assinatura digital (Ed25519):** `guara sign keygen | file | verify | recovery`. Com uma chave configurada, `recover` e `report` assinam sozinhos o manifesto e o relatório. `guara sign recovery` confere a assinatura do manifesto e o hash de cada arquivo recuperado (e recusa caminhos do manifesto que saem da pasta). É Ed25519 comum sobre uma mensagem documentada, então o `openssl` também confere. Comparar a impressão digital da chave (`--trust`) diz de quem ela é; a hora assinada é a do relógio local, não um carimbo de terceiros.
- **Discos virtuais:** QCOW2 com arquivo de apoio, snapshots de VMDK (pai por nome ou, no VirtualBox, por UUID; o filho é recusado se o CID do pai mudou) e DMG com chunks LZFSE.
- **CI com imagens de referência reais** em Linux, macOS e Windows (antes eram puladas) e uma imagem XFS apagada pelo kernel de verdade, gerada com `sudo` no runner Linux.
- Pipeline de release: os pacotes de atualização são assinados com uma chave guardada no ambiente `release` do repositório (só tags `v*`), e o `latest.json` é montado de uma vez por `scripts/make-latest-json.py`.
### Alterado
- **O XFS passa a 🟡.** Num XFS real (v5, exclusão do kernel) o inode e a lista de extensões são zerados, então o caminho por metadados não acha nada; o conteúdo de arquivos com estrutura reconhecível volta por carving, sem nome nem pasta. O caminho anterior só funcionava na exclusão simulada. O teste de nome e pasta fica `ignore`d até o diário ser lido.
- Os passos de `apt` do CI usam `.deb` em cache, tempo limite por tentativa e nova tentativa (levavam até 28 minutos num dia de espelho lento).
- Dependências: `ctutils` 0.4.5 (RUSTSEC-2026-0385); novas `ed25519-compact`, `lzfse_rust` e `tauri-plugin-updater`.
- O README e o ROADMAP passam a dizer que a verificação de atualização é o único uso da rede por padrão.
### Notas
- As versões anteriores à 1.3.0 não têm o atualizador: da 1.2.0, troque o AppImage à mão uma última vez. A primeira atualização dentro do app é da 1.3.0 para a próxima versão.
- Ainda faltam: DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2, carimbo de tempo de terceiros (RFC 3161), leitura do diário do XFS e validação de APFS com exclusão real (precisa de imagem de um Mac de verdade).
- A saída da CLI continua em português.
## [1.2.0] - 2026-10-22
### Adicionado
- **Contêineres de disco virtual (somente leitura):** QCOW2 (v2 e v3, clusters comprimidos com deflate ou zstd), VMDK (esparso, `streamOptimized` e descritores de texto com extensões flat e zero), VHD e VHDX (fixo, dinâmico e diferencial, seguindo a cadeia de pais) e DMG (UDIF com chunks zerados, brutos, ADC, zlib e bzip2). Abrem pelo mesmo caminho do E01 e do AFF4, então partições, sistemas de arquivos e volumes cifrados funcionam dentro deles. Imagens que seriam lidas errado são recusadas com o motivo: QCOW2 com arquivo de apoio ou cifrado, VMDK com pai ou em SEsparse, DMG protegido por senha, DMG com chunks LZFSE. O log do VHDX não é reproduzido.
- **Trilha de auditoria** (`auditoria.jsonl`, `guara audit`): toda ação sobre a evidência (`inspect`, `scan`, `recover`, `image`, `hash`, `search`, `verify`, exportações e sessões) é registrada com hora UTC, usuário do sistema, máquina, versão e detalhes, encadeada por SHA-256. `guara audit --verify` aponta a entrada onde a cadeia quebra. Defina `GUARA_EXAMINER` para registrar o perito; senhas e chaves nunca são gravadas.
- **Relatório em PDF:** `guara report saida.pdf` (e o botão de relatório da interface, pela extensão do arquivo) gera um PDF A4 com as mesmas seções do HTML, com gerador próprio, fontes padrão e nenhuma dependência nova. Os dois formatos passam a mostrar a situação da trilha de auditoria e o hash da última entrada.
- **Mapa de espaço livre do APFS:** o gerenciador de espaço do contêiner (spaceman), os chunk-info blocks e os bitmaps são lidos, e a varredura profunda de um volume APFS cobre só os blocos não alocados. Contêineres que usam blocos de endereço de chunk-info voltam a varrer o volume todo, com aviso.
- `scripts/fetch-testdata.sh` também baixa as imagens de disco virtual do dfvfs usadas nos testes dos contêineres.
### Alterado
- O aviso da partição APFS deixa de dizer que não há suporte a metadados; informa que o mapa de espaço livre é lido e que volumes cifrados (FileVault) precisam da chave.
- O diálogo de relatório da interface explica que a extensão do arquivo escolhe PDF ou HTML.
### Notas
- Os contêineres foram conferidos por hash contra um leitor independente (7-Zip) em imagens reais do dfvfs e do VirtualBox, inclusive VHD e VHDX diferenciais; os fixtures pequenos de `tests/fixtures/vdisk` rodam no CI.
- A exclusão no APFS continua testada só em simulação, por isso o APFS segue 🟡 no roadmap.
- A saída da CLI continua em português.
## [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.
+28 -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,28 @@ 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.
The desktop app signs its update packages: `tauri.conf.json` has `createUpdaterArtifacts` on, so a local `tauri build` needs `TAURI_SIGNING_PRIVATE_KEY` (any key made with `tauri signer generate`; the release key lives only in the repository secrets). For a quick local bundle, pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
## 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.
+43
View File
@@ -0,0 +1,43 @@
# 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.
O app desktop assina os pacotes de atualização: o `tauri.conf.json` tem `createUpdaterArtifacts` ligado, então um `tauri build` local precisa de `TAURI_SIGNING_PRIVATE_KEY` (qualquer chave feita com `tauri signer generate`; a chave da release fica só nos segredos do repositório). Para um pacote local rápido, passe `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
## 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
+191 -213
View File
@@ -10,12 +10,12 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
dependencies = [
"cfg-if",
"cipher",
"cpubits",
"cpufeatures",
]
@@ -55,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]]
@@ -66,23 +66,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
"windows-sys",
]
[[package]]
name = "argon2"
version = "0.5.3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
@@ -116,20 +107,20 @@ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "blake2"
version = "0.10.6"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"generic-array",
"hybrid-array",
]
[[package]]
@@ -200,9 +191,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common",
"inout",
@@ -248,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"
@@ -261,37 +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 = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[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]]
@@ -302,29 +304,28 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
name = "ctutils"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
"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",
"subtle",
"ctutils",
]
[[package]]
@@ -339,6 +340,15 @@ dependencies = [
"objc2",
]
[[package]]
name = "ed25519-compact"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1454db4f2edb7f0e8fe0c5b375b0c978fc63244cc9f010d160e417eb10139aa8"
dependencies = [
"getrandom",
]
[[package]]
name = "equivalent"
version = "1.0.3"
@@ -352,7 +362,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -381,27 +391,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"
@@ -409,8 +398,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasm-bindgen",
]
[[package]]
@@ -425,11 +416,11 @@ dependencies = [
[[package]]
name = "guara-cli"
version = "1.0.0"
version = "1.3.0"
dependencies = [
"clap",
"ctrlc",
"getrandom 0.2.17",
"getrandom",
"guara-core",
"rpassword",
"serde_json",
@@ -438,11 +429,12 @@ dependencies = [
[[package]]
name = "guara-core"
version = "1.0.0"
version = "1.3.0"
dependencies = [
"aes",
"argon2",
"bzip2",
"ed25519-compact",
"flate2",
"hmac",
"image",
@@ -450,6 +442,7 @@ dependencies = [
"jpeg-encoder",
"libc",
"lz4_flex",
"lzfse_rust",
"md-5",
"mp4",
"pbkdf2",
@@ -463,7 +456,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"tiny_http",
"windows-sys 0.59.0",
"windows-sys",
"zip",
]
@@ -481,9 +474,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
@@ -494,6 +487,15 @@ 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"
@@ -532,11 +534,11 @@ dependencies = [
[[package]]
name = "inout"
version = "0.1.4"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [
"generic-array",
"hybrid-array",
]
[[package]]
@@ -559,9 +561,19 @@ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]]
name = "jpeg-encoder"
version = "0.6.1"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "libbz2-rs-sys"
@@ -589,15 +601,21 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lz4_flex"
version = "0.11.6"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
[[package]]
name = "lzfse_rust"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6"
[[package]]
name = "md-5"
version = "0.10.6"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest",
@@ -734,25 +752,35 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "password-hash"
version = "0.5.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"base64ct",
"rand_core",
"subtle",
"getrandom",
"phc",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
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"
@@ -802,17 +830,11 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "ripemd"
version = "0.1.3"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159"
dependencies = [
"digest",
]
@@ -825,7 +847,7 @@ checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -835,7 +857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -848,14 +870,20 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "ruzstd"
version = "0.7.3"
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fad02996bfc73da3e301efe90b1837be9ed8f4a462b6ed410aa35d00381de89f"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ruzstd"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
@@ -905,9 +933,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",
@@ -916,9 +944,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",
@@ -937,24 +965,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -984,10 +1000,10 @@ 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]]
@@ -1044,13 +1060,15 @@ dependencies = [
[[package]]
name = "twox-hash"
version = "1.6.3"
version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675"
dependencies = [
"cfg-if",
"static_assertions",
]
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"
@@ -1071,16 +1089,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "weezl"
@@ -1094,15 +1145,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"
@@ -1112,82 +1154,18 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
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",
]
[[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 = "zip"
version = "2.6.1"
version = "9.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"flate2",
"indexmap",
"memchr",
"nix",
"typed-path",
"zopfli",
]
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "1.0.0"
version = "1.3.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
+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"]
+98 -92
View File
@@ -1,136 +1,142 @@
<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 and APFS (on XFS only the content comes back, by carving, see below).
- **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**, and reads **QCOW2, VMDK, VHD, VHDX** and **DMG** virtual disks.
- **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.
**Updates:** the desktop app (AppImage, Windows and macOS) checks GitHub for a newer version when it opens; if there is one, a card offers **Update now**: it downloads, verifies the package signature, installs and reopens, so you no longer swap the AppImage by hand. The check is a single request for the release's `latest.json` and nothing of yours is sent; turn it off in **Settings**. Installs from `.deb`/`.rpm` only show the notice, because the package manager owns those files.
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/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.
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256.
## Uso (terminal)
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.
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 health /dev/sdb # saúde do disco (SMART), requer smartmontools
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 (religa o disco mesmo com outro nome)
guara sessions # sessões salvas automaticamente
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.
- **Sessões salvas sozinhas e disco que volta com outro nome:** a varredura é salva automaticamente, pausa se o disco for desconectado e o Guará reconhece o mesmo disco ao reconectar.
- **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 trusted timestamp (RFC 3161) on its signatures. 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).
- **XFS:** on a real XFS (v5, kernel deletion) the inode and its extent list are wiped, so Guará recovers the **content** of files with a recognizable structure by carving, but not their name or folder; reading names needs the file system journal, which is not implemented. The old inode-based path only works on simulated deletions. **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, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, Ed25519 signatures, APFS free map |
| **Next** | Trusted timestamps (RFC 3161), 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.
## Novidades da 1.0.0
Metadados de ext4, HFS+, Btrfs, XFS e APFS; abertura de volumes BitLocker, LUKS e FileVault 2 com senha ou chave; RAID (0/1/4/5/6/10) e LVM remontados, com parâmetros descobertos sozinhos; imagens E01, Ex01 e AFF4; JPEG fragmentado e MP4 sem índice. Detalhes e limitações no [CHANGELOG](CHANGELOG.md).
## Novidades da 0.6.0
- Saúde do disco completa, no estilo do CrystalDiskInfo: estado com % de vida útil, temperatura, horas e vezes ligado, total lido e gravado, ficha do dispositivo (modelo, firmware, interface, modo de transferência, rotação, recursos) e a tabela inteira de atributos SMART (ATA e NVMe).
- Novo comando `guara health` no terminal.
- Nova logo: a cabeça do guará recortada das trilhas de um disco.
## Novidades da 0.5.0
- Visualizador de bancos SQLite com recuperação de linhas apagadas.
- Aviso de saúde do disco (SMART) antes da varredura profunda.
- Notificações do sistema, retomada de recuperação interrompida e mapa de fotos com GPS (opcional).
- Interface em inglês e janela de Configurações.
[MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
+142
View File
@@ -0,0 +1,142 @@
<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**, e lê discos virtuais **QCOW2, VMDK, VHD, VHDX** e **DMG**.
- **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+).
**Atualizações:** o app desktop (AppImage, Windows e macOS) consulta o GitHub ao abrir; havendo versão nova, um cartão oferece **Atualizar agora**: ele baixa, confere a assinatura do pacote, instala e reabre, e você deixa de trocar o AppImage na mão. A consulta é uma única requisição ao `latest.json` da release e nada seu é enviado; desligue em **Configurações**. Instalações por `.deb`/`.rpm` só mostram o aviso, porque quem manda nesses arquivos é o gerenciador de pacotes.
> 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 suas assinaturas não têm carimbo de tempo de terceiros (RFC 3161). 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, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, assinaturas Ed25519, mapa livre do APFS |
| **Próximo** | Carimbo de tempo de terceiros (RFC 3161), 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.
+189
View File
@@ -0,0 +1,189 @@
# 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 (the only thing that touches the network by default is the update check, a single request you can turn off), 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 |
| 1.2.0 | 2026-10-22 | QCOW2, VMDK, VHD, VHDX and DMG virtual disks, hash-chained audit trail, PDF report, APFS free-space map |
| 1.3.0 | 2026-10-23 | Update from inside the app, Ed25519 signatures, differencing QCOW2/VMDK and LZFSE DMG, reference images tested on Linux, macOS and Windows, XFS real-deletion test |
Details of each version in the [CHANGELOG](CHANGELOG.md).
## Current status (1.3.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 work on simulated deletion only. With real kernel deletion (tested in CI) the metadata is wiped: content comes back by carving, names do not. **Journal reading is missing** |
| APFS | 🟡 | Container, object map, free-space map (spaceman and bitmaps), snapshots and old nodes. Deletion tested only in simulation; containers with chunk-info address blocks (large ones) fall back to scanning the whole volume |
| 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: names and folders after real deletion (read the journal) | 🔜 | The CI test exists and is ignored until this lands |
| APFS validation with real deletion | 🔜 | Needs an image from a real Mac |
### 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 | ✅ |
| Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ |
| QCOW2 with a backing file, VMDK snapshots (by name or VirtualBox UUID), DMG with LZFSE | ✅ |
| Password-protected DMG, parent identity check for VHD, VHDX and QCOW2 | 🔜 |
| 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 trail** of every examiner action (who, when, what), chained by hash, with `guara audit --verify` | ✅ |
| **Digital signatures** (Ed25519) of manifests, reports and any file; `guara sign recovery` checks a whole recovery folder | ✅ |
| Trusted timestamp (RFC 3161) on signatures | 🔜 |
| **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
| PDF report (A4, built in) | ✅ |
| **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 reference images on Linux, macOS and Windows | ✅ |
| Update from inside the desktop app (signed packages, one click) | ✅ |
| 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.4: trust and custody**
Trusted timestamps, XFS journal reading, APFS validation with real deletions.
**1.5: analysis**
NSRL and hash lists, search in file content, unified timeline.
**1.6: artifacts**
Windows registry, browsers, EVTX and the most common messaging apps, built on the SQLite reader.
**1.7: more structure**
ReFS and Storage Spaces (once there is a reference image), password-protected DMG, identity check of VHD, VHDX and QCOW2 parents, 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).
+189
View File
@@ -0,0 +1,189 @@
# 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 (a única coisa que usa a rede por padrão é a verificação de atualização, uma requisição só que você pode desligar), 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 |
| 1.2.0 | 2026-10-22 | Discos virtuais QCOW2, VMDK, VHD, VHDX e DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa de espaço livre do APFS |
| 1.3.0 | 2026-10-23 | Atualização pelo próprio app, assinaturas Ed25519, QCOW2/VMDK diferenciais e DMG com LZFSE, imagens de referência testadas em Linux, macOS e Windows, teste de exclusão real do XFS |
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
## Situação atual (1.3.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 só funcionam na exclusão simulada. Com exclusão real do kernel (testada no CI) os metadados são zerados: o conteúdo volta por carving, o nome não. **Falta ler o diário** |
| APFS | 🟡 | Contêiner, mapa de objetos, mapa de espaço livre (spaceman e bitmaps), instantâneos e nós antigos. Exclusão testada só em simulação; contêineres com blocos de endereço de chunk-info (grandes) voltam a varrer o volume inteiro |
| 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 |
| XFS: nomes e pastas após exclusão real (ler o diário) | 🔜 | O teste no CI existe e fica ignorado até isso entrar |
| Validação de APFS com exclusão real | 🔜 | Precisa de imagem de um Mac de verdade |
### 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 | ✅ |
| Contêineres de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG (leitura) | ✅ |
| QCOW2 com arquivo de apoio, snapshots de VMDK (por nome ou UUID do VirtualBox), DMG com LZFSE | ✅ |
| DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2 | 🔜 |
| 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 | ✅ |
| **Trilha de auditoria** de cada ação do perito (quem, quando, o quê), encadeada por hash, com `guara audit --verify` | ✅ |
| **Assinatura digital** (Ed25519) de manifestos, relatórios e qualquer arquivo; `guara sign recovery` confere uma pasta de recuperação inteira | ✅ |
| Carimbo de tempo de terceiros (RFC 3161) nas assinaturas | 🔜 |
| **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
| Relatório em PDF (A4, próprio) | ✅ |
| **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 de referência reais em Linux, macOS e Windows | ✅ |
| Atualização dentro do app desktop (pacotes assinados, um clique) | ✅ |
| 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.4: confiança e custódia**
Carimbo de tempo de terceiros, leitura do diário do XFS, validação de APFS com exclusões reais.
**1.5: análise**
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
**1.6: artefatos**
Registro do Windows, navegadores, EVTX e os apps de mensagem mais comuns, apoiados no leitor de SQLite.
**1.7: mais estrutura**
ReFS e Storage Spaces (quando houver imagem de referência), DMG com senha, conferência da identidade do pai em VHD, VHDX e 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.
+3 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
version = "1.0.0"
version = "1.3.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
@@ -17,6 +17,8 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
guara-core = { path = "../../../crates/guara-core" }
+69 -1
View File
@@ -15,6 +15,73 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json:
.map_err(|e| e.to_string())?
}
/// Versão nova encontrada na release do GitHub.
#[derive(serde::Serialize)]
struct UpdateInfo {
current: String,
version: String,
notes: String,
/// O app consegue se atualizar sozinho (AppImage, Windows e macOS). Instalação por pacote
/// (.deb/.rpm) só avisa e leva à página da release.
auto: bool,
url: String,
}
fn self_update_possible() -> bool {
!cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some()
}
/// Procura uma versão mais nova. `None` = já está na última.
#[tauri::command]
async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> {
use tauri_plugin_updater::UpdaterExt;
let found = app.updater().map_err(|e| e.to_string())?.check().await.map_err(|e| e.to_string())?;
Ok(found.map(|u| UpdateInfo {
current: u.current_version.clone(),
notes: u.body.clone().unwrap_or_default(),
url: format!("https://github.com/lucasrguerra/guara/releases/tag/v{}", u.version),
version: u.version,
auto: self_update_possible(),
}))
}
/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app.
/// O progresso sai no evento `update-progress`.
#[tauri::command]
async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Emitter;
use tauri_plugin_updater::UpdaterExt;
if !self_update_possible() {
return Err("Esta instalação não se atualiza sozinha (pacote do sistema). Baixe a versão nova na página da release.".into());
}
let update = app
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.ok_or("Já está na versão mais recente.")?;
let (a, b) = (app.clone(), app.clone());
let mut got = 0u64;
update
.download_and_install(
move |chunk, total| {
got += chunk as u64;
let _ = a.emit("update-progress", serde_json::json!({ "downloaded": got, "total": total }));
},
move || {
let _ = b.emit("update-progress", serde_json::json!({ "installing": true }));
},
)
.await
.map_err(|e| format!("Não foi possível atualizar: {e}"))?;
// Rodando como administrador, o arquivo novo nasce do root: devolve ao usuário que abriu o app.
if let Some(p) = std::env::var_os("APPIMAGE") {
guara_core::owner::give_to_user(std::path::Path::new(&p));
}
app.restart()
}
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
@@ -155,6 +222,7 @@ fn main() {
let for_protocol = engine.clone();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(State(engine))
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
let eng = for_protocol.clone();
@@ -176,7 +244,7 @@ fn main() {
}
Ok(())
})
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated, update_check, update_install])
.run(tauri::generate_context!())
.expect("erro ao iniciar o Guará");
}
+14 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guara",
"version": "1.0.0",
"version": "1.3.0",
"identifier": "dev.guara.app",
"build": {
"frontendDist": "../ui"
@@ -34,6 +34,18 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK",
"endpoints": [
"https://github.com/lucasrguerra/guara/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
+11
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; }
@@ -516,3 +518,12 @@ dialog.lightbox[open] { display: grid; }
/* 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); }
/* atualização do app */
.update-card { position: fixed; right: 18px; bottom: 18px; z-index: 60; width: min(400px, calc(100vw - 36px)); display: grid; gap: 10px; padding: 16px 18px; border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.update-card .upd-head { display: grid; gap: 2px; }
.update-card .upd-ver { font-size: 12.5px; color: var(--text-2); }
.update-card .upd-url { word-break: break-all; font-size: 12.5px; }
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.update-card .sub { margin: 0; }
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
+71 -5
View File
@@ -2,7 +2,7 @@
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
import { createExplorer } from './explorer.js';
import { setLang, LOC } from './i18n.js';
import { setLang, LOC, currentLang } from './i18n.js';
const tauri = window.__TAURI__;
const params = new URLSearchParams(location.search);
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
// ------------------------------------------------------------------ configurações
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
const SETTINGS = { theme: 'auto', lang: 'auto', notify: true, maps: false };
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
async function saveSettings(patch) {
Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
@@ -467,7 +467,7 @@ async function startImage() {
const base = (S.defaults.home || '') + (S.defaults.sep || '/');
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', value: 'raw', options: [['raw', 'Bruta (.img)'], ['e01', 'Expert Witness (.E01, com hash e metadados)'], ['aff4', 'AFF4 (.aff4)']] },
{ 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: '' },
] });
@@ -667,7 +667,7 @@ async function onProgressClick(e) {
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(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: [
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.', ok: 'Gerar relatório', fields: [
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
{ name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' },
{ name: 'notes', label: 'Observações', multiline: true } ] });
@@ -745,18 +745,81 @@ $('#settingsBtn')?.addEventListener('click', () => {
<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>
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o GitHub para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></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.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); });
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 };
saveSettings(patch);
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
setLang(patch.lang);
patch.ui_lang = currentLang();
saveSettings(patch);
EX?.relang?.();
toast('Salvo');
};
d.showModal();
});
// ------------------------------------------------------------------ atualização do app
// O núcleo do app (Tauri) consulta o `latest.json` da release no GitHub, confere a assinatura do
// pacote com a chave pública embutida, instala e reabre. No navegador (`guara ui`) não há nada a fazer.
let UPD = null;
async function checkUpdate(manual) {
if (!tauri?.core?.invoke) return;
try {
UPD = await tauri.core.invoke('update_check');
renderUpdateCard();
if (manual) toast(UPD ? `Nova versão ${UPD.version} disponível` : 'Você já está na versão mais recente');
} catch (e) {
if (manual) toast(`Não foi possível verificar atualizações: ${e}`);
}
}
function renderUpdateCard(state) {
let el = $('#updateCard');
if (!UPD) { el?.remove(); return; }
if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); }
if (state === 'busy') return;
el.innerHTML = `<div class="upd-head"><b>Nova versão do Guará disponível</b><span class="mono upd-ver">v${esc(UPD.current)} → v${esc(UPD.version)}</span></div>
${UPD.auto ? '' : '<p class="sub">Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:</p><p class="mono upd-url">' + esc(UPD.url) + '</p>'}
<div class="upd-actions">
${UPD.auto ? '<button class="btn primary sm" id="updGo">Atualizar agora</button>' : ''}
${UPD.notes ? '<button class="btn sm" id="updNotes">Novidades</button>' : ''}
<button class="btn sm" id="updLater">Depois</button>
</div>`;
$('#updGo')?.addEventListener('click', installUpdate);
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
$('#updNotes')?.addEventListener('click', () => info({ title: `Guará ${UPD.version}`, html: `<pre class="upd-notes">${esc(UPD.notes)}</pre>` }));
}
async function installUpdate() {
const el = $('#updateCard');
el.innerHTML = `<div class="upd-head"><b>Atualizando o Guará…</b></div>
<div class="bar indet" id="updBar"><i style="width:0"></i></div><p class="sub" id="updMsg">Baixando a atualização…</p>`;
renderUpdateCard('busy');
try {
await tauri.core.invoke('update_install');
} catch (e) {
toast(String(e));
renderUpdateCard();
}
}
function updateProgress(p) {
const bar = $('#updBar'); const msg = $('#updMsg');
if (!bar || !msg) return;
if (p.installing) { bar.classList.add('indet'); msg.textContent = 'Instalando… o Guará vai reabrir sozinho.'; return; }
if (p.total) {
const pct = Math.min(100, Math.round((p.downloaded / p.total) * 100));
bar.classList.remove('indet');
bar.firstElementChild.style.width = `${pct}%`;
msg.textContent = `Baixando… ${pct}%`;
}
}
// ------------------------------------------------------------------ fechar com varredura em andamento
function askClose() {
return new Promise((resolve) => {
@@ -803,7 +866,10 @@ function watchClose() {
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?.('update-progress', (e) => updateProgress(e.payload));
if (SETTINGS.updates !== false) checkUpdate(false);
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>`;
+6 -1
View File
@@ -150,6 +150,9 @@ export function createExplorer(deps) {
: 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(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() {
@@ -591,7 +594,7 @@ export function createExplorer(deps) {
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>`
$('#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(() => {});
@@ -669,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 }); },
};
}
+195 -11
View File
@@ -1,7 +1,6 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
export const EN = {
// saúde do disco (SMART)
'Saúde do disco': 'Disk health',
'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',
@@ -13,7 +12,6 @@ export const EN = {
'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',
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
'Estado': 'Status',
'Bom': 'Good',
'Atenção': 'Caution',
@@ -40,9 +38,6 @@ export const EN = {
'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.',
'Nenhum sinal de defeito encontrado pelo SMART.': 'SMART found no signs of failure.',
'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.',
// barra lateral e estrutura
'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices',
@@ -50,9 +45,7 @@ export const EN = {
'Atualizar lista': 'Refresh list',
'Lista atualizada': 'List refreshed',
'Abrir arquivo de imagem…': 'Open disk image file…',
'Abrir arquivo de imagem': 'Open disk image file',
'Abrir sessão salva…': 'Open saved session…',
'Abrir sessão salva': 'Open saved session',
'Sessões': 'Sessions',
'Sessão': 'Session',
'Sessão:': 'Session:',
@@ -181,7 +174,6 @@ export const EN = {
'Por assinatura': 'By signature',
'Sem data': 'No date',
'sem data': 'no date',
'Confiança {n}': 'Confidence {n}',
'Com localização (GPS)': 'With location (GPS)',
'Com GPS': 'With GPS',
'Ocultar duplicadas': 'Hide duplicates',
@@ -308,7 +300,6 @@ export const EN = {
// recuperar
'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover',
'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.': 'Choose a folder on <b>another disk</b>. Guará never writes to the source disk, so it does not overwrite what can still be saved.',
'Pasta de destino': 'Destination folder',
'Escolher…': 'Choose…',
'Organizar os arquivos': 'Organize the files',
@@ -329,7 +320,7 @@ export const EN = {
'{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.',
'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.': 'Generates a print-ready report with the examined source, the findings and the hashes of recovered files. End the name in .pdf for a PDF; with .html you get a web page.',
'Salvar em': 'Save to',
'Número do caso': 'Case number',
'Responsável pelo exame': 'Examiner',
@@ -346,7 +337,6 @@ export const EN = {
'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
'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.': 'Where to save the image file? Use <b>another disk</b> with enough space and a name that does not exist yet.',
'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes',
@@ -406,4 +396,198 @@ export const EN = {
'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.',
'Nova versão do Guará disponível': 'New Guará version available',
'Atualizar agora': 'Update now',
'Novidades': 'What\'s new',
'Depois': 'Later',
'Atualizando o Guará…': 'Updating Guará…',
'Baixando a atualização…': 'Downloading the update…',
'Baixando… {n}%': 'Downloading… {n}%',
'Instalando… o Guará vai reabrir sozinho.': 'Installing… Guará will reopen by itself.',
'Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:': 'This install comes from a system package and cannot update itself. Download the new version from the release page:',
'Atualizações automáticas': 'Automatic updates',
'Ao abrir, consulta o GitHub para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks GitHub for a new version (one request, nothing of yours is sent)',
'Verificar agora': 'Check now',
'Nova versão {n} disponível': 'New version {n} available',
'Você já está na versão mais recente': 'You are already on the latest version',
'Não foi possível verificar atualizações: {n}': 'Could not check for updates: {n}',
};
+1
View File
@@ -7,6 +7,7 @@ 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) {
+1 -1
View File
@@ -16,6 +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"
+289 -45
View File
@@ -18,6 +18,45 @@ struct Cli {
cmd: Cmd,
}
#[derive(Subcommand)]
enum SignCmd {
/// Cria o par de chaves de assinatura (recusa sobrescrever uma chave existente)
Keygen {
/// Onde gravar a chave privada (padrão: assinatura.key na pasta de configuração ou GUARA_SIGNING_KEY)
#[arg(long)]
key: Option<String>,
},
/// Assina um arquivo e grava <arquivo>.assinatura.json
File {
path: String,
#[arg(long)]
key: Option<String>,
/// Nome de quem assina (padrão: GUARA_EXAMINER)
#[arg(long)]
signer: Option<String>,
},
/// Confere a assinatura de um arquivo
Verify {
path: String,
/// Arquivo de assinatura (padrão: <arquivo>.assinatura.json)
#[arg(long)]
sig: Option<String>,
/// Impressão digital esperada da chave, publicada pelo perito
#[arg(long)]
trust: Option<String>,
#[arg(long)]
json: bool,
},
/// Confere uma pasta de recuperação: assinatura do manifesto e hash de cada arquivo
Recovery {
dir: String,
#[arg(long)]
trust: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum Cmd {
/// Lista discos e pendrives detectados
@@ -88,6 +127,23 @@ enum Cmd {
},
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
Verify { path: String },
/// Assina e confere arquivos (manifestos, relatórios) e pastas de recuperação com Ed25519
#[command(subcommand)]
Sign(SignCmd),
/// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash
Audit {
/// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG)
#[arg(long)]
log: Option<String>,
/// Só confere a cadeia de hashes; sai com erro se ela estiver quebrada
#[arg(long)]
verify: bool,
/// Mostra só as últimas N entradas
#[arg(long)]
tail: Option<usize>,
#[arg(long)]
json: bool,
},
/// Exporta os achados de uma sessão para CSV (abre no Excel)
Export {
session: String,
@@ -96,10 +152,10 @@ enum Cmd {
#[command(flatten)]
filter: FilterArgs,
},
/// Gera o relatório HTML de recuperação (identificação do caso, origem, achados, hashes)
/// Gera o relatório de recuperação (identificação do caso, origem, achados, hashes): PDF se o arquivo terminar em .pdf, senão HTML
Report {
session: String,
/// Arquivo HTML de saída
/// Arquivo de saída (.pdf ou .html)
out: String,
#[arg(long, default_value = "")]
case: String,
@@ -427,52 +483,21 @@ fn run(cli: Cli) -> Result<(), String> {
eng.save_session(&job, &session).map_err(s)?;
Ok(())
}
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)"
}
let r = verify_image(&path);
guara_core::audit::record(
"verify",
json!({ "path": path, "result": match &r { Ok(Some(true)) => "conferem", Ok(Some(false)) => "diferem", Ok(None) => "sem hash gravado", Err(_) => "erro" } }),
);
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(())
}
}
r.map(|_| ())
}
Cmd::Audit {
log,
verify,
tail,
json,
} => audit(log, verify, tail, json),
Cmd::Sign(c) => sign_cmd(c),
Cmd::Image {
path,
dest,
@@ -790,6 +815,225 @@ fn recover_job(
Ok(())
}
/// Confere o hash gravado numa imagem E01/Ex01 ou AFF4. `Some(true)` = confere.
fn verify_image(path: &str) -> Result<Option<bool>, String> {
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...");
return match a.verify_stream_md5().map_err(s)? {
Some(true) => {
println!("MD5 gravado e recalculado conferem.");
Ok(Some(true))
}
Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()),
None => {
println!("Esta imagem não traz MD5 para conferir.");
Ok(None)
}
};
}
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(true))
}
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(None)
}
}
}
/// `guara audit`: lista a trilha ou confere a cadeia de hashes.
fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) -> Result<(), String> {
let log = match log {
Some(p) => guara_core::audit::Log::at(p),
None => guara_core::audit::Log::open_default(),
};
let v = log.verify().map_err(s)?;
if verify {
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap());
} else if v.ok {
println!("Cadeia íntegra: {} entradas.", v.entries);
if v.entries > 0 {
println!(
"Hash da última entrada (guarde-o fora desta máquina): {}",
v.head
);
}
}
return match v.broken_at {
None => Ok(()),
Some((n, why)) => Err(format!("cadeia quebrada na entrada {n}: {why}")),
};
}
let mut entries = log.entries().map_err(s)?;
if let Some(n) = tail {
let skip = entries.len().saturating_sub(n);
entries.drain(..skip);
}
if json {
println!("{}", serde_json::to_string_pretty(&entries).unwrap());
} else if entries.is_empty() {
println!("Trilha vazia ({}).", log.path().display());
} else {
for e in &entries {
let who = match &e.examiner {
Some(x) => format!("{}/{x}", e.user),
None => e.user.clone(),
};
println!(
"#{:<4} {} {who}@{} {} {}",
e.seq, e.time, e.host, e.action, e.detail
);
}
}
if !v.ok {
let (n, why) = v.broken_at.unwrap_or_default();
eprintln!(
"{} cadeia quebrada na entrada {n}: {why}",
paint("31", "atenção:")
);
}
Ok(())
}
/// `guara sign`: chaves, assinatura e conferência.
fn sign_cmd(c: SignCmd) -> Result<(), String> {
use guara_core::sign;
use std::path::{Path, PathBuf};
let key_path = |k: Option<String>| k.map(PathBuf::from).unwrap_or_else(sign::default_key_path);
match c {
SignCmd::Keygen { key } => {
let p = key_path(key);
let k = sign::SigningKey::generate();
k.save(&p).map_err(s)?;
guara_core::audit::record("sign.keygen", json!({ "fingerprint": k.fingerprint() }));
println!(
"Chave privada: {} (guarde uma cópia segura; quem a tem assina em seu nome)",
p.display()
);
println!("Chave pública: {}", p.with_extension("pub").display());
println!("Impressão digital: {}", k.fingerprint());
println!("Publique a impressão digital fora desta máquina para que outros confiram as assinaturas.");
Ok(())
}
SignCmd::File { path, key, signer } => {
let p = key_path(key);
let k = sign::SigningKey::load(&p).map_err(s)?;
let who = signer.or_else(|| std::env::var("GUARA_EXAMINER").ok());
let out = sign::sign_file(&k, Path::new(&path), who.as_deref()).map_err(s)?;
guara_core::audit::record(
"sign",
json!({ "path": path, "signature": out.to_string_lossy() }),
);
println!("Assinatura gravada em {}", out.display());
println!("Impressão digital da chave: {}", k.fingerprint());
Ok(())
}
SignCmd::Verify {
path,
sig,
trust,
json,
} => {
let c = sign::verify_file(
Path::new(&path),
sig.as_deref().map(Path::new),
trust.as_deref(),
)
.map_err(s)?;
guara_core::audit::record("sign.verify", json!({ "path": path, "valid": c.valid }));
if json {
println!("{}", serde_json::to_string_pretty(&c).unwrap());
} else if c.valid {
println!("Assinatura válida: o arquivo não mudou desde que foi assinado.");
println!(
"Chave: {} · assinado em {}{}",
c.fingerprint,
c.signed_at,
c.signer
.as_ref()
.map(|s| format!(" por {s}"))
.unwrap_or_default()
);
if c.trusted.is_none() {
println!(
"Confira a impressão digital acima com a que o perito publicou (--trust)."
);
}
}
if c.valid {
Ok(())
} else {
Err(c.problems.join("; "))
}
}
SignCmd::Recovery { dir, trust, json } => {
let c = sign::verify_recovery(Path::new(&dir), trust.as_deref()).map_err(s)?;
guara_core::audit::record(
"sign.verify_recovery",
json!({ "dir": dir, "valid": c.valid, "files": c.files }),
);
if json {
println!("{}", serde_json::to_string_pretty(&c).unwrap());
} else {
println!(
"{} de {} arquivo(s) conferem com o manifesto.",
c.ok, c.files
);
for f in &c.missing {
println!(" ausente: {f}");
}
for f in &c.changed {
println!(" alterado: {f}");
}
match &c.manifest_signature {
Some(m) if m.valid => {
println!("Manifesto assinado e íntegro. Chave: {}", m.fingerprint)
}
Some(m) => println!(
"Assinatura do manifesto com problema: {}",
m.problems.join("; ")
),
None => println!("Manifesto sem assinatura (assine com guara sign file)."),
}
}
if c.valid {
Ok(())
} else {
Err("a recuperação não confere".into())
}
}
}
}
fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String> {
let interrupted = Arc::new(AtomicBool::new(false));
let flag = interrupted.clone();
+1 -1
View File
@@ -26,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}"))
+15 -13
View File
@@ -10,31 +10,33 @@ 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"
jpeg-encoder = "0.7"
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 = "2", default-features = false, features = ["deflate"] }
zip = { version = "9", default-features = false, features = ["deflate"] }
snap = "1"
ruzstd = "0.7"
lz4_flex = { version = "0.11", default-features = false, features = ["safe-decode", "safe-encode"] }
aes = "0.8"
hmac = "0.12"
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
ripemd = "0.1"
argon2 = "0.5"
ruzstd = "0.9"
lz4_flex = { version = "0.14", default-features = false, features = ["alloc", "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"
ed25519-compact = { version = "2", default-features = false, features = ["random", "std"] }
lzfse_rust = "0.2.1"
[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"
+6 -2
View File
@@ -298,10 +298,14 @@ impl Aff4Source {
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(
f.name().to_string(),
name,
Loc {
data_start: f.data_start(),
data_start,
size: f.compressed_size(),
},
);
+324
View File
@@ -0,0 +1,324 @@
//! Trilha de auditoria: cada ação do examinador (abrir uma origem, varrer, recuperar, criar
//! imagem, exportar) vira uma linha JSON com quem, quando e o quê.
//!
//! As linhas são encadeadas por hash: o `hash` de cada uma é o SHA-256 do `hash` da anterior
//! seguido do corpo da própria linha. Alterar ou apagar uma linha do meio quebra a cadeia e
//! `guara audit --verify` aponta onde. Cortar o fim do arquivo não é detectável sozinho; para
//! isso, guarde o hash da última entrada (a "cabeça") fora da máquina, por exemplo no relatório.
//!
//! O arquivo fica na pasta de configuração (`auditoria.jsonl`) ou onde `GUARA_AUDIT_LOG`
//! apontar. Segredos (senhas, chaves) nunca entram no registro.
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[derive(Serialize)]
struct Body<'a> {
seq: u64,
time: &'a str,
user: &'a str,
host: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
examiner: Option<&'a str>,
version: &'a str,
action: &'a str,
detail: &'a Value,
prev: &'a str,
}
/// Uma linha da trilha.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub seq: u64,
/// UTC, ISO 8601.
pub time: String,
pub user: String,
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub examiner: Option<String>,
pub version: String,
pub action: String,
pub detail: Value,
pub prev: String,
pub hash: String,
}
/// Resultado de conferir a cadeia.
#[derive(Debug, Clone, Serialize)]
pub struct Verification {
pub entries: usize,
pub ok: bool,
/// Entrada (1, 2, ...) onde a cadeia quebra e o motivo.
pub broken_at: Option<(u64, String)>,
/// Hash da última entrada; guarde-o fora da máquina para detectar truncamento.
pub head: String,
}
pub struct Log {
path: PathBuf,
}
fn sha_hex(prev: &str, body: &str) -> String {
let mut h = Sha256::new();
h.update(prev.as_bytes());
h.update(b"\n");
h.update(body.as_bytes());
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
/// UTC em ISO 8601 a partir de segundos Unix (conversão civil de Howard Hinnant).
pub fn utc_iso(secs: i64) -> String {
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}Z",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
fn now_iso() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
utc_iso(secs)
}
fn env(k: &str) -> Option<String> {
std::env::var(k).ok().filter(|v| !v.is_empty())
}
/// Usuário do sistema por trás da ação (o que chamou sudo, quando houver).
fn current_user() -> String {
env("SUDO_USER")
.or_else(|| env("USER"))
.or_else(|| env("USERNAME"))
.or_else(|| env("LOGNAME"))
.unwrap_or_else(|| "?".into())
}
fn host_name() -> String {
env("HOSTNAME")
.or_else(|| env("COMPUTERNAME"))
.or_else(|| {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| "?".into())
}
/// Última linha não vazia do arquivo, lendo só o fim.
fn last_line(path: &Path) -> std::io::Result<Option<String>> {
let mut f = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let len = f.metadata()?.len();
let mut want = 8192u64;
loop {
let start = len.saturating_sub(want);
f.seek(SeekFrom::Start(start))?;
let mut buf = vec![];
f.read_to_end(&mut buf)?;
let text = String::from_utf8_lossy(&buf);
let trimmed = text.trim_end();
match trimmed.rfind('\n') {
Some(i) => return Ok(Some(trimmed[i + 1..].to_string())),
None if start == 0 => {
return Ok((!trimmed.is_empty()).then(|| trimmed.to_string()));
}
None => want *= 4,
}
}
}
impl Log {
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
/// `GUARA_AUDIT_LOG` ou `auditoria.jsonl` na pasta de configuração.
pub fn default_path() -> PathBuf {
match env("GUARA_AUDIT_LOG") {
Some(p) => PathBuf::from(p),
None => crate::settings::config_dir().join("auditoria.jsonl"),
}
}
pub fn open_default() -> Self {
Self::at(Self::default_path())
}
pub fn path(&self) -> &Path {
&self.path
}
/// Acrescenta uma entrada ao fim da cadeia.
pub fn append(&self, action: &str, detail: Value) -> Result<Entry> {
let (seq, prev) = match last_line(&self.path)? {
None => (1, GENESIS.to_string()),
Some(l) => {
let e: Entry = serde_json::from_str(&l)
.map_err(|e| Error::msg(format!("última linha da trilha inválida: {e}")))?;
(e.seq + 1, e.hash)
}
};
let (time, user, host) = (now_iso(), current_user(), host_name());
let examiner = env("GUARA_EXAMINER");
let version = env!("CARGO_PKG_VERSION");
let body = serde_json::to_string(&Body {
seq,
time: &time,
user: &user,
host: &host,
examiner: examiner.as_deref(),
version,
action,
detail: &detail,
prev: &prev,
})?;
let hash = sha_hex(&prev, &body);
let line = format!("{},\"hash\":\"{hash}\"}}\n", &body[..body.len() - 1]);
if let Some(dir) = self.path.parent() {
crate::owner::create_dir_all_owned(dir)?;
}
let existed = self.path.exists();
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
f.write_all(line.as_bytes())?;
f.flush()?;
if !existed {
crate::owner::give_to_user(&self.path);
}
Ok(Entry {
seq,
time,
user,
host,
examiner,
version: version.into(),
action: action.into(),
detail,
prev,
hash,
})
}
/// Todas as entradas, na ordem. Linha ilegível vira erro (use [`Log::verify`] para saber onde).
pub fn entries(&self) -> Result<Vec<Entry>> {
let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e.into()),
};
text.lines()
.filter(|l| !l.trim().is_empty())
.enumerate()
.map(|(i, l)| {
serde_json::from_str(l)
.map_err(|e| Error::msg(format!("linha {} da trilha inválida: {e}", i + 1)))
})
.collect()
}
/// Confere a numeração, o encadeamento e o hash de cada linha.
pub fn verify(&self) -> Result<Verification> {
let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let mut prev = GENESIS.to_string();
let mut n = 0usize;
let broken = |n: usize, why: &str, entries: usize, head: &str| Verification {
entries,
ok: false,
broken_at: Some((n as u64, why.to_string())),
head: head.to_string(),
};
for line in text.lines().filter(|l| !l.trim().is_empty()) {
n += 1;
let Ok(e) = serde_json::from_str::<Entry>(line) else {
return Ok(broken(n, "linha ilegível", n - 1, &prev));
};
if e.seq != n as u64 {
return Ok(broken(
n,
"numeração fora de ordem (entrada removida ou inserida)",
n - 1,
&prev,
));
}
if e.prev != prev {
return Ok(broken(n, "não continua a entrada anterior", n - 1, &prev));
}
let Some(i) = line.rfind(",\"hash\":\"") else {
return Ok(broken(n, "linha sem hash", n - 1, &prev));
};
let body = format!("{}}}", &line[..i]);
if sha_hex(&prev, &body) != e.hash {
return Ok(broken(
n,
"conteúdo alterado (o hash não confere)",
n - 1,
&prev,
));
}
prev = e.hash;
}
Ok(Verification {
entries: n,
ok: true,
broken_at: None,
head: prev,
})
}
}
/// Registra uma ação na trilha padrão. Falha ao gravar não interrompe a operação, mas é
/// avisada no stderr: uma ação sem registro precisa ser notada.
pub fn record(action: &str, detail: Value) {
if let Err(e) = Log::open_default().append(action, detail) {
eprintln!("aviso: não foi possível gravar a trilha de auditoria ({action}): {e}");
}
}
/// Atalho para detalhes de um caminho de origem.
pub fn path_detail(path: &str) -> Value {
json!({ "path": path })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso_de_datas_conhecidas() {
assert_eq!(utc_iso(0), "1970-01-01T00:00:00Z");
assert_eq!(utc_iso(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(utc_iso(1_792_000_000 + 3661), "2026-10-14T18:47:41Z");
}
}
+3 -4
View File
@@ -9,8 +9,7 @@ pub mod filevault;
pub mod luks;
use crate::source::Source;
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
use aes::cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use aes::{Aes128, Aes192, Aes256};
use std::io;
use std::sync::Arc;
@@ -33,7 +32,7 @@ impl AesAny {
})
}
pub fn encrypt(&self, b: &mut [u8; 16]) {
let g = GenericArray::from_mut_slice(b);
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),
@@ -41,7 +40,7 @@ impl AesAny {
}
}
pub fn decrypt(&self, b: &mut [u8; 16]) {
let g = GenericArray::from_mut_slice(b);
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),
+123 -1
View File
@@ -3,7 +3,7 @@
//! antigos da árvore (copy-on-write) que ainda estão no disco. Volumes cifrados (FileVault) são
//! detectados e deixados para a varredura por assinatura.
use super::{make_found, Ctx, NewFound, VolumeScan};
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -27,6 +27,9 @@ fn le64(b: &[u8], o: usize) -> u64 {
const OBJ_TYPE_MASK: u32 = 0xFFFF;
const T_BTREE_ROOT: u32 = 2;
const T_BTREE_NODE: u32 = 3;
const T_SPACEMAN: u32 = 5;
const T_CIB: u32 = 7;
const T_CHECKPOINT_MAP: u32 = 0x0C;
const SUBTYPE_FSTREE: u32 = 0x0E;
// tipos de registro da árvore de arquivos
@@ -170,6 +173,112 @@ impl<'a> Apfs<'a> {
}
m
}
/// Endereço físico de um objeto ephemeral (spaceman), achado no mapa de checkpoint mais novo.
fn ephemeral_paddr(&self, nx: &[u8], oid: u64) -> Option<u64> {
let desc_blocks = le32(nx, 104) as u64;
let desc_base = le64(nx, 112);
let max_entries = (self.bs as usize).saturating_sub(40) / 40;
let mut best: Option<(u64, u64)> = None;
for i in 0..desc_blocks {
let Some(b) = self.checked_block(desc_base + i) else {
continue;
};
if le32(&b, 24) & OBJ_TYPE_MASK != T_CHECKPOINT_MAP {
continue;
}
let xid = le64(&b, 16);
if best.is_some_and(|(x, _)| x >= xid) {
continue;
}
let count = (le32(&b, 36) as usize).min(max_entries);
for j in 0..count {
let o = 40 + j * 40;
if le64(&b, o + 24) == oid {
best = Some((xid, le64(&b, o + 32)));
break;
}
}
}
best.map(|(_, p)| p)
}
/// Mapa de espaço livre do contêiner: percorre o spaceman, os chunk-info blocks e os bitmaps
/// (bit 1 = bloco em uso).
fn free_map(&self, nx: &[u8]) -> Option<Vec<Extent>> {
let sm_oid = le64(nx, 152);
if sm_oid == 0 {
return None;
}
let sm = self.checked_block(self.ephemeral_paddr(nx, sm_oid)?)?;
if le32(&sm, 24) & OBJ_TYPE_MASK != T_SPACEMAN {
return None;
}
// Com CABs (contêineres grandes) os endereços dos CIBs não ficam no spaceman.
if le32(&sm, 68) != 0 {
return None;
}
let cib_count = le32(&sm, 64) as usize;
let addr_off = le32(&sm, 80) as usize;
let end = addr_off.checked_add(cib_count.checked_mul(8)?)?;
if cib_count == 0 || end > sm.len() {
return None;
}
let per_cib = (self.bs as usize).saturating_sub(40) / 32;
let mut free = vec![];
for ci in 0..cib_count {
let cp = le64(&sm, addr_off + ci * 8);
let Some(cib) = self.checked_block(cp) else {
continue;
};
if le32(&cib, 24) & OBJ_TYPE_MASK != T_CIB {
continue;
}
let count = (le32(&cib, 36) as usize).min(per_cib);
for j in 0..count {
let o = 40 + j * 32;
let addr = le64(&cib, o + 8);
let n = le32(&cib, o + 16) as u64;
let bm = le64(&cib, o + 24);
if n == 0 || addr >= self.blocks {
continue;
}
let n = n.min(self.blocks - addr);
if bm == 0 {
free.push(Extent::new(self.base + addr * self.bs, n * self.bs));
continue;
}
let Some(b) = self.block(bm) else {
continue;
};
let mut run: Option<u64> = None;
for k in 0..n {
let used = b
.get((k / 8) as usize)
.map(|x| x >> (k % 8) & 1 == 1)
.unwrap_or(true);
match (used, run) {
(false, None) => run = Some(k),
(true, Some(s)) => {
free.push(Extent::new(
self.base + (addr + s) * self.bs,
(k - s) * self.bs,
));
run = None;
}
_ => {}
}
}
if let Some(s) = run {
free.push(Extent::new(
self.base + (addr + s) * self.bs,
(n - s) * self.bs,
));
}
}
}
Some(merge_extents(free))
}
}
fn lookup(m: &HashMap<u64, Vec<(u64, u64)>>, oid: u64, max_xid: u64) -> Option<u64> {
@@ -399,6 +508,13 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
return res;
};
let _ = cont_omap_block;
res.free = a.free_map(&nx);
if res.free.is_none() {
res.warnings.push(
"APFS: não foi possível ler o mapa de espaço livre; a varredura profunda usará todo o volume."
.into(),
);
}
let container_omap = a.omap(le64(&nx, 160));
let max_fs = le32(&nx, 180).min(100) as usize;
let mut vols: Vec<Volume> = vec![];
@@ -610,6 +726,12 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
let _ = res;
}
/// Trechos livres (absolutos) do contêiner, segundo o spaceman.
pub fn free_extents(src: &dyn Source, base: u64) -> Option<Vec<Extent>> {
let (a, nx) = open_container(src, base)?;
a.free_map(&nx)
}
/// Arquivos e pastas atuais do primeiro volume sem cifragem (caminho, tamanho, é pasta).
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some((a, nx)) = open_container(src, base) else {
+1 -1
View File
@@ -723,7 +723,7 @@ fn decode(codec: u8, raw: &[u8], ram: usize) -> Option<Vec<u8>> {
}
2 => {
use std::io::Read;
let mut dec = ruzstd::StreamingDecoder::new(raw).ok()?;
let mut dec = ruzstd::decoding::StreamingDecoder::new(raw).ok()?;
let mut v = Vec::with_capacity(ram);
dec.read_to_end(&mut v).ok()?;
Some(v)
+4
View File
@@ -12,6 +12,7 @@
pub mod aff4;
pub mod assemble;
pub mod audit;
pub mod carve;
pub mod crypt;
pub mod defrag;
@@ -33,6 +34,7 @@ pub mod mp4rebuild;
pub mod notify;
pub mod owner;
pub mod partition;
pub mod pdf;
pub mod raid;
pub mod raid_detect;
pub mod reconnect;
@@ -42,11 +44,13 @@ pub mod scan;
pub mod search;
pub mod service;
pub mod settings;
pub mod sign;
pub mod signatures;
pub mod source;
pub mod sqlite;
pub mod thumb;
pub mod types;
pub mod vdisk;
pub mod volume;
pub use error::{Error, Result};
+28 -18
View File
@@ -444,11 +444,11 @@ fn exif_epoch(s: &str) -> Option<i64> {
Some(crate::fs::days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se)
}
/// "2023:07:14 18:22:05" -> "14/07/2023 18:22:05"
/// "2023:07:14 18:22:05" -> "2023-07-14T18:22:05" (a interface formata por idioma)
fn exif_date(s: &str) -> String {
let b = s.as_bytes();
if b.len() >= 19 && b[4] == b':' && b[7] == b':' {
format!("{}/{}/{} {}", &s[8..10], &s[5..7], &s[0..4], &s[11..19])
format!("{}-{}-{}T{}", &s[0..4], &s[5..7], &s[8..10], &s[11..19])
} else {
s.to_string()
}
@@ -613,17 +613,17 @@ fn pdf_text(b: &[u8]) -> String {
b.iter().map(|&c| c as char).collect()
}
/// "D:20230714182205-03'00'" -> "14/07/2023 18:22:05"
/// "D:20230714182205-03'00'" -> "2023-07-14T18:22:05" (a interface formata por idioma)
fn pdf_date(s: &str) -> String {
let t = s.trim_start_matches("D:");
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.len() >= 8 {
let g = |a: usize, b: usize| digits.get(a..b).unwrap_or("00");
return format!(
"{}/{}/{} {}:{}:{}",
g(6, 8),
g(4, 6),
"{}-{}-{}T{}:{}:{}",
g(0, 4),
g(4, 6),
g(6, 8),
g(8, 10),
g(10, 12),
g(12, 14)
@@ -733,21 +733,18 @@ fn unescape(s: &str) -> String {
.replace("&amp;", "&")
}
/// "2023-07-14T18:22:05Z" -> "14/07/2023 18:22:05 (UTC)"
/// Datas ISO 8601 ficam como estão; a interface as formata no idioma escolhido.
fn iso_date(s: &str) -> String {
let b = s.as_bytes();
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' {
let utc = if s.ends_with('Z') { " (UTC)" } else { "" };
return format!(
"{}/{}/{} {}{}",
&s[8..10],
&s[5..7],
&s[0..4],
&s[11..19],
utc
);
let mut out = s[..19].to_string();
if s.len() == 20 && s.ends_with('Z') {
out.push('Z');
}
out
} else {
s.to_string()
}
s.to_string()
}
fn ooxml(fr: &Fr, m: &mut Meta) {
@@ -890,7 +887,7 @@ fn moov(d: &[u8], m: &mut Meta) {
if created > EPOCH_1904 {
m.add(
"Gravado em (metadado)",
fmt_unix((created - EPOCH_1904) as i64),
iso_unix((created - EPOCH_1904) as i64),
);
}
if ts > 0 && dur > 0 {
@@ -919,6 +916,19 @@ fn moov(d: &[u8], m: &mut Meta) {
}
}
/// Instante em ISO 8601 UTC ("2023-07-14T18:22:05Z"), para a interface formatar.
pub fn iso_unix(t: i64) -> String {
let days = t.div_euclid(86400);
let rem = t.rem_euclid(86400);
let (y, mo, d) = civil_from_days(days);
format!(
"{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
pub fn fmt_unix(t: i64) -> String {
let days = t.div_euclid(86400);
let rem = t.rem_euclid(86400);
+52 -2
View File
@@ -3,9 +3,59 @@
use std::process::{Command, Stdio};
/// Avisos que o núcleo envia ao sistema operacional. O texto é montado no
/// idioma efetivo da interface (`settings::ui_lang`), porque notificações não
/// passam pelo dicionário do DOM.
pub enum Notice {
ScanDone { count: usize, target: String },
RecoverDone { count: usize, destination: String },
DiskLost,
}
impl Notice {
fn text(&self, lang: &str) -> (String, String) {
let en = lang == "en";
match self {
Notice::ScanDone { count, target } => (
"Guará: scan complete".into(),
if en {
format!("{count} file(s) found in {target}.")
} else {
format!("{count} arquivo(s) encontrados em {target}.")
},
),
Notice::RecoverDone { count, destination } => (
if en {
"Guará: recovery complete".into()
} else {
"Guará: recuperação concluída".into()
},
if en {
format!("{count} file(s) saved to {destination}")
} else {
format!("{count} arquivo(s) salvos em {destination}")
},
),
Notice::DiskLost => (
if en {
"Guará: disk disconnected?".into()
} else {
"Guará: disco desconectado?".into()
},
if en {
"The scan was paused without losing anything. Reconnect the disk and continue."
.into()
} else {
"A varredura foi pausada sem perder nada. Reconecte o disco e continue.".into()
},
),
}
}
}
/// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
pub fn send(title: &str, body: &str) {
let (title, body) = (title.to_string(), body.to_string());
pub fn send(notice: Notice) {
let (title, body) = notice.text(&crate::settings::ui_lang());
std::thread::spawn(move || {
if let Some(mut c) = command(&title, &body) {
if let Ok(mut child) = c
+1 -1
View File
@@ -261,7 +261,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
}
if p.fs == FsKind::Apfs {
layout.warnings.push(format!(
"Partição {} é APFS: leitura de metadados ainda não suportada; usando apenas varredura por assinatura. Se estiver cifrada (FileVault), nada será encontrado.",
"Partição {} é APFS: o mapa de espaço livre é lido do spaceman; se o volume estiver cifrado (FileVault), nada será encontrado sem a chave.",
p.index + 1
));
}
+484
View File
@@ -0,0 +1,484 @@
//! Gerador mínimo de PDF (A4, texto e tabelas) para o relatório de recuperação.
//!
//! Usa só as fontes padrão do PDF (Helvetica, Helvetica-Bold e Courier) com codificação
//! WinAnsi, então o arquivo é autocontido e não depende de fontes instaladas. Caracteres fora
//! do Latin-1/WinAnsi viram `?`.
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
const W: f64 = 595.28;
const H: f64 = 841.89;
const MARGIN: f64 = 40.0;
#[derive(Clone, Copy, PartialEq)]
pub enum Font {
Regular,
Bold,
Mono,
}
impl Font {
fn res(self) -> &'static str {
match self {
Font::Regular => "F1",
Font::Bold => "F2",
Font::Mono => "F3",
}
}
}
// Larguras AFM (milésimos de em) dos caracteres ASCII 32..=126.
const HELV: [u16; 95] = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667,
611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500,
222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
];
const HELV_B: [u16; 95] = [
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667,
611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556,
278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
];
/// Letra base de um caractere acentuado do Latin-1 (a largura é a da letra sem acento).
fn base_letter(c: char) -> char {
match c {
'À'..='Å' => 'A',
'Ç' => 'C',
'È'..='Ë' => 'E',
'Ì'..='Ï' => 'I',
'Ñ' => 'N',
'Ò'..='Ö' | 'Ø' => 'O',
'Ù'..='Ü' => 'U',
'Ý' => 'Y',
'à'..='å' => 'a',
'ç' => 'c',
'è'..='ë' => 'e',
'ì'..='ï' => 'i',
'ñ' => 'n',
'ò'..='ö' | 'ø' => 'o',
'ù'..='ü' => 'u',
'ý' | 'ÿ' => 'y',
_ => '?',
}
}
fn char_width(c: char, f: Font) -> f64 {
if f == Font::Mono {
return 0.6;
}
let t = if f == Font::Bold { &HELV_B } else { &HELV };
let c = if (' '..='~').contains(&c) {
c
} else {
base_letter(c)
};
match c {
' '..='~' => t[c as usize - 32] as f64 / 1000.0,
_ => 0.556,
}
}
pub fn text_width(s: &str, size: f64, f: Font) -> f64 {
s.chars().map(|c| char_width(c, f)).sum::<f64>() * size
}
/// Byte WinAnsi de um caractere (ou `?`).
fn win_ansi(c: char) -> u8 {
let n = c as u32;
match c {
'€' => 0x80,
'‘' => 0x91,
'’' => 0x92,
'“' => 0x93,
'”' => 0x94,
'•' => 0x95,
'–' => 0x96,
'—' => 0x97,
'…' => 0x85,
_ if (0x20..=0x7E).contains(&n) || (0xA0..=0xFF).contains(&n) => n as u8,
_ => b'?',
}
}
fn escape(s: &str) -> String {
let mut o = String::new();
for c in s.chars() {
match win_ansi(c) {
b'(' => o.push_str("\\("),
b')' => o.push_str("\\)"),
b'\\' => o.push_str("\\\\"),
b if (0x20..0x7F).contains(&b) => o.push(b as char),
b => o.push_str(&format!("\\{b:03o}")),
}
}
o
}
/// Quebra `s` em linhas de no máximo `width` pontos. Sem espaço onde quebrar (caminhos,
/// hashes), corta no meio da palavra.
pub fn wrap(s: &str, size: f64, f: Font, width: f64) -> Vec<String> {
let mut lines = vec![];
for para in s.split('\n') {
let mut cur = String::new();
let mut cur_w = 0.0;
for word in para.split(' ') {
let ww = text_width(word, size, f);
let sp = if cur.is_empty() {
0.0
} else {
char_width(' ', f) * size
};
if cur_w + sp + ww <= width {
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
cur_w += sp + ww;
continue;
}
if !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
cur_w = 0.0;
}
if ww <= width {
cur = word.to_string();
cur_w = ww;
} else {
for c in word.chars() {
let cw = char_width(c, f) * size;
if cur_w + cw > width && !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
cur_w = 0.0;
}
cur.push(c);
cur_w += cw;
}
}
}
lines.push(cur);
}
lines
}
pub struct Col {
pub title: &'static str,
pub width: f64,
pub font: Font,
pub right: bool,
}
pub struct Pdf {
title: String,
footer: String,
pages: Vec<String>,
cur: String,
y: f64,
}
type Rgb = (f64, f64, f64);
pub const TEAL: Rgb = (0.059, 0.463, 0.431);
pub const GRAY: Rgb = (0.36, 0.43, 0.42);
pub const BLACK: Rgb = (0.06, 0.12, 0.12);
impl Pdf {
pub fn new(title: &str, footer: &str) -> Self {
Self {
title: title.into(),
footer: footer.into(),
pages: vec![],
cur: String::new(),
y: H - MARGIN,
}
}
pub fn content_width() -> f64 {
W - 2.0 * MARGIN
}
fn new_page(&mut self) {
if !self.cur.is_empty() || self.y != H - MARGIN {
self.pages.push(std::mem::take(&mut self.cur));
}
self.y = H - MARGIN;
}
fn ensure(&mut self, h: f64) {
if self.y - h < MARGIN + 14.0 {
self.new_page();
}
}
fn put(&mut self, x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb) {
if s.is_empty() {
return;
}
self.cur.push_str(&format!(
"BT /{} {size:.1} Tf {:.3} {:.3} {:.3} rg {x:.2} {y:.2} Td ({}) Tj ET\n",
f.res(),
c.0,
c.1,
c.2,
escape(s)
));
}
fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Rgb) {
self.cur.push_str(&format!(
"{:.3} {:.3} {:.3} rg {x:.2} {y:.2} {w:.2} {h:.2} re f\n",
c.0, c.1, c.2
));
}
fn line(&mut self, x1: f64, x2: f64, y: f64, c: Rgb, w: f64) {
self.cur.push_str(&format!(
"{:.3} {:.3} {:.3} RG {w} w {x1:.2} {y:.2} m {x2:.2} {y:.2} l S\n",
c.0, c.1, c.2
));
}
pub fn space(&mut self, h: f64) {
self.y -= h;
}
pub fn title(&mut self, s: &str, sub: &str) {
self.ensure(60.0);
self.put(MARGIN, self.y - 20.0, s, 20.0, Font::Bold, BLACK);
self.put(MARGIN, self.y - 36.0, sub, 9.0, Font::Regular, GRAY);
self.line(MARGIN, W - MARGIN, self.y - 44.0, TEAL, 2.0);
self.y -= 58.0;
}
pub fn heading(&mut self, s: &str) {
self.ensure(50.0);
self.y -= 14.0;
self.put(MARGIN, self.y - 11.0, s, 12.0, Font::Bold, TEAL);
self.y -= 20.0;
}
pub fn paragraph(&mut self, s: &str, size: f64, f: Font, c: Rgb) {
for l in wrap(s, size, f, Self::content_width()) {
self.ensure(size * 1.4);
self.put(MARGIN, self.y - size, &l, size, f, c);
self.y -= size * 1.4;
}
self.y -= 3.0;
}
/// Aviso destacado com barra lateral.
pub fn note(&mut self, s: &str) {
let size = 9.0;
let lines = wrap(s, size, Font::Regular, Self::content_width() - 16.0);
let h = lines.len() as f64 * size * 1.4 + 8.0;
self.ensure(h + 4.0);
let top = self.y;
self.rect(MARGIN, top - h, Self::content_width(), h, (1.0, 0.97, 0.88));
self.rect(MARGIN, top - h, 3.0, h, (0.88, 0.63, 0.0));
for (i, l) in lines.iter().enumerate() {
self.put(
MARGIN + 10.0,
top - 4.0 - size * 1.4 * (i as f64 + 0.8),
l,
size,
Font::Regular,
BLACK,
);
}
self.y -= h + 6.0;
}
/// Linhas rótulo/valor; o valor pode ser monoespaçado.
pub fn kv(&mut self, rows: &[(&str, &str, Font)]) {
let lw = 140.0;
let size = 9.5;
for (k, v, f) in rows {
let lines = wrap(v, size, *f, Self::content_width() - lw - 8.0);
let h = lines.len() as f64 * size * 1.4 + 6.0;
self.ensure(h);
let top = self.y;
self.put(MARGIN + 4.0, top - size - 3.0, k, size, Font::Regular, GRAY);
for (i, l) in lines.iter().enumerate() {
self.put(
MARGIN + lw,
top - size - 3.0 - i as f64 * size * 1.4,
l,
size,
*f,
BLACK,
);
}
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
self.y -= h;
}
self.y -= 4.0;
}
/// Tabela com cabeçalho repetido a cada página e linhas que quebram texto.
pub fn table(&mut self, cols: &[Col], rows: &[Vec<String>]) {
let size = 8.0;
let pad = 3.0;
let header = |p: &mut Pdf| {
let h = size * 1.4 + 2.0 * pad;
p.ensure(h + 2.0 * size * 1.4);
let top = p.y;
p.rect(
MARGIN,
top - h,
Self::content_width(),
h,
(0.906, 0.953, 0.949),
);
let mut x = MARGIN;
for c in cols {
let tx = if c.right {
x + c.width - pad - text_width(c.title, size, Font::Bold)
} else {
x + pad
};
p.put(
tx,
top - pad - size,
c.title,
size,
Font::Bold,
(0.043, 0.31, 0.29),
);
x += c.width;
}
p.y -= h;
};
header(self);
for row in rows {
let cells: Vec<Vec<String>> = cols
.iter()
.zip(row)
.map(|(c, t)| wrap(t, size, c.font, c.width - 2.0 * pad))
.collect();
let n = cells.iter().map(|c| c.len()).max().unwrap_or(1).max(1);
let h = n as f64 * size * 1.35 + 2.0 * pad;
if self.y - h < MARGIN + 14.0 {
self.new_page();
header(self);
}
let top = self.y;
let mut x = MARGIN;
for (c, lines) in cols.iter().zip(&cells) {
for (i, l) in lines.iter().enumerate() {
let tx = if c.right {
x + c.width - pad - text_width(l, size, c.font)
} else {
x + pad
};
self.put(
tx,
top - pad - size - i as f64 * size * 1.35,
l,
size,
c.font,
BLACK,
);
}
x += c.width;
}
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
self.y -= h;
}
self.y -= 6.0;
}
/// Fecha o documento. `date` é a data de criação em UTC (`AAAAMMDDHHMMSS`).
pub fn finish(mut self, date: &str) -> Vec<u8> {
if !self.cur.is_empty() || self.pages.is_empty() {
self.pages.push(std::mem::take(&mut self.cur));
}
let total = self.pages.len();
for (i, p) in self.pages.iter_mut().enumerate() {
let label = format!("{} · página {} de {total}", self.footer, i + 1);
let x = W - MARGIN - text_width(&label, 8.0, Font::Regular);
p.push_str(&format!(
"BT /F1 8 Tf {:.3} {:.3} {:.3} rg {x:.2} 24 Td ({}) Tj ET\n",
GRAY.0,
GRAY.1,
GRAY.2,
escape(&label)
));
}
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
let mut offsets: Vec<usize> = vec![];
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
// 1 catálogo, 2 páginas, 3-5 fontes, 6 info, depois (página, conteúdo) por página
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
let kids: String = (0..total)
.map(|i| format!("{} 0 R", 7 + 2 * i))
.collect::<Vec<_>>()
.join(" ");
obj(
&mut out,
format!("<< /Type /Pages /Kids [{kids}] /Count {total} >>").as_bytes(),
);
for name in ["Helvetica", "Helvetica-Bold", "Courier"] {
obj(
&mut out,
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /{name} /Encoding /WinAnsiEncoding >>"
)
.as_bytes(),
);
}
let title: String = format!("\u{feff}{}", self.title)
.encode_utf16()
.map(|u| format!("{u:04X}"))
.collect();
obj(
&mut out,
format!(
"<< /Title <{title}> /Producer (Guara {}) /CreationDate (D:{date}Z) >>",
env!("CARGO_PKG_VERSION")
)
.as_bytes(),
);
for (i, p) in self.pages.iter().enumerate() {
obj(
&mut out,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents {} 0 R >>",
8 + 2 * i
)
.as_bytes(),
);
let mut z = ZlibEncoder::new(vec![], Compression::default());
z.write_all(p.as_bytes()).unwrap();
let z = z.finish().unwrap();
let mut body =
format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", z.len()).into_bytes();
body.extend_from_slice(&z);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
}
let xref = out.len();
out.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes(),
);
for o in &offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R /Info 6 0 R >>\nstartxref\n{xref}\n%%EOF\n",
offsets.len() + 1
)
.as_bytes(),
);
out
}
}
+3 -4
View File
@@ -286,10 +286,9 @@ pub fn recover_resumable(
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
"relatorio": &report,
});
crate::owner::write_owned(
&dest.join("guara-manifesto.json"),
serde_json::to_vec_pretty(&manifest)?,
)?;
let manifest_path = dest.join("guara-manifesto.json");
crate::owner::write_owned(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
crate::sign::sign_if_configured(&manifest_path);
Ok(report)
}
+376
View File
@@ -19,6 +19,14 @@ pub struct CaseInfo {
pub notes: String,
}
/// Situação da trilha de auditoria no momento do relatório.
pub struct AuditNote {
pub entries: usize,
pub ok: bool,
/// Hash da última entrada, para guardar fora da máquina.
pub head: String,
}
pub struct ReportInput<'a> {
pub case: &'a CaseInfo,
pub target: &'a str,
@@ -30,6 +38,7 @@ pub struct ReportInput<'a> {
pub elapsed: f64,
pub recoveries: &'a [RecoverReport],
pub source_hashes: Option<&'a Hashes>,
pub audit: Option<AuditNote>,
}
fn cell(s: &str) -> String {
@@ -190,6 +199,15 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
if r.options.include_allocated { ", incluindo áreas em uso" } else { "" }
));
if let Some(a) = &r.audit {
o.push_str(&format!(
"<h2>Trilha de auditoria</h2><table class=\"kv\"><tr><td>Entradas</td><td>{}</td></tr><tr><td>Encadeamento</td><td>{}</td></tr><tr><td>Hash da última entrada</td><td class=\"mono\">{}</td></tr></table>",
a.entries,
if a.ok { "íntegro" } else { "QUEBRADO: confira com guara audit --verify" },
esc(&a.head)
));
}
if let Some(l) = r.layout {
o.push_str("<h2>Partições</h2><table><tr><th>#</th><th>Sistema</th><th>Início (bytes)</th><th>Tamanho</th><th>Rótulo</th><th>Observação</th></tr>");
for p in &l.partitions {
@@ -299,3 +317,361 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
o.push_str("</table><footer>Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.</footer></main></body></html>");
o
}
/// Relatório em PDF (A4), com as mesmas seções do HTML.
pub fn pdf(r: &ReportInput) -> Vec<u8> {
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let stamp = crate::audit::utc_iso(now);
let version = env!("CARGO_PKG_VERSION");
let mut d = Pdf::new(
"Guará: relatório de recuperação",
&format!("Guará {version}"),
);
d.title(
"Relatório de recuperação de arquivos",
&format!("Gerado pelo Guará {version} em {}", fmt_unix(now)),
);
let nf = |v: &str| {
if v.is_empty() {
"não informado".to_string()
} else {
v.to_string()
}
};
d.heading("Identificação do caso");
let mut rows = vec![
("Número do caso", nf(&r.case.case_id), Font::Regular),
(
"Responsável pelo exame",
nf(&r.case.examiner),
Font::Regular,
),
("Organização", nf(&r.case.organization), Font::Regular),
];
if !r.case.notes.is_empty() {
rows.push(("Observações", r.case.notes.clone(), Font::Regular));
}
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
d.kv(&rows);
d.heading("Origem examinada");
let mut rows: Vec<(&str, String, Font)> =
vec![("Dispositivo / imagem", r.target.to_string(), Font::Mono)];
if let Some(l) = r.layout {
rows.push((
"Tamanho",
format!("{} ({} bytes)", human(l.size), l.size),
Font::Regular,
));
rows.push(("Tabela de partições", l.table.to_uppercase(), Font::Regular));
}
rows.push((
"Hash da origem",
match r.source_hashes {
Some(h) => format!("MD5 {}\nSHA-1 {}\nSHA-256 {}", h.md5, h.sha1, h.sha256),
None => "não calculado nesta sessão (use guara hash ou guara image)".into(),
},
if r.source_hashes.is_some() {
Font::Mono
} else {
Font::Regular
},
));
if let Some(t) = r.started_unix {
rows.push(("Início da varredura", fmt_unix(t), Font::Regular));
}
rows.push((
"Duração da varredura",
format!("{:.1} s", r.elapsed),
Font::Regular,
));
rows.push((
"Modo",
format!(
"{}{}",
if r.options.mode == ScanMode::Deep {
"profunda (metadados + assinatura)"
} else {
"rápida (metadados)"
},
if r.options.include_allocated {
", incluindo áreas em uso"
} else {
""
}
),
Font::Regular,
));
rows.push((
"Garantia de leitura",
"A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.".into(),
Font::Regular,
));
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
d.kv(&rows);
if let Some(a) = &r.audit {
d.heading("Trilha de auditoria");
let ok = if a.ok {
"íntegro"
} else {
"QUEBRADO: confira com guara audit --verify"
};
d.kv(&[
("Entradas", &a.entries.to_string(), Font::Regular),
("Encadeamento", ok, Font::Regular),
("Hash da última entrada", &a.head, Font::Mono),
]);
}
if let Some(l) = r.layout {
d.heading("Partições");
let cols = [
Col {
title: "#",
width: 22.0,
font: Font::Regular,
right: false,
},
Col {
title: "Sistema",
width: 80.0,
font: Font::Regular,
right: false,
},
Col {
title: "Início (bytes)",
width: 90.0,
font: Font::Regular,
right: true,
},
Col {
title: "Tamanho",
width: 65.0,
font: Font::Regular,
right: true,
},
Col {
title: "Rótulo",
width: 90.0,
font: Font::Regular,
right: false,
},
Col {
title: "Observação",
width: 168.28,
font: Font::Regular,
right: false,
},
];
let mut rows: Vec<Vec<String>> = l
.partitions
.iter()
.map(|p| {
let obs = [
p.lost.then_some("encontrada por varredura".to_string()),
p.encrypted
.as_ref()
.map(|e| format!("cifrada ({e}), não examinada")),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("; ");
vec![
(p.index + 1).to_string(),
p.fs.name().to_string(),
p.start.to_string(),
human(p.len),
p.label.clone().unwrap_or_default(),
obs,
]
})
.collect();
for g in &l.gaps {
rows.push(vec![
"-".into(),
"espaço não particionado".into(),
g.start.to_string(),
human(g.len),
String::new(),
String::new(),
]);
}
d.table(&cols, &rows);
}
let mut by_cat = std::collections::BTreeMap::new();
let mut conf = [0usize; 3];
let mut bytes = 0u64;
for f in r.found {
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
conf[f.confidence as usize] += 1;
bytes += f.size;
}
d.heading("Resumo dos achados");
d.kv(&[
(
"Arquivos encontrados",
&format!("{} ({})", r.found.len(), human(bytes)),
Font::Bold,
),
(
"Confiança",
&format!(
"alta {} · média {} · baixa {}",
conf[2], conf[1], conf[0]
),
Font::Regular,
),
(
"Por tipo",
&by_cat
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join(" · "),
Font::Regular,
),
]);
for w in r.warnings {
d.note(w);
}
if !r.recoveries.is_empty() {
d.heading("Arquivos recuperados (integridade)");
for rec in r.recoveries {
d.paragraph(
&format!(
"Destino: {}, {} arquivo(s), {}{}",
rec.destination,
rec.recovered.len(),
human(rec.total_bytes),
if rec.failed.is_empty() {
String::new()
} else {
format!(", {} falha(s)", rec.failed.len())
}
),
9.0,
Font::Regular,
BLACK,
);
let cols = [
Col {
title: "Arquivo",
width: 140.0,
font: Font::Mono,
right: false,
},
Col {
title: "Tamanho",
width: 55.0,
font: Font::Regular,
right: true,
},
Col {
title: "Hashes",
width: 320.28,
font: Font::Mono,
right: false,
},
];
let rows: Vec<Vec<String>> = rec
.recovered
.iter()
.take(MAX_ROWS)
.map(|f| {
vec![
f.path.clone(),
human(f.size),
format!("MD5 {}\nSHA-1 {}\nSHA-256 {}", f.md5, f.sha1, f.sha256),
]
})
.collect();
d.table(&cols, &rows);
}
}
d.heading("Relação de arquivos encontrados");
if r.found.len() > MAX_ROWS {
d.note(&format!(
"Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.",
r.found.len()
));
}
let cols = [
Col {
title: "Nome",
width: 110.0,
font: Font::Regular,
right: false,
},
Col {
title: "Pasta",
width: 110.0,
font: Font::Regular,
right: false,
},
Col {
title: "Tamanho",
width: 50.0,
font: Font::Regular,
right: true,
},
Col {
title: "Modificado (UTC)",
width: 92.0,
font: Font::Regular,
right: false,
},
Col {
title: "Origem",
width: 50.0,
font: Font::Regular,
right: false,
},
Col {
title: "Conf.",
width: 32.0,
font: Font::Regular,
right: false,
},
Col {
title: "Posição",
width: 71.28,
font: Font::Mono,
right: true,
},
];
let rows: Vec<Vec<String>> = r
.found
.iter()
.take(MAX_ROWS)
.map(|f| {
vec![
f.name.clone(),
f.dir.clone().unwrap_or_default(),
human(f.size),
iso(f.modified),
f.origin.label().to_string(),
f.confidence.label_pt().to_string(),
f.first_offset().map(|o| o.to_string()).unwrap_or_default(),
]
})
.collect();
d.table(&cols, &rows);
d.paragraph(
"Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.",
8.0,
Font::Regular,
GRAY,
);
d.finish(&stamp.replace(['-', ':', 'T', 'Z'], ""))
}
+108 -22
View File
@@ -247,6 +247,13 @@ impl Engine {
}
s.warnings.extend(asm.notes.iter().cloned());
}
crate::audit::record(
"scan.start",
json!({
"job": job.id, "source": path, "size": asm.source.size(),
"fingerprint": fp, "encrypted_volumes_opened": asm.unlocked.len(),
}),
);
*job.fingerprint.lock().unwrap() = fp;
*job.session_file.lock().unwrap() = Some(new_session_path(path));
spawn_scan(job.clone(), None);
@@ -487,6 +494,13 @@ impl Engine {
}
}
recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?;
crate::audit::record(
"recover.start",
json!({
"scan_job": sj.id, "source": sj.target, "destination": dest,
"files": files.len(), "bytes": total,
}),
);
let job = self.register(
JobKind::Recover,
dest.to_string(),
@@ -525,20 +539,25 @@ impl Engine {
let mut s = j2.st.lock().unwrap();
match res {
Ok(rep) => {
crate::audit::record(
"recover.finish",
json!({
"scan_job": parent.id, "destination": rep.destination,
"recovered": rep.recovered.len(), "failed": rep.failed.len(),
"skipped": rep.skipped, "bytes": rep.total_bytes,
"cancelled": rep.cancelled,
}),
);
s.report = serde_json::to_value(&rep).ok();
parent.st.lock().unwrap().recoveries.push(rep.clone());
if !rep.cancelled
&& s.started.elapsed().as_secs() >= 8
&& crate::settings::load().notify
{
crate::notify::send(
"Guará: recuperação concluída",
&format!(
"{} arquivo(s) salvos em {}",
rep.recovered.len(),
rep.destination
),
);
crate::notify::send(crate::notify::Notice::RecoverDone {
count: rep.recovered.len(),
destination: rep.destination.clone(),
});
}
s.status = if rep.cancelled {
Status::Stopped
@@ -575,6 +594,10 @@ impl Engine {
Some(Path::new(path)),
Path::new(dest).parent().unwrap_or(Path::new(".")),
)?;
crate::audit::record(
"image.start",
json!({ "source": path, "destination": dest, "format": format!("{format:?}").split([' ', '{']).next().unwrap_or("").to_lowercase() }),
);
let job = self.register(
JobKind::Image,
dest.to_string(),
@@ -596,6 +619,14 @@ impl Engine {
let mut s = j2.st.lock().unwrap();
match res {
Ok(rep) => {
crate::audit::record(
"image.finish",
json!({
"destination": dest, "bytes": rep.bytes, "md5": rep.md5,
"sha1": rep.sha1, "sha256": rep.sha256,
"unreadable_ranges": rep.bad_ranges.len(), "cancelled": rep.cancelled,
}),
);
s.status = if rep.cancelled {
Status::Stopped
} else {
@@ -624,6 +655,10 @@ impl Engine {
None => s.found.iter().collect(),
};
crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?;
crate::audit::record(
"export.csv",
json!({ "job": id, "source": j.target, "path": path, "rows": items.len() }),
);
Ok(items.len())
}
@@ -640,7 +675,16 @@ impl Engine {
let elapsed = s
.finished_in
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
let html = crate::report::html(&crate::report::ReportInput {
let audit = crate::audit::Log::open_default()
.verify()
.ok()
.filter(|v| v.entries > 0)
.map(|v| crate::report::AuditNote {
entries: v.entries,
ok: v.ok,
head: v.head,
});
let input = crate::report::ReportInput {
case: &case,
target: &j.target,
options: &j.opts,
@@ -651,8 +695,22 @@ impl Engine {
elapsed,
recoveries: &s.recoveries,
source_hashes: hashes.as_ref(),
});
crate::owner::write_owned(Path::new(path), html)?;
audit,
};
let as_pdf = Path::new(path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"));
let bytes = if as_pdf {
crate::report::pdf(&input)
} else {
crate::report::html(&input).into_bytes()
};
crate::owner::write_owned(Path::new(path), bytes)?;
crate::sign::sign_if_configured(Path::new(path));
crate::audit::record(
"export.report",
json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }),
);
Ok(())
}
@@ -663,6 +721,10 @@ impl Engine {
return Err(Error::msg("informe pelo menos um termo"));
}
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
crate::audit::record(
"search.start",
json!({ "source": path, "terms": opts.terms }),
);
let job = self.register(
JobKind::Search,
path.to_string(),
@@ -755,7 +817,15 @@ impl Engine {
} else if pos < size {
s.status = Status::Stopped;
} else {
s.report = serde_json::to_value(h.finalize()).ok();
let hashes = h.finalize();
crate::audit::record(
"hash.finish",
json!({
"source": j2.target, "bytes": size, "md5": hashes.md5,
"sha1": hashes.sha1, "sha256": hashes.sha256,
}),
);
s.report = serde_json::to_value(hashes).ok();
s.status = Status::Done;
}
s.finished_in = Some(s.started.elapsed().as_secs_f64());
@@ -767,7 +837,12 @@ impl Engine {
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?;
write_session(&j, Path::new(path))
write_session(&j, Path::new(path))?;
crate::audit::record(
"session.save",
json!({ "job": id, "source": j.target, "path": path }),
);
Ok(())
}
pub fn load_session(&self, path: &str) -> Result<String> {
@@ -838,6 +913,10 @@ impl Engine {
}
// continua salvando automaticamente no mesmo arquivo se a varredura for retomada
*job.session_file.lock().unwrap() = Some(PathBuf::from(path));
crate::audit::record(
"session.load",
json!({ "job": job.id, "source": data.target, "path": path }),
);
if data.done {
spawn_enrich(job.clone());
}
@@ -1025,6 +1104,10 @@ impl Engine {
.transpose()?
.unwrap_or_default();
let raw = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?;
crate::audit::record(
"inspect",
json!({ "source": path, "size": raw.size(), "fingerprint": crate::source::fingerprint(raw.as_ref()) }),
);
let a = crate::assemble::assemble_source(raw, &asm)?;
let mut layout = partition::analyze(a.source.as_ref(), false);
if a.skip_primary {
@@ -1300,14 +1383,10 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
s.status = Status::Done;
s.checkpoint = None;
if s.started.elapsed().as_secs() >= 20 && crate::settings::load().notify {
crate::notify::send(
"Guará: varredura concluída",
&format!(
"{} arquivo(s) encontrados em {}.",
s.found.len(),
job.target
),
);
crate::notify::send(crate::notify::Notice::ScanDone {
count: s.found.len(),
target: job.target.clone(),
});
}
}
Outcome::Interrupted(cp) => {
@@ -1317,7 +1396,7 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
s.status = Status::Paused;
if cp.io_lost {
if crate::settings::load().notify {
crate::notify::send("Guará: disco desconectado?", "A varredura foi pausada sem perder nada. Reconecte o disco e continue.");
crate::notify::send(crate::notify::Notice::DiskLost);
}
s.warnings.push("A leitura do dispositivo falhou repetidamente: ele provavelmente foi desconectado. A varredura foi pausada sem perder nada. Reconecte o disco e clique em Continuar.".into());
}
@@ -1327,6 +1406,13 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
}
s.finished_in = Some(s.started.elapsed().as_secs_f64());
let done = s.status == Status::Done;
crate::audit::record(
"scan.finish",
json!({
"job": job.id, "source": job.target,
"status": format!("{:?}", s.status).to_lowercase(), "found": s.found.len(),
}),
);
drop(s);
if done {
spawn_enrich(job);
+34
View File
@@ -13,12 +13,20 @@ pub struct Settings {
/// Idioma da interface: "auto", "pt" ou "en".
#[serde(default = "auto")]
pub lang: String,
/// Idioma que a interface resolveu de fato ("pt" ou "en"), para as
/// notificações do sistema e a saída do terminal acompanharem a tela.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_lang: Option<String>,
/// Notificação do sistema quando uma tarefa longa termina.
#[serde(default = "yes")]
pub notify: bool,
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
#[serde(default)]
pub maps: bool,
/// Procurar versão nova no GitHub ao abrir o app (uma consulta ao `latest.json` da release,
/// sem enviar nada além do que qualquer navegador envia).
#[serde(default = "yes")]
pub updates: bool,
}
fn auto() -> String {
@@ -34,8 +42,10 @@ impl Default for Settings {
Self {
theme: auto(),
lang: auto(),
ui_lang: None,
notify: true,
maps: false,
updates: true,
}
}
}
@@ -101,6 +111,30 @@ pub fn load() -> Settings {
.unwrap_or_default()
}
/// Idioma efetivo para textos gerados fora da interface (notificações e CLI).
/// Usa o que a interface informou; sem isso, a preferência e o ambiente.
pub fn ui_lang() -> String {
let s = load();
if let Some(l) = s.ui_lang.as_deref() {
if l == "pt" || l == "en" {
return l.to_string();
}
}
if s.lang == "pt" || s.lang == "en" {
return s.lang;
}
let env = std::env::var("LC_ALL")
.or_else(|_| std::env::var("LC_MESSAGES"))
.or_else(|_| std::env::var("LANG"))
.unwrap_or_default()
.to_ascii_lowercase();
if env.starts_with("pt") {
"pt".into()
} else {
"en".into()
}
}
pub fn save(s: &Settings) -> Result<()> {
crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?;
Ok(())
+399
View File
@@ -0,0 +1,399 @@
//! Assinatura digital (Ed25519) de manifestos, relatórios e qualquer outro arquivo, e conferência
//! de uma recuperação inteira.
//!
//! A assinatura fica ao lado do arquivo, em `<arquivo>.assinatura.json`, e cobre o SHA-256 do
//! conteúdo, o tamanho, o nome, a hora e o assinante. Quem confere recalcula o hash do arquivo,
//! refaz a mensagem a partir dos campos do JSON e valida com a chave pública que vem nele. Isso
//! prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de
//! quem é a chave, compare a impressão digital (`fingerprint`) com a que o perito publicou ou
//! registrou fora da máquina, passando-a em `trusted_fingerprint`.
//!
//! A hora é a do relógio da máquina de quem assinou: não é um carimbo de tempo de terceiros
//! (RFC 3161) e, sozinha, não prova *quando* a assinatura foi feita.
//!
//! A chave privada fica em `assinatura.key` na pasta de configuração (ou onde
//! `GUARA_SIGNING_KEY` apontar), em arquivo de texto com permissão 0600 nos sistemas Unix. Quem
//! precisa de mais proteção deve guardá-la num pendrive ou volume cifrado e apontar para lá.
use crate::error::{Error, Result};
use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::{Path, PathBuf};
const FORMAT: &str = "guara-assinatura-v1";
const KEY_HEADER: &str = "guara-ed25519-seed-v1";
const PUB_HEADER: &str = "guara-ed25519-public-v1";
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Option<Vec<u8>> {
let s = s.trim();
if s.len() % 2 != 0 || !s.is_ascii() {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
/// Impressão digital da chave pública: os 10 primeiros bytes do SHA-256, em grupos de 4 hex.
pub fn fingerprint(public_key: &[u8]) -> String {
let h = Sha256::digest(public_key);
let h = hex(&h[..10]);
h.as_bytes()
.chunks(4)
.map(|c| std::str::from_utf8(c).unwrap())
.collect::<Vec<_>>()
.join(":")
}
/// Compara impressões digitais ignorando maiúsculas, `:` e espaços.
fn same_fingerprint(a: &str, b: &str) -> bool {
let n = |s: &str| {
s.chars()
.filter(|c| c.is_ascii_hexdigit())
.map(|c| c.to_ascii_lowercase())
.collect::<String>()
};
let (a, b) = (n(a), n(b));
!a.is_empty() && a == b
}
/// Par de chaves de assinatura.
pub struct SigningKey {
kp: KeyPair,
}
impl SigningKey {
pub fn generate() -> Self {
Self {
kp: KeyPair::from_seed(Seed::generate()),
}
}
pub fn public_bytes(&self) -> [u8; 32] {
*self.kp.pk
}
pub fn fingerprint(&self) -> String {
fingerprint(&self.public_bytes())
}
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|e| {
Error::msg(format!(
"não foi possível ler a chave {}: {e}",
path.display()
))
})?;
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
if lines.next() != Some(KEY_HEADER) {
return Err(Error::msg(format!(
"{} não é uma chave de assinatura do Guará",
path.display()
)));
}
let seed = lines
.next()
.and_then(unhex)
.and_then(|b| <[u8; 32]>::try_from(b).ok())
.ok_or_else(|| Error::msg("chave de assinatura corrompida"))?;
Ok(Self {
kp: KeyPair::from_seed(Seed::new(seed)),
})
}
/// Grava a chave privada (0600 no Unix) e, ao lado, a pública (`.pub`). Recusa sobrescrever.
pub fn save(&self, path: &Path) -> Result<()> {
use std::io::Write;
if let Some(dir) = path.parent() {
crate::owner::create_dir_all_owned(dir)?;
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
Error::msg(format!(
"já existe uma chave em {}; apague-a de propósito se quiser trocar (as assinaturas antigas continuam válidas com a chave pública antiga)",
path.display()
))
} else {
e.into()
}
})?;
f.write_all(format!("{KEY_HEADER}\n{}\n", hex(self.kp.sk.seed().as_ref())).as_bytes())?;
drop(f);
crate::owner::give_to_user(path);
let pubp = pub_path(path);
crate::owner::write_owned(
&pubp,
format!(
"{PUB_HEADER}\n{}\n# impressão digital: {}\n",
hex(&self.public_bytes()),
self.fingerprint()
),
)?;
Ok(())
}
}
fn pub_path(key: &Path) -> PathBuf {
key.with_extension("pub")
}
/// `GUARA_SIGNING_KEY` ou `assinatura.key` na pasta de configuração.
pub fn default_key_path() -> PathBuf {
match std::env::var("GUARA_SIGNING_KEY") {
Ok(p) if !p.is_empty() => PathBuf::from(p),
_ => crate::settings::config_dir().join("assinatura.key"),
}
}
/// Arquivo que acompanha `path` com a assinatura.
pub fn signature_path(path: &Path) -> PathBuf {
let mut n = path.as_os_str().to_os_string();
n.push(".assinatura.json");
PathBuf::from(n)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureFile {
pub formato: String,
pub arquivo: String,
pub tamanho: u64,
pub sha256: String,
pub assinado_em: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assinante: Option<String>,
pub chave_publica: String,
pub impressao_digital: String,
pub assinatura: String,
}
fn message(f: &SignatureFile) -> Vec<u8> {
format!(
"{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante={}\n",
f.formato,
f.arquivo,
f.tamanho,
f.sha256,
f.assinado_em,
f.assinante.as_deref().unwrap_or("")
)
.into_bytes()
}
/// SHA-256 e tamanho de um arquivo, lido em blocos.
pub fn hash_file(path: &Path) -> Result<(String, u64)> {
let mut f = std::fs::File::open(path)?;
let mut h = Sha256::new();
let mut buf = vec![0u8; 1 << 20];
let mut total = 0u64;
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
total += n as u64;
}
Ok((hex(&h.finalize()), total))
}
fn now_iso() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
crate::audit::utc_iso(secs)
}
/// Assina `path` e grava `<path>.assinatura.json`. Devolve o caminho da assinatura.
pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<PathBuf> {
let (sha256, size) = hash_file(path)?;
let mut sig = SignatureFile {
formato: FORMAT.into(),
arquivo: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
tamanho: size,
sha256,
assinado_em: now_iso(),
assinante: signer.filter(|s| !s.is_empty()).map(String::from),
chave_publica: hex(&key.public_bytes()),
impressao_digital: key.fingerprint(),
assinatura: String::new(),
};
sig.assinatura = hex(key
.kp
.sk
.sign(message(&sig), Some(Noise::generate()))
.as_ref());
let out = signature_path(path);
crate::owner::write_owned(&out, serde_json::to_vec_pretty(&sig)?)?;
Ok(out)
}
/// Resultado de conferir uma assinatura.
#[derive(Debug, Clone, Serialize)]
pub struct Check {
/// O arquivo é o mesmo e a assinatura vale para a chave que vem nela.
pub valid: bool,
pub file_matches: bool,
pub signature_valid: bool,
pub fingerprint: String,
pub signed_at: String,
pub signer: Option<String>,
/// `Some(true/false)` quando uma impressão digital de confiança foi informada.
pub trusted: Option<bool>,
pub problems: Vec<String>,
}
pub fn verify_file(
path: &Path,
sig_path: Option<&Path>,
trusted_fingerprint: Option<&str>,
) -> Result<Check> {
let sp = sig_path
.map(Path::to_path_buf)
.unwrap_or_else(|| signature_path(path));
let raw = std::fs::read(&sp).map_err(|e| {
Error::msg(format!(
"não foi possível ler a assinatura {}: {e}",
sp.display()
))
})?;
let sig: SignatureFile = serde_json::from_slice(&raw)
.map_err(|e| Error::msg(format!("assinatura ilegível: {e}")))?;
let mut problems = vec![];
if sig.formato != FORMAT {
problems.push(format!(
"formato de assinatura desconhecido ({})",
sig.formato
));
}
let (sha, size) = hash_file(path)?;
let file_matches = sha == sig.sha256 && size == sig.tamanho;
if !file_matches {
problems.push("o arquivo mudou depois de assinado (hash ou tamanho diferem)".into());
}
let pk = unhex(&sig.chave_publica).and_then(|b| PublicKey::from_slice(&b).ok());
let sg = unhex(&sig.assinatura).and_then(|b| Signature::from_slice(&b).ok());
let signature_valid = match (&pk, &sg) {
(Some(pk), Some(sg)) => pk.verify(message(&sig), sg).is_ok(),
_ => false,
};
if !signature_valid {
problems.push("a assinatura não confere (campos alterados ou chave errada)".into());
}
let real_fp = pk
.as_ref()
.map(|p| fingerprint(p.as_ref()))
.unwrap_or_default();
if real_fp != sig.impressao_digital {
problems.push("a impressão digital declarada não é a da chave pública".into());
}
let trusted = trusted_fingerprint.map(|t| same_fingerprint(t, &real_fp));
if trusted == Some(false) {
problems.push(format!(
"a chave que assinou ({real_fp}) não é a esperada ({})",
trusted_fingerprint.unwrap_or_default()
));
}
Ok(Check {
valid: problems.is_empty(),
file_matches,
signature_valid,
fingerprint: real_fp,
signed_at: sig.assinado_em,
signer: sig.assinante,
trusted,
problems,
})
}
/// Confere uma pasta de recuperação: a assinatura do manifesto (se houver) e o hash de cada
/// arquivo contra o que o manifesto registrou.
#[derive(Debug, Clone, Serialize)]
pub struct RecoveryCheck {
pub manifest_signature: Option<Check>,
pub files: usize,
pub ok: usize,
pub missing: Vec<String>,
pub changed: Vec<String>,
/// `true` quando tudo confere (e, havendo assinatura, ela vale).
pub valid: bool,
}
pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<RecoveryCheck> {
let manifest = dir.join("guara-manifesto.json");
let raw = std::fs::read(&manifest)
.map_err(|e| Error::msg(format!("não foi possível ler {}: {e}", manifest.display())))?;
let v: serde_json::Value = serde_json::from_slice(&raw)?;
let sigp = signature_path(&manifest);
let manifest_signature = if sigp.is_file() {
Some(verify_file(&manifest, None, trusted_fingerprint)?)
} else {
None
};
let list = v["relatorio"]["recovered"]
.as_array()
.cloned()
.unwrap_or_default();
let (mut ok, mut missing, mut changed) = (0, vec![], vec![]);
for f in &list {
let (Some(rel), Some(want)) = (f["path"].as_str(), f["sha256"].as_str()) else {
continue;
};
// o manifesto é dado de entrada: não deixa sair da pasta
if rel.split('/').any(|c| c == "..") || Path::new(rel).is_absolute() {
changed.push(format!("{rel} (caminho fora da pasta)"));
continue;
}
let p = dir.join(rel);
match hash_file(&p) {
Ok((h, _)) if h == want => ok += 1,
Ok(_) => changed.push(rel.to_string()),
Err(_) => missing.push(rel.to_string()),
}
}
let signature_ok = match &manifest_signature {
Some(c) => c.valid,
None => true,
};
let valid = missing.is_empty() && changed.is_empty() && signature_ok;
Ok(RecoveryCheck {
manifest_signature,
files: list.len(),
ok,
missing,
changed,
valid,
})
}
/// Assina `path` se existir uma chave configurada; nunca interrompe quem chamou. Registra na
/// trilha de auditoria.
pub fn sign_if_configured(path: &Path) {
let kp = default_key_path();
if !kp.is_file() {
return;
}
let result = SigningKey::load(&kp)
.and_then(|k| sign_file(&k, path, std::env::var("GUARA_EXAMINER").ok().as_deref()));
match result {
Ok(sig) => crate::audit::record(
"sign",
serde_json::json!({ "path": path.to_string_lossy(), "signature": sig.to_string_lossy() }),
),
Err(e) => eprintln!("aviso: não foi possível assinar {}: {e}", path.display()),
}
}
+7 -1
View File
@@ -286,7 +286,13 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
return Ok(Arc::new(e));
}
Ok(Arc::new(file))
drop(file);
if let Some(v) = crate::vdisk::open(path)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?
{
return Ok(Arc::new(v));
}
Ok(Arc::new(FileSource::open(path)?))
}
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
+1 -1
View File
@@ -170,7 +170,7 @@ impl Packed {
}
PackedCodec::Zstd => {
let mut v = Vec::with_capacity(self.ram_len as usize);
ruzstd::StreamingDecoder::new(&raw[..])
ruzstd::decoding::StreamingDecoder::new(&raw[..])
.map_err(|_| bad("zstd"))?
.read_to_end(&mut v)
.map_err(|_| bad("zstd"))?;
+234
View File
@@ -0,0 +1,234 @@
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem
//! clara.
use super::{bad, be32, be64, read_exact, read_vec, Backend};
use crate::source::{FileSource, Source};
use std::io::{self, Read};
use std::sync::Mutex;
const SECTOR: u64 = 512;
pub(super) fn is_dmg(file: &FileSource) -> bool {
let size = file.size();
if size < 1024 {
return false;
}
let mut t = [0u8; 512];
read_exact(file, size - SECTOR, &mut t).is_ok()
&& &t[..4] == b"koly"
&& be32(&t, 4) == 4
&& be32(&t, 8) == 512
}
/// Imagem cifrada pelo `hdiutil`: começa com `encrcdsa` ou `cdsaencr`.
pub(super) fn is_encrypted(head: &[u8]) -> bool {
head.starts_with(b"encrcdsa") || head.starts_with(b"cdsaencr")
}
#[derive(Clone, Copy)]
struct Chunk {
start: u64,
len: u64,
ty: u32,
off: u64,
comp_len: u64,
}
pub(super) struct Dmg {
file: FileSource,
size: u64,
chunks: Vec<Chunk>,
cache: Mutex<Option<(usize, Vec<u8>)>>,
}
fn base64(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
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,
_ => continue,
};
acc = acc << 6 | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
out
}
/// Descompacta ADC (Apple Data Compression).
fn adc(src: &[u8], max: usize) -> io::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(max);
let mut i = 0;
while i < src.len() && out.len() < max {
let c = src[i];
i += 1;
if c & 0x80 != 0 {
let n = (c & 0x7F) as usize + 1;
let lit = src.get(i..i + n).ok_or_else(|| bad("chunk ADC truncado"))?;
out.extend_from_slice(lit);
i += n;
} else {
let (len, off) = if c & 0x40 != 0 {
let b = src.get(i..i + 2).ok_or_else(|| bad("chunk ADC truncado"))?;
i += 2;
(
(c & 0x3F) as usize + 4,
(b[0] as usize) << 8 | b[1] as usize,
)
} else {
let b = *src.get(i).ok_or_else(|| bad("chunk ADC truncado"))?;
i += 1;
((c >> 2) as usize + 3, ((c & 3) as usize) << 8 | b as usize)
};
let off = off + 1;
if off > out.len() {
return Err(bad("referência ADC inválida"));
}
for _ in 0..len {
out.push(out[out.len() - off]);
}
}
}
Ok(out)
}
impl Dmg {
pub(super) fn open(file: FileSource) -> io::Result<Self> {
let mut head = [0u8; 8];
let _ = file.read_at(0, &mut head);
if is_encrypted(&head) {
return Err(bad(
"DMG cifrado (encrcdsa) não é suportado; desproteja a imagem antes",
));
}
let size = file.size();
let t = read_vec(&file, size - SECTOR, SECTOR as usize)?;
let fork = be64(&t, 24);
let xml_off = be64(&t, 216);
let xml_len = be64(&t, 224);
let sectors = be64(&t, 492);
if xml_len == 0 || xml_len > 256 << 20 {
return Err(bad("plist do DMG ausente ou grande demais"));
}
let xml = read_vec(&file, xml_off, xml_len as usize)?;
let xml = String::from_utf8_lossy(&xml);
let from = xml
.find("<key>blkx</key>")
.ok_or_else(|| bad("tabela blkx não encontrada no DMG"))?;
let rest = &xml[from..];
let rest = &rest[..rest.find("</array>").unwrap_or(rest.len())];
let mut chunks: Vec<Chunk> = vec![];
let mut cur = rest;
while let Some(a) = cur.find("<data>") {
let after = &cur[a + 6..];
let Some(b) = after.find("</data>") else {
break;
};
let mish = base64(&after[..b]);
cur = &after[b + 7..];
if mish.len() < 204 || &mish[..4] != b"mish" {
continue;
}
let first = be64(&mish, 8);
let data_off = be64(&mish, 24);
let n = be32(&mish, 200) as usize;
for i in 0..n {
let Some(c) = mish.get(204 + i * 40..204 + (i + 1) * 40) else {
break;
};
let ty = be32(c, 0);
if ty == 0x7FFF_FFFE || ty == 0xFFFF_FFFF {
continue;
}
chunks.push(Chunk {
start: (first + be64(c, 8)) * SECTOR,
len: be64(c, 16) * SECTOR,
ty,
off: fork + data_off + be64(c, 24),
comp_len: be64(c, 32),
});
}
}
if chunks.is_empty() {
return Err(bad("DMG sem chunks"));
}
chunks.sort_by_key(|c| c.start);
let end = chunks.iter().map(|c| c.start + c.len).max().unwrap_or(0);
Ok(Self {
file,
size: end.max(sectors * SECTOR),
chunks,
cache: Mutex::new(None),
})
}
fn decode(&self, c: &Chunk) -> io::Result<Vec<u8>> {
if c.len > 64 << 20 || c.comp_len > 64 << 20 {
return Err(bad("chunk de DMG grande demais"));
}
let raw = read_vec(&self.file, c.off, c.comp_len as usize)?;
let mut out = Vec::with_capacity(c.len as usize);
match c.ty {
0x8000_0004 => out = adc(&raw, c.len as usize)?,
0x8000_0005 => {
flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut out)?;
}
0x8000_0006 => {
bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?;
}
0x8000_0007 => {
lzfse_rust::decode_bytes(&raw, &mut out)
.map_err(|e| bad(format!("chunk LZFSE inválido: {e}")))?;
}
t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))),
}
out.resize(c.len as usize, 0);
Ok(out)
}
}
impl Backend for Dmg {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let i = self.chunks.partition_point(|c| c.start + c.len <= voff);
let Some(c) = self.chunks.get(i).copied() else {
let n = buf.len().min((self.size - voff) as usize);
buf[..n].fill(0);
return Ok(n);
};
if voff < c.start {
// espaço que nenhuma tabela cobre
let n = buf.len().min((c.start - voff) as usize);
buf[..n].fill(0);
return Ok(n);
}
let rel = voff - c.start;
let n = buf.len().min((c.len - rel) as usize);
match c.ty {
0 | 2 => buf[..n].fill(0),
1 => read_exact(&self.file, c.off + rel, &mut buf[..n])?,
_ => {
let mut cache = self.cache.lock().unwrap();
if cache.as_ref().map(|x| x.0) != Some(i) {
*cache = Some((i, self.decode(&c)?));
}
let data = &cache.as_ref().unwrap().1;
buf[..n].copy_from_slice(&data[rel as usize..rel as usize + n]);
}
}
Ok(n)
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Imagens de disco virtual e contêineres: QCOW2, VMDK, VHD, VHDX e DMG (UDIF).
//!
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
//! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos
//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a
//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi
//! alterado depois do filho, os dados lidos refletem o pai atual.
mod dmg;
mod qcow2;
mod vhd;
mod vhdx;
mod vmdk;
use crate::error::{Error, Result};
use crate::source::{FileSource, Source};
use std::io;
use std::path::Path;
pub(crate) fn be16(b: &[u8], o: usize) -> u16 {
u16::from_be_bytes(b[o..o + 2].try_into().unwrap())
}
pub(crate) fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
}
pub(crate) fn be64(b: &[u8], o: usize) -> u64 {
u64::from_be_bytes(b[o..o + 8].try_into().unwrap())
}
pub(crate) fn le16(b: &[u8], o: usize) -> u16 {
u16::from_le_bytes(b[o..o + 2].try_into().unwrap())
}
pub(crate) fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
pub(crate) fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
/// Leitura dentro de um único bloco do formato; devolve quantos bytes entregou (pode ser
/// menos que `buf.len()`, nunca zero antes do fim do disco).
pub(crate) trait Backend: Send + Sync {
fn size(&self) -> u64;
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize>;
}
/// Disco virtual aberto de uma imagem de contêiner.
pub struct VDisk {
inner: Box<dyn Backend>,
format: &'static str,
}
impl VDisk {
/// Nome do formato (`QCOW2`, `VMDK`, `VHD`, `VHDX`, `DMG`).
pub fn format(&self) -> &'static str {
self.format
}
}
impl Source for VDisk {
fn size(&self) -> u64 {
self.inner.size()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
let size = self.inner.size();
if offset >= size {
return Ok(0);
}
let want = buf.len().min((size - offset) as usize);
let mut done = 0;
while done < want {
let n = self
.inner
.read_chunk(offset + done as u64, &mut buf[done..want])?;
if n == 0 {
break;
}
done += n;
}
Ok(done)
}
}
pub(crate) fn bad(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg.into())
}
pub(crate) fn io_err(e: io::Error, what: &str) -> Error {
Error::msg(format!("{what}: {e}"))
}
/// Cadeias de arquivos pai (VHD/VHDX diferenciais) não passam deste limite, o que também
/// corta ciclos.
const MAX_DEPTH: u32 = 16;
/// Formato de contêiner reconhecido pelos primeiros bytes (e, para VHD fixo e DMG, pelo
/// fim do arquivo). `None` quando é um disco bruto.
pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
if head.starts_with(b"QFI\xfb") {
return Some("QCOW2");
}
if head.starts_with(b"KDMV") || head.starts_with(b"# Disk DescriptorFile") {
return Some("VMDK");
}
if head.starts_with(b"vhdxfile") {
return Some("VHDX");
}
if head.starts_with(b"conectix") || vhd::is_fixed(file) {
return Some("VHD");
}
if dmg::is_encrypted(head) || dmg::is_dmg(file) {
return Some("DMG");
}
None
}
/// Abre `path` como disco virtual, ou `Ok(None)` se não for um contêiner conhecido.
pub fn open(path: &Path) -> Result<Option<VDisk>> {
open_depth(path, 0)
}
pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
if depth > MAX_DEPTH {
return Err(Error::msg("cadeia de discos pai longa demais ou circular"));
}
let file = FileSource::open(path)?;
let mut head = [0u8; 96];
let got = file.read_at(0, &mut head).unwrap_or(0);
let Some(format) = detect(&file, &head[..got]) else {
return Ok(None);
};
let name = format!("{format} {}", path.display());
let inner: Box<dyn Backend> = match format {
"QCOW2" => Box::new(qcow2::Qcow2::open(file, path, depth).map_err(|e| io_err(e, &name))?),
"VMDK" => Box::new(vmdk::Vmdk::open(file, path, depth).map_err(|e| io_err(e, &name))?),
"VHD" => Box::new(vhd::Vhd::open(file, path, depth).map_err(|e| io_err(e, &name))?),
"VHDX" => Box::new(vhdx::Vhdx::open(file, path, depth).map_err(|e| io_err(e, &name))?),
_ => Box::new(dmg::Dmg::open(file).map_err(|e| io_err(e, &name))?),
};
Ok(Some(VDisk { inner, format }))
}
/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o
/// arquivo é tratado como disco bruto sem tentar reconhecer o formato.
pub(crate) fn open_parent(
candidates: &[std::path::PathBuf],
depth: u32,
raw: bool,
) -> io::Result<Box<dyn Source>> {
for p in candidates {
if !p.is_file() {
continue;
}
if !raw {
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
return Ok(Box::new(v));
}
} else if depth > MAX_DEPTH {
return Err(bad("cadeia de discos pai longa demais ou circular"));
}
let f = FileSource::open(p)?;
return Ok(Box::new(f));
}
Err(bad(format!(
"disco pai não encontrado (procurado: {})",
candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)))
}
/// Lê de um arquivo por posição e exige todos os bytes.
pub(crate) fn read_exact(f: &FileSource, off: u64, buf: &mut [u8]) -> io::Result<()> {
crate::source::read_exact_at(f, off, buf)
}
pub(crate) fn read_vec(f: &FileSource, off: u64, len: usize) -> io::Result<Vec<u8>> {
let mut v = vec![0u8; len];
read_exact(f, off, &mut v)?;
Ok(v)
}
/// Tabelas em memória têm teto: um cabeçalho adulterado não pode pedir gigabytes.
pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Result<usize> {
const MAX: u64 = 512 * 1024 * 1024;
if entries.saturating_mul(entry_size) > MAX {
return Err(bad(format!("{what} grande demais ({entries} entradas)")));
}
Ok(entries as usize)
}
/// UTF-16 (big ou little endian) até o primeiro nulo.
pub(crate) fn utf16(b: &[u8], big: bool) -> String {
let units: Vec<u16> = b
.chunks_exact(2)
.map(|c| if big { be16(c, 0) } else { le16(c, 0) })
.take_while(|&u| u != 0)
.collect();
String::from_utf16_lossy(&units)
}
/// Caminhos onde procurar o disco pai: o caminho gravado (relativo ao arquivo filho, com `\`
/// virando `/`) e, como alternativa, só o nome do arquivo na mesma pasta.
pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::PathBuf> {
let mut out: Vec<std::path::PathBuf> = vec![];
for n in names {
let n = n.trim().replace('\\', "/");
let n = n.strip_prefix("file:///").unwrap_or(&n).to_string();
if n.is_empty() {
continue;
}
let rel = n.trim_start_matches("./");
let full = std::path::PathBuf::from(rel);
for p in [
if full.is_absolute() {
full.clone()
} else {
dir.join(rel)
},
dir.join(rel.rsplit('/').next().unwrap_or(rel)),
] {
if !out.contains(&p) {
out.push(p);
}
}
}
out
}
/// Lê do disco pai (ou zeros, sem pai); o que o pai não cobre também é zero.
pub(crate) fn read_parent(
parent: &Option<Box<dyn Source>>,
voff: u64,
buf: &mut [u8],
) -> io::Result<()> {
let Some(p) = parent else {
buf.fill(0);
return Ok(());
};
let mut done = 0;
while done < buf.len() {
let n = p.read_at(voff + done as u64, &mut buf[done..])?;
if n == 0 {
buf[done..].fill(0);
break;
}
done += n;
}
Ok(())
}
+212
View File
@@ -0,0 +1,212 @@
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd),
//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai.
//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um
//! arquivo local (`json:`, `nbd:`, URLs) são recusadas.
use super::{
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
Backend,
};
use crate::source::{FileSource, Source};
use std::io::{self, Read};
use std::path::Path;
use std::sync::Mutex;
const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00;
const COMPRESSED: u64 = 1 << 62;
pub(super) struct Qcow2 {
file: FileSource,
size: u64,
cluster_bits: u32,
l1: Vec<u64>,
zstd: bool,
parent: Option<Box<dyn Source>>,
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
cache: Mutex<Option<(u64, Vec<u8>)>>,
}
impl Qcow2 {
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
let h = read_vec(&file, 0, 112.min(file.size() as usize))?;
if h.len() < 72 {
return Err(bad("cabeçalho QCOW2 truncado"));
}
let version = be32(&h, 4);
if version != 2 && version != 3 {
return Err(bad(format!("versão QCOW {version} não suportada")));
}
let cluster_bits = be32(&h, 20);
if !(9..=21).contains(&cluster_bits) {
return Err(bad(format!(
"tamanho de cluster inválido (2^{cluster_bits})"
)));
}
let size = be64(&h, 24);
match be32(&h, 32) {
0 => {}
1 => return Err(bad("imagem QCOW2 cifrada (AES legado) não é suportada")),
_ => return Err(bad("imagem QCOW2 cifrada (LUKS) não é suportada; abra o volume LUKS depois de convertê-la")),
}
let mut zstd = false;
if version == 3 && h.len() >= 108 {
let incompat = be64(&h, 72);
if incompat & (1 << 2) != 0 {
return Err(bad("QCOW2 com arquivo de dados externo não é suportado"));
}
if incompat & (1 << 4) != 0 {
return Err(bad("QCOW2 com L2 estendido não é suportado"));
}
if incompat & (1 << 3) != 0 {
zstd = h[104] == 1;
if h[104] > 1 {
return Err(bad("tipo de compressão QCOW2 desconhecido"));
}
}
}
let parent = if be64(&h, 8) != 0 {
Some(Self::open_backing(&file, &h, version, path, depth)?)
} else {
None
};
let l1_size = be32(&h, 36) as u64;
let l1_off = be64(&h, 40);
let n = check_table(l1_size, 8, "tabela L1")?;
let raw = read_vec(&file, l1_off, n * 8)?;
let l1 = raw.chunks_exact(8).map(|c| be64(c, 0)).collect();
Ok(Self {
file,
size,
cluster_bits,
l1,
zstd,
parent,
cache: Mutex::new(None),
})
}
/// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw`
/// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz.
fn open_backing(
file: &FileSource,
h: &[u8],
version: u32,
path: &Path,
depth: u32,
) -> io::Result<Box<dyn Source>> {
let (off, len) = (be64(h, 8), be32(h, 16) as usize);
if len == 0 || len > 1023 {
return Err(bad("nome do arquivo de apoio QCOW2 inválido"));
}
let name = String::from_utf8_lossy(&read_vec(file, off, len)?).into_owned();
if name.starts_with("json:")
|| name.contains("://")
|| name.split(':').next().is_some_and(|p| {
matches!(p, "nbd" | "ssh" | "rbd" | "sheepdog" | "gluster" | "iscsi")
})
{
return Err(bad(format!(
"o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados"
)));
}
// extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes
let hlen = if version == 3 && h.len() >= 104 {
be32(h, 100) as u64
} else {
72
};
let mut raw = false;
let mut pos = hlen.div_ceil(8) * 8;
for _ in 0..64 {
let mut eh = [0u8; 8];
if read_exact(file, pos, &mut eh).is_err() {
break;
}
let (kind, elen) = (be32(&eh, 0), be32(&eh, 4) as u64);
if kind == 0 || elen > 1 << 20 {
break;
}
if kind == 0xE279_2ACA {
let fmt = read_vec(file, pos + 8, elen as usize)?;
raw = fmt == b"raw";
}
pos += 8 + elen.div_ceil(8) * 8;
}
if version == 2 {
raw = false;
}
let dir = path.parent().unwrap_or(Path::new("."));
open_parent(&candidate_paths(dir, &[name]), depth, raw)
}
fn cluster_size(&self) -> u64 {
1 << self.cluster_bits
}
fn l2_entry(&self, cluster_idx: u64) -> io::Result<u64> {
let per_l2 = self.cluster_size() / 8;
let l1_idx = (cluster_idx / per_l2) as usize;
let l2_idx = cluster_idx % per_l2;
let Some(&l1e) = self.l1.get(l1_idx) else {
return Ok(0);
};
let l2_off = l1e & OFFSET_MASK;
if l2_off == 0 {
return Ok(0);
}
let mut b = [0u8; 8];
read_exact(&self.file, l2_off + l2_idx * 8, &mut b)?;
Ok(u64::from_be_bytes(b))
}
fn inflate(&self, entry: u64) -> io::Result<Vec<u8>> {
let shift = 62 - (self.cluster_bits - 8);
let host = entry & ((1u64 << shift) - 1);
let nsect = ((entry >> shift) & ((1u64 << (self.cluster_bits - 8)) - 1)) + 1;
let csize = (nsect * 512 - (host & 511)) as usize;
let avail = self.file.size().saturating_sub(host) as usize;
let raw = read_vec(&self.file, host, csize.min(avail))?;
let mut out = Vec::with_capacity(self.cluster_size() as usize);
if self.zstd {
let mut d = ruzstd::decoding::StreamingDecoder::new(&raw[..])
.map_err(|e| bad(format!("cluster zstd inválido: {e}")))?;
d.read_to_end(&mut out)?;
} else {
flate2::read::DeflateDecoder::new(&raw[..]).read_to_end(&mut out)?;
}
out.resize(self.cluster_size() as usize, 0);
Ok(out)
}
}
impl Backend for Qcow2 {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let cs = self.cluster_size();
let idx = voff / cs;
let within = voff % cs;
let n = buf.len().min((cs - within) as usize);
let entry = self.l2_entry(idx)?;
if entry & COMPRESSED != 0 {
let mut cache = self.cache.lock().unwrap();
if cache.as_ref().map(|c| c.0) != Some(idx) {
*cache = Some((idx, self.inflate(entry)?));
}
let data = &cache.as_ref().unwrap().1;
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
return Ok(n);
}
let off = entry & OFFSET_MASK;
if entry & 1 != 0 {
buf[..n].fill(0); // zerado de propósito: não consulta o pai
} else if off == 0 {
read_parent(&self.parent, voff, &mut buf[..n])?;
} else {
read_exact(&self.file, off + within, &mut buf[..n])?;
}
Ok(n)
}
}
+175
View File
@@ -0,0 +1,175 @@
//! VHD (Microsoft Virtual PC e Hyper-V): discos fixos, dinâmicos e diferenciais. O disco
//! diferencial lê o pai (achado pelos localizadores do cabeçalho) nos setores que a camada
//! filha não preencheu.
use super::{
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
utf16, Backend,
};
use crate::source::{FileSource, Source};
use std::io;
use std::path::Path;
const SECTOR: u64 = 512;
const NONE: u32 = 0xFFFF_FFFF;
enum Kind {
Fixed,
Dynamic {
bat: Vec<u32>,
block_size: u64,
bitmap_size: u64,
},
}
pub(super) struct Vhd {
file: FileSource,
size: u64,
kind: Kind,
parent: Option<Box<dyn Source>>,
}
fn footer_ok(f: &[u8]) -> bool {
if f.len() < 512 || &f[..8] != b"conectix" {
return false;
}
let mut sum = 0u32;
for (i, &b) in f[..512].iter().enumerate() {
if !(64..68).contains(&i) {
sum = sum.wrapping_add(b as u32);
}
}
!sum == be32(f, 64)
}
/// VHD fixo: disco bruto seguido de um rodapé de 512 bytes.
pub(super) fn is_fixed(file: &FileSource) -> bool {
let size = file.size();
if size < 2 * SECTOR {
return false;
}
let mut f = [0u8; 512];
read_exact(file, size - SECTOR, &mut f).is_ok()
&& footer_ok(&f)
&& be32(&f, 60) == 2
&& be64(&f, 16) == u64::MAX
}
impl Vhd {
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
let size = file.size();
let mut footer = [0u8; 512];
let tail_ok = size >= SECTOR && {
read_exact(&file, size - SECTOR, &mut footer)?;
footer_ok(&footer)
};
if !tail_ok {
read_exact(&file, 0, &mut footer)?;
if !footer_ok(&footer) {
return Err(bad("rodapé VHD inválido"));
}
}
let current = be64(&footer, 48);
let disk_type = be32(&footer, 60);
match disk_type {
2 => {
return Ok(Self {
file,
size: current,
kind: Kind::Fixed,
parent: None,
});
}
3 | 4 => {}
t => return Err(bad(format!("tipo de VHD {t} desconhecido"))),
}
let dh = read_vec(&file, be64(&footer, 16), 1024)?;
if &dh[..8] != b"cxsparse" {
return Err(bad("cabeçalho de disco dinâmico VHD inválido"));
}
let bat_off = be64(&dh, 16);
let entries = be32(&dh, 28) as u64;
let block_size = be32(&dh, 32) as u64;
if block_size < SECTOR || !block_size.is_power_of_two() {
return Err(bad("tamanho de bloco VHD inválido"));
}
let n = check_table(entries, 4, "tabela de alocação")?;
let raw = read_vec(&file, bat_off, n * 4)?;
let bat = raw.chunks_exact(4).map(|c| be32(c, 0)).collect();
let bitmap_size = (block_size / SECTOR / 8).div_ceil(SECTOR) * SECTOR;
let parent = if disk_type == 4 {
let dir = path.parent().unwrap_or(Path::new("."));
let mut names = vec![];
for i in 0..8 {
let o = 576 + i * 24;
let code = &dh[o..o + 4];
let len = be32(&dh, o + 8) as usize;
let off = be64(&dh, o + 16);
if matches!(code, b"W2ru" | b"W2ku" | b"Wi2r" | b"Wi2k") && len > 0 && len < 4096 {
if let Ok(b) = read_vec(&file, off, len) {
names.push(utf16(&b, false));
}
}
}
names.push(utf16(&dh[64..64 + 512], true));
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
} else {
None
};
Ok(Self {
file,
size: current,
kind: Kind::Dynamic {
bat,
block_size,
bitmap_size,
},
parent,
})
}
}
impl Backend for Vhd {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let Kind::Dynamic {
bat,
block_size,
bitmap_size,
} = &self.kind
else {
let n = buf.len().min((self.size - voff) as usize);
read_exact(&self.file, voff, &mut buf[..n])?;
return Ok(n);
};
let block = (voff / block_size) as usize;
let within = voff % block_size;
let mut n = buf.len().min((block_size - within) as usize);
let entry = bat.get(block).copied().unwrap_or(NONE);
if entry == NONE {
read_parent(&self.parent, voff, &mut buf[..n])?;
return Ok(n);
}
let base = entry as u64 * SECTOR;
let first = within / SECTOR;
let last = (within + n as u64 - 1) / SECTOR;
let nbytes = (last / 8 - first / 8 + 1) as usize;
let bm = read_vec(&self.file, base + first / 8, nbytes)?;
let bit = |s: u64| bm[(s / 8 - first / 8) as usize] & (0x80 >> (s % 8)) != 0;
let present = bit(first);
let mut s = first;
while s <= last && bit(s) == present {
s += 1;
}
n = n.min((s * SECTOR - within) as usize);
if present {
read_exact(&self.file, base + bitmap_size + within, &mut buf[..n])?;
} else {
read_parent(&self.parent, voff, &mut buf[..n])?;
}
Ok(n)
}
}
+206
View File
@@ -0,0 +1,206 @@
//! VHDX (Hyper-V): tabela de regiões, metadados, BAT com blocos de bitmap intercalados,
//! discos fixos, dinâmicos e diferenciais (pai achado pelo localizador de pai).
//!
//! O log de transações não é reproduzido: abrir a imagem nunca a escreve. Se o disco foi
//! desligado no meio de uma escrita, os dados ainda no log não aparecem.
use super::{
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
read_vec, utf16, Backend,
};
use crate::source::{FileSource, Source};
use std::io;
use std::path::Path;
const MB: u64 = 1 << 20;
/// GUID em texto para os 16 bytes gravados em disco (três primeiros campos little-endian).
fn guid(s: &str) -> [u8; 16] {
let hex: Vec<u8> = s
.replace('-', "")
.as_bytes()
.chunks(2)
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
.collect();
let mut g = [0u8; 16];
g[..4].copy_from_slice(&[hex[3], hex[2], hex[1], hex[0]]);
g[4..6].copy_from_slice(&[hex[5], hex[4]]);
g[6..8].copy_from_slice(&[hex[7], hex[6]]);
g[8..].copy_from_slice(&hex[8..]);
g
}
pub(super) struct Vhdx {
file: FileSource,
size: u64,
block_size: u64,
sector_size: u64,
chunk_ratio: u64,
bat: Vec<u64>,
parent: Option<Box<dyn Source>>,
}
impl Vhdx {
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
let rt = read_vec(&file, 192 * 1024, 64 * 1024)?;
if &rt[..4] != b"regi" {
return Err(bad("tabela de regiões VHDX inválida"));
}
let count = le32(&rt, 8).min(2047) as usize;
let (mut bat_at, mut meta_at) = (None, None);
for i in 0..count {
let e = &rt[16 + i * 32..16 + (i + 1) * 32];
let region = (le64(e, 16), le32(e, 24) as u64);
if e[..16] == guid("2DC27766-F623-4200-9D64-115E9BFD4A08") {
bat_at = Some(region);
} else if e[..16] == guid("8B7CA206-4790-4B9A-B8FE-575F050F886E") {
meta_at = Some(region);
}
}
let (Some((bat_off, bat_len)), Some((meta_off, meta_len))) = (bat_at, meta_at) else {
return Err(bad("regiões BAT e metadados não encontradas"));
};
let meta = read_vec(&file, meta_off, meta_len.min(MB) as usize)?;
if &meta[..8] != b"metadata" {
return Err(bad("região de metadados VHDX inválida"));
}
let items = le16(&meta, 10) as usize;
let find = |id: [u8; 16]| -> Option<&[u8]> {
(0..items).find_map(|i| {
let e = meta.get(32 + i * 32..32 + (i + 1) * 32)?;
if e[..16] != id {
return None;
}
let (o, l) = (le32(e, 16) as usize, le32(e, 20) as usize);
meta.get(o..o + l)
})
};
let params = find(guid("CAA16737-FA36-4D43-B3B6-33F0AA44E76B"))
.filter(|p| p.len() >= 8)
.ok_or_else(|| bad("parâmetros do arquivo VHDX ausentes"))?;
let block_size = le32(params, 0) as u64;
let has_parent = le32(params, 4) & 2 != 0;
let size = find(guid("2FA54224-CD1B-4876-B211-5DBED83BF4B8"))
.filter(|p| p.len() >= 8)
.map(|p| le64(p, 0))
.ok_or_else(|| bad("tamanho do disco VHDX ausente"))?;
let sector_size = find(guid("8141BF1D-A96F-4709-BA47-F233A8FAAB5F"))
.filter(|p| p.len() >= 4)
.map(|p| le32(p, 0) as u64)
.unwrap_or(512);
if block_size < MB || !block_size.is_power_of_two() || !matches!(sector_size, 512 | 4096) {
return Err(bad("parâmetros VHDX inválidos"));
}
let chunk_ratio = (1u64 << 23) * sector_size / block_size;
let payload = size.div_ceil(block_size);
// Com pai, cada trecho termina com a entrada do bitmap de setores, mesmo que o disco acabe antes.
let want = if has_parent {
payload.div_ceil(chunk_ratio) * (chunk_ratio + 1)
} else {
payload + payload.div_ceil(chunk_ratio)
};
let n = check_table(want.min(bat_len / 8), 8, "BAT")?;
let raw = read_vec(&file, bat_off, n * 8)?;
let bat = raw.chunks_exact(8).map(|c| le64(c, 0)).collect();
let parent = if has_parent {
let loc = find(guid("A8D35F2D-B30B-454D-ABF7-D3D84834AB0C"))
.ok_or_else(|| bad("localizador de pai VHDX ausente"))?;
let count = if loc.len() >= 20 {
le16(loc, 18) as usize
} else {
0
};
let mut names = vec![];
for i in 0..count {
let Some(e) = loc.get(20 + i * 12..20 + (i + 1) * 12) else {
break;
};
let (ko, vo) = (le32(e, 0) as usize, le32(e, 4) as usize);
let (ks, vs) = (le16(e, 8) as usize, le16(e, 10) as usize);
let (Some(k), Some(v)) = (loc.get(ko..ko + ks), loc.get(vo..vo + vs)) else {
continue;
};
let key = utf16(k, false);
if matches!(
key.as_str(),
"relative_path" | "absolute_win32_path" | "volume_path"
) {
names.push(utf16(v, false));
}
}
let dir = path.parent().unwrap_or(Path::new("."));
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
} else {
None
};
Ok(Self {
file,
size,
block_size,
sector_size,
chunk_ratio,
bat,
parent,
})
}
fn bat(&self, i: u64) -> u64 {
self.bat.get(i as usize).copied().unwrap_or(0)
}
}
impl Backend for Vhdx {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let block = voff / self.block_size;
let within = voff % self.block_size;
let mut n = buf.len().min((self.block_size - within) as usize);
let e = self.bat(block + block / self.chunk_ratio);
let off = e & 0xFFFF_FFFF_FFF0_0000;
match e & 7 {
6 => {
read_exact(&self.file, off + within, &mut buf[..n])?;
}
7 => {
// Parcialmente presente: o bitmap do trecho diz, setor a setor, onde está o dado.
let chunk = block / self.chunk_ratio;
let bm_entry = self.bat(chunk * (self.chunk_ratio + 1) + self.chunk_ratio);
let ss = self.sector_size;
let per_block = self.block_size / ss;
let first = within / ss;
let last = (within + n as u64 - 1) / ss;
let base_bit = (block % self.chunk_ratio) * per_block;
let bm_off = bm_entry & 0xFFFF_FFFF_FFF0_0000;
if bm_entry & 7 != 6 {
read_parent(&self.parent, voff, &mut buf[..n])?;
return Ok(n);
}
let b0 = (base_bit + first) / 8;
let b1 = (base_bit + last) / 8;
let bm = read_vec(&self.file, bm_off + b0, (b1 - b0 + 1) as usize)?;
let bit = |s: u64| {
let p = base_bit + s;
bm[(p / 8 - b0) as usize] >> (p % 8) & 1 == 1
};
let present = bit(first);
let mut s = first;
while s <= last && bit(s) == present {
s += 1;
}
n = n.min((s * ss - within) as usize);
if present {
read_exact(&self.file, off + within, &mut buf[..n])?;
} else {
read_parent(&self.parent, voff, &mut buf[..n])?;
}
}
2 | 3 => buf[..n].fill(0),
_ => read_parent(&self.parent, voff, &mut buf[..n])?,
}
Ok(n)
}
}
+376
View File
@@ -0,0 +1,376 @@
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos
//! que o filho não alocou; o formato SEsparse é recusado.
use super::{
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
read_vec, Backend,
};
use crate::source::{FileSource, Source};
use std::io::{self, Read};
use std::path::Path;
use std::sync::Mutex;
const SECTOR: u64 = 512;
const GD_AT_END: u64 = u64::MAX;
struct Sparse {
file: FileSource,
grain: u64,
gtes: u64,
gd: Vec<u32>,
compressed: bool,
cache: Mutex<Option<(u64, Vec<u8>)>>,
}
enum Kind {
Zero,
Flat { file: FileSource, offset: u64 },
Sparse(Sparse),
}
struct Extent {
start: u64,
len: u64,
kind: Kind,
}
pub(super) struct Vmdk {
extents: Vec<Extent>,
size: u64,
parent: Option<Box<dyn Source>>,
}
/// Valor de uma chave `chave=valor` do descritor, sem aspas.
fn desc_value(descriptor: &str, key: &str) -> Option<String> {
descriptor.lines().find_map(|l| {
l.trim()
.strip_prefix(key)
.and_then(|r| r.trim_start().strip_prefix('='))
.map(|v| v.trim().trim_matches('"').to_string())
})
}
/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido
/// logo depois do cabeçalho de uma extensão esparsa.
fn descriptor_of(path: &Path) -> Option<String> {
let f = FileSource::open(path).ok()?;
let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?;
if head.starts_with(b"KDMV") {
let off = (le64(&head, 28) * SECTOR) as usize;
let len = (le64(&head, 36) * SECTOR) as usize;
let d = head.get(off..(off + len).min(head.len()))?;
let end = d.iter().position(|&b| b == 0).unwrap_or(d.len());
return Some(String::from_utf8_lossy(&d[..end]).into_owned());
}
head.starts_with(b"# Disk Desc")
.then(|| String::from_utf8_lossy(&head).into_owned())
}
/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só
/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter.
struct ParentRef {
name: Option<String>,
uuid: Option<String>,
cid: Option<String>,
}
fn parent_ref(descriptor: &str) -> Option<ParentRef> {
let cid = desc_value(descriptor, "parentCID")
.filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("ffffffff"));
let uuid = desc_value(descriptor, "ddb.uuid.parent")
.filter(|u| !u.is_empty() && u.chars().any(|c| c != '0' && c != '-'));
let name = desc_value(descriptor, "parentFileNameHint").filter(|n| !n.is_empty());
(cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid })
}
/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`.
fn find_by_uuid(dir: &Path, uuid: &str) -> Option<std::path::PathBuf> {
let mut d = Some(dir);
for _ in 0..3 {
let cur = d?;
for e in std::fs::read_dir(cur).ok()?.flatten() {
let p = e.path();
if p.extension()
.is_some_and(|x| x.eq_ignore_ascii_case("vmdk"))
&& descriptor_of(&p)
.and_then(|ds| desc_value(&ds, "ddb.uuid.image"))
.is_some_and(|u| u.eq_ignore_ascii_case(uuid))
{
return Some(p);
}
}
d = cur.parent();
}
None
}
impl Sparse {
fn open(file: FileSource) -> io::Result<(Self, u64, Option<String>)> {
let mut h = read_vec(&file, 0, SECTOR as usize)?;
if &h[..4] == b"SESp" || &h[..4] == b"seSp" {
return Err(bad("VMDK no formato SEsparse (vSphere) não é suportado"));
}
if &h[..4] != b"KDMV" {
return Err(bad("extensão VMDK sem assinatura KDMV"));
}
if le64(&h, 56) == GD_AT_END {
let size = file.size();
if size < 3 * SECTOR {
return Err(bad("VMDK truncado"));
}
h = read_vec(&file, size - 2 * SECTOR, SECTOR as usize)?;
if &h[..4] != b"KDMV" {
return Err(bad("rodapé do VMDK streamOptimized ausente"));
}
}
let flags = le32(&h, 8);
let capacity = le64(&h, 12);
let grain = le64(&h, 20);
let desc_off = le64(&h, 28);
let desc_len = le64(&h, 36);
let gtes = le32(&h, 44) as u64;
let gd_off = le64(&h, 56);
let algo = le16(&h, 77);
if grain == 0 || !grain.is_power_of_two() || gtes == 0 {
return Err(bad("cabeçalho VMDK inválido"));
}
let compressed = flags & (1 << 16) != 0;
if compressed && algo != 1 {
return Err(bad("compressão de grão VMDK desconhecida"));
}
let descriptor = if desc_off != 0 && desc_len != 0 {
let raw = read_vec(
&file,
desc_off * SECTOR,
(desc_len * SECTOR).min(1 << 20) as usize,
)?;
let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
Some(String::from_utf8_lossy(&raw[..end]).into_owned())
} else {
None
};
let gde = capacity.div_ceil(gtes * grain);
let n = check_table(gde, 4, "diretório de grãos")?;
let raw = read_vec(&file, gd_off * SECTOR, n * 4)?;
let gd = raw.chunks_exact(4).map(|c| le32(c, 0)).collect();
Ok((
Self {
file,
grain: grain * SECTOR,
gtes,
gd,
compressed,
cache: Mutex::new(None),
},
capacity * SECTOR,
descriptor,
))
}
/// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo
/// (o chamador completa com o pai); grão zerado de propósito devolve zeros.
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> {
let g = off / self.grain;
let within = off % self.grain;
let n = buf.len().min((self.grain - within) as usize);
let gt_sector = self.gd.get((g / self.gtes) as usize).copied().unwrap_or(0) as u64;
let mut sector = 0u64;
if gt_sector > 1 {
let mut b = [0u8; 4];
read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?;
sector = u32::from_le_bytes(b) as u64;
}
if sector == 0 {
return Ok((n, false));
}
if sector == 1 {
buf[..n].fill(0);
return Ok((n, true));
}
if !self.compressed {
read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?;
return Ok((n, true));
}
let mut cache = self.cache.lock().unwrap();
if cache.as_ref().map(|c| c.0) != Some(g) {
let mut m = [0u8; 12];
read_exact(&self.file, sector * SECTOR, &mut m)?;
let csize = le32(&m, 8) as usize;
if csize == 0 || csize > self.grain as usize * 2 {
return Err(bad("grão VMDK comprimido com tamanho inválido"));
}
let raw = read_vec(&self.file, sector * SECTOR + 12, csize)?;
let mut out = Vec::with_capacity(self.grain as usize);
flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut out)?;
out.resize(self.grain as usize, 0);
*cache = Some((g, out));
}
let data = &cache.as_ref().unwrap().1;
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
Ok((n, true))
}
}
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
let mut out = vec![];
let mut start = 0u64;
for line in text.lines() {
let l = line.trim();
let mut it = l.split_whitespace();
let (Some(access), Some(sectors), Some(kind)) = (it.next(), it.next(), it.next()) else {
continue;
};
if !matches!(access, "RW" | "RDONLY" | "NOACCESS") {
continue;
}
let Ok(sectors) = sectors.parse::<u64>() else {
continue;
};
let len = sectors * SECTOR;
let name = match (l.find('"'), l.rfind('"')) {
(Some(a), Some(b)) if b > a => Some(&l[a + 1..b]),
_ => None,
};
let kind = match kind {
"ZERO" => Kind::Zero,
"FLAT" | "VMFS" => {
let name = name.ok_or_else(|| bad("extensão FLAT sem nome de arquivo"))?;
let offset = l
.rsplit_once('"')
.and_then(|(_, r)| r.trim().parse::<u64>().ok())
.unwrap_or(0);
Kind::Flat {
file: FileSource::open(&dir.join(name))?,
offset: offset * SECTOR,
}
}
"SPARSE" => {
let name = name.ok_or_else(|| bad("extensão SPARSE sem nome de arquivo"))?;
let (sp, cap, _) = Sparse::open(FileSource::open(&dir.join(name))?)?;
if cap != len {
return Err(bad("tamanho da extensão VMDK difere do descritor"));
}
Kind::Sparse(sp)
}
other => return Err(bad(format!("tipo de extensão VMDK {other} não suportado"))),
};
out.push(Extent { start, len, kind });
start += len;
}
if out.is_empty() {
return Err(bad("descritor VMDK sem extensões"));
}
Ok(out)
}
impl Vmdk {
fn open_parent_of(r: &ParentRef, dir: &Path, depth: u32) -> io::Result<Box<dyn Source>> {
let mut found = r
.name
.as_ref()
.map(|n| candidate_paths(dir, std::slice::from_ref(n)))
.unwrap_or_default();
let by_name = found.iter().any(|p| p.is_file());
if !by_name {
if let Some(p) = r.uuid.as_deref().and_then(|u| find_by_uuid(dir, u)) {
found = vec![p];
}
}
if r.name.is_none() && r.uuid.is_none() {
return Err(bad(
"VMDK com pai, mas o descritor não diz qual (sem parentFileNameHint nem ddb.uuid.parent)",
));
}
if r.name.is_none() && found.is_empty() {
return Err(bad(
"VMDK com pai identificado só por UUID (VirtualBox) e o pai não está na pasta do filho nem nas duas acima",
));
}
if let Some(cid) = &r.cid {
// o pai precisa ser o mesmo de quando o filho foi criado
if let Some(p) = found.iter().find(|p| p.is_file()) {
let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID"));
if have
.as_deref()
.is_some_and(|h| !h.eq_ignore_ascii_case(cid))
{
return Err(bad(format!(
"o pai {} foi alterado depois do filho (CID {} em vez de {cid}); os dados do filho não valem mais",
p.display(),
have.unwrap_or_default()
)));
}
}
}
open_parent(&found, depth, false)
}
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
let mut head = [0u8; 4];
let _ = file.read_at(0, &mut head);
let dir = path.parent().unwrap_or(Path::new("."));
let (extents, descriptor) = if &head == b"KDMV" {
let (sp, size, descriptor) = Sparse::open(file)?;
(
vec![Extent {
start: 0,
len: size,
kind: Kind::Sparse(sp),
}],
descriptor,
)
} else {
if file.size() > 1 << 20 {
return Err(bad("descritor VMDK grande demais"));
}
let raw = read_vec(&file, 0, file.size() as usize)?;
let text = String::from_utf8_lossy(&raw).into_owned();
(parse_descriptor(&text, dir)?, Some(text))
};
let parent = match descriptor.as_deref().and_then(parent_ref) {
Some(r) => Some(Self::open_parent_of(&r, dir, depth)?),
None => None,
};
let size = extents.iter().map(|e| e.len).sum();
Ok(Self {
extents,
size,
parent,
})
}
}
impl Backend for Vmdk {
fn size(&self) -> u64 {
self.size
}
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
let i = self.extents.partition_point(|e| e.start + e.len <= voff);
let Some(e) = self.extents.get(i) else {
return Ok(0);
};
let rel = voff - e.start;
let n = buf.len().min((e.len - rel) as usize);
match &e.kind {
Kind::Zero => {
buf[..n].fill(0);
Ok(n)
}
Kind::Flat { file, offset } => {
read_exact(file, offset + rel, &mut buf[..n])?;
Ok(n)
}
Kind::Sparse(s) => {
let (got, allocated) = s.read(rel, &mut buf[..n])?;
if !allocated {
read_parent(&self.parent, voff, &mut buf[..got])?;
}
Ok(got)
}
}
}
}
+13 -2
View File
@@ -2,8 +2,8 @@
mod common;
use common::*;
use guara_core::fs::apfs::{fletcher64, live_paths};
use guara_core::source::FileSource;
use guara_core::fs::apfs::{fletcher64, free_extents, live_paths};
use guara_core::source::{FileSource, Source};
use guara_core::*;
use std::path::PathBuf;
@@ -26,6 +26,17 @@ fn reads_the_live_tree_of_the_reference_image() {
);
}
#[test]
fn free_space_map_matches_the_spaceman() {
let Some(p) = img() else { return };
let src = FileSource::open(&p).unwrap();
let free = free_extents(&src, 0).expect("mapa de espaço livre");
let bytes: u64 = free.iter().map(|e| e.len).sum();
assert_eq!(bytes, 904 * 4096);
assert_eq!(free.first().unwrap().offset, 110 * 4096);
assert!(free.iter().all(|e| e.end() <= src.size()));
}
#[test]
fn checksum_matches_real_blocks() {
let Some(p) = img() else { return };
+142
View File
@@ -0,0 +1,142 @@
//! Trilha de auditoria: encadeamento por hash, detecção de adulteração e registro das ações do motor.
mod common;
use common::*;
use guara_core::audit::Log;
use guara_core::{service, ScanOptions};
use serde_json::json;
fn log_with(n: usize) -> (tempfile::TempDir, Log) {
let dir = tempfile::tempdir().unwrap();
let log = Log::at(dir.path().join("sub/auditoria.jsonl"));
for i in 0..n {
log.append("teste", json!({ "i": i, "caminho": "/dev/sdb" }))
.unwrap();
}
(dir, log)
}
#[test]
fn entries_are_numbered_and_chained() {
let (_d, log) = log_with(4);
let e = log.entries().unwrap();
assert_eq!(e.iter().map(|x| x.seq).collect::<Vec<_>>(), [1, 2, 3, 4]);
assert_eq!(e[0].prev, "0".repeat(64));
for w in e.windows(2) {
assert_eq!(w[1].prev, w[0].hash);
}
assert!(e[0].time.ends_with('Z') && e[0].time.len() == 20);
let v = log.verify().unwrap();
assert!(v.ok && v.entries == 4 && v.head == e[3].hash);
// continua de onde parou, inclusive numa instância nova
let again = Log::at(log.path());
assert_eq!(again.append("mais", json!({})).unwrap().seq, 5);
assert!(again.verify().unwrap().ok);
}
#[test]
fn empty_or_missing_log_is_valid() {
let dir = tempfile::tempdir().unwrap();
let log = Log::at(dir.path().join("nada.jsonl"));
let v = log.verify().unwrap();
assert!(v.ok && v.entries == 0);
assert!(log.entries().unwrap().is_empty());
}
fn tamper(log: &Log, f: impl FnOnce(&mut Vec<String>)) {
let text = std::fs::read_to_string(log.path()).unwrap();
let mut lines: Vec<String> = text.lines().map(String::from).collect();
f(&mut lines);
std::fs::write(log.path(), lines.join("\n") + "\n").unwrap();
}
#[test]
fn editing_a_line_breaks_the_chain_at_that_line() {
let (_d, log) = log_with(5);
tamper(&log, |l| l[2] = l[2].replace("/dev/sdb", "/dev/sdc"));
let v = log.verify().unwrap();
assert!(!v.ok);
let (n, why) = v.broken_at.unwrap();
assert_eq!(n, 3, "{why}");
assert!(why.contains("hash"), "{why}");
}
#[test]
fn removing_or_reordering_lines_is_detected() {
let (_d, log) = log_with(5);
tamper(&log, |l| {
l.remove(1);
});
let (n, why) = log.verify().unwrap().broken_at.unwrap();
assert_eq!(n, 2, "{why}");
let (_d, log) = log_with(5);
tamper(&log, |l| l.swap(1, 2));
assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 2);
// trocar o hash por outro também não passa: o seguinte deixa de continuar
let (_d, log) = log_with(3);
tamper(&log, |l| {
let h = l[0].rsplit('"').nth(1).unwrap().to_string();
l[0] = l[0].replace(&h, &"a".repeat(64));
});
assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 1);
}
#[test]
fn garbage_line_is_reported_not_skipped() {
let (_d, log) = log_with(3);
tamper(&log, |l| l[1] = "isto não é json".into());
let (n, why) = log.verify().unwrap().broken_at.unwrap();
assert_eq!((n, why.as_str()), (2, "linha ilegível"));
}
#[test]
fn engine_actions_are_recorded_without_secrets() {
init();
let dir = tempfile::tempdir().unwrap();
let img = dir.path().join("disco.img");
std::fs::write(&img, vec![0u8; 2 << 20]).unwrap();
let eng = service::Engine::new();
let job = eng
.start_scan(img.to_str().unwrap(), ScanOptions::default())
.unwrap();
wait(&eng, &job);
let hash = eng.start_hash(img.to_str().unwrap()).unwrap();
wait(&eng, &hash);
let csv = dir.path().join("achados.csv");
eng.export_csv(&job, csv.to_str().unwrap(), None).unwrap();
eng.save_session(&job, dir.path().join("s.guara").to_str().unwrap())
.unwrap();
let log = Log::open_default();
let e = log.entries().unwrap();
let actions: Vec<&str> = e.iter().map(|x| x.action.as_str()).collect();
for want in [
"scan.start",
"scan.finish",
"hash.finish",
"export.csv",
"session.save",
] {
assert!(actions.contains(&want), "{want} ausente em {actions:?}");
}
let start = e.iter().find(|x| x.action == "scan.start").unwrap();
assert_eq!(start.detail["source"], img.to_str().unwrap());
assert_eq!(start.detail["size"], 2 << 20);
let h = e.iter().find(|x| x.action == "hash.finish").unwrap();
assert_eq!(h.detail["sha256"].as_str().unwrap().len(), 64);
assert!(log.verify().unwrap().ok);
let all = std::fs::read_to_string(log.path()).unwrap();
assert!(!all.contains("password") && !all.contains("senha"));
}
fn wait(eng: &service::Engine, job: &str) {
for _ in 0..1000 {
if eng.status(job).unwrap()["status"] != "running" {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("job não terminou");
}
+12
View File
@@ -298,6 +298,18 @@ fn configuracoes_de_tema_persistem() {
init();
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
assert_eq!(settings::load().theme, "auto");
assert!(
settings::load().updates,
"a verificação de atualização vem ligada"
);
// arquivo de uma versão anterior, sem o campo novo
std::fs::write(
settings::config_dir().join("settings.json"),
r#"{"theme":"dark"}"#,
)
.unwrap();
assert!(settings::load().updates);
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
settings::save(&settings::Settings {
theme: "dark".into(),
..Default::default()
+17 -4
View File
@@ -139,14 +139,22 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
settings::save(&settings::Settings {
theme: "light".into(),
lang: "en".into(),
ui_lang: Some("en".into()),
notify: false,
maps: true,
updates: false,
})
.unwrap();
let s = settings::load();
assert_eq!(
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
("light", "en", false, true)
(
s.theme.as_str(),
s.lang.as_str(),
s.notify,
s.maps,
s.updates
),
("light", "en", false, true, false)
);
}
@@ -167,7 +175,10 @@ fn notificacao_chama_o_notify_send() {
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let old = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
notify::send("Guará: varredura concluída", "7 arquivos");
notify::send(notify::Notice::ScanDone {
count: 7,
target: "disco".into(),
});
for _ in 0..100 {
if marker.exists() && !std::fs::read(&marker).unwrap().is_empty() {
break;
@@ -176,8 +187,10 @@ fn notificacao_chama_o_notify_send() {
}
std::env::set_var("PATH", old);
let got = String::from_utf8(std::fs::read(&marker).unwrap()).unwrap();
assert!(got.contains("Guará: "), "{got}");
assert!(
got.contains("Guará: varredura concluída") && got.contains("7 arquivos"),
got.contains("7 arquivo(s) encontrados em disco.")
|| got.contains("7 file(s) found in disco."),
"{got}"
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -7
View File
@@ -151,7 +151,7 @@ fn exif_camera_data_e_gps() {
assert!(m
.fields
.iter()
.any(|(k, v)| k == "Foto tirada em" && v == "14/07/2023 18:22:05"));
.any(|(k, v)| k == "Foto tirada em" && v == "2023-07-14T18:22:05"));
}
#[test]
@@ -199,7 +199,7 @@ fn metadados_de_pdf() {
assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva"));
assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer"));
assert_eq!(get("Gerado por").as_deref(), Some("PDF12"));
assert_eq!(get("Criado em").as_deref(), Some("14/07/2023 18:22:05"));
assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05"));
assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12"));
}
@@ -279,10 +279,7 @@ fn metadados_de_documentos_office() {
);
assert_eq!(get("Autor").as_deref(), Some("Ana Souza"));
assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima"));
assert_eq!(
get("Criado em").as_deref(),
Some("14/07/2023 18:22:05 (UTC)")
);
assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05Z"));
assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word"));
assert_eq!(get("Empresa").as_deref(), Some("ACME"));
}
@@ -323,7 +320,7 @@ fn metadados_de_mp4() {
assert_eq!(get("Duração").as_deref(), Some("0:02:05"));
assert_eq!(
get("Gravado em (metadado)").as_deref(),
Some("14/07/2023 18:22:05 (UTC)")
Some("2023-07-14T18:22:05Z")
);
assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
}
@@ -420,8 +417,14 @@ fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
elapsed: 1.5,
recoveries: &[],
source_hashes: Some(&hashes),
audit: Some(report::AuditNote {
entries: 3,
ok: true,
head: "ab".repeat(32),
}),
});
assert!(!html.contains("<script>alert"), "HTML não escapado");
assert!(html.contains("Trilha de auditoria") && html.contains(&"ab".repeat(32)));
assert!(html.contains("&lt;script&gt;alert(1)&lt;/script&gt;.txt"));
assert!(html.contains("2026-001") && html.contains("Perita &lt;Ana&gt;"));
assert!(html.contains(&hashes.sha256));
+180
View File
@@ -0,0 +1,180 @@
//! Relatório em PDF: estrutura do arquivo (xref, páginas), texto com acentos e paginação.
use guara_core::recover::{RecoverReport, RecoveredFile};
use guara_core::report::{self, AuditNote, CaseInfo, ReportInput};
use guara_core::*;
use std::io::Read;
fn found(i: u64) -> Found {
Found {
id: i,
name: format!("fotografia_{i:04}.jpg"),
dir: Some("/Usuários/João/Imagens (cópia)".into()),
category: Category::Image,
ext: "jpg".into(),
size: 1000 + i,
origin: Origin::Carved,
confidence: Confidence::High,
data: DataLoc::Resident { bytes: vec![0; 4] },
modified: Some(1_689_358_925),
created: None,
accessed: None,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note: None,
}
}
fn build(n: u64) -> Vec<u8> {
let files: Vec<Found> = (0..n).map(found).collect();
let rec = RecoverReport {
destination: "/mnt/outro/Recuperados".into(),
recovered: (0..3)
.map(|i| RecoveredFile {
id: i,
path: format!("Fotos/fotografia_{i:04}.jpg"),
size: 1000 + i,
sha256: "ab".repeat(32),
md5: "cd".repeat(16),
sha1: "ef".repeat(20),
confidence: Confidence::High,
origin: Origin::Carved,
partial: false,
})
.collect(),
total_bytes: 3003,
..Default::default()
};
let case = CaseInfo {
case_id: "2026-001".into(),
examiner: "Perita (Ana) \\ Souza".into(),
organization: "Laboratório".into(),
notes: "Observação com acentos: ção, ã, é, ü e “aspas”.".into(),
};
let opts = ScanOptions::default();
report::pdf(&ReportInput {
case: &case,
target: "/dev/sdb",
options: &opts,
layout: None,
found: &files,
warnings: &["aviso importante sobre o disco".to_string()],
started_unix: Some(1_689_358_925),
elapsed: 1.5,
recoveries: &[rec],
source_hashes: None,
audit: Some(AuditNote {
entries: 7,
ok: true,
head: "12".repeat(32),
}),
})
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay[from..]
.windows(needle.len())
.position(|w| w == needle)
.map(|p| p + from)
}
/// Texto dos fluxos de conteúdo, já descomprimidos.
fn streams(pdf: &[u8]) -> Vec<String> {
let mut out = vec![];
let mut at = 0;
while let Some(s) = find(pdf, b">>\nstream\n", at) {
let start = s + 10;
let end = find(pdf, b"\nendstream", start).unwrap();
let mut text = String::new();
flate2::read::ZlibDecoder::new(&pdf[start..end])
.read_to_string(&mut text)
.unwrap_or_else(|e| panic!("fluxo inválido: {e}"));
out.push(text);
at = end;
}
out
}
#[test]
fn pdf_has_a_valid_cross_reference_table() {
let pdf = build(5);
assert!(pdf.starts_with(b"%PDF-1.4"));
assert!(pdf.ends_with(b"%%EOF\n"));
let sx = find(&pdf, b"startxref\n", 0).unwrap() + 10;
let nl = find(&pdf, b"\n", sx).unwrap();
let xref: usize = std::str::from_utf8(&pdf[sx..nl]).unwrap().parse().unwrap();
assert_eq!(&pdf[xref..xref + 4], b"xref");
// cada entrada aponta exatamente para "N 0 obj"
let table = &pdf[xref..];
let header_end = find(table, b"\n", 5).unwrap();
let count: usize = std::str::from_utf8(&table[5..header_end])
.unwrap()
.split(' ')
.nth(1)
.unwrap()
.parse()
.unwrap();
for n in 1..count {
let e = &table[header_end + 1 + n * 20..header_end + 1 + (n + 1) * 20];
let off: usize = std::str::from_utf8(&e[..10]).unwrap().parse().unwrap();
let want = format!("{n} 0 obj");
assert_eq!(&pdf[off..off + want.len()], want.as_bytes(), "objeto {n}");
}
}
#[test]
fn pdf_text_survives_accents_and_escaping() {
let all = streams(&build(5)).join("\n");
// acentos viram bytes WinAnsi em octal; parênteses e barra são escapados
assert!(all.contains("Identifica\\347\\343o do caso"), "{all}");
assert!(all.contains("Perita \\(Ana\\) \\\\ Souza"));
assert!(all.contains("Laborat\\363rio"));
assert!(all.contains("fotografia_0003.jpg"));
assert!(all.contains(&"ab".repeat(32)));
assert!(all.contains(&"12".repeat(32)), "hash da trilha ausente");
// aspas curvas viram os bytes WinAnsi 0x93/0x94
assert!(all.contains("\\223aspas\\224"));
}
#[test]
fn long_lists_paginate_with_numbered_footers() {
let pdf = build(400);
let pages = streams(&pdf);
assert!(pages.len() >= 5, "{} páginas", pages.len());
let last = pages.len();
assert!(pages[last - 1].contains(&format!("p\\341gina {last} de {last}")));
assert!(pages[0].contains(&format!("p\\341gina 1 de {last}")));
// o cabeçalho da tabela se repete nas páginas seguintes
assert!(pages[2].contains("Modificado \\(UTC\\)"));
// todas as linhas aparecem exatamente uma vez
let all = pages.join("\n");
assert_eq!(all.matches("fotografia_0399.jpg").count(), 1);
// a primeira também consta da lista de recuperados
assert_eq!(all.matches("fotografia_0000.jpg").count(), 2);
}
#[test]
fn text_wraps_instead_of_running_off_the_page() {
use guara_core::pdf::{text_width, wrap, Font};
let lines = wrap(&"a".repeat(200), 8.0, Font::Mono, 100.0);
assert!(lines.len() > 5);
assert!(lines
.iter()
.all(|l| text_width(l, 8.0, Font::Mono) <= 100.0 + 1e-6));
let words = wrap(
"uma frase comum com várias palavras curtas",
10.0,
Font::Regular,
90.0,
);
assert!(
words.len() >= 3
&& words
.iter()
.all(|l| text_width(l, 10.0, Font::Regular) <= 90.0)
);
assert_eq!(wrap("a\nb", 10.0, Font::Regular, 100.0), ["a", "b"]);
}
+267
View File
@@ -0,0 +1,267 @@
//! Assinatura digital: chaves, arquivos, adulteração, recuperação inteira e interoperabilidade.
use guara_core::recover;
use guara_core::sign::{self, SigningKey};
use guara_core::source::FileSource;
use guara_core::*;
use std::path::Path;
fn resident(id: u64, name: &str, bytes: Vec<u8>) -> Found {
let ext = name.rsplit('.').next().unwrap_or("").to_string();
Found {
id,
name: name.into(),
dir: None,
category: signatures::category_for_ext(&ext),
ext,
size: bytes.len() as u64,
origin: Origin::Carved,
confidence: Confidence::High,
data: DataLoc::Resident { bytes },
modified: None,
created: None,
accessed: None,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note: None,
}
}
/// `recover` assina sozinho quando `GUARA_SIGNING_KEY` existe; testes que recuperam não se misturam.
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn keyed(dir: &Path) -> SigningKey {
let k = SigningKey::generate();
k.save(&dir.join("k.key")).unwrap();
k
}
#[test]
fn key_roundtrip_and_no_overwrite() {
let dir = tempfile::tempdir().unwrap();
let k = keyed(dir.path());
let again = SigningKey::load(&dir.path().join("k.key")).unwrap();
assert_eq!(k.public_bytes(), again.public_bytes());
assert_eq!(k.fingerprint(), again.fingerprint());
assert_eq!(k.fingerprint().len(), 24); // 5 grupos de 4 + 4 separadores
let pubfile = std::fs::read_to_string(dir.path().join("k.pub")).unwrap();
assert!(pubfile.contains(&k.fingerprint()));
let e = SigningKey::generate()
.save(&dir.path().join("k.key"))
.err()
.unwrap()
.to_string();
assert!(e.contains("já existe uma chave"), "{e}");
// a chave antiga continua intacta
assert_eq!(
SigningKey::load(&dir.path().join("k.key"))
.unwrap()
.fingerprint(),
k.fingerprint()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join("k.key"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"a chave privada precisa ser só do dono"
);
}
std::fs::write(dir.path().join("lixo.key"), "outra coisa").unwrap();
assert!(SigningKey::load(&dir.path().join("lixo.key")).is_err());
}
#[test]
fn signed_file_verifies_and_tampering_is_caught() {
let dir = tempfile::tempdir().unwrap();
let k = keyed(dir.path());
let f = dir.path().join("relatorio.pdf");
std::fs::write(&f, b"conteudo do relatorio").unwrap();
let sig = sign::sign_file(&k, &f, Some("Perita Ana")).unwrap();
assert_eq!(sig, sign::signature_path(&f));
let c = sign::verify_file(&f, None, Some(&k.fingerprint())).unwrap();
assert!(
c.valid && c.trusted == Some(true) && c.signer.as_deref() == Some("Perita Ana"),
"{c:?}"
);
// fingerprint aceito em outro formato (maiúsculas, sem dois pontos)
let loose = k.fingerprint().replace(':', " ").to_uppercase();
assert_eq!(
sign::verify_file(&f, None, Some(&loose)).unwrap().trusted,
Some(true)
);
// outra chave não é a esperada
let other = SigningKey::generate().fingerprint();
let c = sign::verify_file(&f, None, Some(&other)).unwrap();
assert!(!c.valid && c.trusted == Some(false));
// arquivo alterado
std::fs::write(&f, b"conteudo ALTERADO do relatorio").unwrap();
let c = sign::verify_file(&f, None, None).unwrap();
assert!(!c.valid && !c.file_matches && c.signature_valid, "{c:?}");
std::fs::write(&f, b"conteudo do relatorio").unwrap();
assert!(sign::verify_file(&f, None, None).unwrap().valid);
// campos da assinatura alterados (hora, assinante, hash) invalidam
let raw = std::fs::read_to_string(&sig).unwrap();
for (from, to) in [
("Perita Ana", "Outra Pessoa"),
("\"assinado_em\": \"20", "\"assinado_em\": \"19"),
] {
assert!(raw.contains(from), "{from}");
std::fs::write(&sig, raw.replacen(from, to, 1)).unwrap();
let c = sign::verify_file(&f, None, None).unwrap();
assert!(!c.valid && !c.signature_valid, "{from}: {c:?}");
}
// trocar a chave pública pela de outra pessoa também não passa
let mut j: serde_json::Value = serde_json::from_str(&raw).unwrap();
let evil = SigningKey::generate();
j["chave_publica"] = evil
.public_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
.into();
std::fs::write(&sig, serde_json::to_vec(&j).unwrap()).unwrap();
assert!(!sign::verify_file(&f, None, None).unwrap().valid);
}
#[test]
fn recovery_folder_is_checked_file_by_file() {
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let k = keyed(dir.path());
let src = dir.path().join("origem.img");
std::fs::write(&src, b"x").unwrap();
let fs = FileSource::open(&src).unwrap();
let a = resident(1, "a.txt", b"primeiro arquivo".to_vec());
let b = resident(2, "b.txt", b"segundo arquivo".to_vec());
let out = dir.path().join("saida");
let rep = recover::recover(&fs, &[&a, &b], &out, &|| false, &mut |_, _, _| {}).unwrap();
assert_eq!(rep.recovered.len(), 2);
let manifest = out.join("guara-manifesto.json");
sign::sign_file(&k, &manifest, None).unwrap();
let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap();
assert!(c.valid && c.ok == 2 && c.files == 2, "{c:?}");
let first = out.join(&rep.recovered[0].path);
let second = out.join(&rep.recovered[1].path);
std::fs::write(&first, b"adulterado").unwrap();
std::fs::remove_file(&second).unwrap();
let c = sign::verify_recovery(&out, None).unwrap();
assert!(
!c.valid && c.changed.len() == 1 && c.missing.len() == 1,
"{c:?}"
);
// manifesto adulterado (hash trocado para acompanhar o arquivo alterado) quebra a assinatura
let m = std::fs::read_to_string(&manifest).unwrap();
let real = &rep.recovered[0].sha256;
let fake = sign::hash_file(&first).unwrap().0;
std::fs::write(&manifest, m.replace(real, &fake)).unwrap();
let c = sign::verify_recovery(&out, None).unwrap();
let sigcheck = c.manifest_signature.as_ref().unwrap();
assert!(!sigcheck.valid && !sigcheck.file_matches, "{c:?}");
}
#[test]
fn manifest_paths_cannot_escape_the_folder() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fora.txt"), b"segredo").unwrap();
let out = dir.path().join("saida");
std::fs::create_dir(&out).unwrap();
let sha = sign::hash_file(&dir.path().join("fora.txt")).unwrap().0;
std::fs::write(
out.join("guara-manifesto.json"),
serde_json::json!({ "relatorio": { "recovered": [ { "path": "../fora.txt", "sha256": sha } ] } }).to_string(),
)
.unwrap();
let c = sign::verify_recovery(&out, None).unwrap();
assert!(!c.valid && c.changed[0].contains("fora da pasta"), "{c:?}");
}
/// O mesmo que o `openssl` faria: a assinatura é Ed25519 padrão sobre a mensagem documentada.
#[test]
fn signature_verifies_with_openssl() {
let Ok(v) = std::process::Command::new("openssl")
.arg("version")
.output()
else {
return;
};
if !v.status.success() {
return;
}
let dir = tempfile::tempdir().unwrap();
let k = keyed(dir.path());
let f = dir.path().join("doc.txt");
std::fs::write(&f, b"hello\n").unwrap();
let sig = sign::sign_file(&k, &f, Some("Ana")).unwrap();
let j: serde_json::Value = serde_json::from_slice(&std::fs::read(sig).unwrap()).unwrap();
let unhex = |s: &str| -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
};
let msg = format!(
"{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante=Ana\n",
j["formato"].as_str().unwrap(),
j["arquivo"].as_str().unwrap(),
j["tamanho"],
j["sha256"].as_str().unwrap(),
j["assinado_em"].as_str().unwrap()
);
std::fs::write(dir.path().join("msg"), msg).unwrap();
std::fs::write(
dir.path().join("sig"),
unhex(j["assinatura"].as_str().unwrap()),
)
.unwrap();
let mut der = unhex("302a300506032b6570032100");
der.extend(unhex(j["chave_publica"].as_str().unwrap()));
std::fs::write(dir.path().join("pub.der"), der).unwrap();
let r = std::process::Command::new("openssl")
.current_dir(dir.path())
.args([
"pkeyutl", "-verify", "-pubin", "-inkey", "pub.der", "-keyform", "DER", "-rawin",
"-in", "msg", "-sigfile", "sig",
])
.output()
.unwrap();
// OpenSSL 1.1 não tem -rawin para Ed25519; só exige sucesso onde ele existe
let text = String::from_utf8_lossy(&r.stdout).to_string() + &String::from_utf8_lossy(&r.stderr);
if text.contains("Unknown option") || text.contains("unknown option") {
return;
}
assert!(r.status.success(), "{text}");
}
/// Com chave configurada, a recuperação e o relatório saem assinados sem ninguém pedir.
#[test]
fn configured_key_signs_manifest_automatically() {
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let k = keyed(dir.path());
std::env::set_var("GUARA_SIGNING_KEY", dir.path().join("k.key"));
std::env::set_var("GUARA_CONFIG_DIR", dir.path().join("cfg"));
let src = dir.path().join("origem.img");
std::fs::write(&src, b"x").unwrap();
let fs = FileSource::open(&src).unwrap();
let a = resident(1, "a.txt", b"dados".to_vec());
let out = dir.path().join("saida");
recover::recover(&fs, &[&a], &out, &|| false, &mut |_, _, _| {}).unwrap();
std::env::remove_var("GUARA_SIGNING_KEY");
let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap();
assert!(c.valid && c.manifest_signature.is_some(), "{c:?}");
}
+917
View File
@@ -0,0 +1,917 @@
//! Imagens de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG.
//!
//! Os arquivos de `tests/fixtures/vdisk` foram feitos por ferramentas reais (VirtualBox para
//! VMDK/VHD, qemu para QCOW2, hdiutil para DMG) e conferidos contra o conteúdo bruto. VHDX, DMG
//! sintético e discos diferenciais são montados aqui, byte a byte, a partir da especificação.
//! As imagens do dfvfs em target/testdata (scripts/fetch-testdata.sh) são conferidas por hash,
//! calculado com um leitor independente (7-Zip), e pulam quando ausentes.
use guara_core::source::{open_path, read_vec, Source};
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::{Path, PathBuf};
fn fx(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/vdisk")
.join(name)
}
fn raw() -> Vec<u8> {
std::fs::read(fx("disk.raw")).unwrap()
}
fn dfvfs(name: &str) -> Option<PathBuf> {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../target/testdata/dfvfs")
.join(name);
p.exists().then_some(p)
}
fn sha(src: &dyn Source) -> String {
let mut h = Sha256::new();
let mut off = 0;
while off < src.size() {
let n = (1 << 20).min((src.size() - off) as usize);
h.update(read_vec(src, off, n).unwrap());
off += n as u64;
}
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
/// Lê o disco inteiro e também em pedaços que cruzam blocos, comparando com `want`.
fn check_content(path: &Path, want: &[u8]) {
let s = open_path(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
assert_eq!(s.size(), want.len() as u64, "{}", path.display());
assert!(
read_vec(s.as_ref(), 0, want.len()).unwrap() == want,
"{}: conteúdo difere",
path.display()
);
let mut x = 88172645463325252u64;
for _ in 0..200 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let off = x % want.len() as u64;
let len = 1 + (x >> 20) as usize % 70_000;
let end = (off as usize + len).min(want.len());
let got = read_vec(s.as_ref(), off, end - off as usize).unwrap();
assert!(
got == want[off as usize..end],
"{}: @{off}+{len}",
path.display()
);
}
// leitura além do fim devolve só o que existe
let mut buf = [0u8; 64];
assert_eq!(s.read_at(want.len() as u64 - 10, &mut buf).unwrap(), 10);
assert_eq!(s.read_at(want.len() as u64, &mut buf).unwrap(), 0);
}
// ---------- imagens feitas por ferramentas reais ----------
#[test]
fn virtualbox_vmdk_sparse_and_stream_optimized() {
let want = raw();
check_content(&fx("disk.vmdk"), &want);
// streamOptimized: grãos comprimidos com zlib e cabeçalho no rodapé
check_content(&fx("disk-stream.vmdk"), &want);
}
#[test]
fn virtualbox_vhd_dynamic() {
check_content(&fx("disk.vhd"), &raw());
}
#[test]
fn qemu_qcow2_and_hdiutil_dmg_match_a_reference_reader() {
let s = open_path(&fx("ext2.qcow2")).unwrap();
assert_eq!(s.size(), 4 << 20);
assert_eq!(
sha(s.as_ref()),
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80"
);
// UDIF com chunks zlib, zerados e ignorados
let d = open_path(&fx("hfsplus_zlib.dmg")).unwrap();
assert_eq!(d.size(), 1_954_304);
assert_eq!(
sha(d.as_ref()),
"c86d3616818e175caca2f6022f49e190e5b1cfe39b2e8f9373119e22aa7eaf58"
);
}
// ---------- montagem de imagens sintéticas ----------
fn be(v: &mut [u8], o: usize, x: u64, n: usize) {
v[o..o + n].copy_from_slice(&x.to_be_bytes()[8 - n..]);
}
fn le(v: &mut [u8], o: usize, x: u64, n: usize) {
v[o..o + n].copy_from_slice(&x.to_le_bytes()[..n]);
}
fn deflate(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
fn zlib(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
/// QCOW2 v3 com clusters de 64 KiB: zerados (sem entrada e com a flag), brutos e comprimidos.
fn build_qcow2(data: &[u8]) -> Vec<u8> {
const CS: usize = 1 << 16;
let clusters = data.len() / CS;
let mut img = vec![0u8; 3 * CS];
img[..4].copy_from_slice(b"QFI\xfb");
be(&mut img, 4, 3, 4);
be(&mut img, 20, 16, 4);
be(&mut img, 24, data.len() as u64, 8);
be(&mut img, 36, 1, 4);
be(&mut img, 40, CS as u64, 8);
be(&mut img, 100, 112, 4);
be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8); // L1 -> L2 no cluster 2
for i in 0..clusters {
let c = &data[i * CS..(i + 1) * CS];
let entry: u64 = if c.iter().all(|&b| b == 0) {
if i % 2 == 0 {
0
} else {
1 // zerado explícito
}
} else if i % 3 == 0 {
let z = deflate(c);
let host = img.len() as u64;
let sectors = z.len().div_ceil(512);
img.extend_from_slice(&z);
img.resize(img.len().div_ceil(512) * 512, 0);
1 << 62 | ((sectors as u64 - 1) << (62 - 8)) | host
} else {
let host = img.len() as u64;
img.extend_from_slice(c);
host | 1 << 63
};
be(&mut img, 2 * CS + i * 8, entry, 8);
}
img
}
fn vhd_footer(size: u64, kind: u32, data_offset: u64) -> [u8; 512] {
let mut f = [0u8; 512];
f[..8].copy_from_slice(b"conectix");
be(&mut f, 8, 2, 4);
be(&mut f, 12, 0x0001_0000, 4);
be(&mut f, 16, data_offset, 8);
f[28..32].copy_from_slice(b"guar");
be(&mut f, 40, size, 8);
be(&mut f, 48, size, 8);
be(&mut f, 60, kind as u64, 4);
let sum = f.iter().fold(0u32, |a, &b| a.wrapping_add(b as u32));
be(&mut f, 64, !sum as u64, 4);
f
}
fn base64(d: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in d.chunks(3) {
let n = (c[0] as u32) << 16
| (*c.get(1).unwrap_or(&0) as u32) << 8
| *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
if i <= c.len() {
s.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
s.push('=');
}
}
if s.len() % 76 == 0 {
s.push('\n');
}
}
s
}
/// ADC só com literais e cópias de 3 bytes (deslocamento 8), como o `hdiutil` produz.
fn adc_pattern(len: usize) -> (Vec<u8>, Vec<u8>) {
let pat = *b"ADC-test";
let mut enc = vec![0x80 | 7];
enc.extend_from_slice(&pat);
let mut made = 8;
while made < len {
let rem = len - made;
let mut n = rem.min(67);
if rem - n > 0 && rem - n < 3 {
n = rem - 3;
}
if n >= 4 {
enc.extend_from_slice(&[0x40 | (n as u8 - 4), 0, 7]);
} else {
enc.extend_from_slice(&[((n as u8 - 3) << 2), 7]); // 2 bytes, comprimento 3
}
made += n;
}
let want: Vec<u8> = (0..len).map(|i| pat[i % 8]).collect();
(enc, want)
}
struct DmgChunk {
ty: u32,
sectors: u64,
payload: Vec<u8>,
}
fn build_dmg(chunks: &[DmgChunk], total_sectors: u64) -> Vec<u8> {
let mut fork = vec![];
let mut table = vec![0u8; 204];
table[..4].copy_from_slice(b"mish");
be(&mut table, 4, 1, 4);
be(&mut table, 16, total_sectors, 8);
be(&mut table, 200, chunks.len() as u64 + 1, 4);
let mut sector = 0;
for c in chunks {
let mut e = [0u8; 40];
be(&mut e, 0, c.ty as u64, 4);
be(&mut e, 8, sector, 8);
be(&mut e, 16, c.sectors, 8);
be(&mut e, 24, fork.len() as u64, 8);
be(&mut e, 32, c.payload.len() as u64, 8);
table.extend_from_slice(&e);
fork.extend_from_slice(&c.payload);
sector += c.sectors;
}
let mut end = [0u8; 40];
be(&mut end, 0, 0xFFFF_FFFF, 4);
be(&mut end, 8, sector, 8);
table.extend_from_slice(&end);
let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>resource-fork</key><dict>\
<key>blkx</key><array><dict><key>Data</key><data>\n{}\n</data><key>Name</key><string>teste</string></dict></array>\
<key>plst</key><array><dict><key>Data</key><data>AAAA</data></dict></array></dict></dict></plist>",
base64(&table)
);
let mut img = fork;
let xml_off = img.len() as u64;
img.extend_from_slice(xml.as_bytes());
let mut k = vec![0u8; 512];
k[..4].copy_from_slice(b"koly");
be(&mut k, 4, 4, 4);
be(&mut k, 8, 512, 4);
be(&mut k, 32, xml_off, 8);
be(&mut k, 216, xml_off, 8);
be(&mut k, 224, xml.len() as u64, 8);
be(&mut k, 492, total_sectors, 8);
img.extend_from_slice(&k);
img
}
// ---------- QCOW2 ----------
#[test]
fn qcow2_with_zero_raw_and_compressed_clusters() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.qcow2");
std::fs::write(&p, build_qcow2(&want)).unwrap();
check_content(&p, &want);
}
#[test]
fn qcow2_with_encryption_or_a_remote_backing_file_is_refused() {
let dir = tempfile::tempdir().unwrap();
let mut img = build_qcow2(&raw());
be(&mut img, 32, 2, 4);
let p = dir.path().join("e.qcow2");
std::fs::write(&p, &img).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("LUKS"), "{e}");
let mut img = build_qcow2(&raw());
let name = b"nbd://servidor/disco";
img[512..512 + name.len()].copy_from_slice(name);
be(&mut img, 8, 512, 8);
be(&mut img, 16, name.len() as u64, 4);
let p = dir.path().join("r.qcow2");
std::fs::write(&p, &img).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("não é um arquivo local"), "{e}");
}
/// Filho QCOW2 que só aloca o cluster 1 (dados), marca o 2 como zerado e deixa o resto para o pai.
fn build_qcow2_child(backing: &str, fmt: Option<&str>, own: &[u8]) -> Vec<u8> {
const CS: usize = 1 << 16;
let mut img = vec![0u8; 3 * CS];
img[..4].copy_from_slice(b"QFI\xfb");
be(&mut img, 4, 3, 4);
be(&mut img, 16, backing.len() as u64, 4);
be(&mut img, 8, 1024, 8);
img[1024..1024 + backing.len()].copy_from_slice(backing.as_bytes());
be(&mut img, 20, 16, 4);
be(&mut img, 24, 16 * CS as u64, 8);
be(&mut img, 36, 1, 4);
be(&mut img, 40, CS as u64, 8);
be(&mut img, 100, 112, 4);
if let Some(f) = fmt {
// extensão 0xE2792ACA com o nome do formato do apoio, depois o fim das extensões
be(&mut img, 112, 0xE279_2ACA, 4);
be(&mut img, 116, f.len() as u64, 4);
img[120..120 + f.len()].copy_from_slice(f.as_bytes());
}
be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8);
let host = img.len() as u64;
img.extend_from_slice(own);
img.resize(img.len().div_ceil(CS) * CS, 0);
be(&mut img, 2 * CS + 8, host | (1 << 63), 8); // cluster 1
be(&mut img, 2 * CS + 16, 1, 8); // cluster 2: zerado de propósito
img
}
#[test]
fn qcow2_backing_file_supplies_what_the_child_did_not_allocate() {
const CS: usize = 1 << 16;
let base = raw();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("base.raw"), &base).unwrap();
let own = vec![0x42u8; CS];
let mut expect = base.clone();
expect[CS..2 * CS].copy_from_slice(&own);
expect[2 * CS..3 * CS].fill(0);
for (name, fmt) in [("a.qcow2", Some("raw")), ("b.qcow2", None)] {
let p = dir.path().join(name);
std::fs::write(&p, build_qcow2_child("base.raw", fmt, &own)).unwrap();
check_content(&p, &expect);
}
// com o pai em outro formato: o filho aponta para um QCOW2
std::fs::write(dir.path().join("base.qcow2"), build_qcow2(&base)).unwrap();
let p = dir.path().join("c.qcow2");
std::fs::write(&p, build_qcow2_child("base.qcow2", Some("qcow2"), &own)).unwrap();
check_content(&p, &expect);
// sem o pai, a abertura diz o que falta
std::fs::remove_file(dir.path().join("base.raw")).unwrap();
let e = open_path(&dir.path().join("a.qcow2"))
.err()
.unwrap()
.to_string();
assert!(e.contains("disco pai não encontrado"), "{e}");
}
// ---------- VMDK ----------
#[test]
fn vmdk_descriptor_with_flat_and_zero_extents() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
let half = want.len() / 2;
std::fs::write(dir.path().join("a-f001.vmdk"), &want[..half]).unwrap();
std::fs::write(
dir.path().join("a-f002.vmdk"),
&want[half..want.len() - 4096],
)
.unwrap();
let desc = format!(
"# Disk DescriptorFile\nversion=1\nCID=fffffffe\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentFlat\"\n\n# Extent description\nRW {} FLAT \"a-f001.vmdk\" 0\nRW {} FLAT \"a-f002.vmdk\" 0\nRW 8 ZERO\n",
half / 512,
(want.len() - 4096 - half) / 512
);
let p = dir.path().join("a.vmdk");
std::fs::write(&p, desc).unwrap();
let mut expect = want.clone();
expect[want.len() - 4096..].fill(0);
check_content(&p, &expect);
}
/// VMDK esparso feito à mão: 16 grãos de 64 KiB; `grains[i]` = `None` (não alocado, vem do pai),
/// `Some(None)` (zerado de propósito) ou `Some(Some(dados))`.
fn build_vmdk_sparse(descriptor: &str, grains: &[Option<Option<Vec<u8>>>]) -> Vec<u8> {
const GRAIN: usize = 128; // setores
let mut img = vec![0u8; 26 * 512];
img[..4].copy_from_slice(b"KDMV");
le(&mut img, 4, 1, 4);
le(&mut img, 8, 3, 4);
le(&mut img, 12, (grains.len() * GRAIN) as u64, 8);
le(&mut img, 20, GRAIN as u64, 8);
le(&mut img, 28, 1, 8);
le(&mut img, 36, 20, 8);
le(&mut img, 44, 512, 4);
le(&mut img, 56, 21, 8);
le(&mut img, 64, 26, 8);
img[512..512 + descriptor.len()].copy_from_slice(descriptor.as_bytes());
le(&mut img, 21 * 512, 22, 4); // diretório de grãos -> tabela no setor 22
for (i, g) in grains.iter().enumerate() {
let entry = match g {
None => 0,
Some(None) => 1,
Some(Some(d)) => {
let sector = (img.len() / 512) as u64;
let mut d = d.clone();
d.resize(GRAIN * 512, 0);
img.extend_from_slice(&d);
sector
}
};
le(&mut img, 22 * 512 + i * 4, entry, 4);
}
img
}
#[test]
fn vmdk_child_reads_the_parent_for_unallocated_grains() {
let base = raw();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("base.vmdk"), build_vmdk_sparse(
"# Disk DescriptorFile\nversion=1\nCID=11111111\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
&(0..16)
.map(|i| Some(Some(base[i * 65536..(i + 1) * 65536].to_vec())))
.collect::<Vec<_>>(),
))
.unwrap();
let own = vec![0x37u8; 65536];
let mut grains: Vec<Option<Option<Vec<u8>>>> = vec![None; 16];
grains[3] = Some(Some(own.clone()));
grains[5] = Some(None); // zerado de propósito: não consulta o pai
let child = build_vmdk_sparse(
"# Disk DescriptorFile\nversion=1\nCID=22222222\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
&grains,
);
let p = dir.path().join("child.vmdk");
std::fs::write(&p, child).unwrap();
let mut expect = base.clone();
expect[3 * 65536..4 * 65536].copy_from_slice(&own);
expect[5 * 65536..6 * 65536].fill(0);
check_content(&p, &expect);
}
/// Snapshot real do VirtualBox: o filho só diz o UUID do pai (`ddb.uuid.parent`), sem nome.
#[test]
fn virtualbox_snapshot_vmdk_finds_its_parent_by_uuid() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("Snapshots");
std::fs::create_dir(&sub).unwrap();
// o pai fica na pasta de cima, como o VirtualBox organiza a máquina
std::fs::copy(fx("disk.vmdk"), dir.path().join("disco-base.vmdk")).unwrap();
std::fs::copy(fx("vbox-snapshot.vmdk"), sub.join("filho.vmdk")).unwrap();
check_content(&sub.join("filho.vmdk"), &raw());
// sem o pai por perto, a abertura explica
std::fs::remove_file(dir.path().join("disco-base.vmdk")).unwrap();
let e = open_path(&sub.join("filho.vmdk"))
.err()
.unwrap()
.to_string();
assert!(e.contains("identificado só por UUID"), "{e}");
}
#[test]
fn vmdk_child_is_refused_when_the_parent_changed() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("base.vmdk"),
build_vmdk_sparse(
"# Disk DescriptorFile\nversion=1\nCID=aaaaaaaa\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
&vec![None; 16],
),
)
.unwrap();
std::fs::write(
dir.path().join("filho.vmdk"),
build_vmdk_sparse(
"# Disk DescriptorFile\nversion=1\nCID=bbbbbbbb\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
&vec![None; 16],
),
)
.unwrap();
let e = open_path(&dir.path().join("filho.vmdk"))
.err()
.unwrap()
.to_string();
assert!(e.contains("foi alterado depois do filho"), "{e}");
}
#[test]
fn vmdk_with_a_parent_but_no_name_is_refused() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("c.vmdk");
std::fs::write(
&p,
"# Disk DescriptorFile\nversion=1\nparentCID=12345678\ncreateType=\"monolithicFlat\"\nRW 8 ZERO\n",
)
.unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("não diz qual"), "{e}");
}
// ---------- VHD ----------
#[test]
fn vhd_fixed_hides_the_footer() {
let want = raw();
let mut img = want.clone();
img.extend_from_slice(&vhd_footer(want.len() as u64, 2, u64::MAX));
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("f.vhd");
std::fs::write(&p, img).unwrap();
check_content(&p, &want);
}
/// VHD diferencial: a filha só traz o setor 100 e o bloco inteiro do fim; o resto vem do pai.
#[test]
fn vhd_differencing_reads_the_parent_for_missing_sectors() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
let mut parent = want.clone();
parent.extend_from_slice(&vhd_footer(want.len() as u64, 2, u64::MAX));
std::fs::write(dir.path().join("pai.vhd"), &parent).unwrap();
const BLOCK: usize = 1 << 20; // um bloco só
let mut f = vec![0u8; 512 + 1024 + 512];
let hdr = vhd_footer(want.len() as u64, 4, 512);
f[..512].copy_from_slice(&hdr);
let mut dh = [0u8; 1024];
dh[..8].copy_from_slice(b"cxsparse");
be(&mut dh, 8, u64::MAX, 8);
be(&mut dh, 16, 1536, 8); // BAT
be(&mut dh, 28, 1, 4);
be(&mut dh, 32, BLOCK as u64, 4);
let name = "pai.vhd"
.encode_utf16()
.flat_map(|u| u.to_be_bytes())
.collect::<Vec<u8>>();
dh[64..64 + name.len()].copy_from_slice(&name);
f[512..1536].copy_from_slice(&dh);
be(&mut f, 1536, 4, 4); // bloco 0 no setor 4
f.resize(4 * 512, 0);
// bitmap (4096 setores = 512 bytes) com o setor 100 presente, depois os dados
let mut bitmap = vec![0u8; 512];
bitmap[100 / 8] |= 0x80 >> (100 % 8);
f.extend_from_slice(&bitmap);
let mut block = vec![0u8; BLOCK];
let marker = [0xA5u8; 512];
block[100 * 512..101 * 512].copy_from_slice(&marker);
f.extend_from_slice(&block);
f.extend_from_slice(&hdr);
let p = dir.path().join("filho.vhd");
std::fs::write(&p, f).unwrap();
let mut expect = want.clone();
expect[100 * 512..101 * 512].copy_from_slice(&marker);
check_content(&p, &expect);
// sem o pai a abertura falha com o motivo
std::fs::remove_file(dir.path().join("pai.vhd")).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("disco pai não encontrado"), "{e}");
}
// ---------- VHDX ----------
fn guid(s: &str) -> [u8; 16] {
let h: Vec<u8> = s
.replace('-', "")
.as_bytes()
.chunks(2)
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
.collect();
let mut g = [0u8; 16];
g[..4].copy_from_slice(&[h[3], h[2], h[1], h[0]]);
g[4..6].copy_from_slice(&[h[5], h[4]]);
g[6..8].copy_from_slice(&[h[7], h[6]]);
g[8..].copy_from_slice(&h[8..]);
g
}
const MB: usize = 1 << 20;
/// VHDX com blocos de 1 MiB. `blocks[i]`: `None` = não presente, `Some((estado, dados))`.
/// Com `parent`, acrescenta o localizador de pai e o bitmap de setores (`bitmap`).
fn build_vhdx(
size: u64,
blocks: &[Option<(u64, Vec<u8>)>],
parent: Option<&str>,
bitmap: Option<Vec<u8>>,
) -> Vec<u8> {
let mut img = vec![0u8; 4 * MB];
img[..8].copy_from_slice(b"vhdxfile");
img[64 * 1024..64 * 1024 + 4].copy_from_slice(b"head");
img[128 * 1024..128 * 1024 + 4].copy_from_slice(b"head");
// tabela de regiões: BAT em 2 MiB, metadados em 1 MiB
let rt = 192 * 1024;
img[rt..rt + 4].copy_from_slice(b"regi");
le(&mut img, rt + 8, 2, 4);
img[rt + 16..rt + 32].copy_from_slice(&guid("2DC27766-F623-4200-9D64-115E9BFD4A08"));
le(&mut img, rt + 32, 2 * MB as u64, 8);
le(&mut img, rt + 40, MB as u64, 4);
img[rt + 48..rt + 64].copy_from_slice(&guid("8B7CA206-4790-4B9A-B8FE-575F050F886E"));
le(&mut img, rt + 64, MB as u64, 8);
le(&mut img, rt + 72, MB as u64, 4);
// metadados
let m = MB;
img[m..m + 8].copy_from_slice(b"metadata");
let mut items: Vec<([u8; 16], Vec<u8>)> = vec![];
let mut params = vec![0u8; 8];
le(&mut params, 0, MB as u64, 4);
le(&mut params, 4, if parent.is_some() { 2 } else { 0 }, 4);
items.push((guid("CAA16737-FA36-4D43-B3B6-33F0AA44E76B"), params));
items.push((
guid("2FA54224-CD1B-4876-B211-5DBED83BF4B8"),
size.to_le_bytes().to_vec(),
));
items.push((
guid("8141BF1D-A96F-4709-BA47-F233A8FAAB5F"),
512u32.to_le_bytes().to_vec(),
));
if let Some(p) = parent {
let key: Vec<u8> = "relative_path"
.encode_utf16()
.flat_map(|u| u.to_le_bytes())
.collect();
let val: Vec<u8> = p.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let mut loc = vec![0u8; 20 + 12];
loc[..16].copy_from_slice(&guid("B04AEFB7-D19E-4A81-B789-25B8E9445913"));
le(&mut loc, 18, 1, 2);
let (ko, vo) = (32, 32 + key.len());
le(&mut loc, 20, ko as u64, 4);
le(&mut loc, 24, vo as u64, 4);
le(&mut loc, 28, key.len() as u64, 2);
le(&mut loc, 30, val.len() as u64, 2);
loc.extend_from_slice(&key);
loc.extend_from_slice(&val);
items.push((guid("A8D35F2D-B30B-454D-ABF7-D3D84834AB0C"), loc));
}
le(&mut img, m + 10, items.len() as u64, 2);
let mut at = 64 * 1024;
for (i, (id, data)) in items.iter().enumerate() {
let e = m + 32 + i * 32;
img[e..e + 16].copy_from_slice(id);
le(&mut img, e + 16, at as u64, 4);
le(&mut img, e + 20, data.len() as u64, 4);
img[m + at..m + at + data.len()].copy_from_slice(data);
at += data.len().div_ceil(8) * 8;
}
// blocos de dados a partir de 4 MiB; o BAT guarda estado e deslocamento (em MiB)
let ratio = (1usize << 23) * 512 / MB;
let bat = 2 * MB;
for (i, b) in blocks.iter().enumerate() {
let Some((state, data)) = b else { continue };
let mut off = 0;
if !data.is_empty() {
off = img.len();
let mut d = data.clone();
d.resize(MB, 0);
img.extend_from_slice(&d);
}
le(
&mut img,
bat + (i + i / ratio) * 8,
state | (off as u64 >> 20) << 20,
8,
);
}
if let Some(bm) = bitmap {
let off = img.len();
let mut d = bm;
d.resize(MB, 0);
img.extend_from_slice(&d);
le(&mut img, bat + ratio * 8, 6 | (off as u64 >> 20) << 20, 8);
}
img
}
#[test]
fn vhdx_dynamic_with_zero_and_missing_blocks() {
let want = raw(); // 1 MiB = um bloco
let mut expect = want.clone();
expect.extend_from_slice(&vec![0u8; 3 * MB]);
let tail = vec![0x5Au8; 100_000];
expect[3 * MB..3 * MB + tail.len()].copy_from_slice(&tail);
let img = build_vhdx(
4 * MB as u64,
&[
Some((6, want.clone())),
Some((2, vec![])), // zerado
None, // não presente
Some((6, tail)),
],
None,
None,
);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.vhdx");
std::fs::write(&p, img).unwrap();
check_content(&p, &expect);
}
#[test]
fn vhdx_differencing_uses_the_sector_bitmap() {
let want = raw();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pai.raw"), &want).unwrap();
// filho: bloco 0 parcialmente presente, só com os setores 3 e 2000..2010 no filho
let mut data = vec![0u8; MB];
let mut bm = vec![0u8; 512];
let mut expect = want.clone();
for s in std::iter::once(3usize).chain(2000..2010) {
data[s * 512..(s + 1) * 512].fill(0x77);
expect[s * 512..(s + 1) * 512].fill(0x77);
bm[s / 8] |= 1 << (s % 8);
}
let img = build_vhdx(MB as u64, &[Some((7, data))], Some("pai.raw"), Some(bm));
let p = dir.path().join("filho.vhdx");
std::fs::write(&p, img).unwrap();
check_content(&p, &expect);
}
// ---------- DMG ----------
#[test]
fn dmg_with_every_chunk_type() {
let want = raw();
let sec = 512usize;
// 8 chunks de 128 setores (64 KiB)
let part = |i: usize| want[i * 128 * sec..(i + 1) * 128 * sec].to_vec();
let (adc_enc, adc_want) = adc_pattern(128 * sec);
let mut bz = bzip2::write::BzEncoder::new(vec![], bzip2::Compression::default());
bz.write_all(&part(3)).unwrap();
let chunks = vec![
DmgChunk {
ty: 1,
sectors: 128,
payload: part(0),
}, // bruto
DmgChunk {
ty: 0,
sectors: 128,
payload: vec![],
}, // zerado
DmgChunk {
ty: 0x8000_0005,
sectors: 128,
payload: zlib(&part(2)),
},
DmgChunk {
ty: 0x8000_0006,
sectors: 128,
payload: bz.finish().unwrap(),
},
DmgChunk {
ty: 0x8000_0004,
sectors: 128,
payload: adc_enc,
},
DmgChunk {
ty: 2,
sectors: 128,
payload: vec![],
}, // ignorado
DmgChunk {
ty: 0x8000_0005,
sectors: 128,
payload: zlib(&part(6)),
},
];
let mut expect = vec![];
expect.extend(part(0));
expect.extend(vec![0u8; 128 * sec]);
expect.extend(part(2));
expect.extend(part(3));
expect.extend(adc_want);
expect.extend(vec![0u8; 128 * sec]);
expect.extend(part(6));
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.dmg");
std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap();
check_content(&p, &expect);
}
/// Chunks `ULFO` com dados compactados pelo compressor de referência da Apple (`lzfse`):
/// um bloco `bvx2` (FSE, 64 KiB) e um pequeno (`bvxn`/LZVN).
#[test]
fn dmg_lzfse_chunks_from_the_reference_compressor() {
let big = std::fs::read(fx("lzfse-64k.bin")).unwrap();
let small = std::fs::read(fx("lzfse-small.bin")).unwrap();
let mut small_padded = small.clone();
small_padded.resize(small.len().div_ceil(512) * 512, 0);
let chunks = [
DmgChunk {
ty: 0x8000_0007,
sectors: (big.len() / 512) as u64,
payload: std::fs::read(fx("lzfse-64k.bin.lzfse")).unwrap(),
},
DmgChunk {
ty: 0x8000_0007,
sectors: (small_padded.len() / 512) as u64,
payload: std::fs::read(fx("lzfse-small.bin.lzfse")).unwrap(),
},
];
let mut expect = big.clone();
expect.extend_from_slice(&small_padded);
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("l.dmg");
std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap();
check_content(&p, &expect);
}
#[test]
fn dmg_with_a_corrupt_lzfse_chunk_and_encrypted_are_refused_with_a_reason() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("l.dmg");
let chunks = [DmgChunk {
ty: 0x8000_0007,
sectors: 8,
payload: vec![1, 2, 3],
}];
std::fs::write(&p, build_dmg(&chunks, 8)).unwrap();
let s = open_path(&p).unwrap();
let e = read_vec(s.as_ref(), 0, 512).unwrap_err().to_string();
assert!(e.contains("LZFSE"), "{e}");
let p = dir.path().join("e.dmg");
let mut img = b"encrcdsa".to_vec();
img.resize(8192, 0);
std::fs::write(&p, img).unwrap();
let e = open_path(&p).err().expect("deveria recusar").to_string();
assert!(e.contains("cifrado"), "{e}");
}
// ---------- imagens reais do dfvfs (target/testdata) ----------
#[test]
fn dfvfs_images_match_a_reference_reader() {
// hashes do disco virtual inteiro, conferidos com o 7-Zip (árvore de arquivos e partições)
let table: &[(&str, &str)] = &[
(
"ext2.vhdx",
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80",
),
(
"ext2.vmdk",
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80",
),
(
"ext2.vhd",
"870be7ae16c1fa8faab05c6eb9205dc9a7ae35c5f552c5cf8a267c0bc6a5cb99",
),
(
"fvdetest.qcow2",
"732091e57632b7a2332f218d2d3a9ce1355a836ebacce91615a2c30a178e4ec3",
),
(
"windows_volume.qcow2",
"44abe9deca9658f331a2bdba2deb53192d04f35d157a1fb3ba0597b03f0524f2",
),
(
"apm.dmg",
"897f487f4971e72c762e00460935e3b25176cda35620b508b490f65a7e0118ff",
),
(
"ntfs-dynamic.vhd",
"8b10b52e3d1e76075d05a5101a6536b9fa8376a5b8f7c22f502d0987d165c876",
),
(
"ntfs-fixed.vhd",
"cd81fade3878f1b2957f1ece168201876bdf4e90c9b30f60c2f232eba5fea76c",
),
(
"ntfs-dynamic.vhdx",
"b92731f8e4bf3532418444d22a9086823a8122569500acd09cd8d2ce20eb98e9",
),
(
"ntfs-fixed.vhdx",
"5237dd895f38052344e2ef35a207fe9df08cd18d17b6ea857a1b93cc475e1f2e",
),
(
"fat-differential.vhd",
"f4b61a33ee2f7e8c19f737792a4107e46340026a9a829e4169b751901cc3ef02",
),
(
"ntfs-differential.vhd",
"b7922a46a6b08fa4f4fc0c51a25d5587d1d49f15cfade45ca740dd07110ae144",
),
(
"fat-differential.vhdx",
"a538161f34bc44c12bf9a542ce9218741c62728723ea18d0e4f6ea92e9f32e93",
),
(
"ntfs-differential.vhdx",
"1ef1fb5360247aa7d36a38415bc7bf6e1e52f62af5e6bd1283815e82584c7efc",
),
];
for (name, hash) in table {
let Some(p) = dfvfs(name) else { continue };
let s = open_path(&p).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(&sha(s.as_ref()), hash, "{name}");
}
}
#[test]
fn partition_table_is_found_inside_a_qcow2() {
let Some(p) = dfvfs("fvdetest.qcow2") else {
return;
};
let s = open_path(&p).unwrap();
let layout = guara_core::partition::analyze(s.as_ref(), false);
assert_eq!(layout.table, "gpt");
assert_eq!(layout.partitions.len(), 1);
}
+163
View File
@@ -268,3 +268,166 @@ fn reference_image_is_recognized() {
let l = c.layout.unwrap();
assert_eq!(l.partitions[0].fs, FsKind::Xfs);
}
/// Meta: exclusão feita pelo kernel (scripts/make-xfs-real-deletion.sh, precisa de root): cada
/// arquivo apagado volta pelo nome, na pasta certa, com o conteúdo original no começo, e os que
/// ficaram não aparecem como apagados.
///
/// HOJE FALHA, e é por isso que está `ignore`: num XFS v5 real o kernel zera o inode e a lista de
/// extensões ao apagar, então só a simulação (teste acima) deixa a metadados para ler. Recuperar
/// nome e conteúdo de um XFS real exige ler o diário (log) do sistema de arquivos. Rodar com
/// `cargo test -p guara-core --test xfs -- --ignored` para ver o estado.
#[test]
#[ignore = "XFS real: o kernel apaga os metadados; falta ler o diário (ver ROADMAP)"]
fn real_kernel_deletion_names_are_recovered() {
let dir =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete");
let (img, tsv) = (dir.join("disk.img"), dir.join("expected.tsv"));
if !(img.exists() && tsv.exists()) {
assert!(
std::env::var("GUARA_REQUIRE_FIXTURES").is_err(),
"faltou a imagem: sudo scripts/make-xfs-real-deletion.sh"
);
eprintln!("SKIP: rode sudo scripts/make-xfs-real-deletion.sh");
return;
}
init();
let rows: Vec<(String, u64, String, bool)> = std::fs::read_to_string(&tsv)
.unwrap()
.lines()
.map(|l| {
let c: Vec<&str> = l.split('\t').collect();
(
c[0].to_string(),
c[1].parse().unwrap(),
c[2].to_string(),
c[3] == "deleted",
)
})
.collect();
let c = scan_path(
&img,
&ScanOptions {
mode: ScanMode::Deep,
..Default::default()
},
);
let xfs: Vec<&Found> = c.found.iter().filter(|f| f.origin == Origin::Xfs).collect();
let listing = || {
xfs.iter()
.map(|f| {
format!(
"{}/{} ({} bytes, {:?})",
f.dir.as_deref().unwrap_or(""),
f.name,
f.size,
f.confidence
)
})
.collect::<Vec<_>>()
};
let out = tempfile::tempdir().unwrap();
for (rel, size, sha, deleted) in &rows {
let (dirname, name) = match rel.rsplit_once('/') {
Some((d, n)) => (format!("/{d}"), n),
None => (String::new(), rel.as_str()),
};
let hit = xfs
.iter()
.find(|f| f.name == name && f.dir.as_deref().unwrap_or("") == dirname);
if !*deleted {
assert!(
hit.is_none(),
"{rel} não foi apagado, mas apareceu como recuperável: {:?}",
listing()
);
continue;
}
let f = hit.unwrap_or_else(|| {
panic!(
"{rel} apagado pelo kernel não foi encontrado; achados: {:?}",
listing()
)
});
let rep = recover_all(&img, std::slice::from_ref(*f), &out.path().join(name));
assert!(rep.failed.is_empty(), "{rel}: {:?}", rep.failed);
let rp = PathBuf::from(&rep.recovered[0].path);
let got = std::fs::read(if rp.is_absolute() {
rp
} else {
out.path().join(name).join(rp)
})
.unwrap();
// o tamanho exato se perde na exclusão: o arquivo vem com os blocos inteiros
assert!(
got.len() as u64 >= *size,
"{rel}: veio menor ({} < {size})",
got.len()
);
assert_eq!(
sha256(&got[..*size as usize]),
*sha,
"{rel}: o conteúdo inicial difere do original"
);
}
}
/// O que funciona hoje num XFS apagado de verdade: o conteúdo dos blocos livres continua lá e a
/// varredura profunda recupera, por carving, o arquivo com estrutura reconhecível (SQLite), com
/// o conteúdo idêntico ao original. O nome e a pasta se perdem.
#[test]
fn real_kernel_deletion_content_is_recovered_by_carving() {
let dir =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete");
let (img, tsv) = (dir.join("disk.img"), dir.join("expected.tsv"));
if !(img.exists() && tsv.exists()) {
assert!(
std::env::var("GUARA_REQUIRE_FIXTURES").is_err(),
"faltou a imagem: sudo scripts/make-xfs-real-deletion.sh"
);
eprintln!("SKIP: rode sudo scripts/make-xfs-real-deletion.sh");
return;
}
init();
let want = std::fs::read_to_string(&tsv)
.unwrap()
.lines()
.map(|l| l.split('\t').collect::<Vec<_>>())
.find(|c| c[0].ends_with(".sqlite"))
.map(|c| (c[1].parse::<usize>().unwrap(), c[2].to_string()))
.expect("o SQLite apagado consta do expected.tsv");
let c = scan_path(
&img,
&ScanOptions {
mode: ScanMode::Deep,
..Default::default()
},
);
let out = tempfile::tempdir().unwrap();
let mut seen = vec![];
for (i, f) in c.found.iter().enumerate() {
if f.size as usize != want.0 {
continue;
}
let rep = recover_all(
&img,
std::slice::from_ref(f),
&out.path().join(i.to_string()),
);
let rp = PathBuf::from(&rep.recovered[0].path);
let p = if rp.is_absolute() {
rp
} else {
out.path().join(i.to_string()).join(rp)
};
seen.push(sha256_file(&p));
}
assert!(
seen.contains(&want.1),
"o SQLite apagado não voltou idêntico; achados do mesmo tamanho: {seen:?}; todos: {:?}",
c.found
.iter()
.map(|f| (&f.name, f.size, f.origin))
.collect::<Vec<_>>()
);
}
+102
View File
@@ -0,0 +1,102 @@
# Architecture
[English](architecture.md) · [Português](pt-BR/arquitetura.md)
Guará is a Rust workspace with three parts: a core (`guara-core`), a terminal binary (`guara-cli`) and an interface (`apps/gui`) that is the same web page served in two ways.
```
crates/guara-core library: everything that reads, interprets and recovers
crates/guara-cli `guara` binary: commands and the local interface server
apps/gui/ui HTML, CSS and JS of the interface
apps/gui/src-tauri desktop shell (Tauri) that loads the same interface
```
## Flow of a scan
```
source (device or image)
│ source / device read-only access, bad sectors tolerated
▼
virtual disk (optional)
│ assemble joins E01/AFF4 images, RAID, LVM and encrypted volumes
▼
partitions and volumes
│ partition, volume MBR, GPT, volumes lost and found by signature
▼
two searches in parallel
├─ fs/* metadata: NTFS, FAT, exFAT, ext, HFS+, Btrfs, XFS, APFS
└─ carve + signatures file structure in free space and outside partitions
+ formats, jpegscan, defrag, mp4rebuild
▼
scan merges without duplicates and assigns confidence
▼
saved session resume, export, report
▼
recover copies with MD5, SHA-1 and SHA-256 and writes the manifest
```
## Core modules
### Access and assembly
| Module | Role |
|---|---|
| `source`, `device` | Open devices and images read-only; list disks |
| `image` | Raw image tolerant of bad sectors, with an error map |
| `ewf`, `aff4` | Read and write E01/Ex01 and AFF4 |
| `sign` | Ed25519 signatures of files and recovery folders |
| `audit` | Hash-chained audit trail of every examiner action |
| `vdisk/` | Read QCOW2, VMDK, VHD, VHDX and DMG as the disk they contain |
| `assemble` | Builds the virtual disk: image, RAID, LVM and encrypted volume |
| `raid`, `raid_detect`, `lvm` | RAID reassembly (with automatic parameter discovery) and LVM2 |
| `crypt/` | BitLocker, LUKS and FileVault 2 |
| `reconnect` | Recognize the same disk that came back under another name |
### Interpretation
| Module | Role |
|---|---|
| `partition`, `volume` | Partition tables and volume detection |
| `fs/` | One reader per file system |
| `signatures`, `formats` | Format detectors based on structure |
| `carve`, `scan` | Signature scan and merging with the metadata |
| `jpegscan`, `defrag`, `mp4rebuild` | Fragmented JPEG and MP4 without `moov` |
| `meta`, `sqlite`, `thumb` | Metadata, SQLite reader, thumbnails |
### Results and forensics
| Module | Role |
|---|---|
| `recover` | Copies the files, checks the hashes, writes the manifest, resumes recovery |
| `hashing`, `search` | Hashes and text search across the whole disk |
| `report`, `pdf`, `explorer` | HTML and PDF report (the PDF writer is built in, no external library), CSV, explorer filters and groupings |
| `health`, `external` | SMART and external tools (`smartctl`) |
### Service
`service`, `http`, `settings`, `notify`, `owner`: the layer the interface uses (local API, settings, notifications, and writing with the right owner when running as administrator).
## Interface
The interface is a single page (`apps/gui/ui`). There are two ways to run it:
- **Desktop app (Tauri):** the window loads the page and talks to the core directly.
- **`guara ui`:** the binary starts a token-protected server on `127.0.0.1` and opens the browser.
Because the front end is the same, everything that exists in one exists in the other. See [interface.md](interface.md) for what it looks like.
<p align="center"><img src="img/explorer-light-en.png" width="720" alt="The Guará explorer"></p>
## Design decisions
- **Read-only on the source, by construction.** All device access goes through `source::open_path`. Code review rejects any write to the source.
- **Refusing a destination on the same disk** as the source (Linux and macOS), to avoid overwriting what you want to recover.
- **Confidence per file.** Each detector declares how much it managed to verify; merging keeps the best evidence.
- **Own readers.** File systems, ciphers, SQLite and image containers are implemented in the core, to tolerate corrupted structures, which is exactly the use case.
- **Secrets out of the session.** Passwords are never written; at most the volume key, and only with `--remember-keys`.
- **Privileges.** Reading a raw disk requires administrator, but everything Guará writes (sessions, reports, recovered files) keeps the owner and permissions of the user who launched it.
## Tests
About a hundred tests, in `crates/guara-core/tests/` (one file per area: `ext4`, `xfs`, `apfs`, `bitlocker`, `raid`, `ewf`, `jpegscan`, `mp4rebuild`, `sqlite` and others).
- **Images generated on the fly** with `mtools`, `ntfs-3g`, `e2fsprogs` and the like (`scripts/make-fixtures.sh`).
- **Reference images** from external projects (cryptsetup, dfvfs, AFF4) downloaded by `scripts/fetch-testdata.sh` into `target/testdata`. Without them, the corresponding tests are skipped.
- The BitLocker tests take about 75 s because of key derivation.
- In CI the tests with real images run only on Linux.
+151
View File
@@ -0,0 +1,151 @@
# Terminal reference
[English](cli.md) · [Português](pt-BR/cli.md)
Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linux and macOS. Use `guara <command> --help` for the full list of options.
## Commands
| Command | What it does |
|---|---|
| `devices` | Lists disks and USB sticks, warns about TRIM and mounted volumes |
| `inspect <source>` | Partitions, unpartitioned space, encryption |
| `health <source>` | Disk health (SMART); requires `smartmontools` |
| `scan <source>` | Scan for deleted and lost files |
| `resume <session>` | Continues a paused scan |
| `recover <session> <destination>` | Recovers the files of a session |
| `image <source> <destination>` | Forensic image (raw, E01 or AFF4) |
| `verify <image>` | Checks an E01 or Ex01 image against the stored hash |
| `hash <source>` | MD5, SHA-1 and SHA-256 of the device |
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
| `export <session> <csv>` | Spreadsheet of the findings |
| `report <session> <file>` | Printable report, HTML or PDF (by the file extension) |
| `sign keygen\|file\|verify\|recovery` | Ed25519 signatures of manifests, reports and files; checks a whole recovery folder |
| `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain |
| `sessions` | Automatically saved sessions |
| `ui` | Interface in the browser (127.0.0.1, with token) |
`--json` exists on `devices`, `inspect`, `health` and `scan`.
## Scanning
```bash
sudo guara scan /dev/sdb --session s.json --list # deep, with listing
sudo guara scan /dev/sdb --quick # metadata only, in seconds
guara scan disk.img --category image,document --min-confidence medium
guara scan disk.img --recover /mnt/other-disk/Recovered
```
Filters (valid on `scan`, `recover`, `export` and `report`):
| Option | Effect |
|---|---|
| `--category` | `image`, `document`, `video`, `audio`, `archive`, `database`, `other` (comma separated) |
| `--min-confidence` | `low`, `medium` or `high` |
| `--name` | only names and paths that contain the text |
| `--organize` | how to write: `folders` (original folders), `type`, `date` or `flat` |
## Recovery
```bash
guara recover s.json /mnt/other-disk/Recovered --category image
guara resume s.json
```
The destination must be on **another disk**. Every recovery writes `guara-manifesto.json` with the MD5, SHA-1 and SHA-256 of every file. If the recovery is interrupted, repeating the command skips what was already copied and checked.
## Encrypted volumes
```bash
sudo guara scan /dev/sdb1 --ask-password
sudo guara scan /dev/sdb1 --recovery-key 111111-222222-333333-444444-555555-666666-777777-888888
sudo guara scan /dev/sdb1 --key-file key.BEK
sudo guara scan /dev/sdb1 --volume-key 4f2a...
```
| Option | Use |
|---|---|
| `--ask-password` | Asks in the terminal, without echo. **Preferred** |
| `GUARA_PASSWORD` | Environment variable with the password |
| `--password` | Password on the command line (stays in the shell history) |
| `--recovery-key` | BitLocker numeric key |
| `--key-file` | BitLocker `.BEK` or LUKS keyfile |
| `--volume-key` | Volume key in hexadecimal (the one Guará shows when it opens a volume) |
| `--remember-keys` | Stores the keys of opened volumes in the session (never the password) |
## RAID and LVM
Pass the main disk and the other members with `--member`, repeated. Use `-` for a missing disk.
```bash
# With mdadm or LVM metadata: Guará reads everything by itself
guara inspect disk1.img --member disk2.img --member disk3.img
# Without metadata: discovers level, stripe size, layout and order
guara scan disk1.img --member disk2.img --member disk3.img --raid
# Fixing what you already know
guara scan disk1.img --member disk2.img --member - --raid \
--raid-level 5 --raid-chunk-kb 64 --raid-layout left-symmetric --raid-order 0,1,2
```
## Forensic image
```bash
sudo guara image /dev/sdb /mnt/other-disk/sdb.img # raw, with error map
sudo guara image /dev/sdb /mnt/other-disk/sdb --format e01 \
--case 2026-001 --examiner "Name" --evidence E1 --description "Laptop HDD"
sudo guara image /dev/sdb /mnt/other-disk/sdb --format aff4
guara verify /mnt/other-disk/sdb.E01
```
For E01: `--segment-mib` (size of each segment, default 1500, 0 for a single file) and `--compression` (0 to 9, default 1).
## Digital signatures
```bash
guara sign keygen # creates the key pair (never overwrites one)
guara sign file report.pdf # writes report.pdf.assinatura.json
guara sign verify report.pdf --trust 1a2b:3c4d:5e6f:7a8b:9c0d
guara sign recovery /mnt/other-disk/Recovered # manifest signature and the hash of every file
```
With a key configured, `recover` and `report` sign the manifest and the report on their own. The signature covers the SHA-256, size, name, time and signer; anyone can check it with Guará or with `openssl` (it is plain Ed25519 over the message documented in `crates/guara-core/src/sign.rs`). A valid signature proves the file did not change since it was signed **by whoever holds the key**; to know whose key it is, compare the fingerprint with the one the examiner published outside the machine (`--trust`). The private key lives in `assinatura.key` in the configuration folder (or where `GUARA_SIGNING_KEY` points), mode 0600; keep a copy and consider keeping it on an encrypted volume. The time is the signing machine's clock, not a third-party timestamp.
## Audit trail
Every action that touches evidence is appended to `auditoria.jsonl` in the configuration folder (or to the file named by `GUARA_AUDIT_LOG`): opening a source (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exports and sessions. Each line has the time (UTC), the system user (the one behind `sudo`), the host, the Guará version and the details; set `GUARA_EXAMINER` to also record the examiner's name. Passwords and keys are never written.
```bash
guara audit # lists the entries
guara audit --tail 20 --json
guara audit --verify # checks the chain; exits with an error if it is broken
```
The lines are chained: each one carries the SHA-256 of the previous hash plus its own content, so editing, removing or reordering an entry is detected and `--verify` says which one. Cutting the end of the file cannot be detected by the file alone: keep the hash printed by `--verify` somewhere else (for example in the case notes).
## Video without an index (MP4 without moov)
Pass an **intact video from the same camera or drone** so Guará can rebuild the index:
```bash
guara scan card.img --reference GOOD_VIDEO.MP4 --category video
```
`--reference` can be repeated. Audio is not reconstructed.
## Text search
```bash
sudo guara search /dev/sdb "contract" "ssn" --case-sensitive --max 500
```
Searches in UTF-8 and UTF-16 (`--no-utf16` turns it off), including free space and areas outside partitions. Shows position, partition and context. `--max` limits the results (default 200).
## Report
```bash
guara report s.json report.html --case 2026-001 --examiner "Name" --hash-source
```
Includes case identification, source, partitions, summary, hashes, the audit trail status and the list of files. `--hash-source` adds the hash of the whole source. The format follows the extension: `report.pdf` writes an A4 PDF (standard fonts, nothing external), anything else writes HTML.
+66
View File
@@ -0,0 +1,66 @@
# Running Guará with Docker
[English](docker.md) · [Português](pt-BR/docker.md)
The image contains the `guara` command line tool and `smartctl`. It is useful in three cases:
1. **A Linux server or NAS without a desktop**, with the damaged disk plugged in.
2. **Forensic image files** (`.img`, E01, AFF4) on any system with Docker, because no device access is needed.
3. **A reproducible environment** for scripts and CI.
It is **not** the recommended way for most people. For a desktop, use the packages from [Releases](../../../releases).
## Limits
- **Raw devices only work on Linux hosts.** On Windows and macOS, Docker runs inside a virtual machine and does not pass physical disks through. Use an image file instead.
- **The graphical interface is not served from the container.** `guara ui` listens only on `127.0.0.1` and accepts only that `Host`, by design; opening it to the outside would weaken that protection, so it is not part of the image.
- Files written to a mounted folder belong to root unless you pass `--user` (possible when you work on image files).
## Build
```bash
docker build -t guara .
```
## Image files (any system)
Mount the folder with the image read-only and a separate folder for the results:
```bash
docker run --rm \
-v "$PWD/images:/images:ro" \
-v "$PWD/out:/out" \
--user "$(id -u):$(id -g)" \
guara scan /images/disk.img --session /out/s.json --list
docker run --rm \
-v "$PWD/images:/images:ro" -v "$PWD/out:/out" \
--user "$(id -u):$(id -g)" \
guara recover /out/s.json /out/recovered
```
## A physical disk (Linux host)
Pass the device **read-only** with the `:r` flag. Docker then blocks writes to it at the kernel level, in addition to Guará never writing to the source:
```bash
docker run --rm \
--device=/dev/sdb:/dev/sdb:r \
-v /mnt/other-disk/out:/out \
guara scan /dev/sdb --session /out/s.json --list
```
- The destination must be on **another disk** than `/dev/sdb`.
- Reading a raw device needs root inside the container, so do not pass `--user` here.
- Verified locally with a loop device (Linux): `inspect`, `scan` and `recover` read through `--device=...:r`, and a write attempt (`dd of=/dev/loop0`) fails with `Operation not permitted`. Loop devices are hidden from `guara devices`, like other virtual devices, so pass the path explicitly; a real USB disk does appear in the list.
- SMART depends on the host, the controller and the device. `--cap-add=SYS_RAWIO` is the usual addition when `--device` alone is not enough, but this was **not** verified with a loop device, which has no SMART data.
- Avoid `--privileged`: it gives the container access to every device.
## Other commands
Everything in the [terminal reference](cli.md) works the same way, for example:
```bash
docker run --rm --device=/dev/sdb:/dev/sdb:r guara devices
docker run --rm --device=/dev/sdb:/dev/sdb:r guara health /dev/sdb
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+40
View File
@@ -0,0 +1,40 @@
# Graphical interface
[English](interface.md) · [Português](pt-BR/interface.md)
Guará has a desktop app (Tauri) and the same front end in the browser with `guara ui`. Everything below exists in both.
<p align="center"><img src="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"></p>
## The explorer
After a scan, results open in a file-manager style explorer built for thousands of items:
- **Left:** navigation by type, real photo date (EXIF, by year and month), folder, camera, confidence and GPS, with an option to hide small files and duplicates.
- **Center:** thumbnail grid or list, grouped by day or month, with virtual scrolling. A filter chip shows what is active and clears it in one click.
- **Right:** details panel with the preview, folder, dates (taken, modified, created, accessed), camera, resolution and the file metadata. The badge shows the **confidence** of the file (high, medium or low).
- **Bottom bar:** selection count and the recovery buttons. Recovery always goes to another folder and writes a manifest with MD5, SHA-1 and SHA-256.
Arrow keys move through the files, and Space opens the full-screen quick look. Selection works like Finder or Explorer (Ctrl, Shift, keyboard).
## Themes
The switch at the bottom of the sidebar offers **Auto** (follows the system), **Light** and **Dark**. The choice is saved.
<p align="center"><img src="img/explorer-dark-en.png" width="900" alt="The same explorer in the dark theme"></p>
## Language
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
## Updates
When the desktop app opens it asks GitHub for the release's `latest.json`. If a newer version exists, a card appears at the bottom right with the versions, **What's new** (the CHANGELOG notes) and **Update now**. The app downloads the package, verifies its signature against the public key built into the app, installs and reopens by itself. On Linux this works for the AppImage (the file is replaced in place); installs from `.deb` or `.rpm` only show the notice with the release address. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to GitHub. `guara ui` in the browser does not update itself.
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
Error messages that come from the core, and the terminal output, are still in Portuguese.
## About these screenshots
They show the real interface, not a mock-up, running on a synthetic test image: 72 generated JPEGs with fake EXIF (cameras, dates, GPS) that were deleted from a FAT32 image by `scripts/make-photo-fixture.py` and found by a normal `guara scan`. No real data appears. The device name in the sidebar was replaced with a generic one.
+102
View File
@@ -0,0 +1,102 @@
# Arquitetura
[English](../architecture.md) · **Português**
O Guará é um workspace Rust com três partes: um núcleo (`guara-core`), um binário de terminal (`guara-cli`) e uma interface (`apps/gui`) que é a mesma página web servida de duas formas.
```
crates/guara-core biblioteca: tudo que lê, interpreta e recupera
crates/guara-cli binário `guara`: comandos e servidor local da interface
apps/gui/ui HTML, CSS e JS da interface
apps/gui/src-tauri casca desktop (Tauri) que carrega a mesma interface
```
## Fluxo de uma varredura
```
origem (dispositivo ou imagem)
│ source / device leitura somente leitura, setores ruins tolerados
▼
disco virtual (opcional)
│ assemble junta imagem E01/AFF4, RAID, LVM e volumes cifrados
▼
partições e volumes
│ partition, volume MBR, GPT, volumes perdidos por assinatura
▼
duas buscas em paralelo
├─ fs/* metadados: NTFS, FAT, exFAT, ext, HFS+, Btrfs, XFS, APFS
└─ carve + signatures estrutura dos arquivos no espaço livre e fora de partições
+ formats, jpegscan, defrag, mp4rebuild
▼
scan funde sem duplicatas e atribui a confiança
▼
sessão salva retomar, exportar, relatar
▼
recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto
```
## Módulos do núcleo
### Acesso e montagem
| Módulo | Função |
|---|---|
| `source`, `device` | Abrir dispositivos e imagens somente para leitura; listar discos |
| `image` | Imagem bruta tolerante a setores ruins, com mapa de erros |
| `ewf`, `aff4` | Ler e gravar E01/Ex01 e AFF4 |
| `sign` | Assinaturas Ed25519 de arquivos e pastas de recuperação |
| `audit` | Trilha de auditoria encadeada por hash de cada ação do perito |
| `vdisk/` | Ler QCOW2, VMDK, VHD, VHDX e DMG como o disco que contêm |
| `assemble` | Monta o disco virtual: imagem, RAID, LVM e volume cifrado |
| `raid`, `raid_detect`, `lvm` | Remontagem de RAID (com descoberta automática de parâmetros) e LVM2 |
| `crypt/` | BitLocker, LUKS e FileVault 2 |
| `reconnect` | Reconhecer o mesmo disco que voltou com outro nome |
### Interpretação
| Módulo | Função |
|---|---|
| `partition`, `volume` | Tabelas de partição e detecção de volumes |
| `fs/` | Um leitor por sistema de arquivos |
| `signatures`, `formats` | Detectores de formato pela estrutura |
| `carve`, `scan` | Varredura por assinatura e fusão com os metadados |
| `jpegscan`, `defrag`, `mp4rebuild` | JPEG fragmentado e MP4 sem `moov` |
| `meta`, `sqlite`, `thumb` | Metadados, leitor de SQLite, miniaturas |
### Resultado e perícia
| Módulo | Função |
|---|---|
| `recover` | Copia os arquivos, confere os hashes, grava o manifesto, retoma recuperação |
| `hashing`, `search` | Hashes e busca de texto no disco inteiro |
| `report`, `pdf`, `explorer` | Relatório HTML e PDF (o gerador de PDF é próprio, sem biblioteca externa), CSV, filtros e agrupamentos do explorador |
| `health`, `external` | SMART e ferramentas externas (`smartctl`) |
### Serviço
`service`, `http`, `settings`, `notify`, `owner`: a camada que a interface usa (API local, configurações, notificações, e gravação com o dono certo quando roda como administrador).
## Interface
A interface é uma página só (`apps/gui/ui`). Há duas formas de rodá-la:
- **App desktop (Tauri):** a janela carrega a página e fala com o núcleo diretamente.
- **`guara ui`:** o binário sobe um servidor em `127.0.0.1` protegido por token e abre o navegador.
Como o front-end é o mesmo, tudo que existe em um existe no outro. Veja como ele é em [interface.md](interface.md).
<p align="center"><img src="../img/explorer-light-pt.png" width="720" alt="O explorador do Guará"></p>
## Decisões de projeto
- **Somente leitura na origem, por construção.** Todo acesso a dispositivo passa por `source::open_path`. Revisão de código recusa qualquer escrita na origem.
- **Recusa de destino no mesmo disco** da origem (Linux e macOS), para não sobrescrever o que se quer recuperar.
- **Confiança por arquivo.** Cada detector declara o quanto conseguiu conferir; a fusão fica com a melhor evidência.
- **Leitores próprios.** Sistemas de arquivos, cifras, SQLite e contêineres de imagem são implementados no núcleo, para tolerar estruturas corrompidas, que é exatamente o caso de uso.
- **Segredos fora da sessão.** Senhas nunca são gravadas; no máximo a chave do volume, e só com `--remember-keys`.
- **Privilégios.** Ler disco bruto exige administrador, mas tudo que o Guará grava (sessões, relatórios, recuperados) fica com o dono e as permissões do usuário que o abriu.
## Testes
Cerca de cem testes, em `crates/guara-core/tests/` (um arquivo por área: `ext4`, `xfs`, `apfs`, `bitlocker`, `raid`, `ewf`, `jpegscan`, `mp4rebuild`, `sqlite` e outros).
- **Imagens geradas na hora** com `mtools`, `ntfs-3g`, `e2fsprogs` e afins (`scripts/make-fixtures.sh`).
- **Imagens de referência** de projetos externos (cryptsetup, dfvfs, AFF4) baixadas por `scripts/fetch-testdata.sh` para `target/testdata`. Sem elas, os testes correspondentes são pulados.
- Os testes de BitLocker levam cerca de 75 s por causa da derivação de chave.
- No CI os testes com imagens reais rodam só no Linux.
+151
View File
@@ -0,0 +1,151 @@
# Referência do terminal
[English](../cli.md) · **Português**
Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e no macOS. Use `guara <comando> --help` para a lista completa de opções.
## Comandos
| Comando | Para que serve |
|---|---|
| `devices` | Lista discos e pendrives, avisa TRIM e volumes montados |
| `inspect <origem>` | Partições, espaço não particionado, cifragem |
| `health <origem>` | Saúde do disco (SMART); requer `smartmontools` |
| `scan <origem>` | Varredura de arquivos apagados e perdidos |
| `resume <sessão>` | Continua uma varredura pausada |
| `recover <sessão> <destino>` | Recupera os arquivos de uma sessão |
| `image <origem> <destino>` | Imagem forense (bruta, E01 ou AFF4) |
| `verify <imagem>` | Confere uma imagem E01 ou Ex01 contra o hash gravado |
| `hash <origem>` | MD5, SHA-1 e SHA-256 do dispositivo |
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
| `export <sessão> <csv>` | Planilha dos achados |
| `report <sessão> <arquivo>` | Relatório imprimível, HTML ou PDF (pela extensão do arquivo) |
| `sign keygen\|file\|verify\|recovery` | Assinaturas Ed25519 de manifestos, relatórios e arquivos; confere uma pasta de recuperação inteira |
| `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash |
| `sessions` | Sessões salvas automaticamente |
| `ui` | Interface no navegador (127.0.0.1, com token) |
`--json` existe em `devices`, `inspect`, `health` e `scan`.
## Varredura
```bash
sudo guara scan /dev/sdb --session s.json --list # profunda, com lista
sudo guara scan /dev/sdb --quick # só metadados, em segundos
guara scan disco.img --category image,document --min-confidence medium
guara scan disco.img --recover /mnt/outro-disco/Recuperados
```
Filtros (valem em `scan`, `recover`, `export` e `report`):
| Opção | Efeito |
|---|---|
| `--category` | `image`, `document`, `video`, `audio`, `archive`, `database`, `other` (separe por vírgula) |
| `--min-confidence` | `low`, `medium` ou `high` |
| `--name` | só nomes e caminhos que contenham o texto |
| `--organize` | como gravar: `folders` (pastas originais), `type`, `date` ou `flat` |
## Recuperação
```bash
guara recover s.json /mnt/outro-disco/Recuperados --category image
guara resume s.json
```
O destino precisa estar em **outro disco**. Cada recuperação grava `guara-manifesto.json` com MD5, SHA-1 e SHA-256 de todos os arquivos. Se a recuperação for interrompida, repetir o comando pula o que já foi copiado e conferido.
## Volumes cifrados
```bash
sudo guara scan /dev/sdb1 --ask-password
sudo guara scan /dev/sdb1 --recovery-key 111111-222222-333333-444444-555555-666666-777777-888888
sudo guara scan /dev/sdb1 --key-file chave.BEK
sudo guara scan /dev/sdb1 --volume-key 4f2a...
```
| Opção | Uso |
|---|---|
| `--ask-password` | Pergunta no terminal, sem mostrar. **Preferida** |
| `GUARA_PASSWORD` | Variável de ambiente com a senha |
| `--password` | Senha na linha de comando (fica no histórico do shell) |
| `--recovery-key` | Chave numérica do BitLocker |
| `--key-file` | `.BEK` do BitLocker ou keyfile do LUKS |
| `--volume-key` | Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume) |
| `--remember-keys` | Guarda na sessão as chaves dos volumes abertos (nunca a senha) |
## RAID e LVM
Passe o disco principal e os outros membros com `--member`, repetido. Use `-` para um disco ausente.
```bash
# Com metadados do mdadm ou do LVM: o Guará lê tudo sozinho
guara inspect disco1.img --member disco2.img --member disco3.img
# Sem metadados: descobre nível, faixa, layout e ordem
guara scan disco1.img --member disco2.img --member disco3.img --raid
# Fixando o que você já sabe
guara scan disco1.img --member disco2.img --member - --raid \
--raid-level 5 --raid-chunk-kb 64 --raid-layout left-symmetric --raid-order 0,1,2
```
## Imagem forense
```bash
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # bruta, com mapa de erros
sudo guara image /dev/sdb /mnt/outro-disco/sdb --format e01 \
--case 2026-001 --examiner "Nome" --evidence E1 --description "HD do notebook"
sudo guara image /dev/sdb /mnt/outro-disco/sdb --format aff4
guara verify /mnt/outro-disco/sdb.E01
```
Para E01: `--segment-mib` (tamanho de cada segmento, padrão 1500, 0 para um arquivo só) e `--compression` (0 a 9, padrão 1).
## Assinatura digital
```bash
guara sign keygen # cria o par de chaves (nunca sobrescreve uma existente)
guara sign file relatorio.pdf # grava relatorio.pdf.assinatura.json
guara sign verify relatorio.pdf --trust 1a2b:3c4d:5e6f:7a8b:9c0d
guara sign recovery /mnt/outro-disco/Recuperados # assinatura do manifesto e o hash de cada arquivo
```
Com uma chave configurada, `recover` e `report` assinam sozinhos o manifesto e o relatório. A assinatura cobre o SHA-256, o tamanho, o nome, a hora e o assinante; qualquer pessoa confere com o Guará ou com o `openssl` (é Ed25519 comum sobre a mensagem documentada em `crates/guara-core/src/sign.rs`). Uma assinatura válida prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de quem é a chave, compare a impressão digital com a que o perito publicou fora da máquina (`--trust`). A chave privada fica em `assinatura.key` na pasta de configuração (ou onde `GUARA_SIGNING_KEY` apontar), com permissão 0600; guarde uma cópia e considere mantê-la num volume cifrado. A hora é a do relógio da máquina que assinou, não um carimbo de tempo de terceiros.
## Trilha de auditoria
Toda ação que mexe na evidência é acrescentada a `auditoria.jsonl` na pasta de configuração (ou ao arquivo indicado em `GUARA_AUDIT_LOG`): abrir uma origem (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exportações e sessões. Cada linha traz a hora (UTC), o usuário do sistema (o que está por trás do `sudo`), a máquina, a versão do Guará e os detalhes; defina `GUARA_EXAMINER` para registrar também o nome do perito. Senhas e chaves nunca são gravadas.
```bash
guara audit # lista as entradas
guara audit --tail 20 --json
guara audit --verify # confere a cadeia; sai com erro se estiver quebrada
```
As linhas são encadeadas: cada uma leva o SHA-256 do hash anterior mais o próprio conteúdo, então editar, remover ou reordenar uma entrada é detectado e o `--verify` diz qual. Cortar o fim do arquivo não é detectável só pelo arquivo: guarde o hash que o `--verify` imprime em outro lugar (por exemplo nas notas do caso).
## Vídeo sem índice (MP4 sem moov)
Passe um vídeo **íntegro da mesma câmera ou drone** para o Guará refazer o índice:
```bash
guara scan cartao.img --reference VIDEO_BOM.MP4 --category video
```
`--reference` pode ser repetido. O áudio não é reconstruído.
## Busca de texto
```bash
sudo guara search /dev/sdb "contrato" "cpf" --case-sensitive --max 500
```
Procura em UTF-8 e UTF-16 (`--no-utf16` desliga), inclusive no espaço livre e fora de partições. Mostra posição, partição e contexto. `--max` limita os resultados (padrão 200).
## Relatório
```bash
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
```
Traz identificação do caso, origem, partições, resumo, hashes, a situação da trilha de auditoria e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira. O formato segue a extensão: `relatorio.pdf` gera um PDF A4 (fontes padrão, nada externo); qualquer outra gera HTML.
+66
View File
@@ -0,0 +1,66 @@
# Usando o Guará com Docker
[English](../docker.md) · **Português**
A imagem traz o terminal `guara` e o `smartctl`. Ela serve em três casos:
1. **Servidor ou NAS Linux sem desktop**, com o disco danificado conectado.
2. **Arquivos de imagem forense** (`.img`, E01, AFF4) em qualquer sistema com Docker, porque não precisa acessar dispositivo.
3. **Ambiente reproduzível** para scripts e CI.
**Não** é o caminho recomendado para a maioria das pessoas. No desktop, use os pacotes de [Releases](../../../../releases).
## Limites
- **Dispositivos brutos só funcionam em hospedeiros Linux.** No Windows e no macOS o Docker roda dentro de uma máquina virtual e não repassa discos físicos. Use um arquivo de imagem.
- **A interface gráfica não é servida pelo container.** O `guara ui` escuta só em `127.0.0.1` e só aceita esse `Host`, de propósito; abrir isso para fora enfraqueceria a proteção, então não faz parte da imagem.
- Arquivos gravados numa pasta montada pertencem ao root, a menos que você passe `--user` (possível ao trabalhar com arquivos de imagem).
## Construir
```bash
docker build -t guara .
```
## Arquivos de imagem (qualquer sistema)
Monte a pasta da imagem somente para leitura e outra pasta para os resultados:
```bash
docker run --rm \
-v "$PWD/imagens:/imagens:ro" \
-v "$PWD/saida:/saida" \
--user "$(id -u):$(id -g)" \
guara scan /imagens/disco.img --session /saida/s.json --list
docker run --rm \
-v "$PWD/imagens:/imagens:ro" -v "$PWD/saida:/saida" \
--user "$(id -u):$(id -g)" \
guara recover /saida/s.json /saida/recuperados
```
## Disco físico (hospedeiro Linux)
Passe o dispositivo **somente para leitura** com a opção `:r`. O Docker passa a bloquear escritas nele no nível do kernel, além de o Guará nunca gravar na origem:
```bash
docker run --rm \
--device=/dev/sdb:/dev/sdb:r \
-v /mnt/outro-disco/saida:/saida \
guara scan /dev/sdb --session /saida/s.json --list
```
- O destino precisa estar em **outro disco** que não seja o `/dev/sdb`.
- Ler dispositivo bruto exige root dentro do container, então não use `--user` aqui.
- Verificado localmente com um dispositivo de loop (Linux): `inspect`, `scan` e `recover` leem através de `--device=...:r`, e uma tentativa de escrita (`dd of=/dev/loop0`) falha com `Operation not permitted`. Dispositivos de loop ficam ocultos no `guara devices`, como outros dispositivos virtuais, então informe o caminho explicitamente; um pendrive real aparece na lista.
- O SMART depende do hospedeiro, do controlador e do dispositivo. `--cap-add=SYS_RAWIO` é o complemento usual quando só `--device` não basta, mas isso **não** foi verificado com um dispositivo de loop, que não tem dados SMART.
- Evite `--privileged`: ele dá ao container acesso a todos os dispositivos.
## Outros comandos
Tudo da [referência do terminal](cli.md) funciona do mesmo jeito, por exemplo:
```bash
docker run --rm --device=/dev/sdb:/dev/sdb:r guara devices
docker run --rm --device=/dev/sdb:/dev/sdb:r guara health /dev/sdb
```
+40
View File
@@ -0,0 +1,40 @@
# Interface gráfica
[English](../interface.md) · **Português**
O Guará tem um app desktop (Tauri) e o mesmo front-end no navegador com `guara ui`. Tudo o que está abaixo existe nos dois.
<p align="center"><img src="../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"></p>
## O explorador
Depois da varredura, os resultados abrem num explorador no estilo de um gerenciador de arquivos, pensado para milhares de itens:
- **Esquerda:** navegação por tipo, data real da foto (EXIF, por ano e mês), pasta, câmera, confiança e GPS, com opção de ocultar arquivos pequenos e duplicadas.
- **Centro:** grade de miniaturas ou lista, agrupadas por dia ou mês, com rolagem virtual. Uma etiqueta mostra o filtro ativo e o remove com um clique.
- **Direita:** painel de detalhes com a pré-visualização, pasta, datas (tirada, modificada, criada, acessada), câmera, resolução e os metadados do arquivo. O selo mostra a **confiança** do arquivo (alta, média ou baixa).
- **Barra inferior:** quantidade selecionada e os botões de recuperação. A recuperação sempre vai para outra pasta e grava um manifesto com MD5, SHA-1 e SHA-256.
As setas navegam pelos arquivos e a barra de espaço abre a visualização rápida em tela cheia. A seleção funciona como no Finder ou no Explorer (Ctrl, Shift e teclado).
## Temas
O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Claro** e **Escuro**. A escolha fica salva.
<p align="center"><img src="../img/explorer-dark-pt.png" width="900" alt="O mesmo explorador no tema escuro"></p>
## Idioma
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
## Atualizações
Ao abrir, o app desktop pede ao GitHub o `latest.json` da release. Havendo versão mais nova, aparece um cartão no canto inferior direito com as versões, **Novidades** (as notas do CHANGELOG) e **Atualizar agora**. O app baixa o pacote, confere a assinatura com a chave pública embutida nele, instala e reabre sozinho. No Linux isso vale para o AppImage (o arquivo é substituído no lugar); instalações por `.deb` ou `.rpm` só mostram o aviso com o endereço da release. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao GitHub. O `guara ui` no navegador não se atualiza sozinho.
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
As mensagens de erro que vêm do núcleo e a saída do terminal continuam em português.
## Sobre estas telas
Mostram a interface de verdade, não uma maquete, rodando sobre uma imagem de teste sintética: 72 JPEGs gerados com EXIF falso (câmeras, datas, GPS), apagados de uma imagem FAT32 por `scripts/make-photo-fixture.py` e encontrados por um `guara scan` normal. Nenhum dado real aparece. O nome do dispositivo na barra lateral foi trocado por um genérico.
+110
View File
@@ -0,0 +1,110 @@
# O que o Guará suporta
[English](../support.md) · **Português**
Matriz do que existe hoje (versão 1.1.0). Para o que vem a seguir, veja o [ROADMAP](../../ROADMAP.pt-BR.md).
## Tabelas de partição
MBR e GPT. Quando a tabela falha, o Guará procura volumes por assinatura (NTFS, FAT, exFAT, ext, APFS, HFS+, BitLocker, LUKS) e também examina o **espaço não particionado**.
## Sistemas de arquivos
"Metadados" significa que o Guará lê a estrutura do sistema de arquivos e devolve **nome, pasta e datas**. Sem isso, a recuperação depende só do carving, que devolve o conteúdo sem nome.
| Sistema | Metadados | O que é aproveitado | Limites |
|---|---|---|---|
| NTFS | ✅ | MFT, registros apagados, datas de criação, modificação e acesso | Comprimidos e EFS são ignorados ou marcados como incompletos; lista de atributos tratada de forma parcial |
| FAT12/16/32 | ✅ | Entradas apagadas, nomes longos, datas | |
| exFAT | ✅ | Entradas apagadas, datas | Coberto por imagem sintética; falta validar em mais mídias reais |
| ext2/3/4 | ✅ | Inodes apagados, cópias antigas no diário (ext4), mapa de blocos livres | |
| HFS+ | ✅ | Sobras de nós da árvore de catálogo, diário | |
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo | |
| XFS | 🟡 | Inodes liberados, nomes de entradas liberadas e espaço livre por grupo (funciona na exclusão simulada); conteúdo por carving | Testado no CI com exclusão real do kernel: o kernel zera o inode e as extensões, então nome e pasta se perdem e só o carving recupera o conteúdo. Falta ler o diário |
| APFS | 🟡 | Contêiner, mapa de objetos, árvore de arquivos, instantâneos, nós antigos | Sem mapa de espaço livre; exclusão testada só em simulação |
| ReFS | ❌ | Apenas detectado | Recuperação por carving |
| Storage Spaces | ❌ | Não suportado | |
Em todos os casos o carving continua rodando no espaço livre e o resultado é fundido sem duplicatas.
## Volumes cifrados
O Guará abre o volume com a **senha ou a chave que você fornece**, monta um disco virtual e varre por dentro. Ele não quebra senhas.
| Formato | Credenciais aceitas |
|---|---|
| BitLocker | Senha, chave de recuperação (8 grupos de 6 dígitos), arquivo `.BEK`, chave limpa |
| LUKS1 e LUKS2 | Senha, arquivo de chave, chave do volume |
| FileVault 2 (Core Storage) | Senha, chave de recuperação, chave do volume |
As senhas **nunca** vão para o arquivo de sessão. Com `--remember-keys` a sessão guarda a chave do volume já derivada, não a senha. No terminal, prefira `--ask-password` ou a variável `GUARA_PASSWORD` a `--password`, que fica no histórico do shell.
## RAID e LVM
| Item | Suporte |
|---|---|
| RAID 0, 1, 4, 5, 6, 10 e linear a partir de superblocos do mdadm | ✅ inclusive degradado (use `-` em `--member` para um disco ausente) |
| RAID sem metadados | ✅ `--raid` descobre nível, faixa, layout e ordem; `--raid-level`, `--raid-chunk-kb`, `--raid-layout` e `--raid-order` fixam o que você já sabe |
| LVM2 | ✅ linear, em faixas, espelhado e RAID |
| Storage Spaces | ❌ |
## Imagens forenses
| Formato | Ler | Gravar |
|---|---|---|
| Bruta (dd, `.img`) | ✅ | ✅ com mapa de erros de setores ruins |
| E01 e Ex01 (Expert Witness) | ✅ | ✅ com metadados do caso e segmentação |
| AFF4 | ✅ | ✅ |
| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) e arquivo de apoio | ❌ |
| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized`, descritores com extensões flat/zero e snapshots (pai por nome ou, no VirtualBox, por UUID) | ❌ |
| VHD e VHDX (Hyper-V) | ✅ fixo, dinâmico e diferencial (segue a cadeia de pais) | ❌ |
| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib, bzip2 e LZFSE | ❌ |
Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 cifrado, QCOW2 cujo apoio não é um arquivo local, VMDK em SEsparse e DMG protegido por senha. Discos diferenciais (VHD, VHDX, QCOW2 com apoio, snapshot de VMDK) leem o pai no que o filho não gravou; o pai é procurado ao lado do filho (VMDK: por nome ou UUID) e, no VMDK, o CID dele precisa bater, senão a imagem é recusada porque o pai mudou. A identidade dos pais de VHD, VHDX e QCOW2 não é conferida. O log de transações do VHDX não é reproduzido (nada é gravado na imagem).
`guara verify` recalcula o MD5 de uma imagem E01 ou Ex01 e compara com o gravado nela.
## Carving: formatos reconhecidos pela estrutura
Os detectores leem cabeçalhos, blocos e tamanhos internos para devolver o **tamanho exato** e uma **confiança**, em vez de cortar em um número fixo de bytes.
| Família | Formatos |
|---|---|
| Imagem | JPEG, PNG, GIF, BMP, TIFF, ICO, HEIC, WebP |
| Documento | PDF, DOC/XLS/PPT (OLE), DOCX/XLSX/PPTX/ODF/EPUB (ZIP), RTF |
| Vídeo e áudio | MP4/MOV, AVI, WAV, MP3, Matroska/WebM, Ogg, MPEG-TS, FLV, ASF/WMV, MIDI |
| Compactado | ZIP, 7z, RAR, gzip, zstd, ar |
| Banco e sistema | SQLite, registro do Windows (regf), EVTX, PST, pcap/pcapng, EXE/DLL (PE), ELF, SWF |
### Arquivos fragmentados
- **JPEG:** remontado a partir de até três fragmentos (`jpegscan.rs`, `defrag.rs`).
- **MP4/MOV sem índice (`moov`):** o índice é refeito a partir de um vídeo íntegro da **mesma câmera ou drone**, passado com `--reference`. Funciona com H.264 e HEVC. O áudio não é reconstruído.
- Os demais formatos só saem inteiros se estiverem contíguos; do contrário saem truncados com confiança baixa.
## Metadados e pré-visualização
| Tipo | O que é lido |
|---|---|
| Fotos | EXIF: câmera, lente, número de série, data, GPS |
| PDF | Autor, programa, datas |
| Office e OpenDocument | Autor, última modificação por, empresa |
| MP4/MOV | Data de gravação, duração, resolução |
| SQLite | Esquema, tabelas, blobs, datas, linhas apagadas do espaço livre das páginas |
A pré-visualização é lida direto do disco, sem gravar nada: imagem, vídeo, áudio, PDF, texto, Office, ZIP e hexadecimal.
## Plataformas
| | CLI | Interface | Testes com imagens reais |
|---|---|---|---|
| Linux | ✅ | ✅ AppImage e deb | ✅ |
| Windows | ✅ | ✅ msi e exe | só compilação e testes sintéticos |
| macOS | ✅ | ✅ dmg | só compilação e testes sintéticos |
## O que o Guará não faz
- Recuperar dados **sobrescritos**. Em SSD com TRIM o próprio disco pode zerar blocos apagados.
- Desbloquear celulares ou contornar senhas e travas de segurança.
- Substituir uma ferramenta de extração de dispositivos móveis.
- Produzir laudo com validade pericial por si só: ainda não passou por validação externa (NIST CFTT). Veja o [ROADMAP](../../ROADMAP.pt-BR.md).
+110
View File
@@ -0,0 +1,110 @@
# What Guará supports
[English](support.md) · [Português](pt-BR/suporte.md)
Matrix of what exists today (version 1.1.0). For what comes next, see the [ROADMAP](../ROADMAP.md).
## Partition tables
MBR and GPT. When the table fails, Guará searches for volumes by signature (NTFS, FAT, exFAT, ext, APFS, HFS+, BitLocker, LUKS) and also examines the **unpartitioned space**.
## File systems
"Metadata" means Guará reads the file system structure and returns **name, folder and dates**. Without it, recovery depends only on carving, which returns the content without a name.
| File system | Metadata | What is used | Limits |
|---|---|---|---|
| NTFS | ✅ | MFT, deleted records, creation, modification and access dates | Compressed and EFS files are skipped or marked as incomplete; attribute list handled partially |
| FAT12/16/32 | ✅ | Deleted entries, long names, dates | |
| exFAT | ✅ | Deleted entries, dates | Covered by a synthetic image; needs validation on more real media |
| ext2/3/4 | ✅ | Deleted inodes, old copies in the journal (ext4), free block map | |
| HFS+ | ✅ | Leftovers of catalog tree nodes, journal | |
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents | |
| XFS | 🟡 | Freed inodes, names of freed entries and free space per group (works on simulated deletion); content by carving | Tested with real kernel deletion in CI: the kernel wipes the inode and extents, so names and folders are lost and only carving recovers content. Journal reading is missing |
| APFS | 🟡 | Container, object map, file tree, snapshots, old nodes | No free-space map; deletion tested only in simulation |
| ReFS | ❌ | Only detected | Recovery through carving |
| Storage Spaces | ❌ | Not supported | |
In every case carving keeps running on the free space and the result is merged without duplicates.
## Encrypted volumes
Guará opens the volume with the **password or key you provide**, mounts a virtual disk and scans inside it. It does not crack passwords.
| Format | Accepted credentials |
|---|---|
| BitLocker | Password, recovery key (8 groups of 6 digits), `.BEK` file, clear key |
| LUKS1 and LUKS2 | Password, key file, volume key |
| FileVault 2 (Core Storage) | Password, recovery key, volume key |
Passwords are **never** written to the session file. With `--remember-keys` the session stores the already derived volume key, not the password. In the terminal, prefer `--ask-password` or the `GUARA_PASSWORD` variable over `--password`, which stays in the shell history.
## RAID and LVM
| Item | Support |
|---|---|
| RAID 0, 1, 4, 5, 6, 10 and linear from mdadm superblocks | ✅ including degraded (use `-` in `--member` for a missing disk) |
| RAID without metadata | ✅ `--raid` discovers level, stripe size, layout and order; `--raid-level`, `--raid-chunk-kb`, `--raid-layout` and `--raid-order` fix what you already know |
| LVM2 | ✅ linear, striped, mirrored and RAID |
| Storage Spaces | ❌ |
## Forensic images
| Format | Read | Write |
|---|---|---|
| Raw (dd, `.img`) | ✅ | ✅ with a bad sector error map |
| E01 and Ex01 (Expert Witness) | ✅ | ✅ with case metadata and segmentation |
| AFF4 | ✅ | ✅ |
| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) and backing files | ❌ |
| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized`, descriptors with flat/zero extents and snapshots (parent by name or, for VirtualBox, by UUID) | ❌ |
| VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ |
| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib, bzip2 and LZFSE chunks | ❌ |
Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: encrypted QCOW2, QCOW2 whose backing file is not a local file, VMDK in SEsparse, and password-protected DMG. Differencing disks (VHD, VHDX, QCOW2 backing file, VMDK snapshot) read the parent for what the child did not write; the parent is found next to the child (VMDK: by name or UUID) and, for VMDK, its CID must match, otherwise the image is refused because the parent changed. The identity of VHD, VHDX and QCOW2 parents is not checked. The VHDX transaction log is not replayed (nothing is ever written to the image).
`guara verify` recomputes the MD5 of an E01 or Ex01 image and compares it with the one stored in it.
## Carving: formats recognized by structure
The detectors read headers, blocks and internal sizes to return the **exact size** and a **confidence**, instead of cutting at a fixed number of bytes.
| Family | Formats |
|---|---|
| Image | JPEG, PNG, GIF, BMP, TIFF, ICO, HEIC, WebP |
| Document | PDF, DOC/XLS/PPT (OLE), DOCX/XLSX/PPTX/ODF/EPUB (ZIP), RTF |
| Video and audio | MP4/MOV, AVI, WAV, MP3, Matroska/WebM, Ogg, MPEG-TS, FLV, ASF/WMV, MIDI |
| Compressed | ZIP, 7z, RAR, gzip, zstd, ar |
| Database and system | SQLite, Windows registry (regf), EVTX, PST, pcap/pcapng, EXE/DLL (PE), ELF, SWF |
### Fragmented files
- **JPEG:** reassembled from up to three fragments (`jpegscan.rs`, `defrag.rs`).
- **MP4/MOV without an index (`moov`):** the index is rebuilt from an intact video from the **same camera or drone**, passed with `--reference`. Works with H.264 and HEVC. Audio is not reconstructed.
- Other formats only come out whole if they are contiguous; otherwise they come out truncated with low confidence.
## Metadata and preview
| Type | What is read |
|---|---|
| Photos | EXIF: camera, lens, serial number, date, GPS |
| PDF | Author, program, dates |
| Office and OpenDocument | Author, last modified by, company |
| MP4/MOV | Recording date, duration, resolution |
| SQLite | Schema, tables, blobs, dates, deleted rows from the free space of pages |
The preview is read straight from the disk, without writing anything: image, video, audio, PDF, text, Office, ZIP and hex.
## Platforms
| | CLI | Interface | Tests with real images |
|---|---|---|---|
| Linux | ✅ | ✅ AppImage and deb | ✅ |
| Windows | ✅ | ✅ msi and exe | build and synthetic tests only |
| macOS | ✅ | ✅ dmg | build and synthetic tests only |
## What Guará does not do
- Recover **overwritten** data. On an SSD with TRIM the disk itself may zero deleted blocks.
- Unlock phones or bypass passwords and security locks.
- Replace a mobile device extraction tool.
- Produce a report with forensic validity by itself: it has not been through external validation (NIST CFTT) yet. See the [ROADMAP](../ROADMAP.md).
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Instala pacotes do apt no CI sem depender do espelho estar rápido: usa os .deb em cache quando
# existem e, se o apt travar ou falhar, tenta de novo com tempo limite por tentativa.
#
# Os passos de apt do CI já levaram de 13 a 28 minutos num dia de espelho lento, contra uns 35 s
# no normal. O cache evita o download; o tempo limite evita ficar preso.
#
# Uso: scripts/apt-install.sh <pasta-do-cache> <pacote>...
set -euo pipefail
[ $# -ge 2 ] || { echo "uso: $0 <pasta-do-cache> <pacote>..." >&2; exit 2; }
cache="$1"; shift
pkgs=("$@")
mkdir -p "$cache/partial"
opts=(-o "Dir::Cache::archives=$cache" -y)
# 1) com .deb em cache: instala direto, sem nem atualizar o índice do apt
if ls "$cache"/*.deb >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "instalado a partir do cache"
exit 0
fi
# 2) sem cache (ou incompleto): atualiza e instala, até 3 tentativas com tempo limite
for tentativa in 1 2 3; do
if timeout 240 sudo apt-get update && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
echo "instalado (tentativa $tentativa)"
exit 0
fi
echo "apt falhou ou passou do tempo (tentativa $tentativa de 3)" >&2
sleep $((tentativa * 10))
done
echo "não foi possível instalar: ${pkgs[*]}" >&2
exit 1
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env node
// Confere se o dicionário inglês cobre os textos da interface.
//
// A varredura é estática e sem dependências: lê os arquivos da interface
// (JS e HTML), os rótulos que o núcleo em Rust envia para a tela (metadados,
// tipos de arquivo e saúde do disco) e falha quando encontra uma string que
// parece português e não tem tradução. Também recusa chaves duplicadas no
// dicionário (num objeto JS a última vence em silêncio).
//
// Uso: node scripts/check-ui-i18n.mjs
import { readFileSync, readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
const rel = (p) => relative(root, p).replaceAll('\\', '/');
// ---------------------------------------------------------------- dicionário
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
const dictSource = readFileSync(dictPath, 'utf8');
const keyRe = /^\s*(['"])((?:(?!\1).)*)\1\s*:/gm;
const seen = new Map();
const duplicates = [];
let m;
while ((m = keyRe.exec(dictSource))) {
const key = m[2];
const line = dictSource.slice(0, m.index).split('\n').length;
if (seen.has(key)) duplicates.push({ key, first: seen.get(key), again: line });
else seen.set(key, line);
}
if (duplicates.length) {
console.error('Chaves duplicadas em apps/gui/ui/i18n-en.js:');
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (linhas ${d.first} e ${d.again})`);
process.exitCode = 1;
}
const { EN } = await import(new URL('../apps/gui/ui/i18n-en.js', import.meta.url).href);
const exact = new Set(Object.keys(EN).filter((k) => !k.includes('{n}')));
const patterns = Object.keys(EN)
.filter((k) => k.includes('{n}'))
.map((k) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp('^' + parts.join('(.+?)') + '$', 's');
});
function translated(text) {
if (exact.has(text)) return true;
return patterns.some((re) => re.test(text));
}
// ---------------------------------------------------------------- heurística
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
const WORDS = [
'arquivo', 'arquivos', 'pasta', 'pastas', 'selecionado', 'selecionados', 'selecionada',
'selecionadas', 'item', 'itens', 'abrir', 'abre', 'salvar', 'salvo', 'salvos', 'cancelar',
'fechar', 'buscar', 'procurar', 'procurando', 'disco', 'discos', 'senha', 'chave', 'origem',
'destino', 'tamanho', 'nome', 'nenhum', 'nenhuma', 'todos', 'todas', 'cada', 'esta', 'este',
'essa', 'esse', 'para', 'com', 'sem', 'dos', 'das', 'por', 'que', 'não', 'sim', 'mais',
'menos', 'uma', 'uns', 'umas', 'aqui', 'onde', 'quando', 'como', 'qual', 'confiança',
'recuperar', 'recupera', 'recuperação', 'varredura', 'imagem', 'sessão', 'mês', 'dia',
'hora', 'hoje', 'ontem', 'modificado', 'criado', 'acessado', 'tirada', 'foto', 'fotos',
'documentos', 'vídeos', 'vídeo', 'áudio', 'compactados', 'banco', 'bancos', 'dados',
];
const BRAND = /Guará|Português|English|OpenStreetMap|BitLocker|FileVault|LUKS|NO\u00c7\u00d5ES|S\u00c3O/gi;
const isPortuguese = (raw) => {
const s = raw.replace(BRAND, ' ');
return ACCENT.test(s) || WORDS.some((w) => new RegExp(`(^|[^a-zà-ú])${w}([^a-zà-ú]|$)`, 'i').test(s));
};
const ALLOW = new Set([
'Guará', 'Português', 'English', 'Auto', 'SMART', 'NVMe', 'OpenStreetMap', 'SHA-256',
'SHA-1', 'MD5', 'AES', 'LUKS', 'BitLocker', 'FileVault', 'APFS', 'HFS+', 'ext4', 'Btrfs',
'XFS', 'NTFS', 'FAT32', 'exFAT', 'ReFS', 'RAID', 'LVM', 'SQLite', 'CSV', 'HTML', 'PDF',
'JPEG', 'PNG', 'GIF', 'BMP', 'WebP', 'MP4', 'MOV', 'E01', 'Ex01', 'AFF4', 'UTF-8',
'UTF-16', 'USB', 'SSD', 'ATA', 'CRC', 'GPS', 'EXIF', 'TRIM', 'ZIP', 'TAR', 'OK',
]);
const ALLOW_PREFIX = ['http://', 'https://', 'data:', 'file:', 'guara:', '#', '.', '/'];
const ALLOW_EXACT = new Set([
'sem data', 'no date', 'n/a', '—', '-', '·',
]);
function suspicious(text) {
const s = text.replace(/\s+/g, ' ').trim();
if (!s || s.length < 2) return false;
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
if (!isPortuguese(s)) return false;
// técnico: seletores, classes, extensões, expressões
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
return true;
}
// ---------------------------------------------------------------- extratores
function jsStrings(source, file, baseLine = 1) {
const out = [];
let i = 0;
let line = baseLine;
let prev = '';
const skipRegex = () => {
i++;
let inClass = false;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') { i += 2; continue; }
if (ch === '[') inClass = true;
else if (ch === ']') inClass = false;
else if (ch === '/' && !inClass) { i++; break; }
else if (ch === '\n') line++;
i++;
}
while (i < source.length && /[a-z]/i.test(source[i])) i++;
};
while (i < source.length) {
const c = source[i];
if (c === '\n') { line++; i++; prev = '\n'; continue; }
if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
if (c === '/' && source[i + 1] === '/') {
while (i < source.length && source[i] !== '\n') i++;
continue;
}
if (c === '/' && source[i + 1] === '*') {
i += 2;
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { if (source[i] === '\n') line++; i++; }
i += 2;
continue;
}
if (c === '/' && (/[=(:,[!&|?{};+\-*%^~<>]/.test(prev) || prev === 'return')) {
skipRegex();
prev = ')';
continue;
}
if (c === "'" || c === '"') {
const quote = c;
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === 'r') value += '\r';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === quote) { i++; break; }
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
if (c === '`') {
const startLine = line;
let value = '';
i++;
while (i < source.length) {
const ch = source[i];
if (ch === '\\') {
const n = source[i + 1];
if (n === 'n') value += '\n';
else if (n === 't') value += '\t';
else if (n === '\n') line++;
else value += n ?? '';
i += 2;
continue;
}
if (ch === '`') { i++; break; }
if (ch === '$' && source[i + 1] === '{') {
value += '{n}';
i += 2;
const exprStart = i;
const exprLine = line;
let depth = 1;
while (i < source.length && depth > 0) {
if (source[i] === '{') depth++;
else if (source[i] === '}') depth--;
else if (source[i] === '\n') line++;
i++;
}
out.push(...jsStrings(source.slice(exprStart, i - 1), file, exprLine));
continue;
}
if (ch === '\n') line++;
value += ch;
i++;
}
out.push({ text: value, file, line: startLine });
prev = ')';
continue;
}
prev = c;
i++;
}
return out;
}
function htmlStrings(source, file, baseLine = 1) {
const out = [];
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
// todo texto entre tags (inclusive começo e fim) — nós de texto reais
let last = 0;
for (const m of source.matchAll(/<[^>]*>/g)) {
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
last = m.index + m[0].length;
}
out.push({ text: source.slice(last), file, line: lineAt(last) });
// atributos traduzíveis (com aspas simples ou duplas)
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
}
return out;
}
function rustLabels(source, file) {
const out = [];
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
// m.add("Rótulo", ...) e m.add(\n "Rótulo", ...)
for (const m of source.matchAll(/\.add\(\s*"([^"]+)"/g)) out.push({ text: m[1], file, line: lineAt(m.index) });
// m.kind = "Tipo".into()
for (const m of source.matchAll(/\.kind\s*=\s*"([^"]+)"/g)) out.push({ text: m[1], file, line: lineAt(m.index) });
// rótulos de listas (saúde do disco, tipos de arquivo) dentro de arrays de tuplas
for (const m of source.matchAll(/\(\s*"([^"]{2,60})"\s*,\s*"(?:[^"]*)"\s*\)/g)) out.push({ text: m[1], file, line: lineAt(m.index) });
return out;
}
// ---------------------------------------------------------------- coleta
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js')
.map((f) => join(root, 'apps/gui/ui', f));
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
const rustFiles = [
'crates/guara-core/src/meta.rs',
'crates/guara-core/src/explorer.rs',
'crates/guara-core/src/types.rs',
'crates/guara-core/src/health.rs',
].map((f) => join(root, f));
const found = [];
for (const file of uiFiles) {
const source = readFileSync(file, 'utf8');
if (file.endsWith('.html')) found.push(...htmlStrings(source, file));
else {
for (const str of jsStrings(source, file)) {
if (/<[a-z!/][^>]*>/i.test(str.text)) found.push(...htmlStrings(str.text, file, str.line));
else found.push(str);
}
}
}
for (const file of rustFiles) found.push(...rustLabels(readFileSync(file, 'utf8'), file));
const missing = [];
const seenMissing = new Set();
for (const { text, file, line } of found) {
if (!suspicious(text)) continue;
const key = text.replace(/\s+/g, ' ').trim();
if (translated(key)) continue;
const id = `${key}\u0000${rel(file)}\u0000${line}`;
if (seenMissing.has(id)) continue;
seenMissing.add(id);
missing.push({ text: key, file: rel(file), line });
}
if (missing.length) {
console.error('Textos em português sem tradução em apps/gui/ui/i18n-en.js:\n');
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
console.error(`\n${missing.length} texto(s) sem tradução.`);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(`i18n ok: ${found.length} textos verificados, ${Object.keys(EN).length} chaves no dicionário.`);
}
+1 -1
View File
@@ -13,7 +13,7 @@ for n in bitlk-images fvault2-images; do
rm -rf "$tmp"
done
mkdir -p "$dest/dfvfs"
for f in lvm.raw apfs.raw apfs_encrypted.dmg hfsplus.raw hfs.raw xfs.raw xfs-bigtime.raw ext4.raw ext2.raw fvdetest.qcow2 luks1.raw luks2.raw bdetogo.raw; do
for f in lvm.raw apfs.raw apfs_encrypted.dmg hfsplus.raw hfs.raw xfs.raw xfs-bigtime.raw ext4.raw ext2.raw fvdetest.qcow2 ext2.vhd ext2.vhdx ext2.vmdk windows_volume.qcow2 apm.dmg ntfs-dynamic.vhd ntfs-fixed.vhd ntfs-dynamic.vhdx ntfs-fixed.vhdx ntfs-parent.vhd ntfs-differential.vhd ntfs-parent.vhdx ntfs-differential.vhdx fat-parent.vhd fat-differential.vhd fat-parent.vhdx fat-differential.vhdx luks1.raw luks2.raw bdetogo.raw; do
[ -f "$dest/dfvfs/$f" ] || curl -fsSL "https://raw.githubusercontent.com/log2timeline/dfvfs/main/test_data/$f" -o "$dest/dfvfs/$f"
done
mkdir -p "$dest/aff4"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Monta o latest.json que o atualizador do app lê (Tauri updater), a partir dos pacotes e das
assinaturas `.sig` já enviados à release.
Roda no job de publicação, depois que todos os jobs de empacotamento terminaram. Montar o arquivo
aqui, em um passo só, evita a corrida de quem tenta atualizá-lo em paralelo por plataforma.
Uso: make-latest-json.py <tag> <notas.md> <saida.json> (usa `gh` e GH_REPO/GITHUB_REPOSITORY)
"""
import json
import os
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
# (chaves de plataforma, padrão do nome do pacote). {v} = versão sem o "v".
PLATFORMS = [
(["linux-x86_64", "linux-x86_64-appimage"], "Guara_{v}_amd64.AppImage"),
(["windows-x86_64", "windows-x86_64-nsis"], "Guara_{v}_x64-setup.exe"),
(["windows-x86_64-msi"], "Guara_{v}_x64_en-US.msi"),
(["darwin-aarch64", "darwin-aarch64-app"], "Guara_{v}_aarch64.app.tar.gz"),
(["darwin-x86_64", "darwin-x86_64-app"], "Guara_{v}_x64.app.tar.gz"),
]
def build(version, notes, assets, pub_date):
"""`assets` = {nome_do_pacote: (url, assinatura)}. Devolve o dicionário do latest.json.
Plataforma sem pacote ou sem assinatura sai da lista; o chamador decide se isso é erro."""
platforms = {}
for keys, pattern in PLATFORMS:
name = pattern.format(v=version)
if name not in assets:
continue
url, sig = assets[name]
for k in keys:
platforms.setdefault(k, {"signature": sig.strip(), "url": url})
return {"version": version, "notes": notes.strip(), "pub_date": pub_date, "platforms": platforms}
def main():
tag, notes_path, out = sys.argv[1:4]
version = tag.lstrip("v")
repo = os.environ.get("GH_REPO") or os.environ["GITHUB_REPOSITORY"]
rel = json.loads(subprocess.check_output(["gh", "release", "view", tag, "--repo", repo, "--json", "assets"]))
urls = {a["name"]: a["url"] for a in rel["assets"]}
assets = {}
with tempfile.TemporaryDirectory() as d:
for _, pattern in PLATFORMS:
name = pattern.format(v=version)
if name in urls and name + ".sig" in urls:
subprocess.check_call(["gh", "release", "download", tag, "--repo", repo, "-p", name + ".sig", "-D", d])
assets[name] = (urls[name], open(os.path.join(d, name + ".sig")).read())
data = build(version, open(notes_path, encoding="utf-8").read(), assets,
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
# Sem o Linux (AppImage) o atualizador do dono do projeto não funciona: não publica pela metade.
for must in ("linux-x86_64", "windows-x86_64", "darwin-aarch64", "darwin-x86_64"):
if must not in data["platforms"]:
sys.exit(f"faltam o pacote ou a assinatura de {must}; confira se TAURI_SIGNING_PRIVATE_KEY está nos segredos")
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
print(f"{out}: {sorted(data['platforms'])}")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Cria uma imagem XFS com exclusão REAL (feita pelo kernel), para validar a recuperação de XFS
# sem simulação. Precisa de root, porque monta a imagem por loop: sudo scripts/make-xfs-real-deletion.sh
#
# Saída em target/testdata/xfs-real-delete/: disk.img e expected.tsv (nome, tamanho, sha256 e se
# o arquivo foi apagado). O teste em crates/guara-core/tests/xfs.rs usa esses dois arquivos e é
# pulado quando eles não existem.
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo "rode com sudo: sudo $0" >&2; exit 1; }
root="$(cd "$(dirname "$0")/.." && pwd)"
out="${1:-$root/target/testdata/xfs-real-delete}"
mkdir -p "$out"
img="$out/disk.img"
rm -f "$img"
truncate -s 320M "$img" # o mkfs.xfs recente recusa menos de 300 MiB
mkfs.xfs -q -f "$img"
mnt="$(mktemp -d)"
cleanup() { mountpoint -q "$mnt" && umount "$mnt"; rmdir "$mnt" 2>/dev/null || true; }
trap cleanup EXIT
mount -o loop "$img" "$mnt"
python3 - "$mnt" "$out/expected.tsv" "$root/crates/guara-core/tests/data/exemplo.sqlite" <<'PY'
import hashlib, os, sys
mnt, tsv, sqlite = sys.argv[1], sys.argv[2], sys.argv[3]
def data(seed, n):
x, out = seed | 1, bytearray()
for _ in range(n):
x ^= (x << 13) & 0xFFFFFFFFFFFFFFFF; x ^= x >> 7; x ^= (x << 17) & 0xFFFFFFFFFFFFFFFF
out.append((x >> 24) & 0xFF)
return bytes(out)
files = [ # (caminho, tamanho, semente, apagar?)
("docs/relatorio.dat", 150000, 5, True),
("docs/mantido.dat", 90000, 6, False),
("fotos/foto1.bin", 300000, 7, True),
("fotos/foto2.bin", 40000, 8, False),
("raiz-apagada.txt", 20000, 9, True),
]
# um arquivo com estrutura reconhecível (SQLite), para a recuperação por carving
real = open(sqlite, "rb").read()
rows = []
files.append(("dados/exemplo.sqlite", len(real), 0, True))
for rel, n, seed, gone in files:
p = os.path.join(mnt, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
d = real if rel.endswith('.sqlite') else data(seed, n)
with open(p, "wb") as f:
f.write(d)
rows.append((rel, n, hashlib.sha256(d).hexdigest(), "deleted" if gone else "live"))
os.sync()
for rel, n, seed, gone in files:
if gone:
os.remove(os.path.join(mnt, rel))
os.sync()
with open(tsv, "w") as f:
for r in rows:
f.write("\t".join(map(str, r)) + "\n")
PY
sync
umount "$mnt"
[ -n "${SUDO_UID:-}" ] && chown -R "$SUDO_UID:${SUDO_GID:-$SUDO_UID}" "$out"
echo "ok: $img"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Teste de scripts/make-latest-json.py: python3 scripts/test_make_latest_json.py"""
import importlib.util
import os
import unittest
spec = importlib.util.spec_from_file_location("m", os.path.join(os.path.dirname(__file__), "make-latest-json.py"))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
BASE = "https://github.com/x/y/releases/download/v1.3.0/"
def assets(version="1.3.0"):
names = [p.format(v=version) for _, p in m.PLATFORMS]
return {n: (BASE + n, f"assinatura-de-{n}\n") for n in names}
class LatestJson(unittest.TestCase):
def test_all_platforms_and_variants(self):
d = m.build("1.3.0", "### Novidades\n", assets(), "2026-10-23T12:00:00Z")
self.assertEqual(d["version"], "1.3.0")
self.assertEqual(d["notes"], "### Novidades")
p = d["platforms"]
for k in ("linux-x86_64", "linux-x86_64-appimage", "windows-x86_64", "windows-x86_64-nsis",
"windows-x86_64-msi", "darwin-aarch64", "darwin-x86_64"):
self.assertIn(k, p)
# a chave genérica do Windows aponta para o instalador NSIS, não para o MSI
self.assertTrue(p["windows-x86_64"]["url"].endswith("x64-setup.exe"))
self.assertTrue(p["windows-x86_64-msi"]["url"].endswith(".msi"))
self.assertEqual(p["linux-x86_64"]["signature"], "assinatura-de-Guara_1.3.0_amd64.AppImage")
def test_missing_package_is_left_out(self):
a = assets()
del a["Guara_1.3.0_x64.app.tar.gz"]
d = m.build("1.3.0", "", a, "2026-10-23T12:00:00Z")
self.assertNotIn("darwin-x86_64", d["platforms"])
self.assertIn("darwin-aarch64", d["platforms"])
def test_other_versions_do_not_leak(self):
d = m.build("1.3.0", "", assets("1.2.0"), "2026-10-23T12:00:00Z")
self.assertEqual(d["platforms"], {})
if __name__ == "__main__":
unittest.main()