55 Commits
Author SHA1 Message Date
Lucas Rayan Guerra 9c5df497a5 Merge pull request #51 from lucasrguerra/release/1.3.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.3.1
2026-10-23 12:20:02 -03:00
Lucas RayanandClaude Sonnet 5.5 3946adda09 release: 1.3.1
Só a logo nova: o guará com o anel laranja, uma arte transparente para os dois temas, no app, no
favicon, nos ícones e no README. Sem mudança de comportamento.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:16:11 -03:00
Lucas Rayan Guerra f0a5f43c77 Merge pull request #50 from lucasrguerra/feat/nova-logo
Logo oficial: o guará com o anel laranja (arte final do autor)
2026-10-23 12:09:54 -03:00
Lucas RayanandClaude Sonnet 5.5 05340bd8d2 Logo oficial: a versão final do autor (guara.png), sem recorte nosso
O autor gerou a logo já sem fundo; ela passa a ser a arte oficial, usada em qualquer tema, e não é
mais alterada. scripts/processar-logo.py (que tirava o fundo) e a arte original com fundo saem;
scripts/gerar-logo.py só deriva guara-s.png (barra lateral e favicon) e o ícone do app, e os ícones
do Tauri são regenerados.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:05:58 -03:00
Lucas RayanandClaude Sonnet 5.5 0a9622fb96 Logo única: guara.png e guara-s.png
A variante clara tinha artefatos; a arte escura recortada serve aos dois temas. Saem a troca de
variante por tema, os arquivos -light e a fonte clara; os arquivos escuros viram guara.png e
guara-s.png e o script, o app, o servidor do guara ui e o README usam só eles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:58:39 -03:00
Lucas RayanandClaude Sonnet 5.5 9c09f5dd8a Nova logo oficial: o guará com o anel laranja
A arte do usuário (assets/logo/*-fonte.png) passa a ser a logo do projeto. scripts/processar-logo.py
tira o fundo (só o que toca a borda e os bolsões internos, com a faixa da borda refeita pela cor
real do desenho, sem halo) e gera as variantes clara e escura (768 e 128 px) e o ícone do app. O app
escolhe a variante pelo tema (barra lateral, topo e tela inicial), o favicon e o servidor do guara ui
passam a PNG, os ícones do Tauri são regenerados e o README usa <picture> para claro e escuro. A logo
SVG antiga e o gerador dela saem.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:56:36 -03:00
Lucas Rayan Guerra 82c1fb6c8f Merge pull request #49 from lucasrguerra/dev/verificacao-local
Verificação local (scripts/verificar.sh), pre-push e CI que pula só documentação
2026-10-23 11:38:19 -03:00
Lucas Rayan Guerra 13bf00030d Merge pull request #48 from lucasrguerra/ci/apt-cache-ownership
CI: devolve a posse do cache do apt ao usuário (o cache não estava sendo gravado)
2026-10-23 11:37:54 -03:00
Lucas RayanandClaude Sonnet 5.5 9d67890b68 Verificação local (scripts/verificar.sh), hook de pre-push e CI que pula só documentação
scripts/verificar.sh roda em segundos o mesmo que o CI roda (fmt, clippy -D warnings no workspace
inteiro, tradução, scripts, workflows) e, com --completo, os testes e o cargo audit. O hook de
pre-push (scripts/instalar-hooks.sh) a roda antes de cada push. O CI deixa de rodar em mudanças
só de documentação.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:22:23 -03:00
Lucas RayanandClaude Sonnet 5.5 f8fa5f79d5 CI: devolve a posse do cache do apt ao usuário (o cache não estava sendo gravado)
O apt roda como root e deixa arquivos ilegíveis para o usuário do runner; o actions/cache
falhava com 'tar failed with exit code 2' e nunca salvava, então o cache dos .deb não valia.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:18:54 -03:00
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
145 changed files with 6877 additions and 408 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia).
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh"
-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 }
+37 -7
View File
@@ -1,9 +1,12 @@
name: CI name: CI
on: on:
# Mudança só de documentação não precisa de 6 jobs em 3 sistemas.
push: push:
branches: [main] branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
pull_request: pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
@@ -23,6 +26,13 @@ jobs:
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2 - 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 fmt --all -- --check
- run: cargo clippy --workspace --all-targets - run: cargo clippy --workspace --all-targets
@@ -37,12 +47,28 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - 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) - name: Ferramentas para imagens de teste (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | timeout-minutes: 25
sudo apt-get update run: scripts/apt-install.sh ~/apt-cache-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs imagemagick zip - name: Cache das imagens de referência
- name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux) 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 shell: bash
run: | run: |
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
@@ -69,10 +95,14 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
with: with:
node-version: 22 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 - name: Dependências do Tauri
run: | timeout-minutes: 25
sudo apt-get update run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones - 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: 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 - run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
+38 -5
View File
@@ -23,6 +23,7 @@ jobs:
id: notes id: notes
run: | run: |
v="${GITHUB_REF_NAME#v}" v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md 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 [ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9 - uses: actions/github-script@v9
@@ -86,6 +87,8 @@ jobs:
name: App desktop (${{ matrix.name }}) name: App desktop (${{ matrix.name }})
needs: create-release needs: create-release
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
# Os segredos de assinatura do atualizador ficam no ambiente "release", que só aceita tags v*.
environment: release
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -105,30 +108,60 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
with: with:
node-version: 22 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) - name: Dependências do Tauri (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | timeout-minutes: 25
sudo apt-get update run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones - 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: 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: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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: with:
projectPath: apps/gui/src-tauri projectPath: apps/gui/src-tauri
releaseId: ${{ needs.create-release.outputs.release_id }} releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.args }} args: ${{ matrix.args }}
# o latest.json é montado no job de publicação, de uma vez só (scripts/make-latest-json.py)
uploadUpdaterJson: false
publish: publish:
name: Publica a release name: Publica a release
needs: [create-release, cli, gui] needs: [create-release, cli, gui]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- 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 - uses: actions/github-script@v9
with: with:
script: | script: |
const fs = require('fs');
await github.rest.repos.updateRelease({ await github.rest.repos.updateRelease({
owner: context.repo.owner, repo: context.repo.repo, 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
+1
View File
@@ -4,6 +4,7 @@
# Estado de ferramentas locais # Estado de ferramentas locais
.wolf/ .wolf/
.claude/ .claude/
.superset/
CLAUDE.md CLAUDE.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz) # Variáveis de ambiente locais (o setup do Superset copia da raiz)
-5
View File
@@ -1,5 +0,0 @@
{
"setup": ["./.superset/setup.sh"],
"teardown": ["./.superset/teardown.sh"],
"run": ["cargo run -p guara-cli -- ui --no-open"]
}
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env bash
# Runs once when a Superset workspace (git worktree) is created. Safe to run again.
set -euo pipefail
# Local files that are not tracked by git but that a new worktree needs.
# Nothing is overwritten if it already exists in the workspace.
if [ -n "${SUPERSET_ROOT_PATH:-}" ] && [ -d "$SUPERSET_ROOT_PATH" ]; then
for f in "$SUPERSET_ROOT_PATH"/.env "$SUPERSET_ROOT_PATH"/.env.* "$SUPERSET_ROOT_PATH"/CLAUDE.md; do
[ -f "$f" ] || continue
dest="./$(basename "$f")"
[ -e "$dest" ] || cp "$f" "$dest"
done
fi
# Download the crates now so the first build and the first test run do not wait on the network.
cargo fetch --locked
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
# Runs when a Superset workspace is deleted.
# Setup starts no background service. The only long-running process is the Run command
# (`guara ui`), which this workspace builds into its own target/ directory, so stop any
# copy that is still alive before the worktree is removed.
set -uo pipefail
pkill -f -- "$PWD/target/.*/guara ui" 2>/dev/null || true
exit 0
+72
View File
@@ -4,6 +4,78 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.3.1] - 2026-10-23
### Changed
- **New official logo:** the maned wolf (guará) inside the orange ring, drawn by the project's author, with a transparent background so the same art works in the light and dark themes. It replaces the old track-disc logo in the app (sidebar, top bar and home screen), the favicon, the desktop app icons and the README.
- `guara ui` serves the new `guara.png` and `guara-s.png`; `scripts/gerar-logo.py` derives the small logo and the app icon from the official art.
- Development: `scripts/verificar.sh` runs locally what the CI runs, with an optional pre-push hook (`scripts/instalar-hooks.sh`), and the CI no longer runs on documentation-only changes.
### Notes
- No change to scanning, recovery, signing or any other behavior. This is the first release after the in-app updater: an app on 1.3.0 should offer **Update now**.
- The screenshots in `docs/img/` still show the old logo and will be redone.
## [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 ## [1.0.1] - 2026-10-20
### Added ### Added
+72
View File
@@ -4,6 +4,78 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.3.1] - 2026-10-23
### Alterado
- **Logo oficial nova:** o guará dentro do anel laranja, desenhado pelo autor do projeto, com fundo transparente para a mesma arte servir aos temas claro e escuro. Ela substitui a logo antiga das trilhas de disco no app (barra lateral, topo e tela inicial), no favicon, nos ícones do app desktop e no README.
- O `guara ui` serve os novos `guara.png` e `guara-s.png`; o `scripts/gerar-logo.py` deriva a logo pequena e o ícone do app da arte oficial.
- Desenvolvimento: o `scripts/verificar.sh` roda localmente o que o CI roda, com um hook opcional de pre-push (`scripts/instalar-hooks.sh`), e o CI deixa de rodar em mudanças só de documentação.
### Notas
- Nenhuma mudança na varredura, na recuperação, na assinatura ou em qualquer outro comportamento. É a primeira versão depois do atualizador do app: um app na 1.3.0 deve oferecer **Atualizar agora**.
- Os screenshots em `docs/img/` ainda mostram a logo antiga e serão refeitos.
## [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 ## [1.0.1] - 2026-10-20
### Adicionado ### Adicionado
+7 -3
View File
@@ -11,15 +11,19 @@ Thanks for helping! Golden rules:
## Workflow ## Workflow
```bash ```bash
cargo fmt --all scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
cargo clippy --workspace --all-targets scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
cargo test --workspace scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
``` ```
The CI takes 5 to 30 minutes on three systems, so run the check before pushing: almost everything that makes the CI fail shows up locally in seconds. Documentation-only changes (`*.md`, `docs/`) skip the CI.
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). 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).
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. 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.
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 ## 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). 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).
+7 -3
View File
@@ -11,15 +11,19 @@ Obrigado por ajudar! Regras de ouro:
## Fluxo ## Fluxo
```bash ```bash
cargo fmt --all scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
cargo clippy --workspace --all-targets scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
cargo test --workspace scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
``` ```
O CI leva de 5 a 30 minutos em três sistemas, então rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI.
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). 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. 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 ## 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). 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).
Generated
+176 -78
View File
@@ -10,12 +10,12 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aes" name = "aes"
version = "0.8.4" version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
dependencies = [ dependencies = [
"cfg-if",
"cipher", "cipher",
"cpubits",
"cpufeatures", "cpufeatures",
] ]
@@ -71,9 +71,9 @@ dependencies = [
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"blake2", "blake2",
@@ -107,20 +107,20 @@ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]] [[package]]
name = "blake2" name = "blake2"
version = "0.10.6" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [ dependencies = [
"digest", "digest",
] ]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [ dependencies = [
"generic-array", "hybrid-array",
] ]
[[package]] [[package]]
@@ -191,9 +191,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [ dependencies = [
"crypto-common", "crypto-common",
"inout", "inout",
@@ -239,6 +239,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@@ -252,31 +258,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "cpufeatures" name = "const-oid"
version = "0.2.17" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"libc", "libc",
] ]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.2" version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [ dependencies = [
"generic-array", "hybrid-array",
"typenum",
] ]
[[package]] [[package]]
@@ -291,14 +308,24 @@ dependencies = [
] ]
[[package]] [[package]]
name = "digest" name = "ctutils"
version = "0.10.7" version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"const-oid",
"crypto-common", "crypto-common",
"subtle", "ctutils",
] ]
[[package]] [[package]]
@@ -313,6 +340,15 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "ed25519-compact"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1454db4f2edb7f0e8fe0c5b375b0c978fc63244cc9f010d160e417eb10139aa8"
dependencies = [
"getrandom",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.3" version = "1.0.3"
@@ -355,16 +391,6 @@ dependencies = [
"zlib-rs", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@@ -372,8 +398,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -388,7 +416,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-cli" name = "guara-cli"
version = "1.0.1" version = "1.3.1"
dependencies = [ dependencies = [
"clap", "clap",
"ctrlc", "ctrlc",
@@ -401,11 +429,12 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "1.0.1" version = "1.3.1"
dependencies = [ dependencies = [
"aes", "aes",
"argon2", "argon2",
"bzip2", "bzip2",
"ed25519-compact",
"flate2", "flate2",
"hmac", "hmac",
"image", "image",
@@ -413,6 +442,7 @@ dependencies = [
"jpeg-encoder", "jpeg-encoder",
"libc", "libc",
"lz4_flex", "lz4_flex",
"lzfse_rust",
"md-5", "md-5",
"mp4", "mp4",
"pbkdf2", "pbkdf2",
@@ -444,9 +474,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [ dependencies = [
"digest", "digest",
] ]
@@ -457,6 +487,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 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]] [[package]]
name = "image" name = "image"
version = "0.25.10" version = "0.25.10"
@@ -495,11 +534,11 @@ dependencies = [
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [ dependencies = [
"generic-array", "hybrid-array",
] ]
[[package]] [[package]]
@@ -522,9 +561,19 @@ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]] [[package]]
name = "jpeg-encoder" name = "jpeg-encoder"
version = "0.6.1" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "libbz2-rs-sys" name = "libbz2-rs-sys"
@@ -552,15 +601,21 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "lz4_flex" name = "lz4_flex"
version = "0.11.6" version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest", "digest",
@@ -697,25 +752,35 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "password-hash" name = "password-hash"
version = "0.5.0" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [ dependencies = [
"base64ct", "getrandom",
"rand_core", "phc",
"subtle",
] ]
[[package]] [[package]]
name = "pbkdf2" name = "pbkdf2"
version = "0.12.2" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
dependencies = [ dependencies = [
"digest", "digest",
"hmac", "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]] [[package]]
name = "png" name = "png"
version = "0.18.1" version = "0.18.1"
@@ -765,17 +830,11 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "ripemd" name = "ripemd"
version = "0.1.3" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159"
dependencies = [ dependencies = [
"digest", "digest",
] ]
@@ -814,6 +873,12 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]] [[package]]
name = "ruzstd" name = "ruzstd"
version = "0.9.1" version = "0.9.1"
@@ -868,9 +933,9 @@ dependencies = [
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.7" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
@@ -879,9 +944,9 @@ dependencies = [
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
@@ -906,12 +971,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -1007,9 +1066,9 @@ checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]] [[package]]
name = "typed-path" name = "typed-path"
version = "0.12.3" version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]] [[package]]
name = "typenum" name = "typenum"
@@ -1030,10 +1089,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version_check" name = "wasm-bindgen"
version = "0.9.5" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]] [[package]]
name = "weezl" name = "weezl"
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"] members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package] [workspace.package]
version = "1.0.1" version = "1.3.1"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara" repository = "https://github.com/lucasrguerra/guara"
+9 -7
View File
@@ -1,4 +1,4 @@
<p align="center"><img src="apps/gui/ui/guara-logo.svg" width="360" alt="Guará"></p> <p align="center"><img src="apps/gui/ui/guara.png" width="300" alt="Guará"></p>
<h1 align="center">Guará</h1> <h1 align="center">Guará</h1>
<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">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>
@@ -12,12 +12,12 @@
In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately: In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately:
- **File system metadata:** finds deleted files **with name, folder and date** on NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs, XFS and APFS. - **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. - **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. - **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. - **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`. - **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**. - **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. - **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. - **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. - **Per-file confidence** (high, medium, low): intact structure, reused clusters, zeroed content (TRIM) or content that does not match the extension.
@@ -44,6 +44,8 @@ Download the package for your system from **[Releases](../../releases)**:
Every file has a `.sha256` next to it. To build from source: `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+).
**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.
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256. > The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256.
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. 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.
@@ -91,14 +93,14 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f
- **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name. - **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name.
- **Duplicate detection** by content signature. - **Duplicate detection** by content signature.
> **Warning:** Guará has **not been externally validated** yet (for example, in the NIST CFTT tests) and has no audit log or signed report. It is well suited to recovery and triage; for use in court, check the results by other means. The path to get there is in the [ROADMAP](ROADMAP.md). > **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).
## Known limits ## Known limits
- **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. - **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. - **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). - **ReFS and Storage Spaces** are not supported (ReFS is only detected).
- **APFS:** no free-space map. **XFS and APFS:** deletion recovery tested only in simulation. - **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. - **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. - **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. - **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords.
@@ -111,8 +113,8 @@ 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 | | **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** | Audit log, signed and PDF report, APFS free map, tests on Windows and macOS | | **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 | | **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 | | **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 | | **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
+7 -5
View File
@@ -1,4 +1,4 @@
<p align="center"><img src="apps/gui/ui/guara-logo.svg" width="360" alt="Guará"></p> <p align="center"><img src="apps/gui/ui/guara.png" width="300" alt="Guará"></p>
<h1 align="center">Guará</h1> <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">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
@@ -17,7 +17,7 @@ O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec,
- **Volumes cifrados:** abre BitLocker, LUKS e FileVault 2 com a sua senha ou chave e varre por dentro. - **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. - **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`. - **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**. - **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. - **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. - **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. - **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.
@@ -44,6 +44,8 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**:
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+). 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. > 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. 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.
@@ -91,7 +93,7 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando
- **Sessões automáticas** e reconexão: o Guará reconhece o mesmo disco que volta com outro nome. - **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. - **Detecção de duplicadas** por assinatura de conteúdo.
> **Atenção:** o Guará ainda **não foi validado externamente** (por exemplo, nos testes do NIST CFTT) e não tem registro de auditoria nem relatório assinado. Serve bem para recuperação e triagem; para uso em juízo, confira os resultados por outro meio. O caminho para isso está no [ROADMAP](ROADMAP.pt-BR.md). > **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 ## Limites conhecidos
@@ -111,8 +113,8 @@ 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 | | **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** | Registro de auditoria, relatório assinado e em PDF, mapa livre do APFS, testes em Windows e macOS | | **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 | | **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 | | **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 | | **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
+25 -16
View File
@@ -11,7 +11,7 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o
1. **The source is read-only.** Nothing in the core writes to the device being recovered. 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. 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. 3. **Honest confidence.** If the content could not be checked, confidence is low or medium, never high.
4. **Free and auditable.** Open source (MIT or Apache-2.0), no telemetry, and every result can be reproduced. 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 ## History
@@ -25,10 +25,14 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o
| 0.6.0 | 2026-10-18 | Full disk health (ATA and NVMe SMART), `guara health`, new logo | | 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.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.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 |
| 1.3.1 | 2026-10-23 | New official logo |
Details of each version in the [CHANGELOG](CHANGELOG.md). Details of each version in the [CHANGELOG](CHANGELOG.md).
## Current status (1.0.1) against what is expected from a forensic suite ## Current status (1.3.1) against what is expected from a forensic suite
### 1. File systems with metadata ### 1. File systems with metadata
@@ -38,12 +42,13 @@ Details of each version in the [CHANGELOG](CHANGELOG.md).
| ext2/3/4 | ✅ | Deleted inodes and old copies in the journal | | ext2/3/4 | ✅ | Deleted inodes and old copies in the journal |
| HFS+ | ✅ | Tree node leftovers and journal | | HFS+ | ✅ | Tree node leftovers and journal |
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents | | Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents |
| XFS | ✅ | Freed inodes and names of freed entries. Tested only with simulated deletion | | 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, snapshots and old nodes. **Free-space map is missing**; deletion tested only in simulation | | 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 | | ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
| Storage Spaces | 🔜 | Not done. Reference image missing | | Storage Spaces | 🔜 | Not done. Reference image missing |
| NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today | | NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today |
| XFS and APFS validation with real deletion | 🔜 | Replace the simulation with images from real devices | | 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 ### 2. Encrypted volumes
@@ -65,7 +70,9 @@ Today Guará opens volumes when you **already have** the password or key. Passwo
| Raw image (dd) tolerant of bad sectors, with an error map | ✅ | | Raw image (dd) tolerant of bad sectors, with an error map | ✅ |
| E01 and Ex01: read, write and `guara verify` | ✅ | | E01 and Ex01: read, write and `guara verify` | ✅ |
| AFF4: read and write | ✅ | | AFF4: read and write | ✅ |
| Other containers (VMDK, VHD/VHDX, QCOW2, DMG) | 🔜 | | 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) | 🔭 | | Mobile device image (see item 7) | 🔭 |
### 4. Assembling loose disks ### 4. Assembling loose disks
@@ -125,10 +132,11 @@ Physical access is the differentiator of commercial tools like Cellebrite. It de
| `guara-manifesto.json` manifest in every recovery | ✅ | | `guara-manifesto.json` manifest in every recovery | ✅ |
| HTML report with case, examiner, source, partitions, summary and hashes | ✅ | | HTML report with case, examiner, source, partitions, summary and hashes | ✅ |
| Verification of an E01/Ex01 image against the stored hash | ✅ | | Verification of an E01/Ex01 image against the stored hash | ✅ |
| **Audit log** of every examiner action (who, when, what) | 🔜 | | **Audit trail** of every examiner action (who, when, what), chained by hash, with `guara audit --verify` | ✅ |
| **Digitally signed reports** and a timestamped manifest | 🔜 | | **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 | 🔜 | | **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
| PDF report | 🔜 | | PDF report (A4, built in) | ✅ |
| **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 | | **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 |
| Signed binaries (Windows and macOS) | 🔜 | | Signed binaries (Windows and macOS) | 🔜 |
@@ -152,7 +160,8 @@ External validation is what makes a report accepted. Without it, Guará is good
|---|---| |---|---|
| CLI and interface for Windows, Linux and macOS | ✅ | | CLI and interface for Windows, Linux and macOS | ✅ |
| CI on three systems, automatic release by tag | ✅ | | CI on three systems, automatic release by tag | ✅ |
| Tests with real images on Windows and macOS (Linux only today) | 🔜 | | 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 | 🔜 | | GUI: privileged helper process instead of restarting the whole app as administrator | 🔜 |
| Signed and notarized binaries | 🔜 | | Signed and notarized binaries | 🔜 |
| Packages in package managers (winget, Homebrew, Flatpak) | 🔭 | | Packages in package managers (winget, Homebrew, Flatpak) | 🔭 |
@@ -161,17 +170,17 @@ External validation is what makes a report accepted. Without it, Guará is good
No dates, in the order that makes the most sense. No dates, in the order that makes the most sense.
**1.1: trust and custody** **1.4: trust and custody**
Audit log, PDF report, signed manifest, APFS free-space map, XFS and APFS validation with real deletions, tests with real images on Windows and macOS. Trusted timestamps, XFS journal reading, APFS validation with real deletions.
**1.2: analysis** **1.5: analysis**
NSRL and hash lists, search in file content, unified timeline. NSRL and hash lists, search in file content, unified timeline.
**1.3: artifacts** **1.6: artifacts**
Windows registry, browsers, EVTX and the most common messaging apps, built on the SQLite reader. Windows registry, browsers, EVTX and the most common messaging apps, built on the SQLite reader.
**1.4: more structure** **1.7: more structure**
ReFS and Storage Spaces (once there is a reference image), other image containers (VMDK, VHD, QCOW2), expanded fragmented carving. 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** **2.0: mobile devices and validation**
iTunes backups, ADB and Android backups; submission to the NIST CFTT tests. iTunes backups, ADB and Android backups; submission to the NIST CFTT tests.
+25 -16
View File
@@ -11,7 +11,7 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo
1. **A origem é somente leitura.** Nada no núcleo escreve no dispositivo recuperado. 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. 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. 3. **Confiança honesta.** Se o conteúdo não pôde ser conferido, a confiança é baixa ou média, nunca alta.
4. **Livre e auditável.** Código aberto (MIT ou Apache-2.0), sem telemetria, e cada resultado pode ser reproduzido. 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 ## Histórico
@@ -25,10 +25,14 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo
| 0.6.0 | 2026-10-18 | Saúde do disco completa (SMART ATA e NVMe), `guara health`, nova logo | | 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.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.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 |
| 1.3.1 | 2026-10-23 | Nova logo oficial |
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md). Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
## Situação atual (1.0.1) contra o que se espera de uma suíte forense ## Situação atual (1.3.1) contra o que se espera de uma suíte forense
### 1. Sistemas de arquivos com metadados ### 1. Sistemas de arquivos com metadados
@@ -38,12 +42,13 @@ Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
| ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário | | ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário |
| HFS+ | ✅ | Sobras de nós da árvore e diário | | HFS+ | ✅ | Sobras de nós da árvore e diário |
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo | | Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo |
| XFS | ✅ | Inodes liberados e nomes de entradas liberadas. Testado só com exclusão simulada | | 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, instantâneos e nós antigos. **Falta o mapa de espaço livre**; exclusão testada só em simulação | | 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 | | 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 | | Storage Spaces | 🔜 | Não feito. Falta imagem de referência |
| NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos | | NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos |
| Validação de XFS e APFS com exclusão real | 🔜 | Trocar a simulação por imagens de dispositivos reais | | 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 ### 2. Volumes cifrados
@@ -65,7 +70,9 @@ Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recupera
| Imagem bruta (dd) tolerante a setores ruins, com mapa de erros | ✅ | | Imagem bruta (dd) tolerante a setores ruins, com mapa de erros | ✅ |
| E01 e Ex01: leitura, gravação e `guara verify` | ✅ | | E01 e Ex01: leitura, gravação e `guara verify` | ✅ |
| AFF4: leitura e gravação | ✅ | | AFF4: leitura e gravação | ✅ |
| Outros contêineres (VMDK, VHD/VHDX, QCOW2, DMG) | 🔜 | | 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) | 🔭 | | Imagem de dispositivo móvel (ver item 7) | 🔭 |
### 4. Montagem de discos soltos ### 4. Montagem de discos soltos
@@ -125,10 +132,11 @@ O acesso físico é o diferencial de ferramentas comerciais como a Cellebrite. D
| Manifesto `guara-manifesto.json` em toda recuperação | ✅ | | Manifesto `guara-manifesto.json` em toda recuperação | ✅ |
| Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ | | Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ |
| Verificação de imagem E01/Ex01 contra o hash gravado | ✅ | | Verificação de imagem E01/Ex01 contra o hash gravado | ✅ |
| **Registro de auditoria** de cada ação do perito (quem, quando, o quê) | 🔜 | | **Trilha de auditoria** de cada ação do perito (quem, quando, o quê), encadeada por hash, com `guara audit --verify` | ✅ |
| **Relatórios assinados** digitalmente e manifesto com carimbo de tempo | 🔜 | | **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 | 🔜 | | **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
| Relatório em PDF | 🔜 | | Relatório em PDF (A4, próprio) | ✅ |
| **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 | | **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 |
| Binários assinados (Windows e macOS) | 🔜 | | Binários assinados (Windows e macOS) | 🔜 |
@@ -152,7 +160,8 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve
|---|---| |---|---|
| CLI e interface para Windows, Linux e macOS | ✅ | | CLI e interface para Windows, Linux e macOS | ✅ |
| CI em três sistemas, release automática por tag | ✅ | | CI em três sistemas, release automática por tag | ✅ |
| Testes com imagens reais em Windows e macOS (hoje só Linux) | 🔜 | | 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 | 🔜 | | GUI: processo auxiliar privilegiado em vez de reiniciar o app inteiro como administrador | 🔜 |
| Binários assinados e notarizados | 🔜 | | Binários assinados e notarizados | 🔜 |
| Pacotes em gerenciadores (winget, Homebrew, Flatpak) | 🔭 | | Pacotes em gerenciadores (winget, Homebrew, Flatpak) | 🔭 |
@@ -161,17 +170,17 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve
Sem datas, na ordem em que fazem mais sentido. Sem datas, na ordem em que fazem mais sentido.
**1.1: confiança e custódia** **1.4: confiança e custódia**
Registro de auditoria, relatório em PDF, manifesto assinado, mapa de espaço livre do APFS, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS. Carimbo de tempo de terceiros, leitura do diário do XFS, validação de APFS com exclusões reais.
**1.2: análise** **1.5: análise**
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada. NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
**1.3: artefatos** **1.6: artefatos**
Registro do Windows, navegadores, EVTX e os apps de mensagem mais comuns, apoiados no leitor de SQLite. Registro do Windows, navegadores, EVTX e os apps de mensagem mais comuns, apoiados no leitor de SQLite.
**1.4: mais estrutura** **1.7: mais estrutura**
ReFS e Storage Spaces (quando houver imagem de referência), outros contêineres de imagem (VMDK, VHD, QCOW2), carving fragmentado ampliado. 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** **2.0: dispositivos móveis e validação**
Backups do iTunes, ADB e backups do Android; submissão aos testes do NIST CFTT. Backups do iTunes, ADB e backups do Android; submissão aos testes do NIST CFTT.
+3 -1
View File
@@ -3,7 +3,7 @@
[package] [package]
name = "guara-gui" name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos" description = "Guará: interface gráfica de recuperação de arquivos"
version = "1.0.1" version = "1.3.1"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara" repository = "https://github.com/lucasrguerra/guara"
@@ -17,6 +17,8 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
guara-core = { path = "../../../crates/guara-core" } guara-core = { path = "../../../crates/guara-core" }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 406 KiB

+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())? .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 /// 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 /// 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. /// autorização falhar, a janela continua aqui e a interface mostra o motivo.
@@ -155,6 +222,7 @@ fn main() {
let for_protocol = engine.clone(); let for_protocol = engine.clone();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(State(engine)) .manage(State(engine))
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| { .register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
let eng = for_protocol.clone(); let eng = for_protocol.clone();
@@ -176,7 +244,7 @@ fn main() {
} }
Ok(()) 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!()) .run(tauri::generate_context!())
.expect("erro ao iniciar o Guará"); .expect("erro ao iniciar o Guará");
} }
+14 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Guara", "productName": "Guara",
"version": "1.0.1", "version": "1.3.1",
"identifier": "dev.guara.app", "identifier": "dev.guara.app",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -34,6 +34,18 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK",
"endpoints": [
"https://github.com/lucasrguerra/guara/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
} }
} }
+12 -3
View File
@@ -332,7 +332,7 @@ dialog.lightbox[open] { display: grid; }
.lb-head .ttl { flex: 1; min-width: 0; } .lb-head .ttl b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lb-head .ttl small { color: var(--text-2); } .lb-head .ttl { flex: 1; min-width: 0; } .lb-head .ttl b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lb-head .ttl small { color: var(--text-2); }
.lb-body { padding: 12px; min-height: 0; } .lb-body { padding: 12px; min-height: 0; }
.hero-logo { width: min(440px, 80%); height: auto; display: block; margin: 0 0 22px -4px; } .hero-logo { width: min(340px, 70%); height: auto; margin: 0 0 14px -6px; }
.pill.accent { background: var(--accent-soft); color: var(--accent); } .pill.accent { background: var(--accent-soft); color: var(--accent); }
/* ------------------------------------------------------------ explorador de arquivos */ /* ------------------------------------------------------------ explorador de arquivos */
@@ -516,5 +516,14 @@ dialog.lightbox[open] { display: grid; }
.h-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); } .h-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); }
.h-dot.warning { background: var(--warn); } .h-dot.failing { background: var(--bad); } .h-dot.warning { background: var(--warn); } .h-dot.failing { background: var(--bad); }
/* logo inline: segue o tema escolhido no app, não o do sistema */ /* logo: uma arte só (laranja sobre transparente) serve aos temas claro e escuro */
.logo .a { stroke: var(--accent); } .logo .b { stroke: var(--accent-2); } .logo .t { fill: var(--text); } .logo-img { display: block; flex: none; }
/* 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; }
+70 -5
View File
@@ -2,7 +2,7 @@
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token. // • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
import { createExplorer } from './explorer.js'; import { createExplorer } from './explorer.js';
import { setLang, LOC } from './i18n.js'; import { setLang, LOC, currentLang } from './i18n.js';
const tauri = window.__TAURI__; const tauri = window.__TAURI__;
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
// ------------------------------------------------------------------ configurações // ------------------------------------------------------------------ configurações
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui) // (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) { async function saveSettings(patch) {
Object.assign(SETTINGS, patch); Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ } try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
@@ -265,7 +265,7 @@ function renderHome() {
S.device = null; S.job = null; S.device = null; S.job = null;
renderSide(); renderSide();
main.innerHTML = `<div class="page"> main.innerHTML = `<div class="page">
<section class="hero"><img class="hero-logo" src="guara-logo.svg" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1> <section class="hero"><img class="logo-img hero-logo" src="guara.png" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
<p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.</p></section> <p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.</p></section>
<div class="cards"> <div class="cards">
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div> <div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
@@ -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' }); 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); } } 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') { } 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: '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: '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 } ] }); { name: 'notes', label: 'Observações', multiline: true } ] });
@@ -745,19 +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="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="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> <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>`; <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('[name=lang]').value = SETTINGS.lang;
d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); });
d.onclose = () => { d.onclose = () => {
if (d.returnValue !== 'ok') return; 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 }; 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); setLang(patch.lang);
patch.ui_lang = currentLang();
saveSettings(patch);
EX?.relang?.(); EX?.relang?.();
toast('Salvo'); toast('Salvo');
}; };
d.showModal(); 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 // ------------------------------------------------------------------ fechar com varredura em andamento
function askClose() { function askClose() {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -804,7 +866,10 @@ function watchClose() {
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
} catch { /* usa o local */ } } catch { /* usa o local */ }
setLang(SETTINGS.lang); setLang(SETTINGS.lang);
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
watchClose(); 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')); }); tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
} catch (e) { } 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>`; 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>`;
+4 -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' }); : 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' })); 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' })); const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
function geometry() { function geometry() {
@@ -591,7 +594,7 @@ export function createExplorer(deps) {
if (my !== metaToken || !$('#xpMeta')) return; if (my !== metaToken || !$('#xpMeta')) return;
const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k)); const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k));
if (!rows.length) return; 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) : ''); + (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(() => {})); $('#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(() => {}); }).catch(() => {});
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará"><style>.a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}</style><defs><clipPath id="g-head"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

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

Before

Width:  |  Height:  |  Size: 2.6 KiB

+96 -11
View File
@@ -1,7 +1,6 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável. // Dicionário português -> inglês da interface. {n} marca um valor variável.
export const EN = { export const EN = {
// saúde do disco (SMART) // 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.', '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 realocados': 'Reallocated sectors',
'Setores pendentes de realocação': 'Pending sectors', 'Setores pendentes de realocação': 'Pending sectors',
@@ -13,7 +12,6 @@ export const EN = {
'Erros de mídia': 'Media errors', 'Erros de mídia': 'Media errors',
'Reserva de blocos abaixo do limite': 'Spare blocks below threshold', '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', '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', 'Estado': 'Status',
'Bom': 'Good', 'Bom': 'Good',
'Atenção': 'Caution', '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', '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', ', que contém': ', which contains',
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.', '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 // barra lateral e estrutura
'Recuperação de arquivos': 'File recovery', 'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices', 'Dispositivos': 'Devices',
@@ -50,9 +45,7 @@ export const EN = {
'Atualizar lista': 'Refresh list', 'Atualizar lista': 'Refresh list',
'Lista atualizada': 'List refreshed', 'Lista atualizada': 'List refreshed',
'Abrir arquivo de imagem…': 'Open disk image file…', '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…',
'Abrir sessão salva': 'Open saved session',
'Sessões': 'Sessions', 'Sessões': 'Sessions',
'Sessão': 'Session', 'Sessão': 'Session',
'Sessão:': 'Session:', 'Sessão:': 'Session:',
@@ -181,7 +174,6 @@ export const EN = {
'Por assinatura': 'By signature', 'Por assinatura': 'By signature',
'Sem data': 'No date', 'Sem data': 'No date',
'sem data': 'no date', 'sem data': 'no date',
'Confiança {n}': 'Confidence {n}',
'Com localização (GPS)': 'With location (GPS)', 'Com localização (GPS)': 'With location (GPS)',
'Com GPS': 'With GPS', 'Com GPS': 'With GPS',
'Ocultar duplicadas': 'Hide duplicates', 'Ocultar duplicadas': 'Hide duplicates',
@@ -308,7 +300,6 @@ export const EN = {
// recuperar // recuperar
'Recuperar para…': 'Recover to…', 'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover', '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', 'Pasta de destino': 'Destination folder',
'Escolher…': 'Choose…', 'Escolher…': 'Choose…',
'Organizar os arquivos': 'Organize the files', 'Organizar os arquivos': 'Organize the files',
@@ -329,7 +320,7 @@ export const EN = {
'{n} linhas gravadas': '{n} rows written', '{n} linhas gravadas': '{n} rows written',
'Relatório de recuperação': 'Recovery report', 'Relatório de recuperação': 'Recovery report',
'Gerar relatório': 'Generate 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', 'Salvar em': 'Save to',
'Número do caso': 'Case number', 'Número do caso': 'Case number',
'Responsável pelo exame': 'Examiner', '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.', '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).', '{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
// imagem, hash, busca // 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', 'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.', 'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes', 'Calculando hashes da origem': 'Computing source hashes',
@@ -505,4 +495,99 @@ export const EN = {
'Tabela virtual: o conteúdo não fica armazenado como linhas comuns.': 'Virtual table: the content is not stored as ordinary rows.', '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.', '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.', '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'; let lang = 'pt';
export const isEn = () => lang === 'en'; export const isEn = () => lang === 'en';
export const currentLang = () => lang;
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR'); export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
export function resolveLang(pref) { export function resolveLang(pref) {
+3 -3
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>Guará</title> <title>Guará</title>
<link rel="icon" href="guara.svg" type="image/svg+xml"> <link rel="icon" href="guara-s.png" type="image/png">
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
</head> </head>
<body> <body>
@@ -14,10 +14,10 @@
<button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false"> <button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg> <svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
</button> </button>
<svg class="logo" width="24" height="24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-top"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-top)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><strong>Guará</strong> <img class="logo-img" src="guara-s.png" width="24" height="24" alt=""><strong>Guará</strong>
</header> </header>
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões"> <aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
<div class="brand"><svg class="logo" width="30" height="30" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-side"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-side)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div> <div class="brand"><img class="logo-img" src="guara-s.png" width="30" height="30" alt=""><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<nav> <nav>
<div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div> <div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
<ul id="deviceList" class="list"></ul> <ul id="deviceList" class="list"></ul>
+289 -45
View File
@@ -18,6 +18,45 @@ struct Cli {
cmd: Cmd, 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)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Lista discos e pendrives detectados /// 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 /// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
Verify { path: String }, 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) /// Exporta os achados de uma sessão para CSV (abre no Excel)
Export { Export {
session: String, session: String,
@@ -96,10 +152,10 @@ enum Cmd {
#[command(flatten)] #[command(flatten)]
filter: FilterArgs, 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 { Report {
session: String, session: String,
/// Arquivo HTML de saída /// Arquivo de saída (.pdf ou .html)
out: String, out: String,
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
case: String, case: String,
@@ -427,52 +483,21 @@ fn run(cli: Cli) -> Result<(), String> {
eng.save_session(&job, &session).map_err(s)?; eng.save_session(&job, &session).map_err(s)?;
Ok(()) 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 } => { Cmd::Verify { path } => {
let e = guara_core::ewf::EwfSource::open(std::path::Path::new(&path)).map_err(s)?; let r = verify_image(&path);
println!( guara_core::audit::record(
"Formato: {}", "verify",
if e.info.version == 2 { json!({ "path": path, "result": match &r { Ok(Some(true)) => "conferem", Ok(Some(false)) => "diferem", Ok(None) => "sem hash gravado", Err(_) => "erro" } }),
"Ex01 (EnCase 7)"
} else {
"E01 (EnCase)"
}
); );
println!("Segmentos: {}", e.info.segments); r.map(|_| ())
for (k, v) in &e.info.fields {
println!("{k}: {v}");
}
eprintln!("Recalculando o hash...");
match e.verify().map_err(s)? {
Some(true) => {
println!("MD5 gravado e recalculado conferem: {}", e.info.md5.clone().unwrap_or_default());
Ok(())
}
Some(false) => Err("O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada.".into()),
None => {
println!("Esta imagem não traz hash gravado para conferir.");
Ok(())
}
}
} }
Cmd::Audit {
log,
verify,
tail,
json,
} => audit(log, verify, tail, json),
Cmd::Sign(c) => sign_cmd(c),
Cmd::Image { Cmd::Image {
path, path,
dest, dest,
@@ -790,6 +815,225 @@ fn recover_job(
Ok(()) 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> { fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String> {
let interrupted = Arc::new(AtomicBool::new(false)); let interrupted = Arc::new(AtomicBool::new(false));
let flag = interrupted.clone(); let flag = interrupted.clone();
+7 -7
View File
@@ -12,9 +12,9 @@ const JS: &str = include_str!("../../../apps/gui/ui/app.js");
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js"); const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js"); const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js"); const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js");
const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg");
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js"); const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg"); const LOGO: &[u8] = include_bytes!("../../../apps/gui/ui/guara.png");
const LOGO_S: &[u8] = include_bytes!("../../../apps/gui/ui/guara-s.png");
const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs"); const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs");
const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs"); const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs");
@@ -98,8 +98,11 @@ fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")), Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")),
); );
} }
(Method::Get, "/guara-logo.svg") => { (Method::Get, "/guara.png") => {
let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml"))); let _ = req.respond(Response::from_data(LOGO).with_header(ct("image/png")));
}
(Method::Get, "/guara-s.png") => {
let _ = req.respond(Response::from_data(LOGO_S).with_header(ct("image/png")));
} }
(Method::Get, "/explorer.js") => { (Method::Get, "/explorer.js") => {
let _ = req.respond( let _ = req.respond(
@@ -107,9 +110,6 @@ fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
.with_header(ct("text/javascript; charset=utf-8")), .with_header(ct("text/javascript; charset=utf-8")),
); );
} }
(Method::Get, "/guara.svg") => {
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
}
(Method::Get, "/vendor/pdf.min.mjs") => { (Method::Get, "/vendor/pdf.min.mjs") => {
let _ = req.respond( let _ = req.respond(
Response::from_string(PDF_JS).with_header(ct("text/javascript; charset=utf-8")), Response::from_string(PDF_JS).with_header(ct("text/javascript; charset=utf-8")),
+12 -10
View File
@@ -10,11 +10,11 @@ rust-version.workspace = true
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10" sha2 = "0.11"
sha1 = "0.10" sha1 = "0.11"
md-5 = "0.10" md-5 = "0.11"
jpeg-decoder = { version = "0.3", default-features = false } 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"] } image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
thiserror = "2" thiserror = "2"
@@ -23,12 +23,14 @@ bzip2 = "0.6"
zip = { version = "9", default-features = false, features = ["deflate"] } zip = { version = "9", default-features = false, features = ["deflate"] }
snap = "1" snap = "1"
ruzstd = "0.9" ruzstd = "0.9"
lz4_flex = { version = "0.11", default-features = false, features = ["safe-decode", "safe-encode"] } lz4_flex = { version = "0.14", default-features = false, features = ["alloc", "safe-decode", "safe-encode"] }
aes = "0.8" aes = "0.9"
hmac = "0.12" hmac = "0.13"
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
ripemd = "0.1" ripemd = "0.2"
argon2 = "0.5" argon2 = "0.6"
ed25519-compact = { version = "2", default-features = false, features = ["random", "std"] }
lzfse_rust = "0.2.1"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
+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; pub mod luks;
use crate::source::Source; use crate::source::Source;
use aes::cipher::generic_array::GenericArray; use aes::cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
use aes::{Aes128, Aes192, Aes256}; use aes::{Aes128, Aes192, Aes256};
use std::io; use std::io;
use std::sync::Arc; use std::sync::Arc;
@@ -33,7 +32,7 @@ impl AesAny {
}) })
} }
pub fn encrypt(&self, b: &mut [u8; 16]) { 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 { match self {
AesAny::A128(c) => c.encrypt_block(g), AesAny::A128(c) => c.encrypt_block(g),
AesAny::A192(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]) { 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 { match self {
AesAny::A128(c) => c.decrypt_block(g), AesAny::A128(c) => c.decrypt_block(g),
AesAny::A192(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 //! 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. //! 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::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin}; use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
@@ -27,6 +27,9 @@ fn le64(b: &[u8], o: usize) -> u64 {
const OBJ_TYPE_MASK: u32 = 0xFFFF; const OBJ_TYPE_MASK: u32 = 0xFFFF;
const T_BTREE_ROOT: u32 = 2; const T_BTREE_ROOT: u32 = 2;
const T_BTREE_NODE: u32 = 3; 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; const SUBTYPE_FSTREE: u32 = 0x0E;
// tipos de registro da árvore de arquivos // tipos de registro da árvore de arquivos
@@ -170,6 +173,112 @@ impl<'a> Apfs<'a> {
} }
m 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> { 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; return res;
}; };
let _ = cont_omap_block; 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 container_omap = a.omap(le64(&nx, 160));
let max_fs = le32(&nx, 180).min(100) as usize; let max_fs = le32(&nx, 180).min(100) as usize;
let mut vols: Vec<Volume> = vec![]; 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; 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). /// 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)> { pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some((a, nx)) = open_container(src, base) else { let Some((a, nx)) = open_container(src, base) else {
+4
View File
@@ -12,6 +12,7 @@
pub mod aff4; pub mod aff4;
pub mod assemble; pub mod assemble;
pub mod audit;
pub mod carve; pub mod carve;
pub mod crypt; pub mod crypt;
pub mod defrag; pub mod defrag;
@@ -33,6 +34,7 @@ pub mod mp4rebuild;
pub mod notify; pub mod notify;
pub mod owner; pub mod owner;
pub mod partition; pub mod partition;
pub mod pdf;
pub mod raid; pub mod raid;
pub mod raid_detect; pub mod raid_detect;
pub mod reconnect; pub mod reconnect;
@@ -42,11 +44,13 @@ pub mod scan;
pub mod search; pub mod search;
pub mod service; pub mod service;
pub mod settings; pub mod settings;
pub mod sign;
pub mod signatures; pub mod signatures;
pub mod source; pub mod source;
pub mod sqlite; pub mod sqlite;
pub mod thumb; pub mod thumb;
pub mod types; pub mod types;
pub mod vdisk;
pub mod volume; pub mod volume;
pub use error::{Error, Result}; 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) 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 { fn exif_date(s: &str) -> String {
let b = s.as_bytes(); let b = s.as_bytes();
if b.len() >= 19 && b[4] == b':' && b[7] == b':' { 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 { } else {
s.to_string() s.to_string()
} }
@@ -613,17 +613,17 @@ fn pdf_text(b: &[u8]) -> String {
b.iter().map(|&c| c as char).collect() 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 { fn pdf_date(s: &str) -> String {
let t = s.trim_start_matches("D:"); let t = s.trim_start_matches("D:");
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect(); let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.len() >= 8 { if digits.len() >= 8 {
let g = |a: usize, b: usize| digits.get(a..b).unwrap_or("00"); let g = |a: usize, b: usize| digits.get(a..b).unwrap_or("00");
return format!( return format!(
"{}/{}/{} {}:{}:{}", "{}-{}-{}T{}:{}:{}",
g(6, 8),
g(4, 6),
g(0, 4), g(0, 4),
g(4, 6),
g(6, 8),
g(8, 10), g(8, 10),
g(10, 12), g(10, 12),
g(12, 14) g(12, 14)
@@ -733,21 +733,18 @@ fn unescape(s: &str) -> String {
.replace("&amp;", "&") .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 { fn iso_date(s: &str) -> String {
let b = s.as_bytes(); let b = s.as_bytes();
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' { if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' {
let utc = if s.ends_with('Z') { " (UTC)" } else { "" }; let mut out = s[..19].to_string();
return format!( if s.len() == 20 && s.ends_with('Z') {
"{}/{}/{} {}{}", out.push('Z');
&s[8..10], }
&s[5..7], out
&s[0..4], } else {
&s[11..19], s.to_string()
utc
);
} }
s.to_string()
} }
fn ooxml(fr: &Fr, m: &mut Meta) { fn ooxml(fr: &Fr, m: &mut Meta) {
@@ -890,7 +887,7 @@ fn moov(d: &[u8], m: &mut Meta) {
if created > EPOCH_1904 { if created > EPOCH_1904 {
m.add( m.add(
"Gravado em (metadado)", "Gravado em (metadado)",
fmt_unix((created - EPOCH_1904) as i64), iso_unix((created - EPOCH_1904) as i64),
); );
} }
if ts > 0 && dur > 0 { 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 { pub fn fmt_unix(t: i64) -> String {
let days = t.div_euclid(86400); let days = t.div_euclid(86400);
let rem = t.rem_euclid(86400); let rem = t.rem_euclid(86400);
+52 -2
View File
@@ -3,9 +3,59 @@
use std::process::{Command, Stdio}; 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). /// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
pub fn send(title: &str, body: &str) { pub fn send(notice: Notice) {
let (title, body) = (title.to_string(), body.to_string()); let (title, body) = notice.text(&crate::settings::ui_lang());
std::thread::spawn(move || { std::thread::spawn(move || {
if let Some(mut c) = command(&title, &body) { if let Some(mut c) = command(&title, &body) {
if let Ok(mut child) = c 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 { if p.fs == FsKind::Apfs {
layout.warnings.push(format!( 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 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"], "algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
"relatorio": &report, "relatorio": &report,
}); });
crate::owner::write_owned( let manifest_path = dest.join("guara-manifesto.json");
&dest.join("guara-manifesto.json"), crate::owner::write_owned(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
serde_json::to_vec_pretty(&manifest)?, crate::sign::sign_if_configured(&manifest_path);
)?;
Ok(report) Ok(report)
} }
+376
View File
@@ -19,6 +19,14 @@ pub struct CaseInfo {
pub notes: String, 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 struct ReportInput<'a> {
pub case: &'a CaseInfo, pub case: &'a CaseInfo,
pub target: &'a str, pub target: &'a str,
@@ -30,6 +38,7 @@ pub struct ReportInput<'a> {
pub elapsed: f64, pub elapsed: f64,
pub recoveries: &'a [RecoverReport], pub recoveries: &'a [RecoverReport],
pub source_hashes: Option<&'a Hashes>, pub source_hashes: Option<&'a Hashes>,
pub audit: Option<AuditNote>,
} }
fn cell(s: &str) -> String { 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 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 { 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>"); 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 { 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.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 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()); 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.fingerprint.lock().unwrap() = fp;
*job.session_file.lock().unwrap() = Some(new_session_path(path)); *job.session_file.lock().unwrap() = Some(new_session_path(path));
spawn_scan(job.clone(), None); spawn_scan(job.clone(), None);
@@ -487,6 +494,13 @@ impl Engine {
} }
} }
recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?; 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( let job = self.register(
JobKind::Recover, JobKind::Recover,
dest.to_string(), dest.to_string(),
@@ -525,20 +539,25 @@ impl Engine {
let mut s = j2.st.lock().unwrap(); let mut s = j2.st.lock().unwrap();
match res { match res {
Ok(rep) => { 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(); s.report = serde_json::to_value(&rep).ok();
parent.st.lock().unwrap().recoveries.push(rep.clone()); parent.st.lock().unwrap().recoveries.push(rep.clone());
if !rep.cancelled if !rep.cancelled
&& s.started.elapsed().as_secs() >= 8 && s.started.elapsed().as_secs() >= 8
&& crate::settings::load().notify && crate::settings::load().notify
{ {
crate::notify::send( crate::notify::send(crate::notify::Notice::RecoverDone {
"Guará: recuperação concluída", count: rep.recovered.len(),
&format!( destination: rep.destination.clone(),
"{} arquivo(s) salvos em {}", });
rep.recovered.len(),
rep.destination
),
);
} }
s.status = if rep.cancelled { s.status = if rep.cancelled {
Status::Stopped Status::Stopped
@@ -575,6 +594,10 @@ impl Engine {
Some(Path::new(path)), Some(Path::new(path)),
Path::new(dest).parent().unwrap_or(Path::new(".")), 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( let job = self.register(
JobKind::Image, JobKind::Image,
dest.to_string(), dest.to_string(),
@@ -596,6 +619,14 @@ impl Engine {
let mut s = j2.st.lock().unwrap(); let mut s = j2.st.lock().unwrap();
match res { match res {
Ok(rep) => { 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 { s.status = if rep.cancelled {
Status::Stopped Status::Stopped
} else { } else {
@@ -624,6 +655,10 @@ impl Engine {
None => s.found.iter().collect(), None => s.found.iter().collect(),
}; };
crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?; 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()) Ok(items.len())
} }
@@ -640,7 +675,16 @@ impl Engine {
let elapsed = s let elapsed = s
.finished_in .finished_in
.unwrap_or_else(|| s.started.elapsed().as_secs_f64()); .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, case: &case,
target: &j.target, target: &j.target,
options: &j.opts, options: &j.opts,
@@ -651,8 +695,22 @@ impl Engine {
elapsed, elapsed,
recoveries: &s.recoveries, recoveries: &s.recoveries,
source_hashes: hashes.as_ref(), source_hashes: hashes.as_ref(),
}); audit,
crate::owner::write_owned(Path::new(path), html)?; };
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(()) Ok(())
} }
@@ -663,6 +721,10 @@ impl Engine {
return Err(Error::msg("informe pelo menos um termo")); return Err(Error::msg("informe pelo menos um termo"));
} }
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; 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( let job = self.register(
JobKind::Search, JobKind::Search,
path.to_string(), path.to_string(),
@@ -755,7 +817,15 @@ impl Engine {
} else if pos < size { } else if pos < size {
s.status = Status::Stopped; s.status = Status::Stopped;
} else { } 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.status = Status::Done;
} }
s.finished_in = Some(s.started.elapsed().as_secs_f64()); 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<()> { pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?; 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> { 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 // continua salvando automaticamente no mesmo arquivo se a varredura for retomada
*job.session_file.lock().unwrap() = Some(PathBuf::from(path)); *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 { if data.done {
spawn_enrich(job.clone()); spawn_enrich(job.clone());
} }
@@ -1025,6 +1104,10 @@ impl Engine {
.transpose()? .transpose()?
.unwrap_or_default(); .unwrap_or_default();
let raw = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?; 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 a = crate::assemble::assemble_source(raw, &asm)?;
let mut layout = partition::analyze(a.source.as_ref(), false); let mut layout = partition::analyze(a.source.as_ref(), false);
if a.skip_primary { if a.skip_primary {
@@ -1300,14 +1383,10 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
s.status = Status::Done; s.status = Status::Done;
s.checkpoint = None; s.checkpoint = None;
if s.started.elapsed().as_secs() >= 20 && crate::settings::load().notify { if s.started.elapsed().as_secs() >= 20 && crate::settings::load().notify {
crate::notify::send( crate::notify::send(crate::notify::Notice::ScanDone {
"Guará: varredura concluída", count: s.found.len(),
&format!( target: job.target.clone(),
"{} arquivo(s) encontrados em {}.", });
s.found.len(),
job.target
),
);
} }
} }
Outcome::Interrupted(cp) => { Outcome::Interrupted(cp) => {
@@ -1317,7 +1396,7 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
s.status = Status::Paused; s.status = Status::Paused;
if cp.io_lost { if cp.io_lost {
if crate::settings::load().notify { 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()); 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()); s.finished_in = Some(s.started.elapsed().as_secs_f64());
let done = s.status == Status::Done; 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); drop(s);
if done { if done {
spawn_enrich(job); spawn_enrich(job);
+34
View File
@@ -13,12 +13,20 @@ pub struct Settings {
/// Idioma da interface: "auto", "pt" ou "en". /// Idioma da interface: "auto", "pt" ou "en".
#[serde(default = "auto")] #[serde(default = "auto")]
pub lang: String, 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. /// Notificação do sistema quando uma tarefa longa termina.
#[serde(default = "yes")] #[serde(default = "yes")]
pub notify: bool, pub notify: bool,
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet). /// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
#[serde(default)] #[serde(default)]
pub maps: bool, 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 { fn auto() -> String {
@@ -34,8 +42,10 @@ impl Default for Settings {
Self { Self {
theme: auto(), theme: auto(),
lang: auto(), lang: auto(),
ui_lang: None,
notify: true, notify: true,
maps: false, maps: false,
updates: true,
} }
} }
} }
@@ -101,6 +111,30 @@ pub fn load() -> Settings {
.unwrap_or_default() .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<()> { pub fn save(s: &Settings) -> Result<()> {
crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?; crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?;
Ok(()) 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()))?; .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
return Ok(Arc::new(e)); 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 /// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
+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)
}
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more