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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:09:28 -03:00
Lucas RayanandClaude Sonnet 5.5 282161d297 test: sessão no formato da v0.1.0 gerada em tempo de teste
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
O arquivo estático dependia do conteúdo aleatório da imagem local e falhava no CI.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:37:25 -03:00
Lucas RayanandClaude Sonnet 5.5 45d535bdb7 feat: explorador de arquivos, miniaturas, metadados e recursos periciais (0.3.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
- Explorador com grade/lista virtualizada, navegação por tipo, data EXIF, pasta e
  câmera, seleção estilo gerenciador de arquivos e visualização rápida.
- Miniaturas no núcleo, metadados (EXIF, PDF, Office, MP4), datas MACB do NTFS/FAT/exFAT.
- Hashes MD5/SHA-1/SHA-256, busca de texto no disco, exportação CSV, relatório HTML,
  detecção de duplicadas, tema, correções de interface e textos sem travessões.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:16:20 -03:00
Lucas RayanandClaude Sonnet 5.5 1457caa453 feat(brand): nova logo — pixels que se juntam num lobo-guará de silhueta lisa
Validada pelo autor. Gerada de forma determinística por scripts/make-logo.py;
ícones do app regenerados a partir dela.

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

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

No files matched your search

+100
View File
@@ -2,6 +2,106 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.0.0] - 2026-10-19
### Adicionado
- **Sistemas de arquivos com metadados:** ext4 (inodes apagados e cópias antigas no diário), HFS+ (sobras de nós e diário), Btrfs (versões antigas das árvores; extensões zlib, zstd e lzo), XFS (inodes liberados e nomes de entradas liberadas) e APFS (instantâneos e nós antigos). ReFS ainda é só detectado.
- **Volumes cifrados:** BitLocker (senha, chave de recuperação, .BEK, chave limpa), LUKS1/LUKS2 e FileVault 2 (Core Storage) abrem com a senha ou a chave e são varridos por dentro. As senhas nunca vão para o arquivo de sessão.
- **RAID e LVM:** RAID 0, 1, 4, 5, 6, 10 e linear a partir de superblocos do mdadm, inclusive degradado; descoberta automática de nível, faixa, layout e ordem quando não há metadados; LVM2 (linear, em faixas, espelhado e RAID).
- **Imagens forenses:** leitura de E01/Ex01 e AFF4; gravação de E01 e AFF4; comando `guara verify`.
- **Carving:** JPEG fragmentado remontado (até três fragmentos), reconstrução do índice de MP4/MOV sem moov (H.264/HEVC, com vídeo de referência) e 24 formatos reconhecidos pela estrutura.
- **Interface:** campos de senha, RAID, vídeo de referência e formato da imagem.
### Corrigido
- Abrir de novo uma sessão recente já aberta não cria mais cópias iguais na aba de sessões.
### Limitações
- Storage Spaces e ReFS (metadados) não suportados; XFS e APFS testados com exclusões simuladas; áudio não é reconstruído em MP4 sem moov.
## [0.6.0] - 2026-10-18
### Adicionado
- **Saúde do disco no estilo do CrystalDiskInfo:** a tela do dispositivo mostra o estado (Bom, Atenção, Ruim) com a % de vida útil restante em SSD, temperatura, horas ligado, vezes ligado, total lido e total gravado. Também traz a ficha do dispositivo (modelo, firmware, nº de série, interface, modo de transferência, capacidade, rotação, formato e recursos como TRIM) e a tabela completa de atributos SMART, com ID, valor atual, pior, limite e bruto, e um indicador por atributo. Funciona com discos ATA/SATA e NVMe (nos NVMe, os campos do log de saúde viram a tabela).
- **Partições:** ao abrir uma partição (`/dev/sda1`, `/dev/nvme0n1p2`, `/dev/disk2s1`), o SMART é lido do disco que a contém, e a tela avisa isso.
- **Comando `guara health`** no terminal, com saída legível ou `--json`.
### Alterado
- Um atributo SMART com valor normalizado no limite de falha (ou abaixo dele) agora marca o disco como "Ruim", mesmo que a autoavaliação geral ainda passe.
- **Nova logo:** a cabeça do lobo-guará recortada das trilhas de um disco, inteiras de um lado (recuperado) e quebradas do outro (perdido), nas cores do app. Na interface ela acompanha o tema escolhido (claro ou escuro).
## [0.5.0] - 2026-10-18
### Adicionado
- **Visualizador de bancos de dados:** arquivos SQLite (`.db`, `.sqlite`, `.sqlite3`, `.db3`, `.sdb`, `.sqlitedb`, `.storedata`) abrem na pré-visualização com esquema, tabelas paginadas, blobs e datas reconhecidas. Leitor próprio, só leitura, tolerante a arquivos truncados ou corrompidos. Também **recupera linhas apagadas** a partir do espaço livre das páginas.
- **Saúde do disco (SMART):** antes de varrer, o Guará consulta o `smartctl` (quando instalado) e o contador de erros do kernel. Se o disco parece com defeito, recomenda criar a imagem primeiro e pede confirmação antes da varredura profunda.
- **Notificação do sistema** quando uma varredura longa ou uma recuperação termina, ou pausa por desconexão.
- **Retomar recuperação interrompida:** um diário na pasta de destino faz o Guará pular o que já foi copiado e conferido; o resultado mostra quantos foram pulados.
- **Mapa das fotos com GPS** no painel de detalhes (opcional, usa a internet) e botão para abrir no OpenStreetMap.
- **Interface em inglês**, com escolha de idioma (Auto, Português, English) na nova janela de Configurações, que também controla notificações e mapas online.
- **Fechar a janela durante a varredura** pergunta se você quer pausar e sair, sair sem pausar ou continuar.
### Notas
- Mensagens de erro vindas do núcleo e o terminal continuam em português.
## [0.4.0] - 2026-10-18
### Adicionado
- **Reconexão do disco:** se o disco foi desconectado e voltou com outro nome (por exemplo `/dev/sda` virando `/dev/sdb`), o Guará o reconhece sozinho pelo tamanho e pela impressão digital do conteúdo (primeiro e último MiB) e religa a sessão. Se não conseguir, a tela mostra "Reconectar o disco" e "Escolher a origem…", com a lista dos discos conectados e o selo "mesmo disco". Uma origem com conteúdo diferente é recusada, a menos que você marque "usar mesmo assim" (útil para cópias).
- **Desconexão no meio da varredura:** se as leituras falharem várias vezes seguidas, a varredura pausa sozinha com o ponto exato salvo e avisa para reconectar e continuar, em vez de seguir pulando blocos e terminar com buracos.
- **Sessões salvas automaticamente** de tempos em tempos durante a varredura e ao terminar ou pausar, na pasta de configuração do usuário, e listadas em "Recentes" na barra lateral (`guara sessions` no terminal). Gravação atômica, sem risco de corromper a sessão se o programa fechar no meio.
- **Organização ao recuperar:** pastas originais, por tipo, por data (ano e mês) ou tudo na mesma pasta (`--organize` no terminal).
- **Verificação de espaço livre** no destino antes de copiar, com mensagem clara se não couber.
### Corrigido
- Em sessões pausadas não havia como escolher outra origem; só "Continuar", que falhava sem o disco.
## [0.3.2] - 2026-10-18
### Corrigido
- **Travamento ao clicar em um vídeo no app para Linux.** O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer: ao criar um `<video>` ou `<audio>` o processo da página morria e a janela ficava congelada. Agora o app para Linux nunca cria esses elementos; vídeo e áudio têm o botão "Abrir no player do sistema", que copia o arquivo para uma pasta temporária do usuário e o abre no player padrão (inclusive quando o Guará roda como administrador). No navegador (`guara ui`) e nos demais sistemas o vídeo continua tocando dentro do app, mas só na tela grande, nunca no painel pequeno.
### Alterado
- Clicar na pré-visualização do painel de detalhes abre a visualização grande (imagem, PDF, documento e demais tipos), agora maior (até 98% da largura e 95% da altura da janela).
## [0.3.1] - 2026-10-18
### Adicionado
- Filtros "Ocultar arquivos pequenos (menos de 30 KB)" e "Mais de 1 megapixel" no explorador, para separar fotos de ícones e miniaturas quando há milhares de imagens.
### Corrigido
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
- Carving de PNG: texto ASCII logo após um PNG truncado era lido como um bloco de ~1,9 GB e gerava "fotos" gigantes de confiança baixa. Agora o bloco precisa ser plausível (tipo válido, tamanho razoável, dentro do disco) e o arquivo termina no último bloco válido.
- Retomar uma varredura de sessão sem a origem disponível agora avisa na hora, em vez de falhar depois.
- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root.
## [0.3.0] - 2026-10-18
### Adicionado
- **Explorador de arquivos:** grade de miniaturas e lista com rolagem virtual (milhares de itens), agrupamento por dia/mês, navegação lateral por tipo, data da foto, pasta, câmera e confiança, seleção com Ctrl/Shift/teclado, painel de detalhes e visualização rápida em tela cheia.
- **Miniaturas rápidas** geradas no núcleo (JPEG com redução na decodificação, PNG, GIF, BMP, WebP), com orientação EXIF e uso da miniatura embutida quando a imagem está danificada.
- **Metadados:** EXIF (câmera, lente, datas, GPS), PDF, Office/OpenDocument e MP4/MOV; datas de criação, modificação e acesso do NTFS, FAT e exFAT.
- **Perícia:** hashes MD5, SHA-1 e SHA-256, `guara hash`, `guara search` (texto no disco inteiro em UTF-8 e UTF-16), `guara export` (CSV), `guara report` (relatório HTML com cadeia de custódia) e detecção de duplicadas. Tudo também na interface gráfica.
- Nova identidade visual: logo de pixels que se juntam num lobo-guará de silhueta lisa.
### Alterado
- A barra inferior e o painel de progresso não são mais recriados a cada atualização.
- Textos sem travessões.
## [0.2.0] - 2026-10-18
### Adicionado
- **Pré-visualização completa:** imagem (zoom), vídeo (MP4 e outros), áudio, PDF (pdf.js incluso, funciona offline), texto, Word/Excel/PowerPoint/OpenDocument, conteúdo de ZIP e hexadecimal; tela cheia com navegação por teclado (↑ ↓ ← → Espaço Esc). O conteúdo é lido sob demanda com suporte a Range, sem gravar em disco.
- Seletor de tema **Auto / Claro / Escuro**; a escolha é salva e vale também no app reiniciado como administrador.
- Nova identidade visual em verde-azulado, com logo de pixels que se juntam até formar um lobo-guará.
### Corrigido
- Reiniciar como administrador no Linux: no AppImage o executável fica num ponto de montagem inacessível ao root; agora usa o próprio `.AppImage`, mostra o motivo se falhar e fecha o app antigo só quando o novo abre.
- Barra de ações inferior piscava a cada atualização; agora é criada uma vez e só atualiza o texto. O painel de progresso também deixou de ser recriado a cada ciclo.
- FAT32 em mídias pequenas (menos de 65 525 clusters) não era reconhecido.
- O tema não acompanhava a escolha ao abrir como root (o WebView do root usava outro tema do sistema).
## [0.1.0] - 2026-10-18
### Adicionado
Generated
+587 -7
View File
@@ -2,6 +2,23 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -52,18 +69,60 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -82,6 +141,45 @@ dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -100,6 +198,16 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -131,7 +239,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -140,6 +248,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -155,6 +269,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -176,6 +305,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -184,6 +324,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -198,6 +339,12 @@ dependencies = [
"objc2",
]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
version = "0.3.14"
@@ -214,6 +361,26 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -246,43 +413,132 @@ dependencies = [
"r-efi",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "guara-cli"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"clap",
"ctrlc",
"getrandom 0.2.17",
"guara-core",
"rpassword",
"serde_json",
"tiny_http",
]
[[package]]
name = "guara-core"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"aes",
"argon2",
"bzip2",
"flate2",
"hmac",
"image",
"jpeg-decoder",
"jpeg-encoder",
"libc",
"lz4_flex",
"md-5",
"mp4",
"pbkdf2",
"ripemd",
"ruzstd",
"serde",
"serde_json",
"sha1",
"sha2",
"snap",
"tempfile",
"thiserror",
"thiserror 2.0.21",
"tiny_http",
"windows-sys 0.59.0",
"zip",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -295,6 +551,24 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]]
name = "jpeg-encoder"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.191"
@@ -313,12 +587,72 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lz4_flex"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "mp4"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3"
dependencies = [
"byteorder",
"bytes",
"num-rational",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -331,6 +665,46 @@ dependencies = [
"libc",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.5"
@@ -358,6 +732,40 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -367,6 +775,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
@@ -382,6 +802,42 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "ripemd"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
dependencies = [
"digest",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rtoolbox"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "rustix"
version = "1.1.5"
@@ -395,6 +851,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ruzstd"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fad02996bfc73da3e301efe90b1837be9ed8f4a462b6ed410aa35d00381de89f"
dependencies = [
"twox-hash",
]
[[package]]
name = "serde"
version = "1.0.229"
@@ -422,7 +887,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -438,6 +903,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -449,12 +925,47 @@ dependencies = [
"digest",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
@@ -479,13 +990,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -496,7 +1027,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -511,6 +1042,16 @@ dependencies = [
"log",
]
[[package]]
name = "twox-hash"
version = "1.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675"
dependencies = [
"cfg-if",
"static_assertions",
]
[[package]]
name = "typenum"
version = "1.20.1"
@@ -541,6 +1082,12 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -629,8 +1176,41 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zip"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+5 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "0.1.0"
version = "1.0.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
@@ -16,3 +16,7 @@ codegen-units = 1
opt-level = 3
strip = true
panic = "abort"
# Dependências otimizadas também em debug: os testes de cifra fazem milhões de SHA-256.
[profile.dev.package."*"]
opt-level = 3
+51 -6
View File
@@ -1,4 +1,4 @@
<p align="center"><img src="apps/gui/ui/guara.svg" width="96" alt="Guará"></p>
<p align="center"><img src="apps/gui/ui/guara-logo.svg" width="360" alt="Guará"></p>
<h1 align="center">Guará</h1>
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
@@ -17,7 +17,7 @@ O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec,
- A origem é aberta **somente para leitura**. Nada no núcleo escreve no dispositivo recuperado.
- A recuperação **recusa destinos no mesmo disco** da origem (Linux/macOS).
- **Imagem forense** (`guara image`) tolerante a setores ruins, com mapa de erros e SHA-256: trabalhe sempre na cópia de um disco com defeito.
- Cada recuperação grava `guara-manifesto.json` com o **SHA-256** de todos os arquivos.
- Cada recuperação grava `guara-manifesto.json` com **MD5, SHA-1 e SHA-256** de todos os arquivos.
- **Volumes cifrados** (BitLocker, LUKS) são **detectados e avisados**; a v1 não desbloqueia nem varre dentro deles.
## Instalação
@@ -34,20 +34,26 @@ Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --relea
> 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/macOS).
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
## Uso (terminal)
```bash
sudo guara devices # discos e pendrives (avisa TRIM e volumes montados)
sudo guara inspect /dev/sdb # partições, espaço não particionado, cifragem
sudo guara health /dev/sdb # saúde do disco (SMART), requer smartmontools
sudo guara scan /dev/sdb --session s.json --list # varredura profunda
sudo guara scan /dev/sdb --quick # só metadados (segundos)
guara scan disco.img --category image,document --min-confidence medium
guara scan disco.img --recover /mnt/outro-disco/Recuperados
guara recover s.json /mnt/outro-disco/Recuperados --category image
guara resume s.json # continua após Ctrl+C
guara resume s.json # continua após Ctrl+C (religa o disco mesmo com outro nome)
guara sessions # sessões salvas automaticamente
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # imagem com mapa de erros
sudo guara hash /dev/sdb # MD5, SHA-1 e SHA-256 do dispositivo
sudo guara search /dev/sdb "contrato" "cpf" # texto em todo o disco (UTF-8 e UTF-16)
guara export s.json achados.csv --category image # planilha dos achados
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
guara ui # interface no navegador (127.0.0.1 + token)
```
@@ -55,7 +61,29 @@ guara ui # interface no navegador (127.0.0.1 + t
## Interface gráfica
App desktop (Tauri) com barra lateral de dispositivos, mapa do disco, resultados ao vivo, filtros por tipo, busca, pré-visualização de imagens, confiança por arquivo, pausar/continuar e salvar sessão. Tema claro/escuro automático e layout responsivo. O mesmo front-end roda em `guara ui`.
App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando o Guará acha milhares de itens:
- **Grade de miniaturas** (gerada pelo núcleo, rápida mesmo com milhares de fotos) ou **lista**, com controle de tamanho, agrupamento por dia ou mês e rolagem virtual que aguenta dezenas de milhares de arquivos.
- **Navegação lateral** por tipo, **data real da foto (EXIF)**, pasta, **câmera**, confiança, com localização GPS e ocultando duplicadas.
- **Seleção como no Finder/Explorer:** clique, Ctrl/Cmd, Shift, setas, Ctrl+A. Espaço, Enter ou duplo clique abrem a **visualização rápida** em tela cheia com navegação por ← →.
- **Painel de detalhes** com pré-visualização, datas (tirada, modificada, criada, acessada) e metadados.
- Pausar/continuar, salvar sessão, tema **Auto / Claro / Escuro** (lembrado também ao reiniciar como administrador) e layout responsivo. O mesmo front-end roda em `guara ui`.
**Pré-visualização antes de recuperar** (lida direto do disco, sem gravar nada): imagens, **vídeo (MP4/MOV/WebM)** e áudio com player, **PDF** página a página, texto, documentos Word/Excel/PowerPoint/OpenDocument (texto extraído), conteúdo de ZIP e visão hexadecimal para o resto. Quando um arquivo não abre, o aviso ajuda a julgar se ele está danificado. A reprodução de vídeo depende dos codecs do seu sistema (no Linux, instale os plugins `gstreamer1.0-libav` e `gstreamer1.0-plugins-good`).
## Recursos periciais
Pensados para quem precisa documentar o que fez:
- **Hashes MD5, SHA-1 e SHA-256** de cada arquivo recuperado, da imagem forense e do dispositivo inteiro (`guara hash`).
- **Metadados:** EXIF (câmera, lente, número de série, data, **GPS**), informações de PDF (autor, programa, datas), propriedades de documentos Office e OpenDocument (autor, última modificação por, empresa) e dados de vídeo MP4/MOV (data de gravação, duração, resolução).
- **Linha do tempo:** datas de criação, modificação e acesso lidas do NTFS, FAT e exFAT.
- **Sessões salvas sozinhas e disco que volta com outro nome:** a varredura é salva automaticamente, pausa se o disco for desconectado e o Guará reconhece o mesmo disco ao reconectar.
- **Pesquisa de texto no disco inteiro** (`guara search`), inclusive espaço livre e fora de partições, em UTF-8 e UTF-16, mostrando posição, partição e contexto.
- **Exportação CSV** (abre no Excel) e **relatório HTML** pronto para imprimir (`guara report`), com identificação do caso, origem, partições, resumo, hashes e lista dos arquivos.
- **Detecção de duplicadas** por assinatura de conteúdo.
Escopo: o Guará trabalha com **mídias de armazenamento** (HD, SSD, pendrive, cartões e imagens de disco). Ele **não** desbloqueia celulares nem contorna senhas ou travas de segurança, e não substitui ferramentas de extração de dispositivos móveis.
## Limites conhecidos (v0.1)
@@ -68,7 +96,7 @@ Seja realista sobre o que dá para esperar:
- **exFAT:** coberto por teste com imagem sintética; falta validar em mais mídias reais.
- **Cifragem:** somente detecção.
- **Windows/macOS:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Relatos de mídias reais são muito bem-vindos.
- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado — melhoria planejada.
- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado (melhoria planejada).
## Desenvolvimento
@@ -89,3 +117,20 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md). Versões seguem [SemVer](https://semver
## Licença
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
## Novidades da 1.0.0
Metadados de ext4, HFS+, Btrfs, XFS e APFS; abertura de volumes BitLocker, LUKS e FileVault 2 com senha ou chave; RAID (0/1/4/5/6/10) e LVM remontados, com parâmetros descobertos sozinhos; imagens E01, Ex01 e AFF4; JPEG fragmentado e MP4 sem índice. Detalhes e limitações no [CHANGELOG](CHANGELOG.md).
## Novidades da 0.6.0
- Saúde do disco completa, no estilo do CrystalDiskInfo: estado com % de vida útil, temperatura, horas e vezes ligado, total lido e gravado, ficha do dispositivo (modelo, firmware, interface, modo de transferência, rotação, recursos) e a tabela inteira de atributos SMART (ATA e NVMe).
- Novo comando `guara health` no terminal.
- Nova logo: a cabeça do guará recortada das trilhas de um disco.
## Novidades da 0.5.0
- Visualizador de bancos SQLite com recuperação de linhas apagadas.
- Aviso de saúde do disco (SMART) antes da varredura profunda.
- Notificações do sistema, retomada de recuperação interrompida e mapa de fotos com GPS (opcional).
- Interface em inglês e janela de Configurações.
+1 -1
View File
@@ -1,3 +1,3 @@
# Segurança
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades — corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades (corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação) devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
+4 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
version = "0.1.0"
version = "1.0.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
@@ -20,6 +20,9 @@ tauri-plugin-dialog = "2"
serde_json = "1"
guara-core = { path = "../../../crates/guara-core" }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
lto = "thin"
codegen-units = 1
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "default",
"description": "Permissões da janela principal",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+147 -21
View File
@@ -16,40 +16,166 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json:
}
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
/// manifesto já exige elevação.
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
#[tauri::command]
fn relaunch_elevated() -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let mut cmd = std::process::Command::new("pkexec");
cmd.arg("env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] {
if let Ok(v) = std::env::var(k) {
cmd.arg(format!("{k}={v}"));
use tauri::Emitter;
let exe = elevated_exe()?;
let cfg = guara_core::settings::config_dir();
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
let _ = std::fs::write(&log, b"");
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
let err = out.try_clone().map_err(|e| e.to_string())?;
let pid = std::process::id().to_string();
#[cfg(target_os = "linux")]
let mut cmd = {
let mut c = std::process::Command::new("pkexec");
c.arg("/usr/bin/env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}"));
}
}
}
cmd.arg(exe);
cmd.spawn().map_err(|e| format!("pkexec indisponível: {e}"))?;
std::process::exit(0);
}
#[cfg(target_os = "macos")]
{
let script = format!("do shell script \"'{}' &> /dev/null &\" with administrator privileges", exe.display());
std::process::Command::new("osascript").args(["-e", &script]).spawn().map_err(|e| e.to_string())?;
std::process::exit(0);
c.arg(format!("GUARA_CONFIG_DIR={}", cfg.display()));
c.arg(format!("GUARA_REPLACE_PID={pid}"));
c.arg(&exe);
c
};
#[cfg(target_os = "macos")]
let mut cmd = {
let shell = format!(
"GUARA_CONFIG_DIR='{}' GUARA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
cfg.display(),
pid,
exe.display()
);
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
let mut c = std::process::Command::new("osascript");
c.args(["-e", &script]);
c
};
cmd.stdout(out).stderr(err);
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
std::thread::spawn(move || {
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso.
let status = child.wait();
if matches!(&status, Ok(s) if s.success()) {
let _ = std::fs::remove_file(&log);
return;
}
let detail = std::fs::read_to_string(&log).unwrap_or_default();
let msg = match status.ok().and_then(|s| s.code()) {
Some(126) => "Autorização cancelada.".to_string(),
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
};
let _ = app.emit("elevate-failed", msg);
let _ = std::fs::remove_file(&log);
});
Ok(())
}
#[cfg(windows)]
{
let _ = exe;
let _ = app;
Err("o Guará já roda como administrador no Windows".into())
}
}
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o
/// próprio arquivo `.AppImage` (variável `APPIMAGE`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> {
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
return Ok(p);
}
std::env::current_exe().map_err(|e| e.to_string())
}
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`),
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem.
fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
use tauri::http::Response;
if let Some(rest) = req.uri().path().strip_prefix("/thumb/") {
let mut it = rest.split('/');
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
let size = req
.uri()
.query()
.and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s=")))
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(256);
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
let Some(id) = id else { return err(400) };
return match eng.thumb(job, id, size) {
Ok(b) => Response::builder()
.status(200)
.header("Content-Type", "image/jpeg")
.header("Cache-Control", "private, max-age=3600")
.header("Access-Control-Allow-Origin", "*")
.body(b.as_ref().clone())
.unwrap_or_else(|_| err(500)),
Err(code) => err(code),
};
}
const CAP: u64 = 8 * 1024 * 1024;
let mut it = req.uri().path().trim_start_matches("/file/").split('/');
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
let Some(id) = id else { return err(400) };
let range = req.headers().get("range").and_then(|v| v.to_str().ok());
let slice = match eng.file_slice(job, id, range) {
Ok(s) => s,
Err(code) => return err(code),
};
let total = slice.total;
let start = slice.start;
let want = slice.len();
let mut headers = slice.headers();
let partial = slice.partial || want > CAP;
let Ok(data) = slice.read_all(CAP) else { return err(500) };
let mut b = Response::builder().status(if partial { 206 } else { 200 });
headers.retain(|(k, _)| *k != "Content-Range");
for (k, v) in &headers {
b = b.header(*k, v);
}
if partial && !data.is_empty() {
b = b.header("Content-Range", format!("bytes {}-{}/{}", start, start + data.len() as u64 - 1, total));
}
b.body(data).unwrap_or_else(|_| err(500))
}
fn main() {
let engine = Arc::new(Engine::new());
let for_protocol = engine.clone();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(State(Arc::new(Engine::new())))
.manage(State(engine))
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
let eng = for_protocol.clone();
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
})
.setup(|_app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
#[cfg(unix)]
if let Some(pid) = std::env::var("GUARA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200));
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
unsafe {
if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM);
}
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
.run(tauri::generate_context!())
.expect("erro ao iniciar o Guará");
+14 -4
View File
@@ -1,9 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guara",
"version": "0.1.0",
"version": "1.0.0",
"identifier": "dev.guara.app",
"build": { "frontendDist": "../ui" },
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
@@ -16,7 +18,9 @@
"resizable": true
}
],
"security": { "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost https://tile.openstreetmap.org; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
}
},
"bundle": {
"active": true,
@@ -24,6 +28,12 @@
"category": "Utility",
"shortDescription": "Recuperação de arquivos",
"longDescription": "Guará recupera arquivos apagados e partições perdidas de HD, SSD e pendrive, sempre em modo somente leitura na origem.",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+269 -8
View File
@@ -7,10 +7,10 @@
--text-2: #6e6e73;
--text-3: #8e8e93;
--line: rgba(0, 0, 0, 0.09);
--accent: #d9541e;
--accent-2: #ff8a3d;
--accent: #0f766e;
--accent-2: #14b8a6;
--accent-ink: #ffffff;
--accent-soft: rgba(217, 84, 30, 0.1);
--accent-soft: rgba(15, 118, 110, 0.11);
--ok: #1a8f3c;
--warn: #b26a00;
--bad: #d12b2b;
@@ -34,10 +34,10 @@
--text-2: #a1a1a6;
--text-3: #7d7d82;
--line: rgba(255, 255, 255, 0.1);
--accent: #ff7a3d;
--accent-2: #ff9d5c;
--accent-ink: #1a0b03;
--accent-soft: rgba(255, 122, 61, 0.15);
--accent: #2dd4bf;
--accent-2: #5eead4;
--accent-ink: #042f2e;
--accent-soft: rgba(45, 212, 191, 0.15);
--ok: #32d74b;
--warn: #ffb340;
--bad: #ff6961;
@@ -48,6 +48,32 @@
}
}
:root[data-theme="dark"] {
--bg: #000000;
--surface: #1c1c1e;
--surface-2: #232326;
--side: rgba(28, 28, 30, 0.8);
--text: #f5f5f7;
--text-2: #a1a1a6;
--text-3: #7d7d82;
--line: rgba(255, 255, 255, 0.1);
--accent: #2dd4bf;
--accent-2: #5eead4;
--accent-ink: #042f2e;
--accent-soft: rgba(45, 212, 191, 0.15);
--ok: #32d74b;
--warn: #ffb340;
--bad: #ff6961;
--ok-soft: rgba(50, 215, 75, 0.15);
--warn-soft: rgba(255, 179, 64, 0.15);
--bad-soft: rgba(255, 105, 97, 0.15);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
}
:root { color-scheme: light dark; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
@@ -211,7 +237,8 @@ input[type="checkbox"] { accent-color: var(--accent); width: 17px; height: 17px;
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
.kv dt { color: var(--text-3); } .kv dd { margin: 0; overflow-wrap: anywhere; }
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; animation: rise .3s; }
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; }
.actionbar.enter { animation: rise .3s; }
.actionbar .info { color: var(--text-2); font-size: 14px; }
dialog { border: 0; border-radius: 22px; padding: 26px; width: min(520px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
@@ -255,3 +282,237 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* ------------------------------------------------------------ tema */
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 12px; color: var(--text-2); }
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
/* ------------------------------------------------------------ pré-visualização */
.viewer { position: relative; min-height: 160px; max-height: 52vh; overflow: auto; border-radius: 12px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); display: flex; flex-direction: column; align-items: center; }
.viewer.large { max-height: none; height: 100%; min-height: 0; }
.viewer > * { max-width: 100%; }
.pv-img { display: block; max-width: 100%; max-height: 46vh; object-fit: contain; margin: auto; }
.viewer.large .pv-img { max-height: 100%; }
.viewer.zoomable .pv-img { cursor: zoom-in; }
.viewer.actual .pv-img { max-width: none; max-height: none; cursor: zoom-out; }
.viewer.actual { align-items: flex-start; }
.pv-video { width: 100%; max-height: 46vh; background: #000; border-radius: 12px; }
.viewer.large .pv-video { max-height: 100%; height: 100%; }
.pv-audio { width: calc(100% - 24px); margin: auto 12px; }
.pv-note { width: 100%; padding: 8px 12px; font-size: 12.5px; color: var(--text-2); text-align: center; }
.pv-note.bad { text-align: left; color: var(--text); background: var(--warn-soft); border-radius: 10px; margin: 8px; width: calc(100% - 16px); font-size: 13.5px; }
.pv-loading { height: 160px; display: grid; place-items: center; width: 100%; }
.pv-loading i { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.pv-text, .pv-hex { margin: 0; padding: 12px 14px; width: 100%; font: 12.5px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); text-align: left; }
.pv-hex { white-space: pre; overflow-x: auto; overflow-wrap: normal; font-size: 12px; }
.pv-hex .o { color: var(--text-3); } .pv-hex .a { color: var(--accent); }
.pv-page { width: 100%; background: var(--surface); padding: 18px 22px; font-family: Georgia, "Times New Roman", serif; font-size: 14px; line-height: 1.6; text-align: left; }
.pv-page p { margin: 0 0 .8em; white-space: pre-wrap; } .pv-page h4 { margin: 1em 0 .4em; font-family: var(--font); font-size: 16px; }
.pv-sheet { width: 100%; overflow: auto; text-align: left; }
.pv-sheet table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
.pv-sheet th, .pv-sheet td { border: 1px solid var(--line); padding: 4px 8px; white-space: nowrap; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
.pv-sheet th { background: var(--surface); color: var(--text-3); font-weight: 500; position: sticky; top: 0; }
.pv-slide { width: calc(100% - 20px); margin: 10px; padding: 14px 16px; background: var(--surface); border-radius: 10px; box-shadow: 0 0 0 1px var(--line); text-align: left; }
.pv-slide small { color: var(--text-3); } .pv-slide h4 { margin: 4px 0 6px; font-size: 16px; } .pv-slide p { margin: 0 0 4px; font-size: 13.5px; }
.pv-zip { list-style: none; margin: 0; padding: 4px 0; width: 100%; text-align: left; font-size: 13px; }
.pv-zip li { display: flex; justify-content: space-between; gap: 12px; padding: 5px 14px; border-bottom: 1px solid var(--line); }
.pv-zip li span { overflow-wrap: anywhere; } .pv-zip small { color: var(--text-3); flex: none; }
.pv-pdfbar { position: sticky; top: 0; z-index: 2; display: flex; gap: 10px; align-items: center; justify-content: center; padding: 6px; width: 100%; background: var(--side); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
dialog.lightbox { width: min(1760px, 98vw); height: min(95vh, 1100px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
dialog.lightbox[open] { display: grid; }
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
.lb-head .ttl { flex: 1; min-width: 0; } .lb-head .ttl b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lb-head .ttl small { color: var(--text-2); }
.lb-body { padding: 12px; min-height: 0; }
.hero-logo { width: min(440px, 80%); height: auto; display: block; margin: 0 0 22px -4px; }
.pill.accent { background: var(--accent-soft); color: var(--accent); }
/* ------------------------------------------------------------ explorador de arquivos */
.main.fixed { overflow: hidden; }
.page.wide { max-width: none; padding: 14px 18px 0; }
.page.fill { height: 100%; display: flex; flex-direction: column; }
.xp-top { display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 8px; }
.xp-top h1 { font-size: 24px; }
.xp-top .sub { font-size: 12.5px; }
.xp-host { flex: 1; min-height: 0; padding-bottom: 12px; }
.progress-card.slim { padding: 14px 18px; border-radius: 16px; gap: 10px; margin-bottom: 10px; }
.stats.compact { gap: 22px; }
.stats.compact .stat b { font-size: 20px; }
.done-bar { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 8px 14px; border-radius: 12px; background: var(--accent-soft); margin-bottom: 10px; font-size: 13.5px; }
.done-bar .grow { flex: 1; }
.xp { position: relative; display: grid; grid-template-columns: 236px minmax(0, 1fr) 340px; grid-template-rows: auto minmax(0, 1fr); gap: 10px 14px; height: 100%; }
.xp-bar { grid-column: 1 / -1; display: flex; gap: 10px; align-items: center; min-width: 0; }
.xp-crumb { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; white-space: nowrap; overflow: hidden; }
.xp-crumb b { font-size: 15px; overflow: hidden; text-overflow: ellipsis; }
.xp-crumb small { color: var(--text-3); font-variant-numeric: tabular-nums; }
.xp-q { flex: 0 1 300px !important; min-width: 120px; }
.xp-bar .theme-switch button { display: inline-grid; place-items: center; padding: 5px 9px; }
.xp-bar input[type="range"] { width: 110px; accent-color: var(--accent); }
.xp-navbtn { display: none; }
.xp-nav { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 8px 6px 14px; min-height: 0; }
.xp-sec { padding: 4px 0 6px; }
.xp-sec h4 { margin: 8px 10px 4px; font-size: 11.5px; letter-spacing: .02em; color: var(--text-3); font-weight: 600; }
.xp-node { display: flex; align-items: center; gap: 4px; width: 100%; border: 0; background: transparent; cursor: pointer; text-align: left; padding: 6px 8px 6px calc(8px + var(--d, 0) * 14px); border-radius: 9px; font-size: 13.5px; color: var(--text); }
.xp-node:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
.xp-node.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.xp-node .l { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.xp-node small { color: var(--text-3); font-variant-numeric: tabular-nums; font-weight: 400; }
.xp-node .tw { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; flex: none; transition: transform .15s; }
.xp-node .tw:hover { background: color-mix(in srgb, var(--text) 10%, transparent); }
.xp-main { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
.xp-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; padding: 0 12px; }
.xp-chips:not(:empty) { padding: 10px 12px 2px; }
.xp-chips .chip { display: inline-flex; gap: 6px; align-items: center; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.xp-scroll { position: relative; flex: 1; min-height: 0; overflow-y: auto; outline: none; }
.xp-sizer { position: relative; width: 100%; }
.xp-row { position: absolute; left: 0; right: 0; display: flex; gap: 10px; padding: 0 12px; box-sizing: border-box; }
.xp-row.head { align-items: flex-end; }
.xp-head { display: flex; align-items: baseline; gap: 10px; width: 100%; padding: 0 2px 6px; border-bottom: 1px solid var(--line); }
.xp-head b::first-letter { text-transform: uppercase; }
.xp-head b { font-size: 14px; } .xp-head small { color: var(--text-3); } .xp-hsel { margin-left: auto; border: 0; background: transparent; color: var(--accent); cursor: pointer; font-size: 12.5px; opacity: 0; }
.xp-head:hover .xp-hsel, .xp-hsel:focus-visible { opacity: 1; }
.xp-tile { position: relative; flex: none; border-radius: 12px; cursor: pointer; user-select: none; -webkit-user-select: none; }
.xp-thumb { position: relative; border-radius: 12px; overflow: hidden; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); }
.xp-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--surface-2); }
.xp-tile[aria-selected="true"] .xp-thumb { box-shadow: 0 0 0 3px var(--accent); }
.xp-tile[data-cur="1"]:focus-visible .xp-thumb, .xp-tile[data-cur="1"] .xp-thumb { outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent); outline-offset: 2px; }
.xp-ico { width: 100%; height: 100%; display: grid; place-content: center; text-align: center; gap: 2px; color: #fff; }
.xp-ico span { font-size: 34px; line-height: 1; } .xp-ico b { font-size: 12px; letter-spacing: .04em; opacity: .9; }
.c-document { background: linear-gradient(145deg, #3b82f6, #1d4ed8); } .c-video { background: linear-gradient(145deg, #8b5cf6, #5b21b6); }
.c-audio { background: linear-gradient(145deg, #ec4899, #9d174d); } .c-archive { background: linear-gradient(145deg, #f59e0b, #b45309); }
.c-database { background: linear-gradient(145deg, #14b8a6, #0f766e); } .c-other { background: linear-gradient(145deg, #64748b, #334155); } .c-image { background: linear-gradient(145deg, #0ea5e9, #0369a1); }
.c-bad { background: linear-gradient(145deg, #6b7280, #374151); }
.xp-chk { position: absolute; top: 7px; left: 7px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, .35); box-shadow: inset 0 0 0 2px #fff; opacity: 0; transition: opacity .12s; backdrop-filter: blur(4px); }
.xp-tile:hover .xp-chk, .xp-tile[aria-selected="true"] .xp-chk, .xp-lrow:hover .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { opacity: 1; }
.xp-tile[aria-selected="true"] .xp-chk, .xp-lrow[aria-selected="true"] .xp-chk { background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 12.5l4 4 8-9'/%3E%3C/svg%3E") center / 14px no-repeat; }
.xp-dot { position: absolute; right: 7px; bottom: 7px; width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 0 2px rgba(255, 255, 255, .85); }
.xp-dot.ok { background: var(--ok); } .xp-dot.warn { background: var(--warn); } .xp-dot.bad { background: var(--bad); }
.xp-gps { position: absolute; left: 7px; bottom: 6px; color: #fff; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
.xp-cap { padding: 5px 2px 0; min-width: 0; }
.xp-cap b { display: block; font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.xp-cap small { display: block; font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.xp-lrow { position: relative; display: grid; grid-template-columns: 26px 40px minmax(0, 1fr) 132px 76px 58px; gap: 12px; align-items: center; width: 100%; height: 50px; padding: 0 10px; border-radius: 10px; cursor: pointer; user-select: none; }
.xp-lrow:hover { background: var(--surface-2); }
.xp-lrow[aria-selected="true"] { background: var(--accent-soft); }
.xp-lrow .xp-chk { position: static; opacity: .0; }
.xp-lth { width: 40px; height: 40px; border-radius: 8px; overflow: hidden; background: var(--surface-2); display: grid; place-items: center; }
.xp-lth img { width: 100%; height: 100%; object-fit: cover; }
.xp-lth .xp-ico span { font-size: 18px; } .xp-lth .xp-ico b { display: none; }
.xp-lico { font-size: 18px; width: 100%; height: 100%; display: grid; place-items: center; color: #fff; }
.xp-lnm { min-width: 0; } .xp-lnm b { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xp-lnm small { display: block; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.xp-lcol { color: var(--text-2); font-size: 13px; white-space: nowrap; } .xp-lcol.num { text-align: right; font-variant-numeric: tabular-nums; }
.xp-actions { display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--surface); }
.xp-actions .info { color: var(--text-2); font-size: 13.5px; margin-right: auto; }
.xp-insp { overflow-y: auto; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; }
.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; }
.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; }
.xp-insp-x { display: none; align-self: flex-end; }
.xp-veil { display: none; }
.kv dd { overflow-wrap: anywhere; }
@media (max-width: 1280px) { .xp { grid-template-columns: 208px minmax(0, 1fr) 300px; } }
@media (max-width: 1100px) {
.xp { grid-template-columns: minmax(0, 1fr); }
.xp-bar { flex-wrap: wrap; }
.xp-navbtn { display: inline-grid; }
.xp-nav { position: fixed; inset: 0 auto 0 0; width: min(86vw, 310px); z-index: 40; border-radius: 0 var(--radius) var(--radius) 0; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); }
.xp.show-nav .xp-nav { transform: none; }
.xp-insp { position: fixed; left: 0; right: 0; bottom: 0; max-height: 80vh; z-index: 40; border-radius: 20px 20px 0 0; transform: translateY(104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
.xp.show-insp .xp-insp { transform: none; }
.xp-insp-x { display: inline-grid; }
.xp-veil { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .32); opacity: 0; pointer-events: none; transition: opacity .2s; display: block; }
.xp.show-nav .xp-veil, .xp.show-insp .xp-veil { opacity: 1; pointer-events: auto; }
.xp-q { flex: 1 1 140px !important; }
.xp-crumb { flex: 1 1 100%; order: -1; }
}
@media (max-width: 860px) {
.page.wide { padding: 10px 10px 0; }
.xp-lrow { grid-template-columns: 26px 40px minmax(0, 1fr) 64px; } .xp-lrow .xp-lcol { display: none; }
.xp-bar input[type="range"] { display: none !important; }
}
.fld { display: grid; gap: 4px; margin: 10px 0; font-size: 13px; color: var(--text-2); }
.fld textarea { resize: vertical; font: inherit; }
.hits { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.hit { display: grid; grid-template-columns: 130px 80px 90px minmax(0, 1fr); gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--line); font-size: 13px; align-items: start; }
.hit.head { background: var(--surface-2); color: var(--text-3); font-size: 12px; }
.hit .ctx { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; color: var(--text-2); }
.hit mark { background: var(--accent-soft); color: var(--accent); padding: 0 2px; border-radius: 3px; font-weight: 600; }
@media (max-width: 860px) { .hit { grid-template-columns: 100px 1fr; } .hit span:nth-child(2), .hit span:nth-child(3) { display: none; } }
.xp-insp #xpPv { cursor: zoom-in; }
.pv-card { width: 100%; min-height: 220px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 24px; text-align: center; }
.pv-card b { overflow-wrap: anywhere; font-size: 15px; }
.pv-bigicon { font-size: 52px; line-height: 1; }
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); }
.cand { display: grid; gap: 4px; margin: 6px 0 12px; max-height: 240px; overflow: auto; }
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
.cand .s { white-space: normal; }
/* ------------------------------------------------------------ visualizador de bancos de dados */
.db-sum { width: 100%; padding: 16px; display: grid; gap: 8px; text-align: left; }
.db-sum ul { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 2px; font-size: 13px; }
.db-sum li { display: flex; justify-content: space-between; gap: 10px; } .db-sum li small { color: var(--text-3); }
.viewer.db-large { display: block; overflow: hidden; }
.db-wrap { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: 100%; min-height: 0; width: 100%; text-align: left; }
.db-tables { overflow: auto; padding: 10px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
.db-t { display: flex; justify-content: space-between; gap: 8px; border: 0; background: transparent; cursor: pointer; padding: 7px 10px; border-radius: 9px; color: var(--text); font-size: 13.5px; text-align: left; }
.db-t:hover { background: color-mix(in srgb, var(--text) 7%, transparent); } .db-t[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.db-t small { color: var(--text-3); font-weight: 400; }
.db-meta { margin-top: 12px; display: grid; gap: 2px; font-size: 12.5px; color: var(--text-2); } .db-meta b { margin-top: 6px; color: var(--text-3); font-size: 11.5px; }
.db-main { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; }
.db-bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); }
.db-bar .input { flex: 0 1 220px; }
.db-grid { overflow: auto; }
.db-foot { display: flex; justify-content: space-between; align-items: center; padding: 8px 14px; border-top: 1px solid var(--line); }
.db-table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
.db-table th, .db-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
.db-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; color: var(--text-3); font-weight: 600; text-align: left; }
.db-table tbody th { color: var(--text-3); font-weight: 400; text-align: right; }
.db-table tr.del td { background: var(--warn-soft); } .db-table tr.del th { color: var(--warn); }
.db-null { color: var(--text-3); font-style: italic; } .db-blob { color: var(--accent); }
.db-ts { margin-left: 6px; font-style: normal; font-size: 11px; color: var(--accent); background: var(--accent-soft); padding: 0 5px; border-radius: 6px; }
@media (max-width: 860px) { .db-wrap { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } .db-tables { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); } }
.xp-map { position: relative; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 10px; margin-top: 8px; background: var(--surface-2, #e6eeee); }
.xp-tiles { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; height: 100%; }
.xp-tiles img { width: 100%; height: 100%; display: block; }
.xp-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #0f8f8f; border: 3px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
/* saúde do disco (SMART) */
.h-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }
.h-tile { background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; }
.h-tile small { color: var(--text-3); font-size: 12px; }
.h-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
.h-tile.warning b { color: var(--warn); } .h-tile.failing b { color: var(--bad); }
.h-status.ok { background: var(--ok-soft); } .h-status.ok b { color: var(--ok); }
.h-status.warning { background: var(--warn-soft); } .h-status.failing { background: var(--bad-soft); }
.h-status b span { font-weight: 500; opacity: .8; }
.h-info { margin-top: 14px; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 14px 16px; }
.h-scroll { overflow-x: auto; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); }
.h-table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }
.h-table th { text-align: left; color: var(--text-3); font-weight: 500; padding: 8px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.2)); white-space: nowrap; }
.h-table td { padding: 6px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.12)); white-space: nowrap; }
.h-table tr:last-child td { border-bottom: 0; }
.h-table .dim { color: var(--text-3); }
.h-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); }
.h-dot.warning { background: var(--warn); } .h-dot.failing { background: var(--bad); }
/* logo inline: segue o tema escolhido no app, não o do sistema */
.logo .a { stroke: var(--accent); } .logo .b { stroke: var(--accent-2); } .logo .t { fill: var(--text); }
+439 -173
View File
@@ -1,6 +1,9 @@
// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`:
// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`:
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
import { createExplorer } from './explorer.js';
import { setLang, LOC } from './i18n.js';
const tauri = window.__TAURI__;
const params = new URLSearchParams(location.search);
let token = params.get('token');
@@ -21,6 +24,41 @@ async function rpc(method, p = {}) {
return body;
}
const isWin = /Windows/i.test(navigator.userAgent);
const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost') : '';
const thumbUrl = (job, id, s = 256) => `${fileBase}/thumb/${job}/${id}?s=${s}` + (tauri ? '' : `&t=${encodeURIComponent(token || '')}`);
const fileUrl = (job, id) => `${fileBase}/file/${job}/${id}` + (tauri ? '' : `?t=${encodeURIComponent(token || '')}`);
// Atualiza o HTML só quando mudou (evita piscar e perder o foco/hover).
function setHTML(el, html) {
if (!el || el._h === html) return false;
el._h = html;
el.innerHTML = html;
return true;
}
// ------------------------------------------------------------------ tema
function applyTheme(t) {
const root = document.documentElement;
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme;
document.querySelectorAll('[data-theme-pick]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themePick === (t || 'auto'))));
}
try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { applyTheme('auto'); }
document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
const t = b.dataset.themePick;
applyTheme(t);
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
saveSettings({ theme: t });
}));
// ------------------------------------------------------------------ configurações
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
const SETTINGS = { theme: 'auto', lang: 'auto', notify: true, maps: false };
async function saveSettings(patch) {
Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
}
// ------------------------------------------------------------------ utilidades
const $ = (s, el = document) => el.querySelector(s);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -35,7 +73,7 @@ const dur = (s) => {
s = Math.round(s);
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`;
};
const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : '—');
const date = (t) => (t ? new Date(t * 1000).toLocaleString(LOC(), { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data');
const CATS = [
['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'],
['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'],
@@ -75,6 +113,31 @@ function ask({ title, text, value, placeholder, ok = 'OK', pick }) {
});
}
function askForm({ title, text, fields, ok = 'OK' }) {
return new Promise((resolve) => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text || ''}</p>
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${
f.options ? `<select class="select" name="${f.name}">${f.options.map(([v, l]) => `<option value="${esc(v)}" ${v === f.value ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`
: f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>`
: `<span class="row" style="flex-wrap:nowrap"><input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">${f.pick && tauri?.dialog ? `<button type="button" class="btn" data-pick="${f.name}">Escolher…</button>` : ''}</span>`}</label>`).join('')}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
d.querySelectorAll('[data-pick]').forEach((b) => (b.onclick = async () => {
const f = fields.find((x) => x.name === b.dataset.pick);
const r = await tauri.dialog.open(f.pick);
if (r) d.querySelector(`[name="${f.name}"]`).value = Array.isArray(r) ? r[0] : r;
}));
d.onclose = () => {
if (d.returnValue !== 'ok') return resolve(null);
const out = {};
d.querySelectorAll('[name]').forEach((el) => (out[el.name] = el.value.trim()));
resolve(out);
};
d.showModal();
d.querySelector('[name]')?.focus();
});
}
function info({ title, html }) {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2>${html}<menu><button class="btn primary" value="ok">Entendi</button></menu></form>`;
@@ -125,7 +188,7 @@ function renderSide() {
dl.innerHTML = S.devices.map((d, i) => `
<li><button class="dev" data-i="${i}" aria-current="${S.device?.path === d.path}">
<span class="ico">${icon(d.kind)}</span>
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '—'}${d.readable ? '' : ' · requer admin'}</span></span>
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '?'}${d.readable ? '' : ' · requer admin'}</span></span>
</button></li>`).join('');
dl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openDevice(S.devices[+b.dataset.i]); setMenu(false); }));
const sl = $('#sessionList');
@@ -134,6 +197,31 @@ function renderSide() {
<li><button class="dev" data-j="${i}" aria-current="${S.job === s.job}"><span class="ico">${icon('default')}</span>
<span><span class="t">${esc(s.label)}</span><span class="s">${esc(s.target)}</span></span></button></li>`).join('');
sl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openJob(S.sessions[+b.dataset.j]); setMenu(false); }));
const rl = $('#recentList');
rl.dataset.empty = 'Nada salvo ainda';
rl.innerHTML = (S.recent || []).map((r, i) => `
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join('');
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
}
async function loadRecent() {
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
renderSide();
}
async function openRecent(r) {
setMenu(false);
try {
const { job } = await rpc('session_load', { path: r.path });
const st = await rpc('status', { job });
let s = S.sessions.find((x) => x.job === job);
if (!s) {
s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
S.sessions.unshift(s);
}
openJob(s);
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
}
$('#openImageBtn').onclick = async () => {
@@ -161,20 +249,24 @@ $('#loadSessionBtn').onclick = async () => {
try {
const { job } = await rpc('session_load', { path });
const st = await rpc('status', { job });
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
S.sessions.unshift(s);
let s = S.sessions.find((x) => x.job === job);
if (!s) {
s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
S.sessions.unshift(s);
}
openJob(s);
} catch (e) { toast(e.message); }
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
};
// ------------------------------------------------------------------ início
function renderHome() {
main.classList.remove('fixed');
stopPoll();
S.device = null; S.job = null;
renderSide();
main.innerHTML = `<div class="page">
<section class="hero"><h1>Encontre o que você achou que tinha perdido.</h1>
<p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo — arquivos apagados, partições perdidas e espaço não particionado — sem escrever nada no disco de origem.</p></section>
<section class="hero"><img class="hero-logo" src="guara-logo.svg" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
<p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.</p></section>
<div class="cards">
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
<div class="card"><h3>Confiança por arquivo</h3><p>Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.</p></div>
@@ -186,15 +278,74 @@ function renderHome() {
// ------------------------------------------------------------------ dispositivo
async function openDevice(d) {
main.classList.remove('fixed');
stopPoll();
S.device = d; S.job = null; S.layout = null;
renderSide();
main.innerHTML = `<div class="page"><p class="sub">Lendo ${esc(d.name)}…</p></div>`;
main.scrollTop = 0;
let layout = null, err = null;
S.health = null;
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
S.layout = layout;
renderDevice(err);
// saúde (SMART) em segundo plano: não atrasa a tela
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
}
const HEALTH_WORD = { ok: 'Bom', warning: 'Atenção', failing: 'Ruim' };
function paintHealth() {
const el = $('#healthArea');
const h = S.health;
if (!el || !h) { if (el) el.innerHTML = ''; return; }
if (!h.available) {
el.innerHTML = `<div class="section"><h2>Saúde do disco</h2><p class="sub">${esc(h.summary)}</p></div>`;
return;
}
const bad = h.status === 'failing' || h.status === 'warning';
const fmtN = (n) => Number(n).toLocaleString(LOC());
const tile = (label, value, cls = '') => value == null ? '' : `<div class="h-tile ${cls}"><small>${label}</small><b>${value}</b></div>`;
const temp = h.temperature_c;
const tiles = [
`<div class="h-tile h-status ${esc(h.status)}"><small>Estado</small><b>${HEALTH_WORD[h.status] || '?'}${h.health_pct != null ? ` <span>${h.health_pct}%</span>` : ''}</b></div>`,
tile('Temperatura', temp == null ? null : `${temp} °C`, temp >= 60 ? 'failing' : temp >= 50 ? 'warning' : ''),
tile('Horas ligado', h.power_on_hours == null ? null : `${fmtN(h.power_on_hours)} h`),
tile('Vezes ligado', h.power_cycles == null ? null : fmtN(h.power_cycles)),
tile('Total lido', h.host_reads == null ? null : human(h.host_reads)),
tile('Total gravado', h.host_writes == null ? null : human(h.host_writes)),
].join('');
const info = (h.info || []).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
const dash = (x) => x == null ? '<span class="dim">-</span>' : x;
const attrs = h.attributes || [];
const table = attrs.length ? `<details class="adv h-attrs"${bad ? ' open' : ''}><summary>Atributos SMART (${attrs.length})</summary>
<div class="h-scroll"><table class="h-table"><thead><tr><th></th><th>ID</th><th>Atributo</th><th>Atual</th><th>Pior</th><th>Limite</th><th>Bruto</th></tr></thead><tbody>
${attrs.map((a) => `<tr><td><span class="h-dot ${esc(a.status)}" title="${HEALTH_WORD[a.status] || ''}"></span></td><td class="mono">${esc(a.id)}</td><td class="mono">${esc(a.name)}</td><td>${dash(a.value)}</td><td>${dash(a.worst)}</td><td>${dash(a.thresh)}</td><td class="mono">${esc(a.raw)}</td></tr>`).join('')}
</tbody></table></div></details>` : '';
const scope = h.partition ? `<p class="sub">O SMART é do disco inteiro: os dados abaixo são de <span class="mono">${esc(h.disk)}</span>, que contém <span class="mono">${esc(h.partition)}</span>.</p>` : '';
// o que motivou o alerta (os campos da ficha já aparecem abaixo)
const known = new Set(['Modelo', 'Nº de série', 'Horas ligado', 'Temperatura', 'Autoavaliação SMART', 'Vida útil usada']);
const why = (h.details || []).filter(([k]) => !known.has(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
el.innerHTML = `${bad ? `<div class="notice ${h.status === 'failing' ? 'bad' : ''}">${warnSvg}<div><b>${esc(h.summary)}</b>
${why ? `<dl class="kv" style="margin-top:6px">${why}</dl>` : ''}
${(h.advice || []).map((a) => `<div style="margin-top:6px">${esc(a)}</div>`).join('')}
<div class="row" style="margin-top:8px"><button class="btn sm primary" id="healthImg">Criar a imagem primeiro</button></div></div></div>` : ''}
<div class="section"><h2>Saúde do disco</h2>${scope}
<div class="h-tiles">${tiles}</div>
${info ? `<dl class="kv h-info">${info}</dl>` : ''}${table}</div>`;
$('#healthImg')?.addEventListener('click', startImage);
}
/** Disco com sinais de defeito: oferece criar a imagem antes de varrer. */
function confirmRisky() {
return new Promise((resolve) => {
const h = S.health;
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Este disco parece estar com defeito</h2><p>${esc(h.summary)}</p>${(h.advice || []).map((a) => `<p class="sub">${esc(a)}</p>`).join('')}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn" value="scan">Varrer mesmo assim</button><button class="btn primary" value="image">Criar a imagem primeiro</button></menu></form>`;
d.onclose = () => resolve(d.returnValue === 'image' ? 'image' : d.returnValue === 'scan' ? 'scan' : null);
d.showModal();
});
}
function fsClass(p) {
@@ -224,13 +375,14 @@ function renderDevice(err) {
const notes = [];
if (!d.readable) notes.push(['bad', 'Este disco exige permissão de administrador. ' + (tauri ? '<button class="btn sm" id="elevate" style="margin-left:6px">Reiniciar como administrador</button>' : 'Feche e rode de novo com <code>sudo guara ui</code> (Linux/macOS) ou como administrador (Windows).')]);
if (err) notes.push(['bad', esc(err)]);
if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em <code>${esc(d.mounted_at.join(', '))}</code>). O Guará só lê, mas evite gravar nele enquanto recupera — qualquer escrita pode sobrescrever o que você quer salvar.`]);
if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em <code>${esc(d.mounted_at.join(', '))}</code>). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.`]);
if (d.trim) notes.push(['warn', 'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.']);
(l?.warnings || []).forEach((w) => notes.push(['warn', esc(w)]));
main.innerHTML = `<div class="page">
<div class="dev-head"><div><h1>${esc(d.name)}</h1><div class="sub">${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · <span class="mono">${esc(d.path)}</span></div></div></div>
${notes.map(([k, t]) => `<div class="notice ${k === 'bad' ? 'bad' : ''}">${warnSvg}<div>${t}</div></div>`).join('')}
<div id="healthArea"></div>
${l ? `<div class="section"><h2>Mapa do disco</h2>${diskMap(l)}
<div class="legend">${l.partitions.map((p) => `<div><span class="dot ${fsClass(p)}"></span><b>Partição ${p.index + 1}</b> ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''}
${p.lost ? '<span class="pill accent">encontrada por varredura</span>' : ''}${p.encrypted ? `<span class="pill warn">cifrada (${esc(p.encrypted)}) · só detecção</span>` : ''}</div>`).join('')}
@@ -244,24 +396,65 @@ function renderDevice(err) {
<div class="mode"><h3>Criar imagem do disco</h3><p>Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.</p>
<button class="btn lg" id="goImage" ${d.readable ? '' : 'disabled'}>Criar imagem…</button></div>
</div>
<h2 style="margin-top:26px">Ferramentas de perícia</h2>
<div class="modes">
<div class="mode"><h3>Pesquisar texto no disco</h3><p>Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.</p>
<input class="input" id="srchTerms" placeholder="termos separados por vírgula" aria-label="Termos a pesquisar" style="margin-bottom:8px">
<button class="btn lg" id="goSearch" ${d.readable ? '' : 'disabled'}>Pesquisar</button></div>
<div class="mode"><h3>Hashes da origem</h3><p>Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.</p>
<button class="btn lg" id="goHash" ${d.readable ? '' : 'disabled'}>Calcular hashes</button></div>
</div>
<details class="adv"><summary>Opções avançadas</summary>
<label class="check"><input type="checkbox" id="optAlloc"><span><b>Incluir áreas ainda em uso</b><br><span class="sub">Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.</span></span></label>
<div class="row" id="optCats">${CATS.map(([k, n]) => `<label class="check"><input type="checkbox" value="${k}"> ${n}</label>`).join('')}</div>
<div class="sub">Sem nenhuma categoria marcada, todos os tipos são procurados.</div>
</details>
<details class="adv"><summary>Volumes cifrados, RAID e vídeos</summary>
<label class="fld"><span>Senha do volume (BitLocker, LUKS, FileVault 2)</span><input class="input" id="optPw" type="password" autocomplete="off"></label>
<label class="fld"><span>Chave de recuperação do BitLocker</span><input class="input" id="optRk" placeholder="000000-000000-... (8 grupos)" autocomplete="off"></label>
<label class="fld"><span>Arquivo de chave (.BEK ou keyfile)</span><input class="input" id="optKf" placeholder="/caminho/chave.BEK"></label>
<label class="fld"><span>Outros discos do mesmo conjunto (RAID/LVM), um caminho por linha</span><textarea class="input" id="optMembers" rows="3"></textarea></label>
<label class="check"><input type="checkbox" id="optRaid"><span><b>Estes discos formam um RAID sem metadados</b><br><span class="sub">O Guará descobre sozinho o nível, a faixa, o layout e a ordem dos discos.</span></span></label>
<label class="fld"><span>Vídeo íntegro da mesma câmera/drone (refaz o índice de MP4 sem moov)</span><input class="input" id="optRef" placeholder="/caminho/video-bom.mp4"></label>
<label class="check"><input type="checkbox" id="optRemember"><span><b>Lembrar as chaves dos volumes abertos na sessão</b><br><span class="sub">A senha nunca é gravada; só a chave derivada.</span></span></label>
</details>
</div></div>`;
$('#elevate')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
const opts = (mode) => ({
mode,
assembly: (() => {
const v = (id) => ($(id)?.value || '').trim();
const secrets = [];
if (v('#optPw')) secrets.push({ kind: 'password', value: v('#optPw') });
if (v('#optRk')) secrets.push({ kind: 'recovery', value: v('#optRk') });
if (v('#optKf')) secrets.push({ kind: 'keyfile', path: v('#optKf') });
return {
secrets,
members: v('#optMembers').split('\n').map((x) => x.trim()).filter(Boolean),
raid: $('#optRaid')?.checked ? {} : null,
remember_keys: $('#optRemember')?.checked || false,
};
})(),
references: $('#optRef')?.value.trim() ? [$('#optRef').value.trim()] : [],
include_allocated: $('#optAlloc')?.checked || false,
categories: [...document.querySelectorAll('#optCats input:checked')].map((i) => i.value),
});
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
$('#goImage')?.addEventListener('click', startImage);
paintHealth();
$('#goSearch')?.addEventListener('click', startSearch);
$('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); });
$('#goHash')?.addEventListener('click', startHash);
}
async function startScan(options) {
if (S.health && (S.health.status === 'failing' || S.health.status === 'warning') && options.mode === 'deep' && !S.healthAck?.[S.device.path]) {
const c = await confirmRisky();
if (c === 'image') return startImage();
if (!c) return;
(S.healthAck ??= {})[S.device.path] = true;
}
try {
const { job } = await rpc('scan_start', { path: S.device.path, options });
const s = { job, target: S.device.path, label: S.device.name };
@@ -272,10 +465,18 @@ async function startScan(options) {
async function startImage() {
const base = (S.defaults.home || '') + (S.defaults.sep || '/');
const dest = await ask({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.', value: base + 'disco-guara.img', ok: 'Criar imagem' });
if (!dest) return;
const f = await askForm({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.', ok: 'Criar imagem', fields: [
{ name: 'dest', label: 'Arquivo de destino', value: base + 'disco-guara.img' },
{ name: 'format', label: 'Formato', value: 'raw', options: [['raw', 'Bruta (.img)'], ['e01', 'Expert Witness (.E01, com hash e metadados)'], ['aff4', 'AFF4 (.aff4)']] },
{ name: 'case', label: 'Número do caso (E01/AFF4)', value: '' },
{ name: 'examiner', label: 'Examinador (E01/AFF4)', value: '' },
] });
if (!f || !f.dest) return;
const dest = f.dest;
const format = f.format === 'e01' ? { kind: 'e01', case_number: f.case, examiner: f.examiner }
: f.format === 'aff4' ? { kind: 'aff4', case_number: f.case, examiner: f.examiner } : { kind: 'raw' };
try {
const { job } = await rpc('image_start', { path: S.device.path, dest });
const { job } = await rpc('image_start', { path: S.device.path, dest, format });
watchAux(job, 'Criando imagem do disco', (st) => {
const r = st.report || {};
const bad = (r.bad_ranges || []).length;
@@ -284,77 +485,97 @@ async function startImage() {
} catch (e) { toast(e.message); }
}
async function startHash() {
try {
const { job } = await rpc('hash_start', { path: S.device.path });
watchAux(job, 'Calculando hashes da origem', (st) => {
const r = st.report || {};
S.sourceHashes = { ...r, path: S.device.path };
return `<p>Hashes do conteúdo inteiro de <code>${esc(S.device.path)}</code>:</p><dl class="kv"><dt>MD5</dt><dd class="mono">${esc(r.md5)}</dd><dt>SHA-1</dt><dd class="mono">${esc(r.sha1)}</dd><dt>SHA-256</dt><dd class="mono">${esc(r.sha256)}</dd></dl><p class="sub">Eles serão incluídos nos relatórios gerados nesta sessão.</p>`;
});
} catch (e) { toast(e.message); }
}
async function startSearch() {
const terms = ($('#srchTerms')?.value || '').split(',').map((t) => t.trim()).filter(Boolean);
if (!terms.length) { toast('Digite pelo menos um termo.'); $('#srchTerms')?.focus(); return; }
try {
const { job } = await rpc('search_start', { path: S.device.path, terms, ignore_case: true, utf16: true });
openSearch(job, terms);
} catch (e) { toast(e.message); }
}
function openSearch(job, terms) {
stopPoll();
EX?.destroy(); EX = null;
S.job = null;
main.classList.remove('fixed');
main.innerHTML = `<div class="page"><button class="back" id="backBtn">‹ ${esc(S.device?.name || 'Dispositivos')}</button>
<h1 id="shTitle">Procurando…</h1><div class="sub">Termos: ${terms.map((t) => `<code>${esc(t)}</code>`).join(', ')}</div>
<div id="shProg" style="margin:14px 0"></div><div class="hits" id="hits"></div></div>`;
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
const termRe = new RegExp(terms.map((t) => esc(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi');
const tick2 = async () => {
let st, res;
try { st = await rpc('status', { job }); res = await rpc('search_results', { job, offset: 0, limit: 1000 }); } catch { return false; }
const run = st.status === 'running';
const p = st.progress || {};
const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0;
$('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString(LOC())} ocorrência${res.total === 1 ? '' : 's'}`;
setHTML($('#shProg'), run ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString(LOC())} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString(LOC())}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>'));
return run;
};
$('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); };
(async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })();
}
// ------------------------------------------------------------------ varredura
let EX = null; // explorador de arquivos (grade, navegação, detalhes)
function openJob(s) {
stopPoll();
EX?.destroy();
EX = null;
S.job = s.job;
S.filters = { category: null, minConfidence: null, query: '', sort: 'confidence' };
S.items = []; S.total = 0; S.selected = new Set(); S.allFiltered = false; S.current = null; S.status = null;
S.status = null;
renderSide();
renderScan();
tick();
S.poll = setInterval(tick, 500);
main.scrollTop = 0;
}
function stopPoll() { clearInterval(S.poll); S.poll = null; }
let lastFound = -1, lastFilterKey = '';
async function tick() {
if (!S.job) return;
let st;
try { st = await rpc('status', { job: S.job }); } catch (e) { stopPoll(); toast(e.message); return; }
S.status = st;
const key = JSON.stringify(S.filters);
if (st.found !== lastFound || key !== lastFilterKey) {
lastFound = st.found; lastFilterKey = key;
await loadResults(true);
}
paintStatus();
if (st.status !== 'running') {
EX?.onStatus(st);
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
if (st.status !== 'running' && !st.enriching) {
stopPoll();
loadRecent();
if (st.status === 'error') toast(st.error);
await loadResults(true);
paintStatus();
} else if (!S.poll) {
S.poll = setInterval(tick, 700);
}
}
async function loadResults(reset) {
const f = S.filters;
const query = { sort: f.sort, offset: reset ? 0 : S.items.length, limit: 200 };
if (f.category) query.category = f.category;
if (f.minConfidence) query.min_confidence = f.minConfidence;
if (f.query) query.query = f.query;
const r = await rpc('results', { job: S.job, query });
S.items = reset ? r.items : S.items.concat(r.items);
S.total = r.total;
paintList();
}
function renderScan() {
main.innerHTML = `<div class="page">
<button class="back" id="backBtn">‹ Dispositivos</button>
<div class="dev-head"><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
main.classList.add('fixed');
main.innerHTML = `<div class="page wide fill">
<div class="xp-top"><button class="back" id="backBtn">‹ Dispositivos</button><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
<div id="progressArea"></div>
<div id="warnArea"></div>
<div class="toolbar">
<div class="chips" id="chips" role="group" aria-label="Filtrar por tipo"></div>
</div>
<div class="toolbar" style="margin-top:0">
<input class="input" id="q" type="search" placeholder="Buscar por nome ou pasta" value="${esc(S.filters.query)}" aria-label="Buscar">
<select class="select" id="conf" aria-label="Confiança mínima"><option value="">Qualquer confiança</option><option value="medium">Média ou alta</option><option value="high">Só alta</option></select>
<select class="select" id="sort" aria-label="Ordenar"><option value="confidence">Mais confiáveis</option><option value="size">Maiores</option><option value="name">Nome</option></select>
</div>
<div class="split"><div class="table" id="table"></div><aside class="preview" id="preview" aria-live="polite"></aside></div>
</div><div id="barHost"></div>`;
<div class="xp-host" id="xpHost"></div>
</div>`;
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
let t;
$('#q').oninput = (e) => { clearTimeout(t); t = setTimeout(() => { S.filters.query = e.target.value; resetSel(); tick(); }, 250); };
$('#conf').onchange = (e) => { S.filters.minConfidence = e.target.value || null; resetSel(); tick(); };
$('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); };
paintPreview();
$('#progressArea').onclick = onProgressClick;
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent), getSettings: () => SETTINGS });
}
const resetSel = () => { S.selected = new Set(); S.allFiltered = false; };
function paintStatus() {
const st = S.status;
@@ -366,141 +587,124 @@ function paintStatus() {
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
$('#progressArea').innerHTML = `<div class="progress-card">
<div class="row spread"><div><b>${esc(p.phase || (running ? 'Iniciando' : ''))}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div>
<div class="row">${running ? '<button class="btn sm" id="pauseBtn">Pausar</button><button class="btn sm danger" id="stopBtn">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" id="resumeBtn">Continuar</button>' : ''}
${!running && st.found ? '<button class="btn sm" id="saveBtn">Salvar sessão…</button>' : ''}</div></div>
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
<div class="stats">
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></div>
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>confiança alta</span></div>
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
</div></div>`;
$('#pauseBtn')?.addEventListener('click', async () => { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); });
$('#stopBtn')?.addEventListener('click', async () => { await rpc('scan_stop', { job: S.job }); });
$('#resumeBtn')?.addEventListener('click', async () => { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); });
$('#saveBtn')?.addEventListener('click', async () => {
const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' });
if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (e) { toast(e.message); } }
});
$('#warnArea').innerHTML = (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
// chips
const total = st.found;
$('#chips').innerHTML = [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
.concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => `<button class="chip" data-c="${k}" aria-pressed="${S.filters.category === k}">${n}<small>${st.categories[k]}</small></button>`)).join('');
$('#chips').querySelectorAll('.chip').forEach((c) => (c.onclick = () => { S.filters.category = c.dataset.c || null; resetSel(); tick(); }));
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
paintBar();
}
function paintList() {
const el = $('#table');
if (!el) return;
const st = S.status;
if (!S.items.length) {
el.innerHTML = `<div class="empty"><h3>${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}</h3><p>${st && st.status === 'running' ? 'Os arquivos aparecem aqui assim que são encontrados.' : (S.filters.category || S.filters.query || S.filters.minConfidence) ? 'Nenhum arquivo combina com os filtros.' : 'Não encontramos arquivos recuperáveis neste disco.'}</p></div>`;
return;
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : '';
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
if (running || st.status === 'paused') {
setHTML($('#progressArea'), `<div class="progress-card slim">
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
<div class="stats compact">
<div class="stat"><b>${st.found.toLocaleString(LOC())}</b><span>arquivos</span></div>
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div>
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
</div></div>`);
} else {
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString(LOC())}</b> arquivos em ${dur(st.elapsed)}</span>
<span class="sub">confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}</span>${enr}<span class="grow"></span>${tools}</div>`);
}
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
el.innerHTML = `<div class="thead"><input type="checkbox" id="selAll" aria-label="Selecionar todos" ${allSel ? 'checked' : ''}><span>Nome</span><span style="text-align:right">Tamanho</span><span>Origem</span><span style="text-align:right">Confiança</span></div>`
+ S.items.map((i) => {
const [cl, cc] = CONF[i.confidence];
const on = S.allFiltered || S.selected.has(i.id);
return `<div class="tr" role="row" data-id="${i.id}" aria-selected="${on || S.current?.id === i.id}" tabindex="0">
<input type="checkbox" ${on ? 'checked' : ''} aria-label="Selecionar ${esc(i.name)}">
<div class="nm"><b>${esc(i.name)}</b><small>${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}</small></div>
<div class="num">${human(i.size)}</div><div class="num" style="text-align:left">${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}</div>
<div class="num"><span class="pill ${cc}">${cl}</span></div></div>`;
}).join('')
+ (S.items.length < S.total ? `<div class="more"><button class="btn" id="moreBtn">Mostrar mais (${(S.total - S.items.length).toLocaleString('pt-BR')})</button></div>` : '');
$('#selAll').onchange = (e) => {
if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel();
paintList(); paintBar();
};
el.querySelectorAll('.tr').forEach((row) => {
const id = +row.dataset.id;
const cb = $('input', row);
cb.onclick = (e) => { e.stopPropagation(); if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); } cb.checked ? S.selected.add(id) : S.selected.delete(id); paintBar(); row.setAttribute('aria-selected', cb.checked); };
row.onclick = () => focusItem(S.items.find((i) => i.id === id));
row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusItem(S.items.find((i) => i.id === id)); } };
});
$('#moreBtn')?.addEventListener('click', () => loadResults(false));
paintBar();
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
}
async function focusItem(item) {
S.current = item;
document.querySelectorAll('.tr').forEach((r) => r.setAttribute('aria-selected', (+r.dataset.id === item.id) || S.selected.has(+r.dataset.id) || S.allFiltered));
paintPreview();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(item.ext) && item.size < 8 * 1024 * 1024) {
async function chooseSource() {
let cands = [];
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
const st = S.status || {};
const size = st.layout?.size;
const d = $('#dlg');
const badge = (c) => (c.same_content === true ? '<span class="pill ok">mesmo disco</span>'
: c.same_content === false ? '<span class="pill warn">tamanho igual, conteúdo diferente</span>'
: c.same_size ? '<span class="pill accent">mesmo tamanho (precisa de administrador para conferir)</span>' : '');
d.innerHTML = `<form method="dialog"><h2>Escolher a origem</h2>
<p>Esta sessão foi feita sobre <code>${esc(st.target || '')}</code>${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.</p>
<div class="cand">${cands.map((c, i) => `<button type="button" class="dev" data-c="${i}"><span class="ico">${icon(c.device.kind)}</span><span><span class="t">${esc(c.device.name)}</span><span class="s">${esc(c.device.path)} · ${human(c.device.size)} ${badge(c)}</span></span></button>`).join('') || '<p class="sub">Nenhum disco encontrado. Informe o caminho abaixo.</p>'}</div>
<label class="fld"><span>Ou o caminho de um dispositivo ou imagem</span><input class="input" id="srcPath" name="path" placeholder="/dev/sdb ou /caminho/imagem.img" value="${esc(st.source_path || st.target || '')}"></label>
<label class="check"><input type="checkbox" name="force"><span>Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)</span></label>
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Usar esta origem</button></menu></form>`;
d.querySelectorAll('.cand .dev').forEach((b) => (b.onclick = () => {
d.querySelectorAll('.cand .dev').forEach((x) => x.setAttribute('aria-current', 'false'));
b.setAttribute('aria-current', 'true');
$('#srcPath', d).value = cands[+b.dataset.c].device.path;
}));
d.onclose = async () => {
if (d.returnValue !== 'ok') return;
const path = $('#srcPath', d).value.trim();
const force = d.querySelector('[name=force]').checked;
if (!path) return;
try {
const p = await rpc('preview', { job: S.job, id: item.id });
if (S.current?.id === item.id && p.mime) $('.thumb', $('#preview')).innerHTML = `<img alt="Pré-visualização de ${esc(item.name)}" src="data:${p.mime};base64,${p.base64}">`;
} catch { /* sem prévia */ }
await rpc('session_relink', { job: S.job, path, force });
toast('Origem reconectada');
const cur = { job: S.job, target: st.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
openJob(cur);
} catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
};
d.showModal();
}
async function onProgressClick(e) {
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
else if (act === 'resume') {
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
catch (err) { toast(err.message); chooseSource(); }
} else if (act === 'reconnect') {
try {
const r = await rpc('session_reconnect', { job: S.job });
toast(r.renamed ? `Disco encontrado como ${r.renamed}` : 'Disco reconectado');
const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
openJob(cur);
} catch { chooseSource(); }
}
else if (act === 'relink') { chooseSource();
} else if (act === 'csv') {
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} linhas gravadas`); } catch (err) { toast(err.message); } }
} else if (act === 'report') {
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
{ name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' },
{ name: 'notes', label: 'Observações', multiline: true } ] });
if (f) {
try {
const hashes = S.sourceHashes && S.sourceHashes.path === (S.status?.target) ? { md5: S.sourceHashes.md5, sha1: S.sourceHashes.sha1, sha256: S.sourceHashes.sha256 } : null;
await rpc('export_report', { job: S.job, path: f.path, case: { case_id: f.case_id, examiner: f.examiner, organization: f.organization, notes: f.notes }, hashes });
toast('Relatório gravado');
} catch (err) { toast(err.message); }
}
} else if (act === 'save') {
const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' });
if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (err) { toast(err.message); } }
}
}
function paintPreview() {
const el = $('#preview');
if (!el) return;
const i = S.current;
if (!i) { el.innerHTML = `<div class="thumb">Selecione um arquivo para ver os detalhes</div>`; return; }
const [cl, cc] = CONF[i.confidence];
el.innerHTML = `<div class="thumb">${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}</div>
<dl class="kv"><dt>Nome</dt><dd>${esc(i.name)}</dd><dt>Pasta</dt><dd>${esc(i.dir || '—')}</dd><dt>Tamanho</dt><dd>${human(i.size)}</dd>
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
<dt>Confiança</dt><dd><span class="pill ${cc}">${cl}</span></dd>${i.note ? `<dt>Aviso</dt><dd>${esc(i.note)}</dd>` : ''}</dl>
<div class="row"><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
$('#recOne').onclick = () => recover({ ids: [i.id] });
}
function selCount() { return S.allFiltered ? S.total : S.selected.size; }
function paintBar() {
const host = $('#barHost');
if (!host) return;
const st = S.status;
const n = selCount();
const canAll = st && st.found > 0 && st.status !== 'error';
if (!n && !canAll) { host.innerHTML = ''; return; }
const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size
? `<button class="btn sm" id="selFiltered">Selecionar todos os ${S.total.toLocaleString('pt-BR')}</button>` : '';
host.innerHTML = `<div class="actionbar"><span class="info">${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}</span>${moreOffer}
${n ? '<button class="btn primary" id="recSel">Recuperar selecionados</button>' : '<button class="btn primary" id="recAll">Recuperar todos da lista</button>'}</div>`;
$('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); });
$('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }));
$('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() }));
}
function currentFilter() {
const f = S.filters;
const q = { sort: f.sort, limit: 1 };
if (f.category) q.category = f.category;
if (f.minConfidence) q.min_confidence = f.minConfidence;
if (f.query) q.query = f.query;
return q;
}
async function recover(sel) {
const dest = await ask({
const f = await askForm({
title: 'Recuperar para…',
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.',
value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados',
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>',
ok: 'Recuperar',
pick: { directory: true, multiple: false },
fields: [
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
],
});
if (!dest) return;
if (!f || !f.dest) return;
const dest = f.dest;
try {
const { job } = await rpc('recover_start', { job: S.job, dest, ...sel });
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
watchAux(job, 'Recuperando arquivos', (st) => {
const r = st.report || {};
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
${r.skipped ? `<p class="sub">${r.skipped} já estavam recuperados e conferidos (pulados).</p>` : ''}
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''}
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista o SHA-256 de cada arquivo para você conferir a integridade.</p>`;
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.</p>`;
});
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
}
@@ -534,16 +738,78 @@ function watchAux(job, title, doneHtml) {
async function openJobFromList(s) { openJob(s); }
// ------------------------------------------------------------------ configurações (janela)
$('#settingsBtn')?.addEventListener('click', () => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
d.querySelector('[name=lang]').value = SETTINGS.lang;
d.onclose = () => {
if (d.returnValue !== 'ok') return;
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
saveSettings(patch);
setLang(patch.lang);
toast('Salvo');
};
d.showModal();
});
// ------------------------------------------------------------------ fechar com varredura em andamento
function askClose() {
return new Promise((resolve) => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Há uma varredura em andamento</h2><p>A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.</p>
<menu><button class="btn" value="stay">Continuar varrendo</button><button class="btn" value="quit">Sair sem pausar</button><button class="btn primary" value="pause">Pausar e sair</button></menu></form>`;
d.onclose = () => resolve(d.returnValue);
d.showModal();
});
}
function watchClose() {
window.addEventListener('beforeunload', (e) => {
if (S.status?.status === 'running' && !tauri) { e.preventDefault(); e.returnValue = ''; }
});
const win = tauri?.window?.getCurrentWindow?.();
if (!win?.onCloseRequested) return;
win.onCloseRequested(async (ev) => {
if (S.status?.status !== 'running') return;
ev.preventDefault();
const c = await askClose();
if (c === 'stay' || !c) return;
if (c === 'pause') {
try {
await rpc('scan_pause', { job: S.job });
for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); }
await new Promise((r) => setTimeout(r, 600)); // dá tempo do salvamento automático
} catch { /* sai mesmo assim */ }
}
win.destroy();
});
}
// ------------------------------------------------------------------ início do app
(async function init() {
try {
const v = await rpc('version');
$('#versionTag').textContent = `v${v.version}`;
S.defaults = await rpc('defaults');
try {
const st = await rpc('settings_get');
Object.assign(SETTINGS, st);
applyTheme(st.theme);
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
} catch { /* usa o local */ }
setLang(SETTINGS.lang);
watchClose();
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
} catch (e) {
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
return;
}
await loadDevices();
await loadRecent();
renderHome();
})();
+674
View File
@@ -0,0 +1,674 @@
// Explorador de arquivos recuperados: grade de miniaturas (virtualizada, aguenta
// dezenas de milhares de itens), navegação por tipo/data/pasta/câmera, seleção
// estilo gerenciador de arquivos (Ctrl/Shift/teclado), painel de detalhes e
// visualização rápida em tela cheia.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js';
const CAT = {
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
archive: ['Compactados', '🗜'], database: ['Bancos de dados', '🗄'], other: ['Outros', '📦'],
};
const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] };
const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'];
const SORTS = [
['date', 'Data (mais recentes)'], ['date_asc', 'Data (mais antigas)'], ['name', 'Nome'], ['size', 'Tamanho'], ['confidence', 'Confiança'],
];
const GAP = 10;
const PAD = 12;
const svg = (d, w = 18) => `<svg viewBox="0 0 24 24" width="${w}" height="${w}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
const I = {
grid: svg('<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>'),
list: svg('<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/>'),
filter: svg('<path d="M4 5h16l-6 8v6l-4-2v-4z"/>'),
chev: svg('<path d="M9 6l6 6-6 6"/>', 14),
x: svg('<path d="M6 6l12 12M18 6L6 18"/>', 14),
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
};
export function createExplorer(deps) {
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
const inlineMedia = !deps.noInlineMedia;
const dbApi = (it) => ({
info: () => rpc('db_info', { job, id: it.id }),
rows: (table, offset, limit, deleted) => rpc('db_rows', { job, id: it.id, table, offset, limit, deleted }),
});
const openExternal = (it) => {
toast('Preparando para abrir no player do sistema…');
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
};
const E = {
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
sort: 'date',
view: 'grid',
tile: 150,
items: [],
total: 0,
sel: new Set(),
allFiltered: false,
anchor: -1,
curId: null,
facets: null,
expanded: new Set(),
rows: [],
height: 0,
rendered: new Map(),
reloadToken: 0,
lastReload: 0,
lastFound: -1,
wasEnriching: false,
status: null,
};
try {
E.view = localStorage.getItem('guara-view') || 'grid';
E.tile = +localStorage.getItem('guara-tile') || 150;
} catch { /* sem armazenamento */ }
host.innerHTML = `<div class="xp" data-view="${E.view}">
<div class="xp-bar">
<button class="icon-btn xp-navbtn" id="xpNavBtn" aria-label="Filtros">${I.filter}</button>
<div class="xp-crumb" id="xpCrumb"></div>
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button></div>
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
</div>
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
<section class="xp-main">
<div class="xp-chips" id="xpChips"></div>
<div class="xp-scroll" id="xpScroll" tabindex="0" role="listbox" aria-multiselectable="true" aria-label="Arquivos encontrados"><div class="xp-sizer" id="xpSizer"></div></div>
<div class="xp-actions" id="xpActions"></div>
</section>
<aside class="xp-insp" id="xpInsp" aria-label="Detalhes"></aside>
<div class="xp-veil" id="xpVeil"></div>
</div>`;
const $ = (s) => host.querySelector(s);
const root = $('.xp'), scroll = $('#xpScroll'), sizer = $('#xpSizer'), nav = $('#xpNav'), insp = $('#xpInsp'), actions = $('#xpActions');
$('#xpSort').value = E.sort;
$('#xpSize').value = E.tile;
// ------------------------------------------------------------ dados
const toQuery = () => {
const f = E.filters;
const q = {};
if (f.category) q.category = f.category;
if (f.year) q.year = f.year;
if (f.month) q.month = f.month;
if (f.undated) q.undated = true;
if (f.folder) q.folder = f.folder;
if (f.camera) q.camera = f.camera;
if (f.origin) q.origin = f.origin;
if (f.minConf) q.min_confidence = f.minConf;
if (f.gps) q.has_gps = true;
if (f.dedupe) q.dedupe = true;
if (f.bigOnly) q.min_size = 30 * 1024;
if (f.mp1) q.min_mp = 1;
if (f.query) q.query = f.query;
return q;
};
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
async function reload() {
const token = ++E.reloadToken;
E.lastReload = Date.now();
const base = { ...toQuery(), sort: E.sort };
let items = [], total = 0;
for (;;) {
let r;
try { r = await rpc('results', { job, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; }
if (token !== E.reloadToken) return;
items = items.concat(r.items);
total = r.total;
if (!r.items.length || items.length >= total || items.length >= 30000) break;
if (items.length === 2000) { E.items = items; E.total = total; relayout(); paintAll(); }
}
E.items = items;
E.total = total;
relayout();
paintAll();
}
async function loadFacets() {
try { E.facets = await rpc('facets', { job }); } catch { return; }
paintNav();
}
const reloadAll = () => { reload(); loadFacets(); };
// ------------------------------------------------------------ layout virtual
// Agrupa por dia; se houver poucos itens por dia, agrupa por mês.
let groupBy = 'day';
const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7));
const dayLabel = (k) => {
if (k === 'Sem data') return k;
return groupBy === 'day'
? new Date(k + 'T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
};
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
function geometry() {
const W = Math.max(200, scroll.clientWidth - PAD * 2);
if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
const T = E.tile;
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
const cap = tw >= 128 ? 42 : 0;
return { W, cols, tw, cap, rowH: tw + cap + GAP };
}
let G = geometry();
function relayout() {
G = geometry();
const grouped = E.view === 'grid' && (E.sort === 'date' || E.sort === 'date_asc');
if (grouped) {
groupBy = 'day';
const days = new Set(E.items.map((it) => dayKey(it.when)));
if (E.items.length / Math.max(1, days.size) < 2.5) groupBy = 'month';
}
const rows = [];
let y = PAD;
let cur = null, rowItems = [];
const flush = () => {
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
};
E.items.forEach((it, i) => {
if (grouped) {
const k = dayKey(it.when);
if (k !== cur) {
flush();
cur = k;
rows.push({ t: 'head', y, h: 40, key: k, start: i });
y += 40;
}
}
rowItems.push(i);
if (rowItems.length === G.cols) flush();
});
flush();
if (grouped) {
// contagem por grupo
for (let r = 0; r < rows.length; r++) {
if (rows[r].t !== 'head') continue;
let n = 0;
for (let i = rows[r].start; i < E.items.length && dayKey(E.items[i].when) === rows[r].key; i++) n++;
rows[r].count = n;
}
}
E.rows = rows;
E.height = y + PAD;
sizer.style.height = `${E.height}px`;
for (const el of E.rendered.values()) el.remove();
E.rendered.clear();
renderWindow();
}
function rowAt(top) {
let lo = 0, hi = E.rows.length - 1, ans = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (E.rows[mid].y + E.rows[mid].h < top) lo = mid + 1; else { ans = mid; hi = mid - 1; }
}
return ans;
}
function renderWindow() {
if (!E.rows.length) { for (const el of E.rendered.values()) el.remove(); E.rendered.clear(); return; }
const top = Math.max(0, scroll.scrollTop - 400), bottom = scroll.scrollTop + scroll.clientHeight + 400;
const a = rowAt(top);
let b = a;
while (b < E.rows.length && E.rows[b].y < bottom) b++;
for (const [ri, el] of E.rendered) {
if (ri < a || ri >= b) { el.remove(); E.rendered.delete(ri); }
}
const frag = document.createDocumentFragment();
for (let ri = a; ri < b; ri++) {
if (E.rendered.has(ri)) continue;
const row = E.rows[ri];
const el = document.createElement('div');
el.className = `xp-row ${row.t}`;
el.style.top = `${row.y}px`;
el.style.height = `${row.h}px`;
el.dataset.ri = ri;
el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join('');
E.rendered.set(ri, el);
frag.appendChild(el);
}
sizer.appendChild(frag);
}
// ------------------------------------------------------------ itens
const isSel = (id) => E.allFiltered || E.sel.has(id);
function headHtml(row) {
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
}
function thumbSrc(it, px) {
const s = px <= 200 ? 256 : px <= 330 ? 384 : 640;
return thumbUrl(job, it.id, s);
}
function tileHtml(i) {
const it = E.items[i];
const [cl, cc] = CONF[it.confidence];
const on = isSel(it.id);
const [catName, catIco] = CAT[it.category] || CAT.other;
const media = it.category === 'image'
? `<img loading="lazy" decoding="async" alt="" src="${thumbSrc(it, G.tw * (window.devicePixelRatio > 1 ? 1.5 : 1))}" data-th="1">`
: `<div class="xp-ico c-${it.category}"><span>${catIco}</span><b>${esc((it.ext || '?').toUpperCase().slice(0, 5))}</b></div>`;
return `<div class="xp-tile" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''} style="width:${G.tw}px;height:${G.tw + G.cap}px" title="${esc(it.name)}">
<div class="xp-thumb" style="height:${G.tw}px">${media}
<span class="xp-chk" data-chk="1" aria-hidden="true"></span>
<span class="xp-dot ${cc}" title="Confiança ${cl}"></span>
${it.gps ? `<span class="xp-gps" title="Tem localização GPS">${I.pin}</span>` : ''}
</div>
${G.cap ? `<div class="xp-cap"><b>${esc(it.name)}</b><small>${human(it.size)}${it.when != null ? ' · ' + esc(fmtDay(it.when)) : ''}</small></div>` : ''}
</div>`;
}
function listHtml(i) {
const it = E.items[i];
const [cl, cc] = CONF[it.confidence];
const on = isSel(it.id);
const [, catIco] = CAT[it.category] || CAT.other;
const th = it.category === 'image' ? `<img loading="lazy" decoding="async" alt="" src="${thumbUrl(job, it.id, 128)}" data-th="1">` : `<span class="xp-lico c-${it.category}">${catIco}</span>`;
return `<div class="xp-lrow" role="option" data-i="${i}" data-id="${it.id}" aria-selected="${on}" ${E.curId === it.id ? 'data-cur="1"' : ''}>
<span class="xp-chk" data-chk="1" aria-hidden="true"></span><div class="xp-lth">${th}</div>
<div class="xp-lnm"><b>${esc(it.name)}</b><small>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</small></div>
<span class="xp-lcol">${esc(fmtDate(it.when))}</span><span class="xp-lcol num">${human(it.size)}</span><span class="pill ${cc}">${cl}</span>
</div>`;
}
// imagens que falham viram um aviso (arquivo possivelmente danificado)
scroll.addEventListener('error', (e) => {
const img = e.target;
if (img.tagName !== 'IMG' || !img.dataset.th) return;
const wrap = img.closest('.xp-thumb, .xp-lth');
img.remove();
const offline = E.status && E.status.source === false;
wrap?.insertAdjacentHTML('afterbegin', offline
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
}, true);
// ------------------------------------------------------------ seleção
const selCount = () => (E.allFiltered ? E.total : E.sel.size);
const idxOf = (id) => E.items.findIndex((x) => x.id === id);
function paintSel() {
const set = (el) => {
const id = +el.dataset.id;
el.setAttribute('aria-selected', String(isSel(id)));
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
};
scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set);
paintActions();
}
function selectRange(a, b) {
const [lo, hi] = a < b ? [a, b] : [b, a];
for (let i = lo; i <= hi; i++) E.sel.add(E.items[i].id);
}
function setCursor(i, { extend = false, toggle = false, reveal = false } = {}) {
if (i < 0 || i >= E.items.length) return;
const it = E.items[i];
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(E.items.map((x) => x.id)); }
if (extend && E.anchor >= 0) {
E.sel = new Set();
selectRange(E.anchor, i);
} else if (toggle) {
if (E.sel.has(it.id)) E.sel.delete(it.id); else E.sel.add(it.id);
E.anchor = i;
} else {
E.sel = new Set([it.id]);
E.anchor = i;
}
E.curId = it.id;
if (reveal) revealIndex(i);
paintSel();
schedInspector();
if (matchMedia('(max-width: 1100px)').matches && !extend && !toggle) root.classList.add('show-insp');
}
function revealIndex(i) {
const ri = E.rows.findIndex((r) => r.t === 'row' && r.idx.includes(i));
if (ri < 0) return;
const r = E.rows[ri];
if (r.y < scroll.scrollTop + 4) scroll.scrollTop = r.y - 8;
else if (r.y + r.h > scroll.scrollTop + scroll.clientHeight) scroll.scrollTop = r.y + r.h - scroll.clientHeight + 8;
renderWindow();
}
scroll.addEventListener('scroll', () => renderWindow(), { passive: true });
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
scroll.addEventListener('click', (e) => {
const hs = e.target.closest('[data-hsel]');
if (hs) {
const start = +hs.dataset.hsel, n = +hs.dataset.n;
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(); }
for (let i = start; i < start + n; i++) E.sel.add(E.items[i].id);
paintSel();
return;
}
const el = e.target.closest('.xp-tile, .xp-lrow');
if (!el) return;
const i = +el.dataset.i;
if (e.target.closest('[data-chk]')) setCursor(i, { toggle: true });
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
});
scroll.addEventListener('dblclick', (e) => {
const el = e.target.closest('.xp-tile, .xp-lrow');
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
});
scroll.addEventListener('keydown', (e) => {
if (!E.items.length) return;
let i = idxOf(E.curId);
if (i < 0) i = 0;
const cols = G.cols;
const move = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols, PageDown: cols * 4, PageUp: -cols * 4 }[e.key];
if (move != null) {
e.preventDefault();
setCursor(Math.max(0, Math.min(E.items.length - 1, idxOf(E.curId) < 0 ? 0 : i + move)), { extend: e.shiftKey, reveal: true });
} else if (e.key === 'Home') { e.preventDefault(); setCursor(0, { extend: e.shiftKey, reveal: true }); }
else if (e.key === 'End') { e.preventDefault(); setCursor(E.items.length - 1, { extend: e.shiftKey, reveal: true }); }
else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (E.curId == null) setCursor(0); openLightbox(idxOf(E.curId)); }
else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); E.allFiltered = true; paintSel(); }
else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
});
// ------------------------------------------------------------ barra de ações
function paintActions() {
const n = selCount();
const html = n
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
<button class="btn sm" data-a="clear">Limpar seleção</button>
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString(LOC())}</button>` : ''}
<button class="btn primary" data-a="rec">Recuperar ${n === 1 ? 'selecionado' : 'selecionados'}</button>`
: `<span class="info">${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString(LOC())})` : ''}</span>
${E.total ? '<button class="btn sm" data-a="all">Selecionar tudo</button><button class="btn primary" data-a="recall">Recuperar tudo desta vista</button>' : ''}`;
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
}
actions.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
else if (a === 'all') { E.allFiltered = true; paintSel(); }
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
else if (a === 'recall') onRecover({ filter: toQuery() });
});
// ------------------------------------------------------------ navegação lateral
function paintNav() {
const f = E.facets;
if (!f) return;
const F = E.filters;
const node = (label, count, attrs, active, depth = 0, extra = '') =>
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString(LOC())}</small></button>`;
const sec = (title, body) => (body ? `<div class="xp-sec"><h4>${title}</h4>${body}</div>` : '');
const cats = Object.keys(CAT).filter((k) => f.categories[k]).map((k) => node(`${CAT[k][1]} ${CAT[k][0]}`, f.categories[k], `data-f="category" data-v="${k}"`, F.category === k)).join('');
const years = f.years.map((y) => {
const open = E.expanded.has('y' + y.year) || F.year === y.year;
return node(String(y.year), y.count, `data-f="year" data-v="${y.year}"`, F.year === y.year && !F.month, 0,
`<span class="tw" data-tw="y${y.year}" aria-label="Expandir" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>`)
+ (open ? y.months.map((m) => node(MONTHS[m.month - 1], m.count, `data-f="month" data-y="${y.year}" data-v="${m.month}"`, F.year === y.year && F.month === m.month, 1)).join('') : '');
}).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : '');
const folders = f.folders.filter((d) => {
// só mostra se todos os ancestrais estiverem expandidos
const parts = d.path.split('/');
for (let k = 1; k < parts.length; k++) if (!E.expanded.has('d' + parts.slice(0, k).join('/')) && !(F.folder || '').startsWith(parts.slice(0, k).join('/'))) return false;
return true;
}).map((d) => {
const hasKids = f.folders.some((o) => o.path.startsWith(d.path + '/'));
const open = E.expanded.has('d' + d.path) || (F.folder || '').startsWith(d.path + '/');
return node(esc(d.name), d.count, `data-f="folder" data-v="${esc(d.path)}"`, F.folder === d.path, d.depth,
hasKids ? `<span class="tw" data-tw="d${esc(d.path)}" style="${open ? 'transform:rotate(90deg)' : ''}">${I.chev}</span>` : '<span class="tw"></span>');
}).join('')
+ (f.root ? node('/ (raiz)', f.root, 'data-f="folder" data-v="@raiz"', F.folder === '@raiz') : '')
+ (f.carved ? node('Por assinatura', f.carved, 'data-f="folder" data-v="@carved"', F.folder === '@carved') : '');
const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join('');
const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join('');
const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '')
+ (f.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
+ (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : '');
const html = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
+ sec('Tipos', cats) + sec('Datas', years) + sec('Pastas', folders) + sec('Câmeras', cams) + sec('Confiança', conf) + sec('Outros', extra);
if (nav._h !== html) { nav._h = html; const st = nav.scrollTop; nav.innerHTML = html; nav.scrollTop = st; }
}
nav.addEventListener('click', (e) => {
const tw = e.target.closest('[data-tw]');
if (tw) {
const k = tw.dataset.tw;
if (E.expanded.has(k)) E.expanded.delete(k); else E.expanded.add(k);
nav._h = null; paintNav();
e.stopPropagation();
return;
}
const b = e.target.closest('.xp-node');
if (!b) return;
const F = E.filters;
const f = b.dataset.f, v = b.dataset.v;
if (f === 'all') clearFilters(false);
else if (f === 'year') { const same = F.year === +v && !F.month; F.year = same ? null : +v; F.month = null; F.undated = false; E.expanded.add('y' + v); }
else if (f === 'month') { const same = F.year === +b.dataset.y && F.month === +v; F.year = same ? null : +b.dataset.y; F.month = same ? null : +v; F.undated = false; }
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
else if (f === 'gps') F.gps = !F.gps;
else if (f === 'dedupe') F.dedupe = !F.dedupe;
else if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
else if (f === 'mp1') F.mp1 = !F.mp1;
else F[f] = F[f] === v ? null : v;
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
nav._h = null;
root.classList.remove('show-nav');
changed();
});
function clearFilters(refresh = true) {
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
$('#xpQ').value = '';
if (refresh) changed(); else changed();
}
function changed() {
E.sel = new Set(); E.allFiltered = false; E.curId = null; E.anchor = -1;
scroll.scrollTop = 0;
paintCrumb(); paintChips(); paintNav(); paintInspector();
reload();
}
function paintCrumb() {
const F = E.filters, parts = [];
if (F.category) parts.push(CAT[F.category][0]);
if (F.year) parts.push(String(F.year));
if (F.month) parts.push(MONTHS[F.month - 1]);
if (F.undated) parts.push('Sem data');
if (F.folder) parts.push(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : F.folder.split('/').join(' › '));
if (F.camera) parts.push(F.camera);
$('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString(LOC())}</small>`;
}
function paintChips() {
const F = E.filters, chips = [];
const add = (label, key) => chips.push(`<button class="chip" data-x="${key}">${label} ${I.x}</button>`);
if (F.category) add(CAT[F.category][0], 'category');
if (F.year) add(String(F.year), 'year');
if (F.month) add(MONTHS[F.month - 1], 'month');
if (F.undated) add('Sem data', 'undated');
if (F.folder) add(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : esc(F.folder), 'folder');
if (F.camera) add(esc(F.camera), 'camera');
if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
if (F.gps) add('Com GPS', 'gps');
if (F.dedupe) add('Sem duplicadas', 'dedupe');
if (F.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
if (F.mp1) add('Mais de 1 MP', 'mp1');
if (F.query) add(`“${esc(F.query)}”`, 'query');
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
}
$('#xpChips').addEventListener('click', (e) => {
const k = e.target.closest('[data-x]')?.dataset.x;
if (!k) return;
if (k === 'all') return clearFilters();
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
if (k === 'year') E.filters.month = null;
if (k === 'query') $('#xpQ').value = '';
nav._h = null;
changed();
});
// ------------------------------------------------------------ barra superior
let qt;
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
$('#xpSort').addEventListener('change', (e) => { E.sort = e.target.value; reload(); });
const paintView = () => {
root.dataset.view = E.view;
host.querySelectorAll('[data-view]').forEach((b) => { if (b.tagName === 'BUTTON') b.setAttribute('aria-pressed', String(b.dataset.view === E.view)); });
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
};
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
E.view = b.dataset.view;
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
paintView(); relayout();
}));
$('#xpSize').addEventListener('input', (e) => {
E.tile = +e.target.value;
try { localStorage.setItem('guara-tile', String(E.tile)); } catch { /* */ }
relayout();
});
$('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
// ------------------------------------------------------------ painel de detalhes
let inspT, stopPv = null, metaToken = 0;
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); };
function timesHtml(it) {
const rows = [['Tirada em', it.taken], ['Modificado', it.modified], ['Criado', it.created], ['Acessado', it.accessed]].filter(([, v]) => v != null);
return rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(fmtDate(v))}</dd>`).join('');
}
function paintInspector() {
stopPv?.(); stopPv = null;
const n = selCount();
const it = E.items[idxOf(E.curId)];
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
if (n > 1) {
const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0);
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString(LOC())} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''}
<div class="row"><button class="btn primary" data-i="rec">Recuperar selecionados</button><button class="btn" data-i="clear">Limpar seleção</button></div></div>`;
return;
}
if (!it) {
insp.innerHTML = `${close}<div class="xp-empty-insp"><div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar.<br><span class="sub">Setas navegam, Espaço abre em tela cheia.</span></div></div></div>`;
return;
}
const [cl, cc] = CONF[it.confidence];
const url = kindOf(it) === 'image' ? thumbUrl(job, it.id, 640) : fileUrl(job, it.id);
insp.innerHTML = `${close}<div class="viewer" id="xpPv"></div>
<div class="xp-ititle"><b>${esc(it.name)}</b><span class="pill ${cc}">${cl}</span></div>
<div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}</div>
${it.note ? `<div class="pv-note bad" style="text-align:left">${esc(it.note)}</div>` : ''}
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
<div class="xp-meta" id="xpMeta"></div>
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
// clicar na miniatura abre o modal grande
$('#xpPv').addEventListener('click', (ev) => {
if (ev.target.closest('button, a, .pv-pdfbar')) return;
openLightbox(idxOf(it.id));
});
const my = ++metaToken;
rpc('metadata', { job, id: it.id }).then((m) => {
if (my !== metaToken || !$('#xpMeta')) return;
const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k));
if (!rows.length) return;
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`
+ (m.gps ? gpsHtml(m.gps) : '');
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
}).catch(() => {});
}
/** Coordenadas e, se o usuário permitiu, um mapa (blocos do OpenStreetMap, 3x3 ao redor do ponto). */
function gpsHtml([lat, lon]) {
let map = '';
if (deps.getSettings?.().maps) {
const z = 15, n = 2 ** z;
const fx = (lon + 180) / 360 * n;
const fy = (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n;
const tx = Math.floor(fx), ty = Math.floor(fy);
let imgs = '';
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) imgs += `<img src="https://tile.openstreetmap.org/${z}/${tx + dx}/${ty + dy}.png" alt="" loading="lazy" draggable="false">`;
const px = (fx - tx + 1) / 3 * 100, py = (fy - ty + 1) / 3 * 100;
map = `<div class="xp-map"><div class="xp-tiles">${imgs}</div><span class="xp-pin" style="left:${px}%;top:${py}%"></span></div>`;
}
return `<div class="sub" style="font-size:12.5px;margin-top:8px">Coordenadas: ${lat.toFixed(5)}, ${lon.toFixed(5)}</div>${map}
<div class="row" style="margin-top:6px"><button class="btn sm" id="xpOsm">Abrir no OpenStreetMap</button></div>
${map ? '<div class="sub" style="font-size:11px;margin-top:4px">© colaboradores do OpenStreetMap</div>' : ''}`;
}
insp.addEventListener('click', (e) => {
const a = e.target.closest('[data-i]')?.dataset.i;
if (a === 'full') openLightbox(idxOf(E.curId));
else if (a === 'rec1') onRecover({ ids: [E.curId] });
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
else if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
else if (a === 'close') root.classList.remove('show-insp');
});
// ------------------------------------------------------------ tela cheia
let stopLb = null;
function openLightbox(i) {
const it = E.items[i];
if (!it) return;
const d = document.querySelector('#lightbox');
const [cl] = CONF[it.confidence];
d.innerHTML = `<div class="lb-head"><button class="icon-btn" id="lbPrev" aria-label="Anterior" ${i <= 0 ? 'disabled' : ''}>‹</button><button class="icon-btn" id="lbNext" aria-label="Próximo" ${i >= E.items.length - 1 ? 'disabled' : ''}>›</button>
<div class="ttl"><b>${esc(it.name)}</b><small>${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}${it.when != null ? ' · ' + esc(fmtDate(it.when)) : ''} · confiança ${cl} · ${i + 1} de ${E.items.length}</small></div>
<button class="icon-btn" id="lbClose" aria-label="Fechar">${I.close}</button></div>
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
stopLb?.();
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, db: dbApi(it), onExternal: () => openExternal(it) });
const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } };
d.querySelector('#lbPrev').onclick = () => go(-1);
d.querySelector('#lbNext').onclick = () => go(1);
d.querySelector('#lbClose').onclick = () => d.close();
d.querySelector('#lbRec').onclick = () => { d.close(); onRecover({ ids: [it.id] }); };
d.onclose = () => { stopLb?.(); stopLb = null; scroll.focus({ preventScroll: true }); };
d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); };
if (!d.open) d.showModal();
}
// ------------------------------------------------------------ paint geral
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
paintView(); paintCrumb(); paintActions(); paintInspector();
reloadAll();
return {
/** Chamado a cada atualização de status da varredura. */
onStatus(st) {
E.status = st;
const enriching = !!st.enriching;
const now = Date.now();
const foundChanged = st.found !== E.lastFound;
const justDone = st.status !== 'running' && E.running;
E.running = st.status === 'running';
if ((foundChanged && now - E.lastReload > 1500) || justDone || (E.wasEnriching && !enriching) || (enriching && now - E.lastReload > 2500)) {
E.lastFound = st.found;
reloadAll();
}
E.wasEnriching = enriching;
},
query: () => toQuery(),
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
focus() { scroll.focus({ preventScroll: true }); },
};
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 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>

After

Width:  |  Height:  |  Size: 2.6 KiB

+1 -17
View File
@@ -1,17 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff8a3d"/>
<stop offset="1" stop-color="#d9441c"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="30" fill="url(#g)"/>
<!-- orelhas grandes e focinho longo do lobo-guará -->
<path d="M30 22 L52 50 L38 62 Z" fill="#fff"/>
<path d="M98 22 L76 50 L90 62 Z" fill="#fff"/>
<path d="M38 62 L52 50 Q64 44 76 50 L90 62 L74 96 Q64 108 54 96 Z" fill="#fff"/>
<path d="M54 80 Q64 90 74 80 L69 96 Q64 100 59 96 Z" fill="#3a1608"/>
<circle cx="53" cy="68" r="4" fill="#3a1608"/>
<circle cx="75" cy="68" r="4" fill="#3a1608"/>
<path d="M54 52 Q64 47 74 52 L70 60 Q64 56 58 60 Z" fill="#ffd2b0"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 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: 828 B

After

Width:  |  Height:  |  Size: 2.6 KiB

+409
View File
@@ -0,0 +1,409 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
export const EN = {
// saúde do disco (SMART)
'Saúde do disco': 'Disk health',
'O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco.': 'SMART could not be read. Install the smartmontools package (smartctl command) and run as administrator to see the disk health.',
'Setores realocados': 'Reallocated sectors',
'Setores pendentes de realocação': 'Pending sectors',
'Setores incorrigíveis offline': 'Offline uncorrectable sectors',
'Erros incorrigíveis reportados': 'Reported uncorrectable errors',
'Erros de CRC na interface (cabo ou porta)': 'Interface CRC errors (cable or port)',
'Erros registrados no log do disco': 'Errors in the disk log',
'Aviso crítico NVMe': 'NVMe critical warning',
'Erros de mídia': 'Media errors',
'Reserva de blocos abaixo do limite': 'Spare blocks below threshold',
'Erros de leitura/gravação vistos pelo sistema': 'Read/write errors seen by the system',
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
'Estado': 'Status',
'Bom': 'Good',
'Atenção': 'Caution',
'Ruim': 'Bad',
'Temperatura': 'Temperature',
'Horas ligado': 'Power on hours',
'Vezes ligado': 'Power on count',
'Total lido': 'Host reads',
'Total gravado': 'Host writes',
'Atributos SMART ({n})': 'SMART attributes ({n})',
'Atributo': 'Attribute',
'Atual': 'Current',
'Pior': 'Worst',
'Limite': 'Threshold',
'Bruto': 'Raw',
'Modelo': 'Model',
'Nº de série': 'Serial number',
'Família': 'Family',
'Modo de transferência': 'Transfer mode',
'Capacidade': 'Capacity',
'Rotação': 'Rotation rate',
'Formato': 'Form factor',
'Recursos': 'Features',
'O SMART é do disco inteiro: os dados abaixo são de': 'SMART belongs to the whole disk: the data below is for',
', que contém': ', which contains',
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.',
'Nenhum sinal de defeito encontrado pelo SMART.': 'SMART found no signs of failure.',
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
// barra lateral e estrutura
'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices',
'Dispositivos e sessões': 'Devices and sessions',
'Atualizar lista': 'Refresh list',
'Lista atualizada': 'List refreshed',
'Abrir arquivo de imagem…': 'Open disk image file…',
'Abrir arquivo de imagem': 'Open disk image file',
'Abrir sessão salva…': 'Open saved session…',
'Abrir sessão salva': 'Open saved session',
'Sessões': 'Sessions',
'Sessão': 'Session',
'Sessão:': 'Session:',
'Recentes': 'Recent',
'(salvas sozinhas)': '(saved automatically)',
'Nenhuma ainda': 'None yet',
'Nada salvo ainda': 'Nothing saved yet',
'Nenhum disco encontrado': 'No disks found',
'Somente leitura na origem': 'Read-only on the source',
'Abrir menu': 'Open menu',
'Tema': 'Theme',
'Auto': 'Auto',
'Claro': 'Light',
'Escuro': 'Dark',
'Configurações': 'Settings',
'· requer admin': '· needs admin',
'requer admin': 'needs admin',
'{n} · requer admin': '{n} · needs admin',
// início
'Encontre o que você achou que tinha perdido.': 'Find what you thought was lost.',
'Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.': 'Pick a disk, SSD or USB drive on the left. Guará reads everything: deleted files, lost partitions and unpartitioned space, without writing anything to the source disk.',
'Uma varredura, tudo junto': 'One scan, everything together',
'Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.': 'File system metadata and signature search in the same pass, without duplicates.',
'Confiança por arquivo': 'Confidence per file',
'Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.': 'Each finding shows the chance of opening correctly: high, medium or low.',
'Origem protegida': 'Protected source',
'Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.': 'Read-only. Recovery always goes to another folder, with SHA-256 of everything.',
'Nenhum disco apareceu.': 'No disks showed up.',
// dispositivo
'Mapa do disco': 'Disk map',
'Como procurar': 'How to search',
'Recomendado': 'Recommended',
'Varredura profunda': 'Deep scan',
'Varredura rápida': 'Quick scan',
'Lê os metadados e examina todo o espaço livre, partições danificadas e áreas não particionadas por assinatura. Encontra mais, leva mais tempo.': 'Reads the metadata and examines all free space, damaged partitions and unpartitioned areas by signature. Finds more, takes longer.',
'Procurar tudo': 'Search everything',
'Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.': 'Only the file system records. In seconds it shows recently deleted files, with name and folder.',
'Criar imagem do disco': 'Create disk image',
'Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.': 'Copies the whole disk to a file, tolerating bad sectors. Ideal for failing disks: you work on the copy.',
'Criar imagem…': 'Create image…',
'Ferramentas de perícia': 'Forensic tools',
'Pesquisar texto no disco': 'Search text on the disk',
'Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.': 'Searches words across the whole disk, including free space and outside partitions (UTF-8 and UTF-16). Useful when there is no file structure.',
'termos separados por vírgula': 'terms separated by commas',
'Termos a pesquisar': 'Terms to search',
'Pesquisar': 'Search',
'Hashes da origem': 'Source hashes',
'Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.': 'Computes MD5, SHA-1 and SHA-256 of the whole disk to record integrity and chain of custody in the report.',
'Calcular hashes': 'Compute hashes',
'Opções avançadas': 'Advanced options',
'Incluir áreas ainda em uso': 'Include areas still in use',
'Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.': 'Also looks for old copies inside existing files. Much slower.',
'Sem nenhuma categoria marcada, todos os tipos são procurados.': 'With no category checked, all types are searched.',
'Este disco exige permissão de administrador.': 'This disk requires administrator permission.',
'Reiniciar como administrador': 'Restart as administrator',
'Feche e rode de novo com': 'Close and run again with',
'ou como administrador (Windows).': 'or as administrator (Windows).',
'Espaço não particionado': 'Unpartitioned space',
'Não particionado': 'Unpartitioned',
'encontrada por varredura': 'found by scanning',
'Desconhecido': 'Unknown',
'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.': 'This SSD uses TRIM: the system tells the disk which blocks were deleted and the SSD itself may zero them. Files deleted a long time ago tend to be unrecoverable.',
// saúde do disco
'Saúde do disco': 'Disk health',
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
'Nenhum sinal de defeito encontrado pelo SMART.': 'No signs of failure found by SMART.',
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
'Crie a imagem do disco primeiro (botão "Criar imagem" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.': 'Create the disk image first ("Create image" button or `guara image`) and scan the copy. Every extra read can make a failing disk worse.',
'Criar a imagem primeiro': 'Create the image first',
'Varrer mesmo assim': 'Scan anyway',
'Cancelar': 'Cancel',
'Este disco parece estar com defeito': 'This disk seems to be failing',
// varredura
'‹ Dispositivos': '‹ Devices',
'Procurando…': 'Searching…',
'Varredura concluída': 'Scan complete',
'Varredura pausada': 'Scan paused',
'Varredura interrompida': 'Scan stopped',
'Algo deu errado': 'Something went wrong',
'Iniciando': 'Starting',
'Pausar': 'Pause',
'Parar': 'Stop',
'Continuar': 'Continue',
'Pausando…': 'Pausing…',
'Reconectar o disco': 'Reconnect the disk',
'Escolher a origem…': 'Choose the source…',
'Escolher a origem': 'Choose the source',
'Salvar sessão…': 'Save session…',
'Exportar CSV': 'Export CSV',
'Relatório': 'Report',
'arquivos encontrados': 'files found',
'arquivos': 'files',
'decorrido': 'elapsed',
'confiança alta': 'high confidence',
'média': 'medium',
'baixa': 'low',
'alta': 'high',
'· faltam ~{n}': '· ~{n} left',
'· {n}%': '· {n}%',
'{n} arquivos em {n}': '{n} files in {n}',
'confiança alta {n} · média {n} · baixa {n}': 'high confidence {n} · medium {n} · low {n}',
'Lendo datas e metadados das fotos… {n}/{n}': 'Reading dates and metadata from photos… {n}/{n}',
'Lendo tabela de partições': 'Reading partition table',
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
'Varredura profunda por assinatura': 'Deep signature scan',
// explorador
'Navegação': 'Navigation',
'Filtros': 'Filters',
'Todos os arquivos': 'All files',
'Tudo': 'All',
'Tipos': 'Types',
'Datas': 'Dates',
'Pastas': 'Folders',
'Câmeras': 'Cameras',
'Confiança': 'Confidence',
'Outros': 'Other',
'Fotos': 'Photos',
'Documentos': 'Documents',
'Vídeos': 'Videos',
'Áudio': 'Audio',
'Compactados': 'Archives',
'Bancos de dados': 'Databases',
'Raiz': 'Root',
'/ (raiz)': '/ (root)',
'Por assinatura': 'By signature',
'Sem data': 'No date',
'sem data': 'no date',
'Confiança {n}': 'Confidence {n}',
'Com localização (GPS)': 'With location (GPS)',
'Com GPS': 'With GPS',
'Ocultar duplicadas': 'Hide duplicates',
'Sem duplicadas': 'Without duplicates',
'Ocultar arquivos pequenos (menos de 30 KB)': 'Hide small files (under 30 KB)',
'Sem arquivos pequenos': 'Without small files',
'Mais de 1 megapixel': 'Over 1 megapixel',
'Mais de 1 MP': 'Over 1 MP',
'Limpar filtros': 'Clear filters',
'Buscar por nome, pasta ou câmera': 'Search by name, folder or camera',
'Buscar': 'Search',
'Ordenar': 'Sort',
'Data (mais recentes)': 'Date (newest)',
'Data (mais antigas)': 'Date (oldest)',
'Nome': 'Name',
'Tamanho': 'Size',
'Modo de exibição': 'View mode',
'Grade': 'Grid',
'Lista': 'List',
'Grade de miniaturas': 'Thumbnail grid',
'Tamanho das miniaturas': 'Thumbnail size',
'Arquivos encontrados': 'Files found',
'Detalhes': 'Details',
'janeiro': 'January', 'fevereiro': 'February', 'março': 'March', 'abril': 'April', 'maio': 'May', 'junho': 'June',
'julho': 'July', 'agosto': 'August', 'setembro': 'September', 'outubro': 'October', 'novembro': 'November', 'dezembro': 'December',
'{n} item': '{n} item',
'{n} itens': '{n} items',
'{n} itens nesta vista': '{n} items in this view',
'{n} item nesta vista': '{n} item in this view',
'{n} itens nesta vista (mostrando {n})': '{n} items in this view (showing {n})',
'{n} selecionado': '{n} selected',
'{n} selecionados': '{n} selected',
'{n} selecionado (todos desta vista)': '{n} selected (all in this view)',
'{n} selecionados (todos desta vista)': '{n} selected (all in this view)',
'{n} itens selecionados': '{n} items selected',
'{n} no total': '{n} in total',
'Selecionar grupo': 'Select group',
'Selecionar este grupo': 'Select this group',
'Selecionar tudo': 'Select all',
'Selecionar todos os {n}': 'Select all {n}',
'Limpar seleção': 'Clear selection',
'Recuperar tudo desta vista': 'Recover everything in this view',
'Recuperar selecionado': 'Recover selected',
'Recuperar selecionados': 'Recover selected',
'Recuperar este arquivo': 'Recover this file',
'Tela cheia': 'Full screen',
'Fechar': 'Close',
'Anterior': 'Previous',
'Próximo': 'Next',
'← → para navegar · Esc para fechar': '← → to navigate · Esc to close',
'Selecione um arquivo para pré-visualizar.': 'Select a file to preview.',
'Selecione um arquivo para pré-visualizar': 'Select a file to preview',
'Setas navegam, Espaço abre em tela cheia.': 'Arrows navigate, Space opens full screen.',
'Pasta': 'Folder',
'Modificado': 'Modified',
'Criado': 'Created',
'Acessado': 'Accessed',
'Tirada em': 'Taken on',
'Câmera': 'Camera',
'Resolução': 'Resolution',
'Metadados': 'Metadata',
'Metadados · {n}': 'Metadata · {n}',
'Coordenadas:': 'Coordinates:',
'Aviso': 'Warning',
'Encontrado por assinatura': 'Found by signature',
'Assinatura de formato': 'Format signature',
'Origem': 'Source',
'Origem desconectada: sem miniatura': 'Source disconnected: no thumbnail',
'Não foi possível gerar a miniatura (arquivo possivelmente danificado)': 'Could not generate the thumbnail (file possibly damaged)',
'Tem localização GPS': 'Has GPS location',
'Confiança {n}': 'Confidence {n}',
'Nada por aqui': 'Nothing here',
// pré-visualização
'Imagem': 'Image',
'Vídeo': 'Video',
'PDF': 'PDF',
'Texto': 'Text',
'Documento (texto extraído)': 'Document (extracted text)',
'Conteúdo do arquivo compactado': 'Archive contents',
'Dados brutos (hexadecimal)': 'Raw data (hexadecimal)',
'Banco de dados SQLite': 'SQLite database',
'Banco SQLite': 'SQLite database',
'Arquivo vazio.': 'Empty file.',
'Não foi possível pré-visualizar.': 'Could not preview.',
'O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.': 'The file may be damaged, incomplete or partially overwritten, which also means the chance of recovery is lower.',
'A imagem não pôde ser decodificada.': 'The image could not be decoded.',
'Este arquivo não parece ser texto.': 'This file does not look like text.',
'Mostrando o início ({n} de {n}).': 'Showing the beginning ({n} of {n}).',
'Mostrando os primeiros {n} de {n}.': 'Showing the first {n} of {n}.',
'{n} × {n} px. Clique para alternar entre ajustar e tamanho real.': '{n} × {n} px. Click to toggle between fit and actual size.',
'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.': 'Text extracted from the document. The original formatting, images and charts are not shown.',
'{n} item(ns) no arquivo compactado.': '{n} item(s) in the archive.',
'Sem texto.': 'No text.',
'Página {n} de {n}': 'Page {n} of {n}',
'Página anterior': 'Previous page',
'Próxima página': 'Next page',
'Reproduzir em tela grande': 'Play full size',
'Abrir no player do sistema': 'Open in the system player',
'Preparando para abrir no player do sistema…': 'Preparing to open in the system player…',
'Aberto no player do sistema': 'Opened in the system player',
'Neste app o vídeo abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, video opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'Neste app o áudio abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, audio opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'pasta': 'folder',
// banco de dados
'Abrir o visualizador': 'Open the viewer',
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
'{n} linhas · {n} colunas': '{n} rows · {n} columns',
'Tabelas': 'Tables',
'Views': 'Views',
'Índices': 'Indexes',
'Filtrar esta página': 'Filter this page',
'Filtrar': 'Filter',
'Registros apagados (prováveis)': 'Deleted records (likely)',
'Ver esquema': 'View schema',
'Ver dados': 'View data',
'apagado': 'deleted',
'Nenhuma linha.': 'No rows.',
'Nenhuma tabela.': 'No tables.',
'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.': 'No deleted records recognized in this table. That does not guarantee there are none: the content may have been overwritten.',
'{n} registro(s) provável(is) apagado(s)': '{n} likely deleted record(s)',
'{n} a {n} de {n}': '{n} to {n} of {n}',
'‹ Anterior': '‹ Previous',
'Próximas ›': 'Next ›',
// recuperar
'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover',
'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.': 'Choose a folder on <b>another disk</b>. Guará never writes to the source disk, so it does not overwrite what can still be saved.',
'Pasta de destino': 'Destination folder',
'Escolher…': 'Choose…',
'Organizar os arquivos': 'Organize the files',
'Pastas originais (e por tipo quando não há pasta)': 'Original folders (and by type when there is no folder)',
'Por tipo (Fotos, Documentos, Vídeos...)': 'By type (Photos, Documents, Videos...)',
'Por data (ano e mês)': 'By date (year and month)',
'Tudo na mesma pasta': 'Everything in the same folder',
'Recuperando arquivos': 'Recovering files',
'Pronto': 'Done',
'Interrompido': 'Stopped',
'Salvar sessão': 'Save session',
'Guarda os resultados para recuperar depois, sem varrer de novo.': 'Saves the results so you can recover later without scanning again.',
'Sessão salva': 'Session saved',
'Salvar': 'Save',
'Exportar para CSV': 'Export to CSV',
'Exportar': 'Export',
'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.': 'Writes the files in the current view (with filters applied). The file opens directly in Excel.',
'{n} linhas gravadas': '{n} rows written',
'Relatório de recuperação': 'Recovery report',
'Gerar relatório': 'Generate report',
'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.': 'Generates a print-ready HTML report with the examined source, the findings and the hashes of recovered files.',
'Salvar em': 'Save to',
'Número do caso': 'Case number',
'Responsável pelo exame': 'Examiner',
'Organização': 'Organization',
'Observações': 'Notes',
'Relatório gravado': 'Report saved',
'lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.': 'lists the MD5, SHA-1 and SHA-256 of each file so you can verify integrity.',
'O arquivo': 'The file',
'Não foi possível recuperar': 'Could not recover',
'Não foi possível iniciar': 'Could not start',
'Entendi': 'Got it',
'{n} arquivo(s) salvos em {n} ({n}).': '{n} file(s) saved to {n} ({n}).',
'{n} arquivo(s) não puderam ser gravados.': '{n} file(s) could not be written.',
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
// imagem, hash, busca
'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.': 'Where to save the image file? Use <b>another disk</b> with enough space and a name that does not exist yet.',
'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes',
'Eles serão incluídos nos relatórios gerados nesta sessão.': 'They will be included in reports generated in this session.',
'Digite pelo menos um termo.': 'Type at least one term.',
'Posição': 'Position',
'Partição': 'Partition',
'Codificação': 'Encoding',
'Contexto': 'Context',
'fora': 'outside',
'Nada encontrado': 'Nothing found',
'Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).': 'These terms do not appear anywhere on the disk (neither in UTF-8 nor UTF-16).',
'{n} ocorrência': '{n} occurrence',
'{n} ocorrências': '{n} occurrences',
'{n} ocorrência{n}': '{n} occurrence{n}',
'Termos: {n}': 'Terms: {n}',
'{n}% · {n} até agora': '{n}% · {n} so far',
// sessões e origem
'Abrir arquivo de imagem': 'Open disk image file',
'Caminho completo de uma imagem de disco (.img, .dd, .iso…).': 'Full path of a disk image (.img, .dd, .iso…).',
'Caminho do arquivo de sessão (.json) criado pelo Guará.': 'Path of the session file (.json) created by Guará.',
'Abrir': 'Open',
'Abrir sessão salva': 'Open saved session',
'Não foi possível abrir a sessão': 'Could not open the session',
'Disco encontrado como {n}': 'Disk found as {n}',
'Disco reconectado': 'Disk reconnected',
'Origem reconectada': 'Source reconnected',
'Não foi possível usar essa origem': 'Could not use this source',
'Esta sessão foi feita sobre': 'This session was made on',
'Escolha o disco que voltou ou uma imagem dele.': 'Choose the disk that came back or an image of it.',
'Nenhum disco encontrado. Informe o caminho abaixo.': 'No disks found. Enter the path below.',
'Ou o caminho de um dispositivo ou imagem': 'Or the path to a device or image',
'Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)': 'Use it anyway, even if the initial content differs (for example, an edited copy)',
'Usar esta origem': 'Use this source',
'mesmo disco': 'same disk',
'tamanho igual, conteúdo diferente': 'same size, different content',
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
// configurações e fechamento
'Idioma': 'Language',
'Notificações': 'Notifications',
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
'Mapas online': 'Online maps',
'Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)': 'Show maps for photos with GPS (uses the internet: downloads OpenStreetMap tiles)',
'Salvo': 'Saved',
'Mostrar mapa': 'Show map',
'Mostrar mapa (usa a internet)': 'Show map (uses the internet)',
'Abrir no OpenStreetMap': 'Open in OpenStreetMap',
'Há uma varredura em andamento': 'A scan is in progress',
'Pausar e sair': 'Pause and quit',
'Sair sem pausar': 'Quit without pausing',
'Continuar varrendo': 'Keep scanning',
'A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.': 'The session is saved automatically. If you pause now, you resume exactly where you stopped.',
// erros comuns vindos do núcleo
'Não foi possível falar com o núcleo do Guará: {n}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando': 'Could not talk to the Guará core: {n}. If you opened this page manually, use the full address shown by the command',
'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
'Autorização cancelada.': 'Authorization canceled.',
'Origem desconectada': 'Source disconnected',
'Este disco está em uso (montado em': 'This disk is in use (mounted at',
'). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.': '). Guará only reads, but avoid writing to it while recovering, because any write can overwrite what you want to save.',
};
+121
View File
@@ -0,0 +1,121 @@
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
// continua observando a página para traduzir o que for desenhado depois.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files").
import { EN } from './i18n-en.js';
let lang = 'pt';
export const isEn = () => lang === 'en';
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
export function resolveLang(pref) {
if (pref === 'pt' || pref === 'en') return pref;
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
// Dados do usuário (nomes, caminhos, conteúdo) nunca são traduzidos.
const SKIP = '.xp-cap, .xp-lnm, .db-table, .pv-zip, .hit, pre, code, .mono, .xp-ititle b, dd, textarea, input, script, style';
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
const patterns = Object.entries(EN)
.filter(([k]) => k.includes('{n}'))
.map(([k, v]) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
})
// padrões mais específicos (mais texto fixo) primeiro
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
export function tr(text) {
if (!isEn()) return text;
const lead = text.match(/^\s*/)[0];
const trail = text.match(/\s*$/)[0];
const core = text.trim();
if (!core) return text;
if (cache.has(core)) return lead + cache.get(core) + trail;
let out = exact.get(core);
if (out === undefined) {
for (const p of patterns) {
const m = core.match(p.re);
if (m) {
let i = 0;
// os valores capturados também podem ter partes traduzíveis (ex.: nomes de tipos)
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break;
}
}
}
if (out === undefined) out = core;
if (cache.size > 5000) cache.clear();
cache.set(core, out);
return lead + out + trail;
}
const orig = new WeakMap(); // nó -> texto original em português
let observer = null;
function translateNode(n) {
if (n.nodeType === 3) {
if (n.parentElement?.closest(SKIP)) return;
const o = orig.get(n) ?? n.nodeValue;
const t = tr(o);
if (t !== n.nodeValue) {
if (!orig.has(n)) orig.set(n, o);
n.nodeValue = t;
}
} else if (n.nodeType === 1) {
if (n.matches?.(SKIP) && n.tagName !== 'INPUT') return;
for (const a of ATTRS) {
if (n.hasAttribute?.(a)) {
const key = `i18n-${a}`;
const o = n[key] ?? n.getAttribute(a);
const t = tr(o);
if (t !== n.getAttribute(a)) { n[key] = o; n.setAttribute(a, t); }
}
}
for (const c of n.childNodes) translateNode(c);
}
}
function restoreAll(root) {
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) { if (orig.has(n)) { n.nodeValue = orig.get(n); orig.delete(n); } }
root.querySelectorAll('*').forEach((el) => {
for (const a of ATTRS) { const key = `i18n-${a}`; if (el[key] !== undefined) { el.setAttribute(a, el[key]); delete el[key]; } }
});
}
function start() {
translateNode(document.body);
document.title = tr(document.title);
if (observer) return;
observer = new MutationObserver((muts) => {
observer.disconnect();
for (const m of muts) {
if (m.type === 'childList') m.addedNodes.forEach(translateNode);
else if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
else if (m.type === 'attributes') { delete m.target[`i18n-${m.attributeName}`]; translateNode(m.target); }
}
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
}
function stop() {
observer?.disconnect();
observer = null;
restoreAll(document.body);
}
export function setLang(pref) {
const next = resolveLang(pref);
const changed = next !== lang;
lang = next;
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
if (lang === 'en') start();
else if (changed || observer) stop();
}
+6 -3
View File
@@ -14,25 +14,28 @@
<button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
</button>
<img src="guara.svg" alt="" width="24" height="24"><strong>Guará</strong>
<svg class="logo" width="24" height="24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-top"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-top)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><strong>Guará</strong>
</header>
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
<div class="brand"><img src="guara.svg" alt="" width="30" height="30"><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<div class="brand"><svg class="logo" width="30" height="30" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-side"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-side)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<nav>
<div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
<ul id="deviceList" class="list"></ul>
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
<div class="side-title">Sessões</div>
<ul id="sessionList" class="list"></ul>
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
<ul id="recentList" class="list"></ul>
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
</nav>
<footer class="side-foot"><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
<footer class="side-foot"><button class="icon-btn small" id="settingsBtn" title="Configurações" aria-label="Configurações"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
</aside>
<div class="scrim" id="scrim"></div>
<main class="main" id="main" tabindex="-1"></main>
</div>
<dialog id="dlg"></dialog>
<dialog id="lightbox" class="lightbox" aria-label="Pré-visualização"></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="app.js" type="module"></script>
</body>
+411
View File
@@ -0,0 +1,411 @@
import { LOC } from './i18n.js';
// Pré-visualização de arquivos recuperados.
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal.
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']);
const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
const DBS = new Set(['db', 'sqlite', 'sqlite3', 'db3', 'sdb', 'sqlitedb', 'storedata']);
export function kindOf(item) {
const e = (item.ext || '').toLowerCase();
if (IMG.has(e)) return 'image';
if (VID.has(e)) return 'video';
if (AUD.has(e)) return 'audio';
if (e === 'pdf') return 'pdf';
if (DBS.has(e)) return 'database';
if (OFFICE.has(e)) return 'office';
if (ZIPS.has(e)) return 'zip';
if (TXT.has(e)) return 'text';
return 'hex';
}
export const KIND_LABEL = {
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
zip: 'Conteúdo do arquivo compactado', database: 'Banco de dados SQLite', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
};
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = (n) => {
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0, v = n;
while (v >= 1024 && i < 3) { v /= 1024; i++; }
return i ? `${v.toFixed(1)} ${u[i]}` : `${n} B`;
};
async function fetchRange(url, a, b) {
const r = await fetch(url, { headers: { Range: `bytes=${a}-${b}` } });
if (!r.ok) throw new Error(`leitura falhou (${r.status})`);
return new Uint8Array(await r.arrayBuffer());
}
// ------------------------------------------------------------------ ZIP mínimo
async function zipDir(url, size) {
const tailLen = Math.min(size, 66000);
const tail = await fetchRange(url, size - tailLen, size - 1);
const dv = new DataView(tail.buffer);
let e = -1;
for (let i = tail.length - 22; i >= 0; i--) if (dv.getUint32(i, true) === 0x06054b50) { e = i; break; }
if (e < 0) throw new Error('diretório do ZIP não encontrado (arquivo possivelmente incompleto)');
const n = dv.getUint16(e + 10, true);
const cdSize = dv.getUint32(e + 12, true);
const cdOff = dv.getUint32(e + 16, true);
if (cdOff + cdSize > size) throw new Error('diretório do ZIP inconsistente');
const cd = await fetchRange(url, cdOff, cdOff + cdSize - 1);
const cv = new DataView(cd.buffer);
const out = [];
let p = 0;
for (let i = 0; i < n && p + 46 <= cd.length; i++) {
if (cv.getUint32(p, true) !== 0x02014b50) break;
const nl = cv.getUint16(p + 28, true), xl = cv.getUint16(p + 30, true), cl = cv.getUint16(p + 32, true);
out.push({
method: cv.getUint16(p + 10, true), csize: cv.getUint32(p + 20, true), usize: cv.getUint32(p + 24, true),
off: cv.getUint32(p + 42, true), name: new TextDecoder().decode(cd.subarray(p + 46, p + 46 + nl)),
});
p += 46 + nl + xl + cl;
}
return out;
}
async function zipRead(url, e, cap = 8 << 20) {
if (e.usize > cap) throw new Error('parte grande demais');
const lh = await fetchRange(url, e.off, e.off + 29);
const dv = new DataView(lh.buffer);
const start = e.off + 30 + dv.getUint16(26, true) + dv.getUint16(28, true);
const data = e.csize ? await fetchRange(url, start, start + e.csize - 1) : new Uint8Array(0);
if (e.method === 0) return data;
if (e.method !== 8) throw new Error('compressão não suportada');
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
// ------------------------------------------------------------------ renderizadores
function note(host, html, cls = '') {
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
}
function failure(host, item, err, withHex) {
host.innerHTML = '';
note(host, `<b>Não foi possível pré-visualizar.</b><br>${esc(err?.message || err || '')} O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.`, 'bad');
return withHex ? hex(host, item, host.dataset.url, false) : null;
}
async function hex(host, item, url, large) {
const n = Math.min(item.size, large ? 4096 : 1024);
if (!n) { note(host, 'Arquivo vazio.'); return; }
const b = await fetchRange(url, 0, n - 1);
const lines = [];
for (let i = 0; i < b.length; i += 16) {
const row = [...b.subarray(i, i + 16)];
const h = row.map((x) => x.toString(16).padStart(2, '0')).join(' ').padEnd(47);
const a = row.map((x) => (x >= 32 && x < 127 ? String.fromCharCode(x) : '·')).join('');
lines.push(`<span class="o">${i.toString(16).padStart(6, '0')}</span> ${h} <span class="a">${esc(a)}</span>`);
}
host.insertAdjacentHTML('beforeend', `<pre class="pv-hex">${lines.join('\n')}</pre>`);
if (item.size > n) note(host, `Mostrando os primeiros ${human(n)} de ${human(item.size)}.`);
}
async function text(host, item, url, large) {
const cap = large ? 262144 : 24576;
const b = await fetchRange(url, 0, Math.min(item.size, cap) - 1);
const sample = b.subarray(0, 2048);
const bin = sample.filter((x) => x === 0 || (x < 9) ).length;
if (bin > sample.length * 0.02) { note(host, 'Este arquivo não parece ser texto.'); return hex(host, item, url, large); }
let s = new TextDecoder('utf-8', { fatal: false }).decode(b);
if ((s.match(/�/g) || []).length > s.length * 0.02) s = new TextDecoder('windows-1252').decode(b);
host.insertAdjacentHTML('beforeend', `<pre class="pv-text">${esc(s)}</pre>`);
if (item.size > b.length) note(host, `Mostrando o início (${human(b.length)} de ${human(item.size)}).`);
}
async function image(host, item, url, state, thumb) {
await new Promise((resolve) => {
const img = new Image();
img.alt = `Pré-visualização de ${item.name}`;
img.className = 'pv-img';
img.decoding = 'async';
img.onload = () => {
if (state.dead) return resolve();
host.innerHTML = '';
host.appendChild(img);
if (!thumb) {
host.classList.add('zoomable');
img.onclick = () => host.classList.toggle('actual');
}
if (!thumb) note(host, `${img.naturalWidth} × ${img.naturalHeight} px. Clique para alternar entre ajustar e tamanho real.`);
resolve();
};
img.onerror = () => { if (!state.dead) failure(host, item, 'A imagem não pôde ser decodificada.', true); resolve(); };
img.src = url;
});
}
function media(host, item, url, tag, state) {
const el = document.createElement(tag);
el.controls = true;
el.preload = 'metadata';
el.className = `pv-${tag}`;
if (tag === 'video') el.playsInline = true;
el.onerror = () => { if (!state.dead) failure(host, item, `Não foi possível reproduzir este ${tag === 'video' ? 'vídeo' : 'áudio'}.`, true); };
el.src = url;
host.innerHTML = '';
host.appendChild(el);
state.cleanup.push(() => { el.pause?.(); el.removeAttribute('src'); el.load?.(); });
}
let pdfjsPromise;
function loadPdfjs() {
pdfjsPromise ??= import('./vendor/pdf.min.mjs').then((m) => {
m.GlobalWorkerOptions.workerSrc = new URL('./vendor/pdf.worker.min.mjs', import.meta.url).href;
return m;
});
return pdfjsPromise;
}
async function pdf(host, item, url, state, large) {
const pdfjs = await loadPdfjs();
const task = pdfjs.getDocument({ url, disableAutoFetch: true, isEvalSupported: false });
state.cleanup.push(() => task.destroy());
const doc = await task.promise;
if (state.dead) return;
host.innerHTML = '<div class="pv-pdfbar"><button class="btn sm" data-d="-1" aria-label="Página anterior">‹</button><span class="pg"></span><button class="btn sm" data-d="1" aria-label="Próxima página">›</button></div><div class="pv-pdfwrap"><canvas class="pv-canvas"></canvas></div>';
const canvas = host.querySelector('canvas');
const label = host.querySelector('.pg');
let n = 1, busy = false;
async function draw() {
if (busy || state.dead) return;
busy = true;
try {
const page = await doc.getPage(n);
const base = page.getViewport({ scale: 1 });
const width = Math.max(240, (host.clientWidth || 320) - 16);
const scale = (width / base.width) * (large ? 1 : 1);
const dpr = window.devicePixelRatio || 1;
const vp = page.getViewport({ scale: scale * dpr });
canvas.width = vp.width; canvas.height = vp.height;
canvas.style.width = `${vp.width / dpr}px`;
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
label.textContent = `Página ${n} de ${doc.numPages}`;
} catch (e) {
if (!state.dead) { host.innerHTML = ''; failure(host, item, e, true); }
}
busy = false;
}
host.querySelectorAll('[data-d]').forEach((b) => (b.onclick = () => { n = Math.min(doc.numPages, Math.max(1, n + +b.dataset.d)); draw(); }));
await draw();
}
const colIndex = (ref) => [...ref.replace(/\d+/g, '')].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1;
const colName = (i) => { let s = ''; i++; while (i > 0) { s = String.fromCharCode(65 + ((i - 1) % 26)) + s; i = Math.floor((i - 1) / 26); } return s; };
async function office(host, item, url, state) {
const ents = await zipDir(url, item.size);
const find = (n) => ents.find((e) => e.name === n);
const ext = item.ext.toLowerCase();
let html = '';
if (ext === 'docx') {
const d = xml(await zipRead(url, find('word/document.xml')));
const paras = [...d.getElementsByTagName('w:p')].slice(0, 400).map((p) => {
let t = '';
p.querySelectorAll('t, tab, br').forEach((n) => { t += n.localName === 't' ? n.textContent : n.localName === 'tab' ? '\t' : '\n'; });
const h = p.querySelector('pStyle')?.getAttribute('w:val') || '';
const tag = /^(Heading|Ttulo|Title)/i.test(h) ? 'h4' : 'p';
return t.trim() ? `<${tag}>${esc(t)}</${tag}>` : '';
});
html = `<div class="pv-page">${paras.join('') || '<p class="sub">Sem texto.</p>'}</div>`;
} else if (ext === 'odt') {
const d = xml(await zipRead(url, find('content.xml')));
const els = [...d.getElementsByTagName('text:p'), ...[]].slice(0, 400);
html = `<div class="pv-page">${els.map((p) => (p.textContent.trim() ? `<p>${esc(p.textContent)}</p>` : '')).join('') || '<p class="sub">Sem texto.</p>'}</div>`;
} else if (ext === 'xlsx') {
const ss = find('xl/sharedStrings.xml');
const strings = ss ? [...xml(await zipRead(url, ss)).getElementsByTagName('si')].map((si) => si.textContent) : [];
const sheet = ents.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0];
const d = xml(await zipRead(url, sheet));
const rows = [...d.getElementsByTagName('row')].slice(0, 80);
let maxc = 0;
const grid = rows.map((r) => {
const cells = {};
[...r.getElementsByTagName('c')].forEach((c) => {
const i = colIndex(c.getAttribute('r') || 'A1');
const v = c.getElementsByTagName('v')[0]?.textContent ?? c.getElementsByTagName('t')[0]?.textContent ?? '';
cells[i] = c.getAttribute('t') === 's' ? strings[+v] ?? '' : v;
maxc = Math.max(maxc, i + 1);
});
return [r.getAttribute('r'), cells];
});
maxc = Math.min(maxc, 16);
html = `<div class="pv-sheet"><table><thead><tr><th></th>${Array.from({ length: maxc }, (_, i) => `<th>${colName(i)}</th>`).join('')}</tr></thead><tbody>${grid
.map(([n, cells]) => `<tr><th>${esc(n)}</th>${Array.from({ length: maxc }, (_, i) => `<td>${esc(cells[i] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else if (ext === 'ods') {
const d = xml(await zipRead(url, find('content.xml')));
const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80);
html = `<div class="pv-sheet"><table><tbody>${rows.map((r) => `<tr>${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => `<td>${esc(c.textContent)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else {
// pptx / odp: texto por slide
let slides;
if (ext === 'pptx') {
const files = ents.filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).slice(0, 60);
slides = [];
for (const f of files) {
const d = xml(await zipRead(url, f));
slides.push([...d.getElementsByTagName('a:p')].map((p) => p.textContent).filter((t) => t.trim()));
}
} else {
const d = xml(await zipRead(url, find('content.xml')));
slides = [...d.getElementsByTagName('draw:page')].slice(0, 60).map((pg) => [...pg.getElementsByTagName('text:p')].map((p) => p.textContent).filter((t) => t.trim()));
}
html = slides.map((s, i) => `<div class="pv-slide"><small>Slide ${i + 1}</small>${s.map((t, j) => (j === 0 ? `<h4>${esc(t)}</h4>` : `<p>${esc(t)}</p>`)).join('') || '<p class="sub">Sem texto.</p>'}</div>`).join('');
}
if (state.dead) return;
host.innerHTML = html;
note(host, 'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.');
}
async function zip(host, item, url, state) {
const ents = await zipDir(url, item.size);
if (state.dead) return;
host.innerHTML = `<ul class="pv-zip">${ents.slice(0, 400).map((e) => `<li><span>${esc(e.name)}</span><small>${e.name.endsWith('/') ? 'pasta' : human(e.usize)}</small></li>`).join('')}</ul>`;
note(host, `${ents.length} item(ns) no arquivo compactado.`);
}
// ------------------------------------------------------------------ bancos de dados (SQLite)
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
function tsHint(v) {
if (!Number.isInteger(v)) return null;
let ms = null, kind = '';
if (v > 9.4e8 && v < 4.2e9) { ms = v * 1000; kind = 'Unix (s)'; }
else if (v > 9.4e11 && v < 4.2e12) { ms = v; kind = 'Unix (ms)'; }
else if (v > 9.4e14 && v < 4.2e15) { ms = v / 1000; kind = 'Unix (µs)'; }
else if (v > 1.2e16 && v < 1.4e16) { ms = v / 1000 - 11644473600000; kind = 'Chrome/WebKit'; }
else if (v > 3e8 && v < 9.4e8) { ms = (v + 978307200) * 1000; kind = 'macOS/CoreData'; }
if (ms == null) return null;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : { kind, text: d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' };
}
function cellHtml(c) {
switch (c.t) {
case 'null': return '<span class="db-null">NULL</span>';
case 'int': { const h = tsHint(c.v); return h ? `<span title="${esc(h.kind)}: ${esc(h.text)}">${c.v}<i class="db-ts">${esc(h.text.slice(0, 10))}</i></span>` : String(c.v); }
case 'real': return String(c.v);
case 'text': return `<span title="${esc(c.v.slice(0, 600))}">${esc(c.v.length > 240 ? c.v.slice(0, 240) + '…' : c.v)}</span>`;
default: return `<span class="db-blob" title="${esc(c.v.head)}">BLOB ${c.v.mime ? esc(c.v.mime) + ' ' : ''}${human(c.v.len)}</span>`;
}
}
async function database(host, item, state, large, db, onOpen) {
if (!db) throw new Error('visualizador indisponível');
const info = await db.info();
if (state.dead) return;
const tables = info.tables;
const warn = (info.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
if (!large) {
const total = tables.reduce((a, t) => a + (t.rows || 0), 0);
host.innerHTML = `<div class="db-sum"><b>Banco SQLite</b><span class="sub">${tables.length} tabela(s) · ${total.toLocaleString(LOC())} linhas · páginas de ${human(info.page_size)}</span>
<ul>${tables.slice(0, 10).map((t) => `<li><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></li>`).join('')}</ul>
${tables.length > 10 ? `<span class="sub">e mais ${tables.length - 10}…</span>` : ''}${warn}
<button class="btn primary sm" data-pv="open">Abrir o visualizador</button></div>`;
host.onclick = (e) => { if (e.target.closest('[data-pv]')) onOpen?.(); };
return;
}
const S = { table: tables.find((t) => t.rows > 0)?.name || tables[0]?.name, offset: 0, limit: 100, deleted: false, q: '', schema: false };
host.classList.add('db-large');
host.innerHTML = `<div class="db-wrap"><nav class="db-tables" aria-label="Tabelas"></nav><section class="db-main"><div class="db-bar"></div><div class="db-grid"></div><div class="db-foot"></div></section></div>`;
const $ = (sel) => host.querySelector(sel);
const nav = $('.db-tables'), bar = $('.db-bar'), grid = $('.db-grid'), foot = $('.db-foot');
nav.innerHTML = tables.map((t) => `<button class="db-t" data-t="${esc(t.name)}"><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></button>`).join('')
+ (info.indexes.length || info.views.length ? `<div class="db-meta">${info.views.length ? `<b>Views</b>${info.views.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}${info.indexes.length ? `<b>Índices</b>${info.indexes.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}</div>` : '')
+ warn;
async function load() {
if (state.dead) return;
nav.querySelectorAll('.db-t').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.t === S.table)));
const t = tables.find((x) => x.name === S.table);
if (!t) { grid.innerHTML = '<div class="pv-note">Nenhuma tabela.</div>'; return; }
bar.innerHTML = `<b>${esc(t.name)}</b><span class="sub">${(t.rows || 0).toLocaleString(LOC())} linhas · ${t.columns.length} colunas</span>
<input class="input" id="dbq" type="search" placeholder="Filtrar esta página" value="${esc(S.q)}" aria-label="Filtrar">
<label class="check" style="padding:0"><input type="checkbox" id="dbdel" ${S.deleted ? 'checked' : ''}><span>Registros apagados (prováveis)</span></label>
<button class="btn sm" id="dbsch">${S.schema ? 'Ver dados' : 'Ver esquema'}</button>`;
if (S.schema) { grid.innerHTML = `<pre class="pv-text">${esc(t.sql)}</pre>`; foot.innerHTML = ''; wire(); return; }
grid.innerHTML = '<div class="pv-loading"><i></i></div>';
let page;
try { page = await db.rows(S.table, S.offset, S.limit, S.deleted); } catch (e) { grid.innerHTML = `<div class="pv-note bad">${esc(e.message || e)}</div>`; wire(); return; }
if (state.dead) return;
const q = S.q.toLowerCase();
const rows = q ? page.rows.filter((r) => r.cells.some((c) => String(c.t === 'blob' ? '' : c.v).toLowerCase().includes(q))) : page.rows;
const notes = (page.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
grid.innerHTML = notes + (rows.length ? `<table class="db-table"><thead><tr><th>#</th>${page.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r, i) => `<tr class="${r.deleted ? 'del' : ''}"><th>${r.deleted ? 'apagado' : (r.rowid ?? S.offset + i + 1)}</th>${r.cells.map((c) => `<td>${cellHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`
: `<div class="pv-note">${S.deleted ? 'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.' : 'Nenhuma linha.'}</div>`);
const total = S.deleted ? page.total : (t.rows || page.total);
foot.innerHTML = `<span class="sub">${S.deleted ? `${total.toLocaleString(LOC())} registro(s) provável(is) apagado(s)` : `${total ? S.offset + 1 : 0} a ${Math.min(S.offset + S.limit, total)} de ${total.toLocaleString(LOC())}`}</span>
<span class="row"><button class="btn sm" id="dbprev" ${S.offset <= 0 ? 'disabled' : ''}>‹ Anterior</button><button class="btn sm" id="dbnext" ${S.offset + S.limit >= total ? 'disabled' : ''}>Próximas ›</button></span>`;
wire();
}
function wire() {
$('#dbq')?.addEventListener('input', (e) => { S.q = e.target.value; clearTimeout(S.t); S.t = setTimeout(load, 200); });
$('#dbdel')?.addEventListener('change', (e) => { S.deleted = e.target.checked; S.offset = 0; load(); });
$('#dbsch')?.addEventListener('click', () => { S.schema = !S.schema; load(); });
$('#dbprev')?.addEventListener('click', () => { S.offset = Math.max(0, S.offset - S.limit); load(); });
$('#dbnext')?.addEventListener('click', () => { S.offset += S.limit; load(); });
}
nav.addEventListener('click', (e) => { const b = e.target.closest('.db-t'); if (b) { S.table = b.dataset.t; S.offset = 0; S.q = ''; S.deleted = false; S.schema = false; load(); } });
load();
}
// ------------------------------------------------------------------ API
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
const ico = kind === 'video' ? '🎞' : '🎵';
const canInline = inlineMedia && large;
host.innerHTML = `<div class="pv-card"><div class="pv-bigicon" aria-hidden="true">${ico}</div>
<b>${esc(item.name)}</b><span class="sub">${esc(KIND_LABEL[kind])} · ${human(item.size)}</span>
${inlineMedia
? '<button class="btn primary" data-pv="open">Reproduzir em tela grande</button>'
: '<button class="btn primary" data-pv="ext">Abrir no player do sistema</button><span class="pv-why">Neste app o ' + (kind === 'video' ? 'vídeo' : 'áudio') + ' abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.</span>'}
</div>`;
host.onclick = (e) => {
const a = e.target.closest('[data-pv]')?.dataset.pv;
if (a === 'open') onOpen?.();
else if (a === 'ext') onExternal?.();
};
void canInline;
}
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null, db = null }) {
const state = { dead: false, cleanup: [] };
host.dataset.url = url;
host.classList.remove('zoomable', 'actual');
host.innerHTML = '<div class="pv-loading"><i></i></div>';
const kind = kindOf(item);
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
return () => { state.dead = true; };
}
(async () => {
try {
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
if (kind === 'image') await image(host, item, url, state, thumb);
else if (kind === 'video') media(host, item, url, 'video', state);
else if (kind === 'audio') media(host, item, url, 'audio', state);
else if (kind === 'pdf') await pdf(host, item, url, state, large);
else if (kind === 'database') await database(host, item, state, large, db, onOpen);
else if (kind === 'office') await office(host, item, url, state);
else if (kind === 'zip') await zip(host, item, url, state);
else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); }
else { host.innerHTML = ''; await hex(host, item, url, large); }
} catch (e) {
if (!state.dead) await failure(host, item, e, kind !== 'hex' && kind !== 'text');
}
})();
return () => { state.dead = true; state.cleanup.forEach((f) => { try { f(); } catch { /* ignora */ } }); };
}
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+3
View File
@@ -0,0 +1,3 @@
# Bibliotecas de terceiros
- **pdf.js** 4.10.38 (build "legacy"), © Mozilla, licença Apache-2.0 (`PDFJS-LICENSE`). Usada só para pré-visualizar PDFs, localmente e sem internet. Origem: https://github.com/mozilla/pdf.js
+21
View File
@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var t={9306:(t,e,i)=>{var s=i(4901),n=i(6823),r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not a function")}},3506:(t,e,i)=>{var s=i(3925),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r("Can't set "+n(t)+" as a prototype")}},7080:(t,e,i)=>{var s=i(4402).has;t.exports=function(t){s(t);return t}},679:(t,e,i)=>{var s=i(1625),n=TypeError;t.exports=function(t,e){if(s(e,t))return t;throw new n("Incorrect invocation")}},8551:(t,e,i)=>{var s=i(34),n=String,r=TypeError;t.exports=function(t){if(s(t))return t;throw new r(n(t)+" is not an object")}},7811:t=>{t.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(t,e,i)=>{var s=i(4576),n=i(6706),r=i(2195),a=s.ArrayBuffer,o=s.TypeError;t.exports=a&&n(a.prototype,"byteLength","get")||function(t){if("ArrayBuffer"!==r(t))throw new o("ArrayBuffer expected");return t.byteLength}},3238:(t,e,i)=>{var s=i(4576),n=i(7476),r=i(7394),a=s.ArrayBuffer,o=a&&a.prototype,l=o&&n(o.slice);t.exports=function(t){if(0!==r(t))return!1;if(!l)return!1;try{l(t,0,0);return!1}catch(t){return!0}}},5169:(t,e,i)=>{var s=i(3238),n=TypeError;t.exports=function(t){if(s(t))throw new n("ArrayBuffer is detached");return t}},5636:(t,e,i)=>{var s=i(4576),n=i(9504),r=i(6706),a=i(7696),o=i(5169),l=i(7394),h=i(4483),c=i(1548),d=s.structuredClone,u=s.ArrayBuffer,p=s.DataView,g=Math.min,f=u.prototype,m=p.prototype,b=n(f.slice),v=r(f,"resizable","get"),y=r(f,"maxByteLength","get"),w=n(m.getInt8),A=n(m.setInt8);t.exports=(c||h)&&function(t,e,i){var s,n=l(t),r=void 0===e?n:a(e),f=!v||!v(t);o(t);if(c){t=d(t,{transfer:[t]});if(n===r&&(i||f))return t}if(n>=r&&(!i||f))s=b(t,0,r);else{var m=i&&!f&&y?{maxByteLength:y(t)}:void 0;s=new u(r,m);for(var x=new p(t),_=new p(s),E=g(r,n),S=0;S<E;S++)A(_,S,w(x,S))}c||h(t);return s}},4644:(t,e,i)=>{var s,n,r,a=i(7811),o=i(3724),l=i(4576),h=i(4901),c=i(34),d=i(9297),u=i(6955),p=i(6823),g=i(6699),f=i(6840),m=i(2106),b=i(1625),v=i(2787),y=i(2967),w=i(8227),A=i(3392),x=i(1181),_=x.enforce,E=x.get,S=l.Int8Array,C=S&&S.prototype,T=l.Uint8ClampedArray,M=T&&T.prototype,P=S&&v(S),D=C&&v(C),k=Object.prototype,R=l.TypeError,I=w("toStringTag"),L=A("TYPED_ARRAY_TAG"),O="TypedArrayConstructor",N=a&&!!y&&"Opera"!==u(l.opera),B=!1,H={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},U={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(t){var e=v(t);if(c(e)){var i=E(e);return i&&d(i,O)?i[O]:getTypedArrayConstructor(e)}},isTypedArray=function(t){if(!c(t))return!1;var e=u(t);return d(H,e)||d(U,e)};for(s in H)(r=(n=l[s])&&n.prototype)?_(r)[O]=n:N=!1;for(s in U)(r=(n=l[s])&&n.prototype)&&(_(r)[O]=n);if(!N||!h(P)||P===Function.prototype){P=function TypedArray(){throw new R("Incorrect invocation")};if(N)for(s in H)l[s]&&y(l[s],P)}if(!N||!D||D===k){D=P.prototype;if(N)for(s in H)l[s]&&y(l[s].prototype,D)}N&&v(M)!==D&&y(M,D);if(o&&!d(D,I)){B=!0;m(D,I,{configurable:!0,get:function(){return c(this)?this[L]:void 0}});for(s in H)l[s]&&g(l[s],L,s)}t.exports={NATIVE_ARRAY_BUFFER_VIEWS:N,TYPED_ARRAY_TAG:B&&L,aTypedArray:function(t){if(isTypedArray(t))return t;throw new R("Target is not a typed array")},aTypedArrayConstructor:function(t){if(h(t)&&(!y||b(P,t)))return t;throw new R(p(t)+" is not a typed array constructor")},exportTypedArrayMethod:function(t,e,i,s){if(o){if(i)for(var n in H){var r=l[n];if(r&&d(r.prototype,t))try{delete r.prototype[t]}catch(i){try{r.prototype[t]=e}catch(t){}}}D[t]&&!i||f(D,t,i?e:N&&C[t]||e,s)}},exportTypedArrayStaticMethod:function(t,e,i){var s,n;if(o){if(y){if(i)for(s in H)if((n=l[s])&&d(n,t))try{delete n[t]}catch(t){}if(P[t]&&!i)return;try{return f(P,t,i?e:N&&P[t]||e)}catch(t){}}for(s in H)!(n=l[s])||n[t]&&!i||f(n,t,e)}},getTypedArrayConstructor,isView:function isView(t){if(!c(t))return!1;var e=u(t);return"DataView"===e||d(H,e)||d(U,e)},isTypedArray,TypedArray:P,TypedArrayPrototype:D}},5370:(t,e,i)=>{var s=i(6198);t.exports=function(t,e,i){for(var n=0,r=arguments.length>2?i:s(e),a=new t(r);r>n;)a[n]=e[n++];return a}},9617:(t,e,i)=>{var s=i(5397),n=i(5610),r=i(6198),createMethod=function(t){return function(e,i,a){var o=s(e),l=r(o);if(0===l)return!t&&-1;var h,c=n(a,l);if(t&&i!=i){for(;l>c;)if((h=o[c++])!=h)return!0}else for(;l>c;c++)if((t||c in o)&&o[c]===i)return t||c||0;return!t&&-1}};t.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(t,e,i)=>{var s=i(3724),n=i(4376),r=TypeError,a=Object.getOwnPropertyDescriptor,o=s&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(t){return t instanceof TypeError}}();t.exports=o?function(t,e){if(n(t)&&!a(t,"length").writable)throw new r("Cannot set read only .length");return t.length=e}:function(t,e){return t.length=e}},7680:(t,e,i)=>{var s=i(9504);t.exports=s([].slice)},7628:(t,e,i)=>{var s=i(6198);t.exports=function(t,e){for(var i=s(t),n=new e(i),r=0;r<i;r++)n[r]=t[i-r-1];return n}},9928:(t,e,i)=>{Line truncated
+21
View File
@@ -0,0 +1,21 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/var e={9306:(e,t,i)=>{var a=i(4901),r=i(6823),s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not a function")}},3506:(e,t,i)=>{var a=i(3925),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s("Can't set "+r(e)+" as a prototype")}},7080:(e,t,i)=>{var a=i(4402).has;e.exports=function(e){a(e);return e}},679:(e,t,i)=>{var a=i(1625),r=TypeError;e.exports=function(e,t){if(a(t,e))return e;throw new r("Incorrect invocation")}},8551:(e,t,i)=>{var a=i(34),r=String,s=TypeError;e.exports=function(e){if(a(e))return e;throw new s(r(e)+" is not an object")}},7811:e=>{e.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},7394:(e,t,i)=>{var a=i(4576),r=i(6706),s=i(2195),n=a.ArrayBuffer,o=a.TypeError;e.exports=n&&r(n.prototype,"byteLength","get")||function(e){if("ArrayBuffer"!==s(e))throw new o("ArrayBuffer expected");return e.byteLength}},3238:(e,t,i)=>{var a=i(4576),r=i(7476),s=i(7394),n=a.ArrayBuffer,o=n&&n.prototype,g=o&&r(o.slice);e.exports=function(e){if(0!==s(e))return!1;if(!g)return!1;try{g(e,0,0);return!1}catch(e){return!0}}},5169:(e,t,i)=>{var a=i(3238),r=TypeError;e.exports=function(e){if(a(e))throw new r("ArrayBuffer is detached");return e}},5636:(e,t,i)=>{var a=i(4576),r=i(9504),s=i(6706),n=i(7696),o=i(5169),g=i(7394),c=i(4483),l=i(1548),h=a.structuredClone,C=a.ArrayBuffer,Q=a.DataView,E=Math.min,u=C.prototype,d=Q.prototype,f=r(u.slice),p=s(u,"resizable","get"),m=s(u,"maxByteLength","get"),y=r(d.getInt8),w=r(d.setInt8);e.exports=(l||c)&&function(e,t,i){var a,r=g(e),s=void 0===t?r:n(t),u=!p||!p(e);o(e);if(l){e=h(e,{transfer:[e]});if(r===s&&(i||u))return e}if(r>=s&&(!i||u))a=f(e,0,s);else{var d=i&&!u&&m?{maxByteLength:m(e)}:void 0;a=new C(s,d);for(var b=new Q(e),D=new Q(a),S=E(s,r),k=0;k<S;k++)w(D,k,y(b,k))}l||c(e);return a}},4644:(e,t,i)=>{var a,r,s,n=i(7811),o=i(3724),g=i(4576),c=i(4901),l=i(34),h=i(9297),C=i(6955),Q=i(6823),E=i(6699),u=i(6840),d=i(2106),f=i(1625),p=i(2787),m=i(2967),y=i(8227),w=i(3392),b=i(1181),D=b.enforce,S=b.get,k=g.Int8Array,R=k&&k.prototype,N=g.Uint8ClampedArray,G=N&&N.prototype,M=k&&p(k),U=R&&p(R),x=Object.prototype,L=g.TypeError,H=y("toStringTag"),J=w("TYPED_ARRAY_TAG"),v="TypedArrayConstructor",Y=n&&!!m&&"Opera"!==C(g.opera),K=!1,T={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},q={BigInt64Array:8,BigUint64Array:8},getTypedArrayConstructor=function(e){var t=p(e);if(l(t)){var i=S(t);return i&&h(i,v)?i[v]:getTypedArrayConstructor(t)}},isTypedArray=function(e){if(!l(e))return!1;var t=C(e);return h(T,t)||h(q,t)};for(a in T)(s=(r=g[a])&&r.prototype)?D(s)[v]=r:Y=!1;for(a in q)(s=(r=g[a])&&r.prototype)&&(D(s)[v]=r);if(!Y||!c(M)||M===Function.prototype){M=function TypedArray(){throw new L("Incorrect invocation")};if(Y)for(a in T)g[a]&&m(g[a],M)}if(!Y||!U||U===x){U=M.prototype;if(Y)for(a in T)g[a]&&m(g[a].prototype,U)}Y&&p(G)!==U&&m(G,U);if(o&&!h(U,H)){K=!0;d(U,H,{configurable:!0,get:function(){return l(this)?this[J]:void 0}});for(a in T)g[a]&&E(g[a],J,a)}e.exports={NATIVE_ARRAY_BUFFER_VIEWS:Y,TYPED_ARRAY_TAG:K&&J,aTypedArray:function(e){if(isTypedArray(e))return e;throw new L("Target is not a typed array")},aTypedArrayConstructor:function(e){if(c(e)&&(!m||f(M,e)))return e;throw new L(Q(e)+" is not a typed array constructor")},exportTypedArrayMethod:function(e,t,i,a){if(o){if(i)for(var r in T){var s=g[r];if(s&&h(s.prototype,e))try{delete s.prototype[e]}catch(i){try{s.prototype[e]=t}catch(e){}}}U[e]&&!i||u(U,e,i?t:Y&&R[e]||t,a)}},exportTypedArrayStaticMethod:function(e,t,i){var a,r;if(o){if(m){if(i)for(a in T)if((r=g[a])&&h(r,e))try{delete r[e]}catch(e){}if(M[e]&&!i)return;try{return u(M,e,i?t:Y&&M[e]||t)}catch(e){}}for(a in T)!(r=g[a])||r[e]&&!i||u(r,e,t)}},getTypedArrayConstructor,isView:function isView(e){if(!l(e))return!1;var t=C(e);return"DataView"===t||h(T,t)||h(q,t)},isTypedArray,TypedArray:M,TypedArrayPrototype:U}},5370:(e,t,i)=>{var a=i(6198);e.exports=function(e,t,i){for(var r=0,s=arguments.length>2?i:a(t),n=new e(s);s>r;)n[r]=t[r++];return n}},9617:(e,t,i)=>{var a=i(5397),r=i(5610),s=i(6198),createMethod=function(e){return function(t,i,n){var o=a(t),g=s(o);if(0===g)return!e&&-1;var c,l=r(n,g);if(e&&i!=i){for(;g>l;)if((c=o[l++])!=c)return!0}else for(;g>l;l++)if((e||l in o)&&o[l]===i)return e||l||0;return!e&&-1}};e.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},4527:(e,t,i)=>{var a=i(3724),r=i(4376),s=TypeError,n=Object.getOwnPropertyDescriptor,o=a&&!function(){if(void 0!==this)return!0;try{Object.defineProperty([],"length",{writable:!1}).length=1}catch(e){return e instanceof TypeError}}();e.exports=o?function(e,t){if(r(e)&&!n(e,"length").writable)throw new s("Cannot set read only .length");return e.length=t}:function(e,t){return e.length=t}},7680:(e,t,i)=>{var a=i(9504);e.exports=a([].slice)},7628:(e,t,i)=>{var a=i(6198);e.exports=function(e,t){for(var i=a(e),r=new t(i),s=0;s<i;s++)r[s]=e[i-s-1];return r}},9928:(e,t,i)=>{Line truncated
+1
View File
@@ -18,3 +18,4 @@ serde_json = "1"
tiny_http = "0.12"
getrandom = "0.2"
ctrlc = "3"
rpassword = "7"
+541 -10
View File
@@ -12,7 +12,7 @@ use std::sync::Arc;
use std::time::Duration;
#[derive(Parser)]
#[command(name = "guara", version, about = "Guará — recuperação de arquivos (somente leitura na origem)", long_about = None)]
#[command(name = "guara", version, about = "Guará: recuperação de arquivos (somente leitura na origem)", long_about = None)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
@@ -31,6 +31,15 @@ enum Cmd {
path: String,
#[arg(long)]
json: bool,
#[command(flatten)]
asm: AsmArgs,
},
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos
Health {
/// Dispositivo ou partição (o SMART lido é o do disco que a contém)
path: String,
#[arg(long)]
json: bool,
},
/// Procura arquivos apagados/perdidos (metadados + assinatura)
Scan(ScanArgs),
@@ -49,7 +58,82 @@ enum Cmd {
filter: FilterArgs,
},
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
Image { path: String, dest: String },
Image {
path: String,
dest: String,
/// Formato da imagem: raw (padrão), e01 (Expert Witness) ou aff4 (AFF4 v1.0), com hash e metadados
#[arg(long, default_value = "raw")]
format: String,
/// E01: número do caso
#[arg(long, default_value = "")]
case: String,
/// E01: examinador
#[arg(long, default_value = "")]
examiner: String,
/// E01: descrição da evidência
#[arg(long, default_value = "")]
description: String,
/// E01: número da evidência
#[arg(long, default_value = "")]
evidence: String,
/// E01: observações
#[arg(long, default_value = "")]
notes: String,
/// E01: tamanho máximo de cada segmento em MiB (0 = um arquivo só)
#[arg(long, default_value_t = 1500)]
segment_mib: u64,
/// E01: compressão 0 (nenhuma) a 9
#[arg(long, default_value_t = 1)]
compression: u32,
},
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
Verify { path: String },
/// Exporta os achados de uma sessão para CSV (abre no Excel)
Export {
session: String,
/// Arquivo CSV de saída
csv: String,
#[command(flatten)]
filter: FilterArgs,
},
/// Gera o relatório HTML de recuperação (identificação do caso, origem, achados, hashes)
Report {
session: String,
/// Arquivo HTML de saída
out: String,
#[arg(long, default_value = "")]
case: String,
#[arg(long, default_value = "")]
examiner: String,
#[arg(long, default_value = "")]
org: String,
#[arg(long, default_value = "")]
notes: String,
/// Calcula MD5/SHA-1/SHA-256 da origem e inclui no relatório (pode demorar)
#[arg(long)]
hash_source: bool,
},
/// Procura texto em todo o disco, inclusive espaço livre (UTF-8 e UTF-16)
Search {
path: String,
/// Termos a procurar
#[arg(required = true)]
terms: Vec<String>,
/// Diferencia maiúsculas de minúsculas
#[arg(long)]
case_sensitive: bool,
/// Não procura em UTF-16
#[arg(long)]
no_utf16: bool,
#[arg(long, default_value_t = 200)]
max: usize,
#[arg(long)]
json: bool,
},
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
Hash { path: String },
/// Lista as sessões salvas automaticamente
Sessions,
/// Abre a interface gráfica no navegador (modo local)
Ui {
#[arg(long, default_value_t = 0)]
@@ -70,6 +154,9 @@ struct FilterArgs {
/// Só nomes/caminhos que contenham este texto
#[arg(long)]
name: Option<String>,
/// Como organizar o que for recuperado: folders (pastas originais), type, date ou flat
#[arg(long, default_value = "folders")]
organize: String,
}
#[derive(clap::Args, Clone)]
@@ -84,6 +171,120 @@ struct OutArgs {
json: bool,
}
/// Montagem do disco: volumes cifrados, RAID e LVM.
#[derive(clap::Args, Clone, Default)]
struct AsmArgs {
/// Senha de volume cifrado (BitLocker, LUKS, FileVault 2). Prefira --ask-password ou GUARA_PASSWORD
#[arg(long = "password")]
passwords: Vec<String>,
/// Pergunta a senha no terminal, sem mostrar o que é digitado
#[arg(long)]
ask_password: bool,
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos)
#[arg(long = "recovery-key")]
recovery_keys: Vec<String>,
/// Arquivo de chave (.BEK do BitLocker ou keyfile do LUKS)
#[arg(long = "key-file")]
key_files: Vec<String>,
/// Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume)
#[arg(long = "volume-key")]
volume_keys: Vec<String>,
/// Outro disco ou imagem do mesmo conjunto (RAID/LVM). Pode repetir; "-" marca um disco ausente
#[arg(long = "member")]
members: Vec<String>,
/// Os discos (alvo + --member, nesta ordem) formam um RAID sem metadados: descobre sozinho nível, faixa e layout
#[arg(long)]
raid: bool,
/// Fixa o nível do RAID: 0, 5, 6, 1
#[arg(long)]
raid_level: Option<String>,
/// Fixa o tamanho da faixa do RAID em KiB
#[arg(long)]
raid_chunk_kb: Option<u64>,
/// Fixa o layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
#[arg(long)]
raid_layout: Option<String>,
/// Ordem dos discos no RAID (índices; 0 = alvo)
#[arg(long, value_delimiter = ',')]
raid_order: Vec<usize>,
/// Guarda na sessão as chaves dos volumes abertos (a senha nunca é guardada)
#[arg(long)]
remember_keys: bool,
/// Vídeo íntegro da mesma câmera/drone para refazer o índice de MP4 sem moov (pode repetir)
#[arg(long = "reference")]
references: Vec<String>,
}
impl AsmArgs {
fn assembly(&self) -> Result<guara_core::assemble::Assembly, String> {
use guara_core::assemble::{Assembly, RaidSpec, SecretInput};
let mut secrets = vec![];
for p in &self.passwords {
secrets.push(SecretInput::Password { value: p.clone() });
}
if let Ok(p) = std::env::var("GUARA_PASSWORD") {
if !p.is_empty() {
secrets.push(SecretInput::Password { value: p });
}
}
if self.ask_password {
let p = rpassword::prompt_password("Senha do volume: ").map_err(s)?;
secrets.push(SecretInput::Password { value: p });
}
for r in &self.recovery_keys {
secrets.push(SecretInput::Recovery { value: r.clone() });
}
for k in &self.key_files {
secrets.push(SecretInput::Keyfile { path: k.clone() });
}
for k in &self.volume_keys {
secrets.push(SecretInput::Key { hex: k.clone() });
}
let raid = if self.raid
|| self.raid_level.is_some()
|| self.raid_chunk_kb.is_some()
|| self.raid_layout.is_some()
|| !self.raid_order.is_empty()
{
let level = match self.raid_level.as_deref() {
None => None,
Some("0") => Some(guara_core::raid::Level::R0),
Some("1") => Some(guara_core::raid::Level::R1),
Some("5") => Some(guara_core::raid::Level::R5),
Some("6") => Some(guara_core::raid::Level::R6),
Some(x) => {
return Err(format!(
"nível de RAID não suportado: {x} (use 0, 1, 5 ou 6)"
))
}
};
let layout = match self.raid_layout.as_deref() {
None => None,
Some("left-symmetric") => Some(guara_core::raid::Layout::LeftSymmetric),
Some("left-asymmetric") => Some(guara_core::raid::Layout::LeftAsymmetric),
Some("right-symmetric") => Some(guara_core::raid::Layout::RightSymmetric),
Some("right-asymmetric") => Some(guara_core::raid::Layout::RightAsymmetric),
Some(x) => return Err(format!("layout desconhecido: {x}")),
};
Some(RaidSpec {
level,
chunk_kb: self.raid_chunk_kb,
layout,
order: (!self.raid_order.is_empty()).then(|| self.raid_order.clone()),
})
} else {
None
};
Ok(Assembly {
raid,
members: self.members.iter().filter(|m| *m != "-").cloned().collect(),
secrets,
remember_keys: self.remember_keys,
keys: vec![],
})
}
}
#[derive(clap::Args)]
struct ScanArgs {
/// Dispositivo ou imagem
@@ -98,6 +299,8 @@ struct ScanArgs {
#[arg(long)]
recover: Option<String>,
#[command(flatten)]
asm: AsmArgs,
#[command(flatten)]
filter: FilterArgs,
#[command(flatten)]
out: OutArgs,
@@ -148,8 +351,14 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Inspect { path, json } => {
let v = eng.rpc("inspect", json!({ "path": path })).map_err(s)?;
Cmd::Inspect { path, json, asm } => {
let a = asm.assembly()?;
let v = eng
.rpc(
"inspect",
json!({ "path": path, "assembly": serde_json::to_value(&a).map_err(s)?}),
)
.map_err(s)?;
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap());
} else {
@@ -157,6 +366,15 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Health { path, json } => {
let v = eng.rpc("health", json!({ "path": path })).map_err(s)?;
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap());
} else {
print_health(&v);
}
Ok(())
}
Cmd::Scan(a) => {
let opts = ScanOptions {
mode: if a.quick {
@@ -166,6 +384,8 @@ fn run(cli: Cli) -> Result<(), String> {
},
include_allocated: a.include_allocated,
categories: parse_cats(&a.filter.category)?,
assembly: a.asm.assembly()?,
references: a.asm.references.clone(),
..Default::default()
};
let job = eng.start_scan(&a.path, opts).map_err(s)?;
@@ -186,6 +406,7 @@ fn run(cli: Cli) -> Result<(), String> {
category: vec![],
min_confidence: "low".into(),
name: None,
organize: "folders".into(),
},
None,
)
@@ -202,10 +423,92 @@ fn run(cli: Cli) -> Result<(), String> {
"a varredura dessa sessão não terminou; use `guara resume` primeiro".into(),
);
}
recover_job(&eng, &job, &dest, &filter)
recover_job(&eng, &job, &dest, &filter)?;
eng.save_session(&job, &session).map_err(s)?;
Ok(())
}
Cmd::Image { path, dest } => {
let job = eng.start_image(&path, &dest).map_err(s)?;
Cmd::Verify { path } if path.to_ascii_lowercase().ends_with(".aff4") => {
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(&path)).map_err(s)?;
println!("Formato: AFF4");
for (k, v) in &a.info {
println!("{k}: {v}");
}
eprintln!("Recalculando o hash...");
match a.verify_stream_md5().map_err(s)? {
Some(true) => {
println!("MD5 gravado e recalculado conferem.");
Ok(())
}
Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()),
None => {
println!("Esta imagem não traz MD5 para conferir.");
Ok(())
}
}
}
Cmd::Verify { path } => {
let e = guara_core::ewf::EwfSource::open(std::path::Path::new(&path)).map_err(s)?;
println!(
"Formato: {}",
if e.info.version == 2 {
"Ex01 (EnCase 7)"
} else {
"E01 (EnCase)"
}
);
println!("Segmentos: {}", e.info.segments);
for (k, v) in &e.info.fields {
println!("{k}: {v}");
}
eprintln!("Recalculando o hash...");
match e.verify().map_err(s)? {
Some(true) => {
println!("MD5 gravado e recalculado conferem: {}", e.info.md5.clone().unwrap_or_default());
Ok(())
}
Some(false) => Err("O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada.".into()),
None => {
println!("Esta imagem não traz hash gravado para conferir.");
Ok(())
}
}
}
Cmd::Image {
path,
dest,
format,
case,
examiner,
description,
evidence,
notes,
segment_mib,
compression,
} => {
let fmt = match format.as_str() {
"raw" => guara_core::image::ImageFormat::Raw,
"e01" => guara_core::image::ImageFormat::E01 {
case_number: case,
description,
evidence_number: evidence,
examiner,
notes,
segment_mib,
compression,
},
"aff4" => guara_core::image::ImageFormat::Aff4 {
case_number: case,
examiner,
evidence_number: evidence,
notes,
},
x => {
return Err(format!(
"formato de imagem desconhecido: {x} (use raw, e01 ou aff4)"
))
}
};
let job = eng.start_image_as(&path, &dest, &fmt).map_err(s)?;
let st = wait(&eng, &job, None)?;
if st["status"] == "error" {
return Err(st["error"].as_str().unwrap_or("falha").to_string());
@@ -220,6 +523,124 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Export {
session,
csv,
filter,
} => {
let job = eng.load_session(&session).map_err(s)?;
let q = query_from(&filter)?;
let r = eng
.rpc("export_csv", json!({ "job": job, "path": csv, "query": q }))
.map_err(s)?;
println!("{} linha(s) gravadas em {csv}", r["rows"]);
Ok(())
}
Cmd::Report {
session,
out,
case,
examiner,
org,
notes,
hash_source,
} => {
let job = eng.load_session(&session).map_err(s)?;
let mut hashes = Value::Null;
if hash_source {
let st = eng.status(&job).map_err(s)?;
let target = st["target"].as_str().unwrap_or("").to_string();
let hj = eng.start_hash(&target).map_err(s)?;
let hst = wait(&eng, &hj, None)?;
eprintln!();
if hst["status"] == "error" {
return Err(hst["error"].as_str().unwrap_or("falha").to_string());
}
hashes = hst["report"].clone();
}
let case = json!({ "case_id": case, "examiner": examiner, "organization": org, "notes": notes });
eng.rpc(
"export_report",
json!({ "job": job, "path": out, "case": case, "hashes": hashes }),
)
.map_err(s)?;
println!("Relatório gravado em {out}");
Ok(())
}
Cmd::Search {
path,
terms,
case_sensitive,
no_utf16,
max,
json,
} => {
let opts = json!({ "path": path, "terms": terms, "ignore_case": !case_sensitive, "utf16": !no_utf16, "max_hits": max });
let job = eng.rpc("search_start", opts).map_err(s)?["job"]
.as_str()
.unwrap_or("")
.to_string();
let st = wait(&eng, &job, None)?;
eprintln!();
if st["status"] == "error" {
return Err(st["error"].as_str().unwrap_or("falha").to_string());
}
let res = eng
.rpc("search_results", json!({ "job": job, "limit": 2000 }))
.map_err(s)?;
if json {
println!("{}", serde_json::to_string_pretty(&res).unwrap());
return Ok(());
}
for h in res["items"].as_array().into_iter().flatten() {
println!(
"{:>14} {} {:<6} {:<8} {}",
h["offset"],
h["partition"]
.as_u64()
.map(|p| format!("part.{p}"))
.unwrap_or_else(|| "fora".into()),
h["encoding"].as_str().unwrap_or(""),
h["term"].as_str().unwrap_or(""),
h["context"].as_str().unwrap_or("")
);
}
println!("{} ocorrência(s).", res["total"]);
Ok(())
}
Cmd::Hash { path } => {
let job = eng.start_hash(&path).map_err(s)?;
let st = wait(&eng, &job, None)?;
eprintln!();
if st["status"] == "error" {
return Err(st["error"].as_str().unwrap_or("falha").to_string());
}
let r = &st["report"];
println!(
"MD5 {}\nSHA-1 {}\nSHA-256 {}",
r["md5"].as_str().unwrap_or(""),
r["sha1"].as_str().unwrap_or(""),
r["sha256"].as_str().unwrap_or("")
);
Ok(())
}
Cmd::Sessions => {
let list = eng.rpc("sessions_list", json!({})).map_err(s)?;
let items = list.as_array().cloned().unwrap_or_default();
if items.is_empty() {
println!("Nenhuma sessão salva automaticamente ainda.");
}
for i in items {
println!(
"{:<10} {:>8} arquivos {:<22} {}",
i["status"].as_str().unwrap_or(""),
i["found"],
i["target"].as_str().unwrap_or(""),
i["path"].as_str().unwrap_or("")
);
}
Ok(())
}
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
}
}
@@ -246,6 +667,7 @@ fn query_from(f: &FilterArgs) -> Result<Query, String> {
sort: Some("confidence".into()),
offset: 0,
limit: 2000,
..Default::default()
})
}
@@ -290,6 +712,9 @@ fn drive_scan(
}
if let Some(dest) = recover {
recover_job(eng, job, dest, filter)?;
if let Some(p) = &out.session {
eng.save_session(job, p).map_err(s)?;
}
} else if st["found"].as_u64().unwrap_or(0) > 0 && out.session.is_some() {
eprintln!(
"Para recuperar: guara recover {} <pasta-de-destino>",
@@ -332,7 +757,13 @@ fn recover_job(
}
ids = Some(all);
}
let rj = eng.start_recover(scan_job, ids, Some(q), dest).map_err(s)?;
let layout = guara_core::recover::RecoverLayout::parse(&filter.organize).ok_or(format!(
"organização inválida: {} (use folders, type, date ou flat)",
filter.organize
))?;
let rj = eng
.start_recover(scan_job, ids, Some(q), dest, layout)
.map_err(s)?;
let st = wait(eng, &rj, None)?;
eprintln!();
if st["status"] == "error" {
@@ -391,8 +822,13 @@ fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String>
.and_then(|v| v.checked_div(t))
.map_or(" … ".to_string(), |v| format!("{v:>3}%"));
let found = st["found"].as_u64().unwrap_or(0);
let found = if st["hits"].as_u64().unwrap_or(0) > 0 {
st["hits"].as_u64().unwrap_or(0)
} else {
found
};
let line = format!(
"{pct} {} · {found} arquivo(s) encontrado(s) · {:.0}s",
"{pct} {} · {found} resultado(s) · {:.0}s",
p["phase"].as_str().unwrap_or(""),
st["elapsed"].as_f64().unwrap_or(0.0)
);
@@ -443,7 +879,7 @@ fn print_layout(v: &Value) {
fn print_summary(st: &Value) {
println!(
"Concluído em {:.1}s — {} arquivo(s) encontrado(s).",
"Concluído em {:.1}s: {} arquivo(s) encontrado(s).",
st["elapsed"].as_f64().unwrap_or(0.0),
st["found"]
);
@@ -517,3 +953,98 @@ fn paint(code: &str, s: &str) -> String {
fn s<E: std::fmt::Display>(e: E) -> String {
e.to_string()
}
fn print_health(v: &Value) {
let st = v["status"].as_str().unwrap_or("unknown");
if !v["available"].as_bool().unwrap_or(false) {
println!("{}", v["summary"].as_str().unwrap_or(""));
return;
}
let word = match st {
"ok" => paint("32", "Bom"),
"warning" => paint("33", "Atenção"),
"failing" => paint("31", "Ruim"),
_ => "?".into(),
};
let pct = v["health_pct"]
.as_u64()
.map(|p| format!(" ({p}%)"))
.unwrap_or_default();
println!("Disco: {}", v["disk"].as_str().unwrap_or(""));
if let Some(p) = v["partition"].as_str() {
println!(" (o SMART é do disco inteiro que contém {p})");
}
println!(
"Estado: {word}{pct} {}",
v["summary"].as_str().unwrap_or("")
);
let num = |k: &str| v[k].as_i64();
let bytes = |k: &str| v[k].as_u64().map(human);
let mut m = Vec::new();
if let Some(t) = num("temperature_c") {
m.push(format!("Temperatura: {t} °C"));
}
if let Some(h) = num("power_on_hours") {
m.push(format!("Horas ligado: {h}"));
}
if let Some(c) = num("power_cycles") {
m.push(format!("Vezes ligado: {c}"));
}
if let Some(r) = bytes("host_reads") {
m.push(format!("Total lido: {r}"));
}
if let Some(w) = bytes("host_writes") {
m.push(format!("Total gravado: {w}"));
}
if !m.is_empty() {
println!("{}", m.join(" · "));
}
if st != "ok" {
for kv in v["details"].as_array().into_iter().flatten() {
println!(
" ! {}: {}",
kv[0].as_str().unwrap_or(""),
kv[1].as_str().unwrap_or("")
);
}
}
println!();
for kv in v["info"].as_array().into_iter().flatten() {
println!(
" {:<24}{}",
kv[0].as_str().unwrap_or(""),
kv[1].as_str().unwrap_or("")
);
}
let attrs = v["attributes"].as_array().cloned().unwrap_or_default();
if !attrs.is_empty() {
let o = |x: &Value| {
x.as_i64()
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into())
};
println!(
"\n ID {:<34}{:>6}{:>6}{:>7} Bruto",
"Atributo", "Atual", "Pior", "Limite"
);
for a in attrs {
let dot = match a["status"].as_str() {
Some("failing") => paint("31", "●"),
Some("warning") => paint("33", "●"),
_ => paint("32", "●"),
};
println!(
" {dot} {:<3} {:<34}{:>6}{:>6}{:>7} {}",
a["id"].as_str().unwrap_or(""),
a["name"].as_str().unwrap_or(""),
o(&a["value"]),
o(&a["worst"]),
o(&a["thresh"]),
a["raw"].as_str().unwrap_or("")
);
}
}
for adv in v["advice"].as_array().into_iter().flatten() {
println!("\n{}", adv.as_str().unwrap_or(""));
}
}
+120 -62
View File
@@ -3,12 +3,20 @@
use guara_core::service::Engine;
use serde_json::json;
use std::io::Read;
use tiny_http::{Header, Method, Response, Server};
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server};
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js");
const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg");
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs");
const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs");
pub fn run(port: u16, open: bool) -> Result<(), String> {
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?;
@@ -19,78 +27,128 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
.unwrap_or(port);
let mut tok = [0u8; 16];
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
let token: String = tok.iter().map(|b| format!("{b:02x}")).collect();
let token: Arc<str> = tok
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
.into();
let url = format!("http://127.0.0.1:{real_port}/?token={token}");
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
if open {
open_browser(&url);
}
let eng = Engine::new();
let hosts = [
let eng = Arc::new(Engine::new());
let hosts: Arc<[String; 2]> = Arc::new([
format!("127.0.0.1:{real_port}"),
format!("localhost:{real_port}"),
];
for mut req in server.incoming_requests() {
let host_ok = req
.headers()
.iter()
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
if !host_ok {
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
continue;
}
let path = req.url().split('?').next().unwrap_or("/").to_string();
let ct = |v: &str| Header::from_bytes("Content-Type", v).unwrap();
match (req.method().clone(), path.as_str()) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
let _ = req.respond(
Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")),
);
}
(Method::Get, "/app.css") => {
let _ = req
.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
}
(Method::Get, "/app.js") => {
let _ = req.respond(
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/guara.svg") => {
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
}
(Method::Post, p) if p.starts_with("/rpc/") => {
let ok = req
.headers()
.iter()
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
if !ok {
let _ =
req.respond(Response::from_string("token inválido").with_status_code(401));
continue;
}
let method = p.trim_start_matches("/rpc/").to_string();
let mut body = String::new();
let _ = req.as_reader().take(1 << 20).read_to_string(&mut body);
let params = serde_json::from_str(&body).unwrap_or(json!({}));
let (code, out) = match eng.rpc(&method, params) {
Ok(v) => (200, v),
Err(e) => (400, json!({ "error": e.to_string() })),
};
let _ = req.respond(
Response::from_string(out.to_string())
.with_status_code(code)
.with_header(ct("application/json")),
);
}
_ => {
let _ = req.respond(Response::from_string("não encontrado").with_status_code(404));
}
}
]);
for req in server.incoming_requests() {
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
}
Ok(())
}
fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
}
fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
let host_ok = req
.headers()
.iter()
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
if !host_ok {
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
return;
}
let Some(mut req) = guara_core::http::handle_media(eng, token, req) else {
return;
};
let full = req.url().to_string();
let (path, _query) = full.split_once('?').unwrap_or((full.as_str(), ""));
let ct = |v: &str| header("Content-Type", v);
match (req.method().clone(), path) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
let _ = req
.respond(Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")));
}
(Method::Get, "/app.css") => {
let _ =
req.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
}
(Method::Get, "/app.js") => {
let _ = req.respond(
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/preview.js") => {
let _ = req.respond(
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/i18n.js") => {
let _ = req.respond(
Response::from_string(I18N_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/i18n-en.js") => {
let _ = req.respond(
Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/guara-logo.svg") => {
let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml")));
}
(Method::Get, "/explorer.js") => {
let _ = req.respond(
Response::from_string(EXPLORER_JS)
.with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/guara.svg") => {
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
}
(Method::Get, "/vendor/pdf.min.mjs") => {
let _ = req.respond(
Response::from_string(PDF_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/vendor/pdf.worker.min.mjs") => {
let _ = req.respond(
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Post, p) if p.starts_with("/rpc/") => {
let ok = req
.headers()
.iter()
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
if !ok {
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
return;
}
let method = p.trim_start_matches("/rpc/").to_string();
let mut body = String::new();
let _ = req.as_reader().take(1 << 20).read_to_string(&mut body);
let params = serde_json::from_str(&body).unwrap_or(json!({}));
let (code, out) = match eng.rpc(&method, params) {
Ok(v) => (200, v),
Err(e) => (400, json!({ "error": e.to_string() })),
};
let _ = req.respond(
Response::from_string(out.to_string())
.with_status_code(code)
.with_header(ct("application/json")),
);
}
_ => {
let _ = req.respond(Response::from_string("não encontrado").with_status_code(404));
}
}
}
fn open_browser(url: &str) {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
+18
View File
@@ -11,7 +11,24 @@ rust-version.workspace = true
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sha1 = "0.10"
md-5 = "0.10"
jpeg-decoder = { version = "0.3", default-features = false }
jpeg-encoder = "0.6"
image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
thiserror = "2"
tiny_http = "0.12"
bzip2 = "0.6"
zip = { version = "2", default-features = false, features = ["deflate"] }
snap = "1"
ruzstd = "0.7"
lz4_flex = { version = "0.11", default-features = false, features = ["safe-decode", "safe-encode"] }
aes = "0.8"
hmac = "0.12"
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
ripemd = "0.1"
argon2 = "0.5"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -21,3 +38,4 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage
[dev-dependencies]
tempfile = "3"
mp4 = "0.14"
+846
View File
@@ -0,0 +1,846 @@
//! AFF4 (Advanced Forensic Format 4, padrão v1.0): contêiner ZIP com metadados em Turtle.
//!
//! Leitura de imagens lineares e mapeadas (inclusive esparsas), com blocos comprimidos por
//! snappy, deflate, LZ4 ou sem compressão. Gravação de uma imagem linear com blocos snappy.
//! Conjuntos em vários volumes (spanned/striped) não são suportados.
use crate::error::{Error, Result};
use crate::hashing::MultiHasher;
use crate::image::ImageReport;
use crate::source::{read_exact_at, Source};
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, Read, Write};
use std::path::Path;
use std::sync::Mutex;
pub fn is_aff4(head: &[u8]) -> bool {
// ZIP local file header + primeiro nome "container.description" ou "version.txt".
head.len() > 50
&& head.starts_with(b"PK\x03\x04")
&& (find(&head[30..head.len().min(80)], b"container.description")
|| find(&head[30..head.len().min(80)], b"version.txt"))
}
fn find(h: &[u8], n: &[u8]) -> bool {
h.windows(n.len()).any(|w| w == n)
}
const NS: &str = "http://aff4.org/Schema#";
// ------------------------------------------------------------------ Turtle
#[derive(Debug, Default, Clone)]
struct Resource {
types: Vec<String>,
props: HashMap<String, Vec<String>>,
}
impl Resource {
fn get(&self, k: &str) -> Option<&str> {
self.props
.get(k)
.and_then(|v| v.first())
.map(|s| s.as_str())
}
fn num(&self, k: &str) -> Option<u64> {
self.get(k)?.parse().ok()
}
fn is(&self, t: &str) -> bool {
self.types.iter().any(|x| x == t)
}
}
struct Turtle<'a> {
b: &'a [u8],
i: usize,
prefixes: HashMap<String, String>,
}
impl Turtle<'_> {
fn ws(&mut self) {
loop {
while self.i < self.b.len() && self.b[self.i].is_ascii_whitespace() {
self.i += 1;
}
if self.i < self.b.len() && self.b[self.i] == b'#' {
while self.i < self.b.len() && self.b[self.i] != b'\n' {
self.i += 1;
}
} else {
break;
}
}
}
fn expand(&self, tok: &str) -> String {
if let Some((p, l)) = tok.split_once(':') {
if let Some(base) = self.prefixes.get(p) {
return format!("{base}{l}");
}
}
tok.to_string()
}
/// Um termo: <iri>, "literal"[^^tipo], nome:prefixado ou `a`.
fn term(&mut self) -> Option<String> {
self.ws();
let c = *self.b.get(self.i)?;
match c {
b'<' => {
let s = self.i + 1;
while self.i < self.b.len() && self.b[self.i] != b'>' {
self.i += 1;
}
let v = String::from_utf8_lossy(&self.b[s..self.i]).to_string();
self.i += 1;
Some(v)
}
b'"' => {
self.i += 1;
let mut out = Vec::new();
while self.i < self.b.len() && self.b[self.i] != b'"' {
if self.b[self.i] == b'\\' && self.i + 1 < self.b.len() {
self.i += 1;
}
out.push(self.b[self.i]);
self.i += 1;
}
self.i += 1;
if self.b[self.i..].starts_with(b"^^") {
self.i += 2;
let _ = self.term();
} else if self.b.get(self.i) == Some(&b'@') {
while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() {
self.i += 1;
}
}
Some(String::from_utf8_lossy(&out).to_string())
}
_ => {
let s = self.i;
while self.i < self.b.len()
&& !self.b[self.i].is_ascii_whitespace()
&& !matches!(self.b[self.i], b';' | b',')
{
self.i += 1;
}
// ponto final de sentença colado ao termo
let mut e = self.i;
if e > s && self.b[e - 1] == b'.' {
e -= 1;
self.i = e;
}
let tok = String::from_utf8_lossy(&self.b[s..e]).to_string();
Some(if tok == "a" { tok } else { self.expand(&tok) })
}
}
}
}
fn parse_turtle(text: &str) -> HashMap<String, Resource> {
let mut t = Turtle {
b: text.as_bytes(),
i: 0,
prefixes: HashMap::new(),
};
let mut out: HashMap<String, Resource> = HashMap::new();
loop {
t.ws();
if t.i >= t.b.len() {
break;
}
if t.b[t.i..].starts_with(b"@prefix") {
t.i += 7;
t.ws();
let s = t.i;
while t.i < t.b.len() && t.b[t.i] != b':' {
t.i += 1;
}
let name = String::from_utf8_lossy(&t.b[s..t.i]).trim().to_string();
t.i += 1;
let iri = t.term().unwrap_or_default();
t.ws();
if t.b.get(t.i) == Some(&b'.') {
t.i += 1;
}
t.prefixes.insert(name, iri);
continue;
}
// sujeito
let Some(subj) =
(if t.b[t.i] == b':' && t.b.get(t.i + 1).map_or(true, |c| c.is_ascii_whitespace()) {
t.i += 1;
Some(t.prefixes.get("").cloned().unwrap_or_default())
} else {
t.term()
})
else {
break;
};
let res = out.entry(subj).or_default();
loop {
let Some(pred) = t.term() else { return out };
let mut objs = vec![];
loop {
let Some(o) = t.term() else { return out };
objs.push(o);
t.ws();
if t.b.get(t.i) == Some(&b',') {
t.i += 1;
continue;
}
break;
}
if pred == "a" {
res.types.extend(objs);
} else {
res.props.entry(pred).or_default().extend(objs);
}
t.ws();
match t.b.get(t.i) {
Some(b';') => {
t.i += 1;
t.ws();
// ";" final antes de "."
if t.b.get(t.i) == Some(&b'.') {
t.i += 1;
break;
}
}
Some(b'.') => {
t.i += 1;
break;
}
_ => break,
}
}
}
out
}
// ------------------------------------------------------------------ leitor
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Method {
Stored,
Deflate,
Zlib,
Snappy,
Lz4,
}
#[derive(Clone)]
struct ImageStream {
urn: String,
chunk: u64,
per_bevy: u64,
method: Method,
size: u64,
}
#[derive(Clone, Copy)]
struct MapEntry {
start: u64,
len: u64,
target_off: u64,
target: u32,
}
#[derive(Clone)]
enum Target {
Stream(usize),
Zero,
Fill(u8),
}
#[derive(Clone, Copy)]
struct Loc {
data_start: u64,
size: u64,
}
pub struct Aff4Source {
file: File,
entries: HashMap<String, Loc>,
streams: Vec<ImageStream>,
targets: Vec<Target>,
map: Vec<MapEntry>,
size: u64,
/// Metadados legíveis: caso, examinador, notas, hashes.
pub info: HashMap<String, String>,
cache: Mutex<Option<(usize, u64, Vec<u8>)>>,
index_cache: Mutex<HashMap<(usize, u64), std::sync::Arc<Vec<u8>>>>,
}
fn enc(urn: &str) -> String {
urn.replace(':', "%3A").replace('/', "%2F")
}
fn method_of(uri: &str) -> Method {
let u = uri.to_ascii_lowercase();
if u.contains("snappy") {
Method::Snappy
} else if u.contains("lz4") {
Method::Lz4
} else if u.contains("rfc1951") {
Method::Deflate
} else if u.contains("rfc1950") {
Method::Zlib
} else {
Method::Stored
}
}
impl Aff4Source {
pub fn open(path: &Path) -> Result<Self> {
let file = File::open(path)?;
let mut zip = zip::ZipArchive::new(file.try_clone()?)
.map_err(|e| Error::msg(format!("AFF4 inválido (ZIP): {e}")))?;
let mut entries = HashMap::new();
for i in 0..zip.len() {
let f = zip.by_index_raw(i).map_err(|e| Error::msg(e.to_string()))?;
entries.insert(
f.name().to_string(),
Loc {
data_start: f.data_start(),
size: f.compressed_size(),
},
);
}
let turtle = {
let mut f = zip
.by_name("information.turtle")
.map_err(|_| Error::msg("AFF4 sem information.turtle"))?;
let mut s = String::new();
f.read_to_string(&mut s)?;
s
};
let res = parse_turtle(&turtle);
let img_t = format!("{NS}Image");
let disk_t = format!("{NS}DiskImage");
let ds = format!("{NS}dataStream");
let image = res
.values()
.find(|r| (r.is(&img_t) || r.is(&disk_t)) && r.get(&ds).is_some())
.ok_or_else(|| {
Error::msg("AFF4 sem objeto de imagem (aff4:Image com aff4:dataStream)")
})?;
let mut info = HashMap::new();
if let Some(v) = image.get(&format!("{NS}diskModel")) {
info.insert("Modelo do disco".to_string(), v.to_string());
}
for (k, label) in [
("caseNumber", "Número do caso"),
("examiner", "Examinador"),
("notes", "Notas"),
("evidenceNumber", "Número da evidência"),
("caseName", "Nome do caso"),
] {
if let Some(v) = res.values().find_map(|r| r.get(&format!("{NS}{k}"))) {
info.insert(label.to_string(), v.to_string());
}
}
let data_urn = image.get(&ds).unwrap().to_string();
// Hashes gravados no fluxo de dados (ou no mapa).
for r in res
.values()
.filter(|r| r.get(&format!("{NS}hash")).is_some())
{
for h in r.props.get(&format!("{NS}hash")).into_iter().flatten() {
match h.len() {
32 if h.chars().all(|c| c.is_ascii_hexdigit()) => {
info.entry("md5".to_string()).or_insert_with(|| h.clone());
}
40 if h.chars().all(|c| c.is_ascii_hexdigit()) => {
info.entry("sha1".to_string()).or_insert_with(|| h.clone());
}
_ => {}
}
}
}
let mut me = Self {
file,
entries,
streams: vec![],
targets: vec![],
map: vec![],
size: 0,
info,
cache: Mutex::new(None),
index_cache: Mutex::new(HashMap::new()),
};
let top = res
.get(&data_urn)
.ok_or_else(|| Error::msg("AFF4: fluxo de dados da imagem não encontrado"))?;
let map_t = format!("{NS}Map");
if top.is(&map_t) {
me.size = top.num(&format!("{NS}size")).unwrap_or(0);
let mp = enc(&data_urn);
let idx_txt = me.read_entry(&format!("{mp}/idx"))?;
for line in String::from_utf8_lossy(&idx_txt).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(sym) = line.strip_prefix(&format!("{NS}SymbolicStream")) {
let b = u8::from_str_radix(sym, 16)
.map_err(|_| Error::msg("AFF4: fluxo simbólico inválido"))?;
me.targets.push(Target::Fill(b));
} else if line == format!("{NS}Zero") {
me.targets.push(Target::Zero);
} else if line.starts_with(NS) {
me.targets.push(Target::Zero);
} else {
let s = me.load_stream(&res, line)?;
me.streams.push(s);
me.targets.push(Target::Stream(me.streams.len() - 1));
}
}
let m = me.read_entry(&format!("{mp}/map"))?;
for c in m.chunks_exact(28) {
me.map.push(MapEntry {
start: u64::from_le_bytes(c[0..8].try_into().unwrap()),
len: u64::from_le_bytes(c[8..16].try_into().unwrap()),
target_off: u64::from_le_bytes(c[16..24].try_into().unwrap()),
target: u32::from_le_bytes(c[24..28].try_into().unwrap()),
});
}
me.map.sort_by_key(|e| e.start);
} else {
let s = me.load_stream(&res, &data_urn)?;
me.size = s.size;
me.streams.push(s);
me.targets.push(Target::Stream(0));
me.map = vec![MapEntry {
start: 0,
len: me.size,
target_off: 0,
target: 0,
}];
}
if me.size == 0 {
me.size = image.num(&format!("{NS}size")).unwrap_or(0);
}
Ok(me)
}
fn load_stream(&self, res: &HashMap<String, Resource>, urn: &str) -> Result<ImageStream> {
let r = res
.get(urn)
.ok_or_else(|| Error::msg(format!("AFF4: fluxo {urn} não descrito")))?;
Ok(ImageStream {
urn: urn.to_string(),
chunk: r.num(&format!("{NS}chunkSize")).unwrap_or(32768),
per_bevy: r.num(&format!("{NS}chunksInSegment")).unwrap_or(2048),
method: r
.get(&format!("{NS}compressionMethod"))
.map(method_of)
.unwrap_or(Method::Stored),
size: r.num(&format!("{NS}size")).unwrap_or(0),
})
}
/// MD5 do primeiro fluxo de blocos (o que o AFF4 grava em `aff4:hash`), para conferir a integridade.
pub fn verify_stream_md5(&self) -> Result<Option<bool>> {
let Some(want) = self.info.get("md5").cloned() else {
return Ok(None);
};
let Some(st) = self.streams.first() else {
return Ok(None);
};
let mut h = MultiHasher::new();
let mut buf = vec![0u8; st.chunk as usize];
let mut pos = 0u64;
while pos < st.size {
let n = (st.size - pos).min(st.chunk) as usize;
self.read_stream(0, pos, &mut buf[..n])?;
h.update(&buf[..n]);
pos += n as u64;
}
Ok(Some(h.finalize().md5 == want))
}
fn read_entry(&self, name: &str) -> Result<Vec<u8>> {
let loc = self
.entries
.get(name)
.ok_or_else(|| Error::msg(format!("AFF4: entrada {name} ausente")))?;
let mut v = vec![0u8; loc.size as usize];
read_exact_at_file(&self.file, loc.data_start, &mut v)?;
Ok(v)
}
fn chunk(&self, si: usize, chunk_no: u64) -> io::Result<Vec<u8>> {
{
let c = self.cache.lock().unwrap();
if let Some((s, n, d)) = c.as_ref() {
if *s == si && *n == chunk_no {
return Ok(d.clone());
}
}
}
let st = &self.streams[si];
let (bevy, within) = (chunk_no / st.per_bevy, chunk_no % st.per_bevy);
let base = format!("{}/{:08}", enc(&st.urn), bevy);
let index = {
let mut ic = self.index_cache.lock().unwrap();
if let Some(i) = ic.get(&(si, bevy)) {
i.clone()
} else {
let loc = self.entries.get(&format!("{base}.index")).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "índice do bevy ausente")
})?;
let mut v = vec![0u8; loc.size as usize];
read_exact_at_file(&self.file, loc.data_start, &mut v)?;
let a = std::sync::Arc::new(v);
ic.insert((si, bevy), a.clone());
a
}
};
let e = index
.get(within as usize * 12..within as usize * 12 + 12)
.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "bloco fora do índice"))?;
let (off, len) = (
u64::from_le_bytes(e[0..8].try_into().unwrap()),
u32::from_le_bytes(e[8..12].try_into().unwrap()) as usize,
);
let loc = self
.entries
.get(&base)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "bevy ausente"))?;
let mut raw = vec![0u8; len];
read_exact_at_file(&self.file, loc.data_start + off, &mut raw)?;
let want = st.chunk.min(st.size.saturating_sub(chunk_no * st.chunk)) as usize;
let data = if len == st.chunk as usize || st.method == Method::Stored {
raw
} else {
let bad = |m: String| io::Error::new(io::ErrorKind::InvalidData, m);
match st.method {
Method::Snappy => snap::raw::Decoder::new()
.decompress_vec(&raw)
.map_err(|e| bad(format!("snappy: {e}")))?,
Method::Lz4 => lz4_flex::block::decompress(&raw, st.chunk as usize)
.map_err(|e| bad(format!("lz4: {e}")))?,
Method::Deflate => {
let mut v = Vec::with_capacity(st.chunk as usize);
flate2::read::DeflateDecoder::new(&raw[..])
.read_to_end(&mut v)
.map_err(|e| bad(format!("deflate: {e}")))?;
v
}
Method::Zlib => {
let mut v = Vec::with_capacity(st.chunk as usize);
flate2::read::ZlibDecoder::new(&raw[..])
.read_to_end(&mut v)
.map_err(|e| bad(format!("zlib: {e}")))?;
v
}
Method::Stored => unreachable!(),
}
};
let mut data = data;
data.resize(want.max(data.len().min(st.chunk as usize)), 0);
*self.cache.lock().unwrap() = Some((si, chunk_no, data.clone()));
Ok(data)
}
fn read_stream(&self, si: usize, off: u64, buf: &mut [u8]) -> io::Result<()> {
let cs = self.streams[si].chunk;
let mut done = 0;
while done < buf.len() {
let pos = off + done as u64;
let (cn, within) = (pos / cs, (pos % cs) as usize);
let d = self.chunk(si, cn)?;
if within >= d.len() {
buf[done..].fill(0);
return Ok(());
}
let n = (d.len() - within).min(buf.len() - done);
buf[done..done + n].copy_from_slice(&d[within..within + n]);
done += n;
}
Ok(())
}
}
fn read_exact_at_file(f: &File, off: u64, buf: &mut [u8]) -> io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::FileExt::read_exact_at(f, buf, off)
}
#[cfg(windows)]
{
let mut done = 0;
while done < buf.len() {
let n =
std::os::windows::fs::FileExt::seek_read(f, &mut buf[done..], off + done as u64)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"AFF4 truncado",
));
}
done += n;
}
Ok(())
}
}
impl Source for Aff4Source {
fn size(&self) -> u64 {
self.size
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.size || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((self.size - offset) as usize);
let mut done = 0usize;
while done < want {
let pos = offset + done as u64;
let i = self.map.partition_point(|e| e.start + e.len <= pos);
let Some(e) = self.map.get(i).filter(|e| e.start <= pos) else {
// vão do mapa: zeros até a próxima entrada
let next = self.map.get(i).map(|e| e.start).unwrap_or(self.size);
let n = ((next - pos) as usize).min(want - done);
buf[done..done + n].fill(0);
done += n;
continue;
};
let n = ((e.start + e.len - pos) as usize).min(want - done);
let toff = e.target_off + (pos - e.start);
match self.targets.get(e.target as usize) {
Some(Target::Stream(si)) => {
self.read_stream(*si, toff, &mut buf[done..done + n])?
}
Some(Target::Fill(b)) => buf[done..done + n].fill(*b),
_ => buf[done..done + n].fill(0),
}
done += n;
}
Ok(done)
}
}
// ------------------------------------------------------------------ gravação
#[derive(Debug, Clone)]
pub struct Aff4Options {
pub case_name: String,
pub examiner: String,
pub notes: String,
pub evidence_number: String,
pub chunk_size: u32,
}
impl Default for Aff4Options {
fn default() -> Self {
Self {
case_name: String::new(),
examiner: String::new(),
notes: String::new(),
evidence_number: String::new(),
chunk_size: 32768,
}
}
}
fn new_uuid() -> String {
let mut b = [0u8; 16];
let _ = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut b));
if b == [0u8; 16] {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(1);
b = (t ^ (std::process::id() as u128) << 64).to_le_bytes();
}
b[6] = (b[6] & 0x0F) | 0x40;
b[8] = (b[8] & 0x3F) | 0x80;
let h: Vec<String> = b.iter().map(|x| format!("{x:02x}")).collect();
format!(
"{}-{}-{}-{}-{}",
h[0..4].concat(),
h[4..6].concat(),
h[6..8].concat(),
h[8..10].concat(),
h[10..16].concat()
)
}
fn iso_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
fn esc(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
/// Grava uma imagem AFF4 linear (um fluxo de blocos snappy) de `src`.
pub fn write_aff4(
src: &dyn Source,
dest: &Path,
opts: &Aff4Options,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(u64, u64),
) -> Result<ImageReport> {
let out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dest)
.map_err(|e| {
Error::msg(format!(
"não foi possível criar {}: {e} (o arquivo não pode existir)",
dest.display()
))
})?;
crate::owner::give_to_user(dest);
let total = src.size();
let chunk = opts.chunk_size as u64;
let per_bevy = 2048u64;
let volume = format!("aff4://{}", new_uuid());
let stream = format!("aff4://{}", new_uuid());
let image = format!("aff4://{}", new_uuid());
let mut zip = zip::ZipWriter::new(out);
let stored = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.large_file(true);
let result = (|| -> Result<ImageReport> {
zip.start_file("container.description", stored)
.map_err(zerr)?;
zip.write_all(volume.as_bytes())?;
zip.start_file("version.txt", stored).map_err(zerr)?;
zip.write_all(format!("major=1\nminor=2\ntool=Guara {}\n", crate::VERSION).as_bytes())?;
let mut rep = ImageReport::default();
let mut hasher = MultiHasher::new();
let mut buf = vec![0u8; chunk as usize];
let mut pos = 0u64;
let mut chunk_no = 0u64;
let mut enc_snap = snap::raw::Encoder::new();
while pos < total {
let bevy = chunk_no / per_bevy;
let name = format!("{}/{:08}", enc(&stream), bevy);
zip.start_file(name.as_str(), stored).map_err(zerr)?;
let mut index: Vec<u8> = vec![];
let mut boff = 0u64;
for _ in 0..per_bevy {
if pos >= total {
break;
}
if cancelled() {
rep.cancelled = true;
break;
}
let want = (total - pos).min(chunk) as usize;
let b = &mut buf[..want];
if read_exact_at(src, pos, b).is_err() {
let mut o = 0;
while o < want {
let n = 512.min(want - o);
if read_exact_at(src, pos + o as u64, &mut b[o..o + n]).is_err() {
b[o..o + n].fill(0);
match rep.bad_ranges.last_mut() {
Some(l) if l.0 + l.1 == pos + o as u64 => l.1 += n as u64,
_ => rep.bad_ranges.push((pos + o as u64, n as u64)),
}
}
o += n;
}
}
hasher.update(b);
// bloco curto (último) é guardado como está; comprimido só se ficar menor
let comp = enc_snap
.compress_vec(b)
.map_err(|e| Error::msg(e.to_string()))?;
// Só um bloco cheio pode ficar sem compressão (comprimento == tamanho do bloco).
let data: &[u8] = if want as u64 == chunk && comp.len() >= want {
b
} else {
&comp
};
zip.write_all(data)?;
index.extend_from_slice(&boff.to_le_bytes());
index.extend_from_slice(&(data.len() as u32).to_le_bytes());
boff += data.len() as u64;
pos += want as u64;
chunk_no += 1;
progress(pos, total);
}
zip.start_file(format!("{name}.index").as_str(), stored)
.map_err(zerr)?;
zip.write_all(&index)?;
if rep.cancelled {
break;
}
}
let h = hasher.finalize();
rep.bytes = pos;
rep.md5 = h.md5.clone();
rep.sha1 = h.sha1.clone();
rep.sha256 = h.sha256.clone();
let now = iso_now();
let sectors = total.div_ceil(512);
let ttl = format!(
"@prefix aff4: <http://aff4.org/Schema#> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n\n\
<{stream}>\n a aff4:ImageStream ;\n aff4:chunkSize \"{chunk}\"^^xsd:int ;\n aff4:chunksInSegment \"{per_bevy}\"^^xsd:int ;\n aff4:compressionMethod <http://code.google.com/p/snappy/> ;\n aff4:hash \"{sha1}\"^^aff4:SHA1 , \"{md5}\"^^aff4:MD5 ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:version \"1\"^^xsd:int .\n\n\
<{image}>\n a aff4:ContiguousImage , aff4:DiskImage , aff4:Image ;\n aff4:blockSize \"512\"^^xsd:int ;\n aff4:dataStream <{stream}> ;\n aff4:sectorCount \"{sectors}\"^^xsd:long ;\n aff4:size \"{total}\"^^xsd:long ;\n aff4:stored <{volume}> .\n\n\
<aff4://{notes_id}>\n a aff4:CaseNotes ;\n aff4:caseNumber \"{case}\" ;\n aff4:evidenceNumber \"{ev}\" ;\n aff4:examiner \"{ex}\" ;\n aff4:notes \"{notes}\" ;\n aff4:stored <{volume}> ;\n aff4:target <{image}> ;\n aff4:timestamp \"{now}\"^^xsd:dateTime .\n\n\
<{volume}>\n a aff4:ZipVolume ;\n aff4:contains <{stream}> , <{image}> ;\n aff4:creationTime \"{now}\"^^xsd:dateTime ;\n aff4:interface aff4:Volume ;\n aff4:stored \"{fname}\" .\n",
sha1 = h.sha1,
md5 = h.md5,
notes_id = new_uuid(),
case = esc(&opts.case_name),
ev = esc(&opts.evidence_number),
ex = esc(&opts.examiner),
notes = esc(&opts.notes),
fname = esc(&dest.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()),
);
zip.start_file("information.turtle", stored).map_err(zerr)?;
zip.write_all(ttl.as_bytes())?;
Ok(rep)
})();
match result {
Ok(rep) => {
zip.finish().map_err(zerr)?;
crate::owner::write_owned(
&dest.with_extension("map.json"),
serde_json::to_vec_pretty(&rep)?,
)?;
Ok(rep)
}
Err(e) => {
drop(zip);
let _ = std::fs::remove_file(dest);
Err(e)
}
}
}
fn zerr(e: zip::result::ZipError) -> Error {
Error::msg(format!("AFF4/ZIP: {e}"))
}
+541
View File
@@ -0,0 +1,541 @@
//! Monta o disco virtual a partir do que o usuário informou: membros extras (RAID/LVM) e
//! senhas ou chaves para volumes cifrados. É a única porta de entrada para varrer e recuperar
//! discos que não são "simples".
use crate::crypt::{self, Secret, UnlockError};
use crate::error::{Error, Result};
use crate::partition;
use crate::source::{open_path, Source, SubSource};
use crate::types::{FsKind, PartInfo};
use crate::volume::{Overlay, Region};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SecretInput {
Password {
value: String,
},
Recovery {
value: String,
},
/// Caminho de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
Keyfile {
path: String,
},
/// Chave do volume em hexadecimal.
Key {
hex: String,
},
}
/// Chave de volume guardada na sessão (só quando o usuário pede para lembrar).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SavedKey {
pub start: u64,
pub kind: String,
pub key_hex: String,
}
/// RAID sem metadados: o que o usuário sabe; o resto é descoberto.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RaidSpec {
pub level: Option<crate::raid::Level>,
/// Tamanho da faixa em KiB.
pub chunk_kb: Option<u64>,
pub layout: Option<crate::raid::Layout>,
/// Ordem dos discos: índice 0 é o alvo principal, depois `members` na ordem dada.
pub order: Option<Vec<usize>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Assembly {
/// Trata o alvo e `members` como discos de um RAID sem metadados (parâmetros descobertos).
#[serde(default)]
pub raid: Option<RaidSpec>,
/// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal.
#[serde(default)]
pub members: Vec<String>,
/// Senhas e chaves: o `snapshot` da sessão as descarta antes de gravar.
#[serde(default)]
pub secrets: Vec<SecretInput>,
/// Guarda as chaves de volume derivadas na sessão, para reabrir sem pedir a senha de novo.
#[serde(default)]
pub remember_keys: bool,
#[serde(default)]
pub keys: Vec<SavedKey>,
}
impl Assembly {
pub fn is_plain(&self) -> bool {
self.members.is_empty()
&& self.secrets.is_empty()
&& self.keys.is_empty()
&& self.raid.is_none()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct UnlockInfo {
pub start: u64,
pub len: u64,
pub kind: String,
pub description: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct LockedInfo {
pub start: u64,
pub kind: String,
pub reason: String,
}
pub struct Assembled {
pub source: Arc<dyn Source>,
pub unlocked: Vec<UnlockInfo>,
pub locked: Vec<LockedInfo>,
/// Chaves derivadas, para guardar na sessão se o usuário permitir.
pub keys: Vec<SavedKey>,
/// Volumes lógicos / partições extras que não estão na tabela do disco.
pub extra_parts: Vec<PartInfo>,
/// Partições reais que são só contêineres (membros de RAID/LVM já remontados).
pub covered: Vec<u64>,
/// O disco principal é só um membro de RAID: não varrer o disco cru.
pub skip_primary: bool,
pub notes: Vec<String>,
}
fn to_secrets(a: &Assembly) -> Result<Vec<Secret>> {
let mut v = vec![];
for s in &a.secrets {
v.push(match s {
SecretInput::Password { value } => Secret::Password(value.clone()),
SecretInput::Recovery { value } => Secret::RecoveryKey(value.clone()),
SecretInput::Keyfile { path } => Secret::KeyFile(std::fs::read(path).map_err(|e| {
Error::msg(format!(
"não foi possível ler o arquivo de chave {path}: {e}"
))
})?),
SecretInput::Key { hex } => Secret::VolumeKey(
crypt::from_hex(hex)
.ok_or_else(|| Error::msg("chave do volume em hexadecimal inválida"))?,
),
});
}
Ok(v)
}
fn kind_name(fs: FsKind) -> &'static str {
match fs {
FsKind::Bitlocker => "bitlocker",
FsKind::Luks => "luks",
FsKind::Corestorage => "filevault",
_ => "?",
}
}
fn try_unlock(window: Arc<dyn Source>, fs: FsKind, secrets: &[Secret]) -> crypt::UnlockResult {
match fs {
FsKind::Bitlocker => crypt::bitlocker::unlock(window, secrets),
FsKind::Luks => crypt::luks::unlock(window, secrets),
FsKind::Corestorage => crypt::filevault::unlock(window, secrets),
_ => Err(UnlockError::Unsupported("tipo".into())),
}
}
/// Abre `path` e aplica a montagem pedida.
pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
let base = open_path(Path::new(path)).map_err(|e| Error::msg(format!("{path}: {e}")))?;
assemble_source(base, asm)
}
const MIB: u64 = 1 << 20;
/// Coloca `src` em `start`; se cair dentro de uma região virtual, a substitui por dentro.
fn patch(regions: &mut Vec<Region>, start: u64, src: Arc<dyn Source>, label: String) {
let len = src.size();
if let Some(r) = regions
.iter_mut()
.find(|r| r.start <= start && start < r.start + r.len)
{
let inner = Region {
start: start - r.start,
len,
src,
label: label.clone(),
};
let wrapped = Overlay::new(r.src.clone(), vec![inner]);
if start == r.start {
r.len = r.len.max(len);
}
r.src = Arc::new(wrapped);
return;
}
regions.push(Region {
start,
len,
src,
label,
});
}
/// Candidato a membro de RAID/LVM: uma janela de disco (inteiro ou partição).
struct Window {
src: Arc<dyn Source>,
/// Início no espaço do disco virtual, se a janela faz parte dele.
primary_start: Option<u64>,
}
fn windows_of(src: &Arc<dyn Source>, primary: bool) -> Vec<Window> {
let mut v = vec![Window {
src: src.clone(),
primary_start: primary.then_some(0),
}];
let layout = partition::analyze(src.as_ref(), false);
for p in &layout.partitions {
let len = if p.len == 0 {
src.size().saturating_sub(p.start)
} else {
p.len
};
v.push(Window {
src: Arc::new(SubSource::new(src.clone(), p.start, len)),
primary_start: primary.then_some(p.start),
});
}
v
}
pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assembled> {
let secrets = to_secrets(asm)?;
let mut regions: Vec<Region> = vec![];
let mut out = Assembled {
source: base.clone(),
unlocked: vec![],
locked: vec![],
keys: vec![],
extra_parts: vec![],
covered: vec![],
skip_primary: false,
notes: vec![],
};
let mut members: Vec<Arc<dyn Source>> = vec![];
for m in &asm.members {
members.push(open_path(Path::new(m)).map_err(|e| Error::msg(format!("{m}: {e}")))?);
}
let mut next_virtual = base.size().div_ceil(MIB) * MIB + MIB;
if let Some(spec) = &asm.raid {
let mut disks: Vec<Arc<dyn Source>> = vec![base.clone()];
disks.extend(members.iter().cloned());
let params = pick_raid(&disks, spec, &mut out.notes)?;
let src: Arc<dyn Source> =
Arc::new(crate::raid::RaidSource::new(params).map_err(Error::msg)?);
out.notes.push(format!(
"RAID montado a partir de {} disco(s) sem metadados.",
disks.len()
));
out.skip_primary = true;
members.clear();
add_virtual(
&mut regions,
&mut next_virtual,
&mut out,
src,
"RAID".into(),
);
}
let mut done_containers: std::collections::BTreeSet<String> = Default::default();
let mut tried_pool = false;
// Rodadas: o que foi aberto/montado pode conter outro volume cifrado, RAID ou LVM.
for round in 0..4 {
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false);
let mut all_parts: Vec<PartInfo> = layout.partitions.clone();
for p in &out.extra_parts {
if !all_parts.iter().any(|q| q.start == p.start) {
all_parts.push(p.clone());
}
}
let mut progress = false;
// 1) RAID e LVM: reúne janelas do disco principal e dos membros extras.
if round == 0 || !tried_pool || !out.extra_parts.is_empty() {
let mut pool: Vec<Window> = vec![];
pool.push(Window {
src: current.clone(),
primary_start: Some(0),
});
for p in &all_parts {
if matches!(p.fs, FsKind::Raid | FsKind::Lvm) {
let len = if p.len == 0 {
current.size().saturating_sub(p.start)
} else {
p.len
};
pool.push(Window {
src: Arc::new(SubSource::new(current.clone(), p.start, len)),
primary_start: Some(p.start),
});
}
}
for m in &members {
pool.extend(windows_of(m, false));
}
tried_pool = true;
// RAID
let mut md: Vec<(Arc<dyn Source>, crate::raid::MdSuper)> = vec![];
let mut md_primary: Vec<u64> = vec![];
for w in &pool {
if let Some(sup) = crate::raid::read_md_super(w.src.as_ref()) {
if let Some(st) = w.primary_start {
md_primary.push(st);
}
md.push((w.src.clone(), sup));
}
}
let new_md: Vec<_> = md
.into_iter()
.filter(|(_, s)| {
!done_containers.contains(&format!("md{}", crypt::to_hex(&s.uuid)))
})
.collect();
if !new_md.is_empty() {
for (_, s) in &new_md {
done_containers.insert(format!("md{}", crypt::to_hex(&s.uuid)));
}
for res in crate::raid::assemble_md(new_md) {
match res {
Ok(a) => {
let label = format!(
"{} {}",
a.info.level.name(),
if a.name.is_empty() {
a.uuid.clone()
} else {
a.name.clone()
}
);
if a.missing > 0 {
out.notes.push(format!(
"{label}: montado sem {} membro(s) (modo degradado).",
a.missing
));
} else {
out.notes.push(format!("{label} montado."));
}
add_virtual(
&mut regions,
&mut next_virtual,
&mut out,
a.source.clone(),
label,
);
progress = true;
}
Err(e) => out.notes.push(format!("RAID não montado: {e}")),
}
}
out.covered.extend(md_primary);
}
// LVM
let mut pvs: Vec<(Arc<dyn Source>, crate::lvm::PvInfo)> = vec![];
let mut pv_primary: Vec<u64> = vec![];
let md_srcs: Vec<Arc<dyn Source>> = regions.iter().map(|r| r.src.clone()).collect();
let mut lvm_pool: Vec<(Arc<dyn Source>, Option<u64>)> = pool
.iter()
.map(|w| (w.src.clone(), w.primary_start))
.collect();
for (r, s) in regions.iter().zip(md_srcs) {
lvm_pool.push((s, Some(r.start)));
}
for (src, st) in lvm_pool {
if let Some(pv) = crate::lvm::read_pv(src.as_ref()) {
if !done_containers.insert(format!("pv{}", pv.uuid)) {
continue;
}
if let Some(st) = st {
pv_primary.push(st);
}
pvs.push((src, pv));
}
}
if !pvs.is_empty() {
for vg in crate::lvm::assemble_vgs(pvs) {
for w in &vg.warnings {
out.notes.push(format!("LVM {}: {w}", vg.name));
}
for (name, lv) in vg.lvs {
add_virtual(
&mut regions,
&mut next_virtual,
&mut out,
lv,
format!("LVM {}/{}", vg.name, name),
);
progress = true;
}
}
out.covered.extend(pv_primary);
}
}
// 2) Desbloqueio de volumes cifrados.
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false);
let mut all_parts: Vec<PartInfo> = layout.partitions.clone();
for p in &out.extra_parts {
if !all_parts.iter().any(|q| q.start == p.start) {
all_parts.push(p.clone());
}
}
out.locked.clear();
for p in &all_parts {
if p.encrypted.is_none() || out.unlocked.iter().any(|u| u.start == p.start) {
continue;
}
let len = if p.len == 0 {
current.size().saturating_sub(p.start)
} else {
p.len
};
let window: Arc<dyn Source> = Arc::new(SubSource::new(current.clone(), p.start, len));
let mut all = secrets.clone();
for k in asm.keys.iter().filter(|k| k.start == p.start) {
if let Some(b) = crypt::from_hex(&k.key_hex) {
all.push(Secret::VolumeKey(b));
}
}
match try_unlock(window, p.fs, &all) {
Ok(u) => {
let ulen = u.source.size();
out.unlocked.push(UnlockInfo {
start: p.start,
len: ulen,
kind: kind_name(p.fs).into(),
description: u.description.clone(),
});
if let Some(k) = &u.volume_key {
out.keys.push(SavedKey { start: p.start, kind: kind_name(p.fs).into(), key_hex: crypt::to_hex(k) });
}
patch(&mut regions, p.start, u.source, u.description);
progress = true;
}
Err(e) => out.locked.push(LockedInfo {
start: p.start,
kind: kind_name(p.fs).into(),
reason: match e {
UnlockError::WrongSecret if secrets.is_empty() && asm.keys.is_empty() => "volume cifrado: informe a senha, a chave de recuperação ou o arquivo de chave".into(),
other => other.to_string(),
},
}),
}
}
if !progress {
break;
}
}
if !regions.is_empty() {
out.source = Arc::new(Overlay::new(base, regions));
// Atualiza o que se sabe sobre as partições virtuais (algumas foram desbloqueadas depois).
for p in &mut out.extra_parts {
if let Some((fs, len)) = partition::detect_fs(out.source.as_ref(), p.start) {
p.fs = fs;
p.encrypted = fs.encrypted().map(String::from);
if let Some(l) = len {
p.len = p.len.min(l).max(1);
}
}
}
}
out.covered.sort_unstable();
out.covered.dedup();
Ok(out)
}
/// Anexa um volume virtual depois do fim do disco e registra as partições que ele contém.
fn add_virtual(
regions: &mut Vec<Region>,
next: &mut u64,
out: &mut Assembled,
src: Arc<dyn Source>,
label: String,
) {
let start = *next;
let len = src.size();
if len == 0 {
return;
}
*next = (start + len).div_ceil(MIB) * MIB + MIB;
let inner = partition::analyze(src.as_ref(), false);
if inner.partitions.is_empty() {
out.extra_parts.push(PartInfo {
index: 0,
start,
len,
fs: FsKind::Unknown,
label: Some(label.clone()),
lost: false,
encrypted: None,
table_type: Some(label.clone()),
});
} else {
for p in inner.partitions {
let mut q = p.clone();
q.start += start;
q.label = q.label.or_else(|| Some(label.clone()));
out.extra_parts.push(q);
}
}
regions.push(Region {
start,
len,
src,
label,
});
}
/// Escolhe os parâmetros do RAID: o que o usuário deu manda; o resto vem da detecção.
fn pick_raid(
disks: &[Arc<dyn Source>],
spec: &RaidSpec,
notes: &mut Vec<String>,
) -> Result<crate::raid::RaidParams> {
use crate::raid::{Member, RaidParams};
if let (Some(level), Some(chunk), Some(layout), Some(order)) =
(spec.level, spec.chunk_kb, spec.layout, spec.order.clone())
{
if order.len() == disks.len() && order.iter().all(|&i| i < disks.len()) {
let members = order
.iter()
.map(|&i| {
Some(Member {
src: disks[i].clone(),
data_offset: 0,
})
})
.collect();
return Ok(RaidParams::simple(level, chunk * 1024, layout, members));
}
}
let cands = crate::raid_detect::detect(disks, &|| false);
let c = cands
.into_iter()
.find(|c| {
spec.level.map_or(true, |l| c.level == l)
&& spec.chunk_kb.map_or(true, |k| c.chunk == k * 1024)
&& spec.layout.map_or(true, |l| c.layout == l)
&& spec.order.as_ref().map_or(true, |o| *o == c.order)
})
.ok_or_else(|| Error::msg("não foi possível descobrir os parâmetros do RAID (nenhuma combinação produziu um sistema de arquivos coerente)"))?;
notes.push(format!(
"Parâmetros do RAID descobertos: {}, faixa de {} KiB, layout {:?}, ordem dos discos {:?} (pontuação {:.1}).",
c.level.name(),
c.chunk / 1024,
c.layout,
c.order,
c.score
));
Ok(c.params(disks))
}
+35 -9
View File
@@ -25,23 +25,33 @@ pub fn carve_range(
cfg: &CarveCfg,
cancelled: &(dyn Fn() -> bool + Sync),
ev: &mut dyn CarveEvents,
) -> bool {
) -> CarveEnd {
let end = end.min(src.size());
let mut pos = (*next).max(start);
// Alinha ao setor.
pos = pos.div_ceil(SECTOR) * SECTOR;
let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
let mut skip_until = 0u64;
let mut fails = 0u32;
while pos < end {
if cancelled() {
*next = pos;
return false;
return CarveEnd::Cancelled;
}
let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD);
let got = match read_full(src, pos, &mut buf[..want]) {
Ok(n) => n,
Ok(n) => {
fails = 0;
n
}
Err(_) => {
// Setor ruim: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
// Várias falhas seguidas = dispositivo sumiu: para com o ponto de retomada salvo.
fails += 1;
if fails >= 4 {
*next = pos - (fails as u64 - 1) * CHUNK as u64;
return CarveEnd::IoLost;
}
// Setor ruim isolado: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
pos += CHUNK as u64;
ev.advance(CHUNK as u64);
*next = pos;
@@ -52,6 +62,7 @@ pub fn carve_range(
break;
}
let scan_len = got.min(CHUNK).min((end - pos) as usize);
ev.chunk(pos, &buf[..scan_len]);
let mut i = 0usize;
while i < scan_len {
let abs = pos + i as u64;
@@ -68,10 +79,12 @@ pub fn carve_range(
|| cfg.categories.contains(&category_for_ext(hit.ext));
if hit.len >= MIN_LEN && allowed {
let action = ev.hit(abs, &hit);
if matches!(action, HitAction::Skip)
&& hit.confidence != crate::types::Confidence::Low
{
skip_until = abs + hit.len;
match action {
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
skip_until = abs + hit.len;
}
HitAction::SkipLen(n) => skip_until = abs + n,
_ => {}
}
}
}
@@ -84,12 +97,23 @@ pub fn carve_range(
*next = pos;
}
*next = end;
true
CarveEnd::Done
}
/// Como o carving de um intervalo terminou.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CarveEnd {
Done,
Cancelled,
/// Várias leituras seguidas falharam: o dispositivo provavelmente foi desconectado.
IoLost,
}
/// Receptor de eventos do carving.
pub trait CarveEvents {
fn advance(&mut self, bytes: u64);
/// Cada trecho lido, antes de procurar assinaturas (para indexar o que serve a recomposição).
fn chunk(&mut self, _abs: u64, _data: &[u8]) {}
fn hit(&mut self, offset: u64, hit: &Hit) -> HitAction;
}
@@ -97,6 +121,8 @@ pub trait CarveEvents {
pub enum HitAction {
/// Pula o conteúdo do arquivo reconhecido (comportamento padrão).
Skip,
/// Pula só estes bytes (arquivo cujo resto não é confiável).
SkipLen(u64),
/// Segue setor a setor (acerto descartado).
Continue,
}
+529
View File
@@ -0,0 +1,529 @@
//! BitLocker (Windows 7 em diante, inclusive BitLocker To Go): AES-CBC com ou sem difusor
//! Elephant, e AES-XTS. Protetores: senha, chave de recuperação, chave de inicialização (.BEK),
//! chave limpa (proteção suspensa) e chave do volume já conhecida. TPM não é suportado.
use super::*;
use crate::source::{read_vec, SubSource};
use sha2::{Digest, Sha256};
fn le16(b: &[u8], o: usize) -> usize {
u16::from_le_bytes([b[o], b[o + 1]]) as usize
}
fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
const GUID_NORMAL: [u8; 16] = [
0x3b, 0xd6, 0x67, 0x49, 0x29, 0x2e, 0xd8, 0x4a, 0x83, 0x99, 0xf6, 0xa3, 0x39, 0xe3, 0xd0, 0x01,
];
const GUID_EOW: [u8; 16] = [
0x3b, 0x4d, 0xa8, 0x92, 0x80, 0xdd, 0x0e, 0x4d, 0x9e, 0x4e, 0xb1, 0xe3, 0x28, 0x4e, 0xae, 0xd8,
];
/// Posição da tabela de deslocamentos FVE, se for BitLocker (normal ou To Go).
fn offsets_pos(vbr: &[u8]) -> Option<usize> {
if vbr.len() >= 11 && &vbr[3..11] == b"-FVE-FS-" {
return Some(176);
}
// BitLocker To Go: parece FAT ("MSWIN4.1"), mas guarda o GUID do BitLocker no setor de boot.
if vbr.len() >= 464 && (vbr[424..440] == GUID_NORMAL || vbr[424..440] == GUID_EOW) {
return Some(440);
}
None
}
pub fn detect(vbr: &[u8]) -> bool {
offsets_pos(vbr).is_some()
}
// ------------------------------------------------------------- Elephant
fn rotl(x: u32, n: u32) -> u32 {
x.rotate_left(n)
}
const RA: [u32; 4] = [9, 0, 13, 0];
const RB: [u32; 4] = [0, 10, 0, 25];
fn words(buf: &[u8]) -> Vec<u32> {
buf.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect()
}
// Decifragem (ordem do dm-crypt do Linux): somas em ordem crescente.
fn diffuser_a_decrypt(d: &mut [u32]) {
let n = d.len();
for _ in 0..5 {
for i in 0..n {
let a = d[(i + n - 2) % n];
let b = rotl(d[(i + n - 5) % n], RA[i % 4]);
d[i] = d[i].wrapping_add(a ^ b);
}
}
}
fn diffuser_b_decrypt(d: &mut [u32]) {
let n = d.len();
for _ in 0..3 {
for i in 0..n {
let a = d[(i + 2) % n];
let b = rotl(d[(i + 5) % n], RB[i % 4]);
d[i] = d[i].wrapping_add(a ^ b);
}
}
}
/// Decifra um setor AES-CBC + difusor Elephant (BitLocker Vista/7 sem XTS).
pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off: u64) {
let mut iv = [0u8; 16];
iv[..8].copy_from_slice(&byte_off.to_le_bytes());
key.encrypt(&mut iv);
cbc_decrypt(key, &iv, buf);
let mut d = words(buf);
diffuser_b_decrypt(&mut d);
diffuser_a_decrypt(&mut d);
for (i, w) in d.iter().enumerate() {
buf[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes());
}
// Chave do setor: duas cifragens do deslocamento com a chave de ajuste.
let mut k0 = [0u8; 16];
k0[..8].copy_from_slice(&byte_off.to_le_bytes());
let mut k1 = k0;
k1[15] = 0x80;
tweak.encrypt(&mut k0);
tweak.encrypt(&mut k1);
let mut sk = [0u8; 32];
sk[..16].copy_from_slice(&k0);
sk[16..].copy_from_slice(&k1);
for (i, b) in buf.iter_mut().enumerate() {
*b ^= sk[i % 32];
}
}
// ------------------------------------------------------------------ CCM
/// AES-CCM (nonce de 12 bytes, tag de 16): `blob` = tag cifrada (16) + dados cifrados.
fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
if nonce.len() != 12 || blob.len() < 16 {
return None;
}
let ctr = |i: u32| -> [u8; 16] {
let mut a = [0u8; 16];
a[0] = 2;
a[1..13].copy_from_slice(nonce);
a[13..16].copy_from_slice(&i.to_be_bytes()[1..]);
key.encrypt(&mut a);
a
};
let s0 = ctr(0);
let mut tag = [0u8; 16];
for i in 0..16 {
tag[i] = blob[i] ^ s0[i];
}
let ct = &blob[16..];
let mut pt = Vec::with_capacity(ct.len());
for (bi, ch) in ct.chunks(16).enumerate() {
let s = ctr(bi as u32 + 1);
pt.extend(ch.iter().zip(s.iter()).map(|(a, b)| a ^ b));
}
// CBC-MAC sobre B0 + dados.
let mut b0 = [0u8; 16];
b0[0] = 0x3A;
b0[1..13].copy_from_slice(nonce);
b0[13..16].copy_from_slice(&(pt.len() as u32).to_be_bytes()[1..]);
key.encrypt(&mut b0);
let mut x = b0;
for ch in pt.chunks(16) {
for i in 0..ch.len() {
x[i] ^= ch[i];
}
key.encrypt(&mut x);
}
(x == tag).then_some(pt)
}
// --------------------------------------------------------------- chaves
fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] {
let mut last = [0u8; 32];
let mut buf = [0u8; 88];
buf[32..64].copy_from_slice(initial);
buf[64..80].copy_from_slice(salt);
for i in 0u64..0x10_0000 {
buf[..32].copy_from_slice(&last);
buf[80..88].copy_from_slice(&i.to_le_bytes());
last.copy_from_slice(&Sha256::digest(buf));
}
last
}
fn password_key(pw: &str, salt: &[u8]) -> [u8; 32] {
let utf16: Vec<u8> = pw.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let h1 = Sha256::digest(&utf16);
let mut init = [0u8; 32];
init.copy_from_slice(&Sha256::digest(h1));
stretch(&init, salt)
}
/// Chave de recuperação: 8 blocos de 6 dígitos, cada um múltiplo de 11.
fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
let digits: String = rp.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 48 {
return None;
}
let mut raw = [0u8; 16];
for i in 0..8 {
let n: u32 = digits[i * 6..i * 6 + 6].parse().ok()?;
if n % 11 != 0 || n / 11 > 0xFFFF {
return None;
}
raw[i * 2..i * 2 + 2].copy_from_slice(&((n / 11) as u16).to_le_bytes());
}
let mut init = [0u8; 32];
init.copy_from_slice(&Sha256::digest(raw));
Some(stretch(&init, salt))
}
// ----------------------------------------------------------- metadados
#[derive(Debug, Clone)]
struct Entry {
etype: usize,
vtype: usize,
data: Vec<u8>,
}
fn parse_entries(b: &[u8]) -> Vec<Entry> {
let mut v = vec![];
let mut o = 0;
while o + 8 <= b.len() {
let size = le16(b, o);
if size < 8 || o + size > b.len() {
break;
}
v.push(Entry {
etype: le16(b, o + 2),
vtype: le16(b, o + 4),
data: b[o + 8..o + size].to_vec(),
});
o += size;
}
v
}
struct Vmk {
protection: usize,
nested: Vec<Entry>,
}
impl Vmk {
fn ccm(&self) -> Option<&Entry> {
self.nested.iter().find(|e| e.vtype == 5)
}
fn stretch_salt(&self) -> Option<&[u8]> {
self.nested
.iter()
.find(|e| e.vtype == 3)
.filter(|e| e.data.len() >= 20)
.map(|e| &e.data[4..20])
}
}
struct Fve {
method: usize,
volume_header_offset: u64,
volume_header_size: u64,
encrypted_size: u64,
meta_size: u64,
vmks: Vec<Vmk>,
fvek: Option<Entry>,
}
fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
let h = read_vec(src, offset, 64 + 48).ok()?;
if h.len() < 112 || &h[..8] != b"-FVE-FS-" {
return None;
}
let version = le16(&h, 10);
if version != 2 {
return None; // Vista (versão 1) não suportado
}
let encrypted_size = le64(&h, 16);
let hdr_sectors = le32(&h, 28) as u64;
let volume_header_offset = le64(&h, 56);
let meta_size = le32(&h, 64) as usize;
if !(48..=1 << 20).contains(&meta_size) {
return None;
}
let method = le16(&h, 64 + 36);
let all = read_vec(src, offset + 64, meta_size).ok()?;
if all.len() < meta_size {
return None;
}
let entries = parse_entries(&all[48..]);
let mut vmks = vec![];
let mut fvek = None;
let (mut vh_off, mut vh_size) = (volume_header_offset, hdr_sectors * 512);
for e in entries {
match (e.etype, e.vtype) {
(15, 15) if e.data.len() >= 16 => {
vh_off = le64(&e.data, 0);
vh_size = le64(&e.data, 8);
}
(2, 8) if e.data.len() > 28 => vmks.push(Vmk {
protection: le16(&e.data, 26),
nested: parse_entries(&e.data[28..]),
}),
(3, 5) => fvek = Some(e),
_ => {}
}
}
Some(Fve {
method,
volume_header_offset: vh_off,
volume_header_size: vh_size,
encrypted_size,
meta_size: 0x10000,
vmks,
fvek,
})
}
/// Chave de um arquivo .BEK (chave de inicialização).
fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
if file.len() < 48 {
return None;
}
let es = parse_entries(&file[48..]);
for e in &es {
if e.vtype == 9 && e.data.len() > 24 {
for n in parse_entries(&e.data[24..]) {
if n.vtype == 1 && n.data.len() >= 36 {
return Some(n.data[4..36].to_vec());
}
}
}
}
None
}
fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = vec![];
// Chave limpa: o próprio protetor guarda a chave em claro.
if v.protection == 0 {
for n in &v.nested {
if n.vtype == 1 && n.data.len() >= 36 {
out.push(n.data[4..36].to_vec());
}
}
}
for s in secrets {
match s {
Secret::Password(p) if v.protection == 0x2000 => {
if let Some(salt) = v.stretch_salt() {
out.push(password_key(p, salt).to_vec());
}
}
Secret::RecoveryKey(r) if v.protection == 0x0800 => {
if let Some(k) = v.stretch_salt().and_then(|salt| recovery_key(r, salt)) {
out.push(k.to_vec());
}
}
Secret::KeyFile(f) if v.protection == 0x0200 => {
if let Some(k) = bek_key(f) {
out.push(k);
}
}
_ => {}
}
}
out
}
fn decrypt_key_entry(key: &[u8], e: &Entry) -> Option<Vec<u8>> {
let aes = AesAny::new(key)?;
if e.data.len() < 12 + 16 + 8 {
return None;
}
let pt = ccm_decrypt(&aes, &e.data[..12], &e.data[12..])?;
// payload = cabeçalho de entrada (8) + método (4) + chave
(pt.len() > 12).then(|| pt[12..].to_vec())
}
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let vbr = read_vec(src.as_ref(), 0, 512).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if !detect(&vbr) || vbr.len() < 512 {
return Err(UnlockError::Corrupt("não é BitLocker".into()));
}
let bps = le16(&vbr, 11).max(512) as u64;
let pos = offsets_pos(&vbr).unwrap_or(176);
// Três cópias dos metadados; usa a primeira legível.
let mut fve = None;
let mut meta_offsets = vec![];
for i in 0..3 {
let off = le64(&vbr, pos + i * 8);
if off != 0 {
meta_offsets.push(off);
}
if off != 0 && off < src.size() {
if let Some(f) = read_fve(src.as_ref(), off) {
fve = Some(f);
break;
}
}
}
let fve = fve.ok_or_else(|| {
UnlockError::Unsupported("metadados FVE ilegíveis ou do Windows Vista".into())
})?;
let fvek_entry = fve
.fvek
.as_ref()
.ok_or_else(|| UnlockError::Corrupt("sem FVEK".into()))?;
let mut volume_key: Option<Vec<u8>> = None;
for s in secrets {
if let Secret::VolumeKey(k) = s {
volume_key = Some(k.clone());
}
}
let mut vmk_found: Option<Vec<u8>> = None;
if volume_key.is_none() {
'outer: for v in &fve.vmks {
let Some(ccm) = v.ccm() else { continue };
for k in candidate_keys(v, secrets) {
if let Some(vmk) = decrypt_key_entry(&k, ccm) {
if vmk.len() >= 32 {
vmk_found = Some(vmk[..32].to_vec());
break 'outer;
}
}
}
}
}
let fvek_bytes = if let Some(k) = volume_key {
k
} else {
let vmk = vmk_found.ok_or(UnlockError::WrongSecret)?;
decrypt_key_entry(&vmk, fvek_entry)
.ok_or_else(|| UnlockError::Corrupt("FVEK não decifra com a VMK".into()))?
};
let k = &fvek_bytes;
let bad = || UnlockError::Corrupt("tamanho de chave incompatível com o método".into());
let (cipher, name) = match fve.method {
0x8000 | 0x8001 => {
let n = if fve.method == 0x8000 { 16 } else { 32 };
if k.len() < 32 + n {
return Err(bad());
}
(
SectorCipher::Elephant {
key: AesAny::new(&k[..n]).ok_or_else(bad)?,
tweak: AesAny::new(&k[32..32 + n]).ok_or_else(bad)?,
},
if n == 16 {
"AES-128-CBC + difusor"
} else {
"AES-256-CBC + difusor"
},
)
}
0x8002 | 0x8003 => {
let n = if fve.method == 0x8002 { 16 } else { 32 };
if k.len() < n {
return Err(bad());
}
let key = AesAny::new(&k[..n]).ok_or_else(bad)?;
(
SectorCipher::Cbc {
key: key.clone(),
iv: Iv::Eboiv(key),
},
if n == 16 {
"AES-128-CBC"
} else {
"AES-256-CBC"
},
)
}
0x8004 | 0x8005 => {
let n = if fve.method == 0x8004 { 16 } else { 32 };
if k.len() < 2 * n {
return Err(bad());
}
let iv = if bps > 512 {
Iv::Unit(bps)
} else {
Iv::Plain64
};
(
SectorCipher::Xts {
k1: AesAny::new(&k[..n]).ok_or_else(bad)?,
k2: AesAny::new(&k[n..2 * n]).ok_or_else(bad)?,
iv,
},
if n == 16 {
"AES-128-XTS"
} else {
"AES-256-XTS"
},
)
}
m => {
return Err(UnlockError::Unsupported(format!(
"método de cifragem BitLocker {m:#x}"
)))
}
};
let len = src.size();
let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps);
let mut patches = vec![];
// Áreas de metadados FVE: o Windows não as expõe como dados.
for off in meta_offsets {
patches.push(Patch {
at: off,
len: fve.meta_size,
from: 0,
decrypt_from: None,
zero: true,
});
}
if fve.volume_header_size > 0 && fve.volume_header_offset > 0 {
// O início do volume original foi movido para outro lugar e cifrado lá.
patches.push(Patch {
at: fve.volume_header_offset,
len: fve.volume_header_size,
from: 0,
decrypt_from: None,
zero: true,
});
patches.push(Patch {
at: 0,
len: fve.volume_header_size,
from: fve.volume_header_offset,
decrypt_from: Some(fve.volume_header_offset),
zero: false,
});
}
cs = cs.with_patches(patches);
if fve.encrypted_size > 0 && fve.encrypted_size < len {
cs = cs.with_plain_from(fve.encrypted_size);
}
Ok(Unlocked {
source: Arc::new(cs),
description: format!("BitLocker ({name})"),
volume_key: Some(fvek_bytes),
})
}
pub fn unlock_window(
base: Arc<dyn Source>,
start: u64,
len: u64,
secrets: &[Secret],
) -> UnlockResult {
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
}
+261
View File
@@ -0,0 +1,261 @@
//! FileVault 2 sobre Core Storage (volumes HFS+ cifrados do macOS 10.7 a 10.12 e Fusion Drives).
//! Senha ou chave do volume; chave de recuperação institucional não é suportada.
use super::*;
use crate::source::{read_vec, SubSource};
use sha2::{Digest, Sha256};
const MD_BLOCK: usize = 8192;
fn le32(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
pub fn detect(head: &[u8]) -> bool {
head.len() >= 512 && head[88] == 0x43 && head[89] == 0x53 && head[10] == 0x10
}
/// Desembrulha uma chave AES-128 de 24 bytes (RFC 3394).
fn unwrap_key(kek: &[u8], wrapped: &[u8]) -> Option<[u8; 16]> {
let aes = AesAny::new(kek)?;
if wrapped.len() != 24 {
return None;
}
let mut a: [u8; 8] = wrapped[..8].try_into().unwrap();
let mut r2: [u8; 8] = wrapped[8..16].try_into().unwrap();
let mut r3: [u8; 8] = wrapped[16..24].try_into().unwrap();
for t in (1..=12u64).rev() {
let prev_r2 = r2;
let tb = t.to_be_bytes();
let mut blk = [0u8; 16];
for i in 0..8 {
blk[i] = a[i] ^ tb[i];
}
blk[8..].copy_from_slice(&r3);
aes.decrypt(&mut blk);
a.copy_from_slice(&blk[..8]);
r2.copy_from_slice(&blk[8..]);
r3 = prev_r2;
}
if a != [0xA6; 8] {
return None;
}
let mut out = [0u8; 16];
out[..8].copy_from_slice(&r2);
out[8..].copy_from_slice(&r3);
Some(out)
}
fn b64(s: &str) -> Option<Vec<u8>> {
let mut out = vec![];
let (mut acc, mut bits) = (0u32, 0);
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
_ => return None,
} as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
/// Valor de `<key>NOME</key><tipo ...>VALOR</tipo>` num plist simples (primeira ocorrência com valor).
fn plist_value(xml: &str, key: &str) -> Option<String> {
let k = format!("<key>{key}</key>");
let mut from = 0;
while let Some(p) = xml[from..].find(&k) {
let rest = &xml[from + p + k.len()..];
from += p + k.len();
let Some(open) = rest.find('<').filter(|&o| o == 0) else {
continue;
};
let Some(gt) = rest[open..].find('>') else {
continue;
};
if rest[open..open + gt].ends_with('/') {
continue; // <reference .../>
}
let Some(te) = rest[open + 1..].find([' ', '>']) else {
continue;
};
let tag = &rest[open + 1..open + 1 + te];
let vstart = open + gt + 1;
if let Some(e) = rest[vstart..].find(&format!("</{tag}>")) {
let v = rest[vstart..vstart + e].trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
fn xml_of(blk: &[u8], off_pos: usize) -> Option<String> {
let off = le32(blk, off_pos) as usize;
let size = le32(blk, off_pos + 4) as usize;
if off >= MD_BLOCK || size > MD_BLOCK - off {
return None;
}
Some(String::from_utf8_lossy(&blk[off..off + size]).to_string())
}
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let corrupt = |s: &str| UnlockError::Corrupt(s.to_string());
let h = read_vec(src.as_ref(), 0, 512).map_err(|e| corrupt(&e.to_string()))?;
if !detect(&h) {
return Err(corrupt("não é um volume Core Storage"));
}
let block_size = le32(&h, 96) as u64;
let disklbl = le64(&h, 104);
if block_size == 0 || block_size > 1 << 20 || le32(&h, 168) != 16 {
return Err(UnlockError::Unsupported(
"Core Storage com chave de metadados que não é AES-128".into(),
));
}
let mut md_key = [0u8; 32];
md_key[..16].copy_from_slice(&h[176..192]);
md_key[16..].copy_from_slice(&h[304..320]);
let (k1, k2) = (
AesAny::new(&md_key[..16]).unwrap(),
AesAny::new(&md_key[16..]).unwrap(),
);
let md_cipher = SectorCipher::Xts {
k1,
k2,
iv: Iv::Plain64,
};
let label = read_vec(src.as_ref(), disklbl * block_size, MD_BLOCK)
.map_err(|e| corrupt(&e.to_string()))?;
if label.len() < MD_BLOCK {
return Err(corrupt("rótulo do disco truncado"));
}
let vgd_off = le32(&label, 220) as u64;
let vgd = read_vec(src.as_ref(), disklbl * block_size + vgd_off, 40)
.map_err(|e| corrupt(&e.to_string()))?;
if vgd.len() < 40 {
return Err(corrupt("descritor de grupos truncado"));
}
let md_blocks = le64(&vgd, 8);
let md_start = le64(&vgd, 32) * block_size;
let (mut iters, mut salt, mut wkek, mut wvk) = (0u32, vec![], vec![], vec![]);
let (mut lv_size, mut lv_off) = (0u64, 0u64);
let mut family = [0u8; 16];
let mut got = 0u8;
for i in 0..md_blocks.min(64) {
let mut b = read_vec(src.as_ref(), md_start + i * MD_BLOCK as u64, MD_BLOCK)
.map_err(|e| corrupt(&e.to_string()))?;
if b.len() < MD_BLOCK || b.iter().all(|&x| x == 0) {
break;
}
// XTS sobre o bloco inteiro, com o número do bloco como ajuste inicial.
md_cipher.decrypt_sector(&mut b, i, 0);
match u16::from_le_bytes([b[10], b[11]]) {
0x0019 => {
if let Some(xml) = xml_of(&b, 112) {
let pwk = plist_value(&xml, "PassphraseWrappedKEKStruct").and_then(|s| b64(&s));
let kw = plist_value(&xml, "KEKWrappedVolumeKeyStruct").and_then(|s| b64(&s));
if let (Some(p), Some(k)) = (pwk, kw) {
if p.len() >= 172 && k.len() >= 32 {
salt = p[8..24].to_vec();
wkek = p[32..56].to_vec();
iters = le32(&p, 168);
wvk = k[8..32].to_vec();
got |= 1;
}
}
}
}
0x001a => {
if let Some(xml) = xml_of(&b, 128) {
if let Some(v) = plist_value(&xml, "com.apple.corestorage.lv.size") {
lv_size = u64::from_str_radix(v.trim_start_matches("0x"), 16).unwrap_or(0);
got |= 8;
if let Some(u) = plist_value(&xml, "com.apple.corestorage.lv.familyUUID") {
let hex: String = u.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if let Some(b) = super::from_hex(&hex).filter(|b| b.len() == 16) {
family.copy_from_slice(&b);
got |= 2;
}
}
}
}
}
0x0305 => {
lv_off = le32(&b, 104) as u64 * block_size;
got |= 4;
}
_ => {}
}
}
if got != 7 | 8 {
return Err(corrupt("metadados Core Storage incompletos"));
}
let finish = |vk16: [u8; 16]| -> UnlockResult {
let mut full = [0u8; 32];
full[..16].copy_from_slice(&vk16);
full[16..].copy_from_slice(
&Sha256::new()
.chain_update(vk16)
.chain_update(family)
.finalize()[..16],
);
let cipher = SectorCipher::Xts {
k1: AesAny::new(&full[..16]).unwrap(),
k2: AesAny::new(&full[16..]).unwrap(),
iv: Iv::Plain64,
};
let cs = CryptSource::new(src.clone(), cipher, lv_off, lv_size, 512);
Ok(Unlocked {
source: Arc::new(cs),
description: "FileVault 2 (Core Storage, AES-128-XTS)".into(),
volume_key: Some(vk16.to_vec()),
})
};
for s in secrets {
if let Secret::VolumeKey(k) = s {
if k.len() >= 16 {
let mut a = [0u8; 16];
a.copy_from_slice(&k[..16]);
return finish(a);
}
}
}
for s in secrets {
if let Secret::Password(p) = s {
let mut pk = [0u8; 16];
pbkdf2::pbkdf2_hmac::<Sha256>(p.as_bytes(), &salt, iters.max(1), &mut pk);
if let Some(kek) = unwrap_key(&pk, &wkek) {
if let Some(vk) = unwrap_key(&kek, &wvk) {
return finish(vk);
}
}
}
}
Err(UnlockError::WrongSecret)
}
pub fn unlock_window(
base: Arc<dyn Source>,
start: u64,
len: u64,
secrets: &[Secret],
) -> UnlockResult {
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
}
+474
View File
@@ -0,0 +1,474 @@
//! LUKS1 e LUKS2 (cifras AES; PBKDF2 e Argon2; hashes SHA-1/256/512 e RIPEMD-160).
use super::*;
use crate::source::{read_vec, SubSource};
use sha1::Sha1;
use sha2::{Digest, Sha256, Sha512};
pub const MAGIC: &[u8; 6] = b"LUKS\xBA\xBE";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hash {
Sha1,
Sha256,
Sha512,
Ripemd160,
}
impl Hash {
pub fn parse(s: &str) -> Option<Hash> {
Some(match s.to_ascii_lowercase().as_str() {
"sha1" => Hash::Sha1,
"sha256" => Hash::Sha256,
"sha512" => Hash::Sha512,
"ripemd160" | "rmd160" => Hash::Ripemd160,
_ => return None,
})
}
pub fn size(self) -> usize {
match self {
Hash::Sha1 | Hash::Ripemd160 => 20,
Hash::Sha256 => 32,
Hash::Sha512 => 64,
}
}
pub fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
let mut d = D::new();
for p in parts {
d.update(p);
}
d.finalize().to_vec()
}
match self {
Hash::Sha1 => run::<Sha1>(parts),
Hash::Sha256 => run::<Sha256>(parts),
Hash::Sha512 => run::<Sha512>(parts),
Hash::Ripemd160 => run::<ripemd::Ripemd160>(parts),
}
}
pub fn pbkdf2(self, pass: &[u8], salt: &[u8], iterations: u32, len: usize) -> Vec<u8> {
let mut out = vec![0u8; len];
let it = iterations.max(1);
match self {
Hash::Sha1 => pbkdf2::pbkdf2_hmac::<Sha1>(pass, salt, it, &mut out),
Hash::Sha256 => pbkdf2::pbkdf2_hmac::<Sha256>(pass, salt, it, &mut out),
Hash::Sha512 => pbkdf2::pbkdf2_hmac::<Sha512>(pass, salt, it, &mut out),
Hash::Ripemd160 => pbkdf2::pbkdf2_hmac::<ripemd::Ripemd160>(pass, salt, it, &mut out),
}
out
}
}
/// Difusão do "anti-forensic splitter".
fn diffuse(block: &mut [u8], h: Hash) {
let ds = h.size();
let mut i = 0u32;
let mut off = 0;
while off < block.len() {
let n = ds.min(block.len() - off);
let d = h.digest(&[&i.to_be_bytes(), &block[off..off + n]]);
block[off..off + n].copy_from_slice(&d[..n]);
off += n;
i += 1;
}
}
/// Recompõe a chave a partir das `stripes` faixas (AFmerge).
pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec<u8> {
let mut buf = vec![0u8; block];
for i in 0..stripes.saturating_sub(1) {
for (b, s) in buf.iter_mut().zip(&src[i * block..(i + 1) * block]) {
*b ^= *s;
}
diffuse(&mut buf, h);
}
let last = &src[(stripes - 1) * block..stripes * block];
buf.iter().zip(last).map(|(a, b)| a ^ b).collect()
}
/// Cifra descrita por texto como "aes-xts-plain64" ou "aes-cbc-essiv:sha256".
pub fn parse_cipher(spec: &str, key: &[u8]) -> std::result::Result<SectorCipher, UnlockError> {
let (alg, rest) = spec
.split_once('-')
.ok_or_else(|| UnlockError::Unsupported(spec.into()))?;
if !alg.eq_ignore_ascii_case("aes") {
return Err(UnlockError::Unsupported(format!(
"cifra {alg} (apenas AES)"
)));
}
let (mode, ivs) = rest.split_once('-').unwrap_or((rest, "plain"));
let bad = || UnlockError::Unsupported(format!("chave inválida para {spec}"));
let mk_iv = |k: &[u8]| -> std::result::Result<Iv, UnlockError> {
let ivl = ivs.to_ascii_lowercase();
Ok(match ivl.as_str() {
"plain64" => Iv::Plain64,
"plain" | "plain32" => Iv::Plain32,
"plain64be" => Iv::Plain64Be,
s if s.starts_with("essiv:") => {
let h =
Hash::parse(&s[6..]).ok_or_else(|| UnlockError::Unsupported(spec.into()))?;
Iv::Essiv(AesAny::new(&h.digest(&[k])).ok_or_else(bad)?)
}
_ => return Err(UnlockError::Unsupported(format!("IV {ivs}"))),
})
};
match mode.to_ascii_lowercase().as_str() {
"xts" => {
if key.len() % 2 != 0 {
return Err(bad());
}
let h = key.len() / 2;
Ok(SectorCipher::Xts {
k1: AesAny::new(&key[..h]).ok_or_else(bad)?,
k2: AesAny::new(&key[h..]).ok_or_else(bad)?,
iv: mk_iv(&key[..h])?,
})
}
"cbc" => Ok(SectorCipher::Cbc {
key: AesAny::new(key).ok_or_else(bad)?,
iv: mk_iv(key)?,
}),
m => Err(UnlockError::Unsupported(format!("modo {m}"))),
}
}
fn passwords(secrets: &[Secret]) -> Vec<Vec<u8>> {
secrets
.iter()
.filter_map(|s| match s {
Secret::Password(p) => Some(p.as_bytes().to_vec()),
Secret::KeyFile(k) => Some(k.clone()),
_ => None,
})
.collect()
}
fn volume_keys(secrets: &[Secret]) -> Vec<Vec<u8>> {
secrets
.iter()
.filter_map(|s| match s {
Secret::VolumeKey(k) => Some(k.clone()),
_ => None,
})
.collect()
}
pub fn detect(head: &[u8]) -> bool {
head.len() >= 8 && &head[..6] == MAGIC
}
pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let head = read_vec(src.as_ref(), 0, 8).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if !detect(&head) {
return Err(UnlockError::Corrupt("não é LUKS".into()));
}
match u16::from_be_bytes([head[6], head[7]]) {
1 => unlock_v1(src, secrets),
2 => unlock_v2(src, secrets),
v => Err(UnlockError::Unsupported(format!("LUKS versão {v}"))),
}
}
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
}
fn cstr(b: &[u8]) -> String {
let n = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(&b[..n]).to_string()
}
fn unlock_v1(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let h = read_vec(src.as_ref(), 0, 592).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if h.len() < 592 {
return Err(UnlockError::Corrupt("cabeçalho LUKS1 truncado".into()));
}
let cipher_name = cstr(&h[8..40]);
let cipher_mode = cstr(&h[40..72]);
let hash = Hash::parse(&cstr(&h[72..104]))
.ok_or_else(|| UnlockError::Unsupported(format!("hash {}", cstr(&h[72..104]))))?;
let payload = be32(&h, 104) as u64 * 512;
let key_bytes = be32(&h, 108) as usize;
let mk_digest = &h[112..132];
let mk_salt = &h[132..164];
let mk_iter = be32(&h, 164);
let spec = format!("{cipher_name}-{cipher_mode}");
if key_bytes == 0 || key_bytes > 128 {
return Err(UnlockError::Corrupt("tamanho de chave inválido".into()));
}
let check = |mk: &[u8]| hash.pbkdf2(mk, mk_salt, mk_iter, 20) == mk_digest;
let finish = |mk: Vec<u8>| -> UnlockResult {
let cipher = parse_cipher(&spec, &mk)?;
let len = src.size().saturating_sub(payload);
let cs = CryptSource::new(src.clone(), cipher, payload, len, 512);
Ok(Unlocked {
source: Arc::new(cs),
description: format!("LUKS1 ({spec})"),
volume_key: Some(mk),
})
};
for vk in volume_keys(secrets) {
if vk.len() == key_bytes && check(&vk) {
return finish(vk);
}
}
let pws = passwords(secrets);
for slot in 0..8 {
let o = 208 + slot * 48;
if be32(&h, o) != 0x00AC_71F3 {
continue;
}
let iter = be32(&h, o + 4);
let salt = &h[o + 8..o + 40];
let km_off = be32(&h, o + 40) as u64 * 512;
let stripes = be32(&h, o + 44) as usize;
if stripes == 0 || stripes > 100_000 {
continue;
}
let km_len = (key_bytes * stripes).div_ceil(512) * 512;
let Ok(mut km) = read_vec(src.as_ref(), km_off, km_len) else {
continue;
};
if km.len() < km_len {
continue;
}
for pw in &pws {
let dk = hash.pbkdf2(pw, salt, iter, key_bytes);
let cipher = parse_cipher(&spec, &dk)?;
let mut m = km.clone();
for (i, s) in m.chunks_exact_mut(512).enumerate() {
cipher.decrypt_sector(s, i as u64, i as u64 * 512);
}
let mk = af_merge(&m, key_bytes, stripes, hash);
if check(&mk) {
return finish(mk);
}
}
km.clear();
}
Err(UnlockError::WrongSecret)
}
fn b64(s: &str) -> Option<Vec<u8>> {
let mut out = vec![];
let mut acc = 0u32;
let mut bits = 0;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' | b'\n' | b'\r' => continue,
_ => return None,
} as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
fn jstr<'a>(v: &'a serde_json::Value, k: &str) -> Option<&'a str> {
v.get(k)?.as_str()
}
/// Números no JSON do LUKS2 vêm como texto (inteiros grandes) ou número.
fn jnum(v: &serde_json::Value, k: &str) -> Option<u64> {
let x = v.get(k)?;
x.as_u64().or_else(|| x.as_str()?.parse().ok())
}
fn unlock_v2(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let h = read_vec(src.as_ref(), 0, 4096).map_err(|e| UnlockError::Corrupt(e.to_string()))?;
if h.len() < 4096 {
return Err(UnlockError::Corrupt("cabeçalho LUKS2 truncado".into()));
}
let hdr_size = u64::from_be_bytes(h[8..16].try_into().unwrap());
if !(16384..=(4 << 20)).contains(&hdr_size) {
return Err(UnlockError::Corrupt(
"tamanho de cabeçalho LUKS2 inválido".into(),
));
}
let area = read_vec(src.as_ref(), 4096, (hdr_size - 4096) as usize)
.map_err(|e| UnlockError::Corrupt(e.to_string()))?;
let end = area.iter().position(|&c| c == 0).unwrap_or(area.len());
let json: serde_json::Value = serde_json::from_slice(&area[..end])
.map_err(|e| UnlockError::Corrupt(format!("JSON: {e}")))?;
let segments = json
.get("segments")
.and_then(|s| s.as_object())
.ok_or_else(|| UnlockError::Corrupt("sem segments".into()))?;
let (_, seg) = segments
.iter()
.find(|(_, s)| jstr(s, "type") == Some("crypt"))
.ok_or_else(|| UnlockError::Unsupported("sem segmento cifrado".into()))?;
let seg_off =
jnum(seg, "offset").ok_or_else(|| UnlockError::Corrupt("offset do segmento".into()))?;
let seg_len = match seg.get("size").and_then(|s| s.as_str()) {
Some("dynamic") | None => src.size().saturating_sub(seg_off),
Some(n) => n.parse().unwrap_or(0),
};
let seg_enc = jstr(seg, "encryption")
.unwrap_or("aes-xts-plain64")
.to_string();
let sector = jnum(seg, "sector_size").unwrap_or(512);
let iv_tweak = jnum(seg, "iv_tweak").unwrap_or(0);
let digests = json
.get("digests")
.and_then(|d| d.as_object())
.ok_or_else(|| UnlockError::Corrupt("sem digests".into()))?;
let check = |vk: &[u8]| -> bool {
for (_, d) in digests {
if jstr(d, "type") != Some("pbkdf2") {
continue;
}
let (Some(hash), Some(salt), Some(want), Some(it)) = (
jstr(d, "hash").and_then(Hash::parse),
jstr(d, "salt").and_then(b64),
jstr(d, "digest").and_then(b64),
jnum(d, "iterations"),
) else {
continue;
};
if hash.pbkdf2(vk, &salt, it as u32, want.len()) == want {
return true;
}
}
false
};
let finish = |vk: Vec<u8>| -> UnlockResult {
let cipher = parse_cipher(&seg_enc, &vk)?;
let cs =
CryptSource::new(src.clone(), cipher, seg_off, seg_len, sector).with_iv_tweak(iv_tweak);
Ok(Unlocked {
source: Arc::new(cs),
description: format!("LUKS2 ({seg_enc})"),
volume_key: Some(vk),
})
};
for vk in volume_keys(secrets) {
if check(&vk) {
return finish(vk);
}
}
let pws = passwords(secrets);
let slots = json
.get("keyslots")
.and_then(|d| d.as_object())
.ok_or_else(|| UnlockError::Corrupt("sem keyslots".into()))?;
let mut unsupported: Option<String> = None;
for (_, slot) in slots {
if jstr(slot, "type") != Some("luks2") {
continue;
}
let key_size = jnum(slot, "key_size").unwrap_or(0) as usize;
let af = slot.get("af");
let stripes = af.and_then(|a| jnum(a, "stripes")).unwrap_or(4000) as usize;
let af_hash = af
.and_then(|a| jstr(a, "hash"))
.and_then(Hash::parse)
.unwrap_or(Hash::Sha256);
let ar = slot.get("area");
let (Some(a_off), Some(a_size)) = (
ar.and_then(|a| jnum(a, "offset")),
ar.and_then(|a| jnum(a, "size")),
) else {
continue;
};
let a_enc = ar
.and_then(|a| jstr(a, "encryption"))
.unwrap_or("aes-xts-plain64");
let a_ksize = ar.and_then(|a| jnum(a, "key_size")).unwrap_or(64) as usize;
let kdf = slot.get("kdf");
let Some(kdf) = kdf else { continue };
let salt = jstr(kdf, "salt").and_then(b64).unwrap_or_default();
let km_len = (key_size * stripes).div_ceil(512) * 512;
if key_size == 0 || stripes == 0 || km_len as u64 > a_size {
continue;
}
let Ok(km) = read_vec(src.as_ref(), a_off, km_len) else {
continue;
};
if km.len() < km_len {
continue;
}
for pw in &pws {
let dk: Vec<u8> = match jstr(kdf, "type") {
Some("pbkdf2") => {
let hh = jstr(kdf, "hash")
.and_then(Hash::parse)
.unwrap_or(Hash::Sha256);
hh.pbkdf2(
pw,
&salt,
jnum(kdf, "iterations").unwrap_or(1000) as u32,
a_ksize,
)
}
Some(t @ ("argon2i" | "argon2id")) => {
let alg = if t == "argon2i" {
argon2::Algorithm::Argon2i
} else {
argon2::Algorithm::Argon2id
};
let params = match argon2::Params::new(
jnum(kdf, "memory").unwrap_or(65536) as u32,
jnum(kdf, "time").unwrap_or(4) as u32,
jnum(kdf, "cpus").unwrap_or(1) as u32,
Some(a_ksize),
) {
Ok(p) => p,
Err(e) => {
unsupported = Some(format!("parâmetros Argon2: {e}"));
continue;
}
};
let mut out = vec![0u8; a_ksize];
if argon2::Argon2::new(alg, argon2::Version::V0x13, params)
.hash_password_into(pw, &salt, &mut out)
.is_err()
{
continue;
}
out
}
Some(t) => {
unsupported = Some(format!("KDF {t}"));
continue;
}
None => continue,
};
let cipher = parse_cipher(a_enc, &dk)?;
let mut m = km.clone();
for (i, s) in m.chunks_exact_mut(512).enumerate() {
cipher.decrypt_sector(s, i as u64, i as u64 * 512);
}
let vk = af_merge(&m, key_size, stripes, af_hash);
if check(&vk) {
return finish(vk);
}
}
}
match unsupported {
Some(u) => Err(UnlockError::Unsupported(u)),
None => Err(UnlockError::WrongSecret),
}
}
/// Atalho: abre uma janela da origem como LUKS.
pub fn unlock_window(
base: Arc<dyn Source>,
start: u64,
len: u64,
secrets: &[Secret],
) -> UnlockResult {
unlock(Arc::new(SubSource::new(base, start, len)), secrets)
}
+365
View File
@@ -0,0 +1,365 @@
//! Cifras de disco (somente leitura) e desbloqueio de volumes cifrados.
//!
//! O volume desbloqueado é exposto como uma `Source` comum: o restante do Guará
//! (varredura, recuperação) enxerga o sistema de arquivos em claro, sem saber
//! que existe cifra por baixo.
pub mod bitlocker;
pub mod filevault;
pub mod luks;
use crate::source::Source;
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
use aes::{Aes128, Aes192, Aes256};
use std::io;
use std::sync::Arc;
/// AES com tamanho de chave escolhido em tempo de execução.
#[derive(Clone)]
pub enum AesAny {
A128(Aes128),
A192(Aes192),
A256(Aes256),
}
impl AesAny {
pub fn new(key: &[u8]) -> Option<Self> {
Some(match key.len() {
16 => AesAny::A128(Aes128::new_from_slice(key).ok()?),
24 => AesAny::A192(Aes192::new_from_slice(key).ok()?),
32 => AesAny::A256(Aes256::new_from_slice(key).ok()?),
_ => return None,
})
}
pub fn encrypt(&self, b: &mut [u8; 16]) {
let g = GenericArray::from_mut_slice(b);
match self {
AesAny::A128(c) => c.encrypt_block(g),
AesAny::A192(c) => c.encrypt_block(g),
AesAny::A256(c) => c.encrypt_block(g),
}
}
pub fn decrypt(&self, b: &mut [u8; 16]) {
let g = GenericArray::from_mut_slice(b);
match self {
AesAny::A128(c) => c.decrypt_block(g),
AesAny::A192(c) => c.decrypt_block(g),
AesAny::A256(c) => c.decrypt_block(g),
}
}
}
fn xor16(a: &mut [u8; 16], b: &[u8]) {
for i in 0..16 {
a[i] ^= b[i];
}
}
/// Como o vetor de inicialização de cada setor é calculado.
#[derive(Clone)]
pub enum Iv {
/// Número do setor (de 512 bytes) em 64 bits little-endian.
Plain64,
/// Número do setor em 32 bits little-endian.
Plain32,
Plain64Be,
/// ESSIV: AES(SHA-256(chave), setor).
Essiv(AesAny),
/// Número da unidade de dados (setor de `usize` bytes) em 64 bits little-endian.
Unit(u64),
/// BitLocker (Windows 10 1511+): AES(chave, offset em bytes).
Eboiv(AesAny),
/// BitLocker CBC com difusor Elephant: IV = AES(chave, offset em bytes).
Bitlocker(AesAny),
}
impl Iv {
fn make(&self, sector_512: u64, byte_off: u64) -> [u8; 16] {
let mut iv = [0u8; 16];
match self {
Iv::Plain64 => iv[..8].copy_from_slice(&sector_512.to_le_bytes()),
Iv::Plain32 => iv[..4].copy_from_slice(&(sector_512 as u32).to_le_bytes()),
Iv::Plain64Be => iv[8..].copy_from_slice(&sector_512.to_be_bytes()),
Iv::Unit(u) => iv[..8].copy_from_slice(&(byte_off / u).to_le_bytes()),
Iv::Essiv(k) => {
iv[..8].copy_from_slice(&sector_512.to_le_bytes());
k.encrypt(&mut iv);
}
Iv::Eboiv(k) | Iv::Bitlocker(k) => {
iv[..8].copy_from_slice(&byte_off.to_le_bytes());
k.encrypt(&mut iv);
}
}
iv
}
}
/// Cifra aplicada setor a setor.
#[derive(Clone)]
pub enum SectorCipher {
Xts {
k1: AesAny,
k2: AesAny,
iv: Iv,
},
Cbc {
key: AesAny,
iv: Iv,
},
/// BitLocker AES-CBC com difusor Elephant (chave de dados + chave de ajuste).
Elephant {
key: AesAny,
tweak: AesAny,
},
}
fn gf_double(t: &mut [u8; 16]) {
let mut carry = 0u8;
for b in t.iter_mut() {
let next = *b >> 7;
*b = (*b << 1) | carry;
carry = next;
}
if carry != 0 {
t[0] ^= 0x87;
}
}
impl SectorCipher {
/// Decifra um setor no lugar. `sector_512` conta setores de 512 bytes; `byte_off` é a posição
/// em bytes relativa ao início dos dados cifrados.
pub fn decrypt_sector(&self, buf: &mut [u8], sector_512: u64, byte_off: u64) {
match self {
SectorCipher::Xts { k1, k2, iv } => {
let mut t = iv.make(sector_512, byte_off);
k2.encrypt(&mut t);
for ch in buf.chunks_exact_mut(16) {
let mut b = [0u8; 16];
b.copy_from_slice(ch);
xor16(&mut b, &t);
k1.decrypt(&mut b);
xor16(&mut b, &t);
ch.copy_from_slice(&b);
gf_double(&mut t);
}
}
SectorCipher::Cbc { key, iv } => {
cbc_decrypt(key, &iv.make(sector_512, byte_off), buf);
}
SectorCipher::Elephant { key, tweak } => {
bitlocker::elephant_decrypt(key, tweak, buf, byte_off);
}
}
}
}
pub fn cbc_decrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
let mut prev = *iv;
for ch in buf.chunks_exact_mut(16) {
let mut c = [0u8; 16];
c.copy_from_slice(ch);
let mut b = c;
key.decrypt(&mut b);
xor16(&mut b, &prev);
ch.copy_from_slice(&b);
prev = c;
}
}
pub fn cbc_encrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
let mut prev = *iv;
for ch in buf.chunks_exact_mut(16) {
let mut b = [0u8; 16];
b.copy_from_slice(ch);
xor16(&mut b, &prev);
key.encrypt(&mut b);
ch.copy_from_slice(&b);
prev = b;
}
}
/// Volume cifrado visto em claro: `len` bytes que começam em `data_offset` na origem.
pub struct CryptSource {
inner: Arc<dyn Source>,
cipher: SectorCipher,
data_offset: u64,
len: u64,
sector: u64,
/// Setores antes deste limite estão em claro (BitLocker parcialmente cifrado) ou
/// são cobertos por uma cópia (os primeiros setores do BitLocker).
patches: Vec<Patch>,
/// A partir deste byte (relativo aos dados) o conteúdo ainda não foi cifrado.
plain_from: u64,
/// Somado ao número do setor no cálculo do IV (iv_tweak do LUKS2).
iv_tweak: u64,
}
/// Trecho do volume lido de outro lugar da origem (BitLocker move o início do volume).
#[derive(Clone)]
pub struct Patch {
/// Início no volume em claro.
pub at: u64,
pub len: u64,
/// Onde estão os dados, na origem, já em claro.
pub from: u64,
/// Se os bytes de origem precisam ser decifrados (usando a posição `from_crypt`).
pub decrypt_from: Option<u64>,
/// Devolve zeros (áreas de metadados do BitLocker).
pub zero: bool,
}
impl CryptSource {
pub fn new(
inner: Arc<dyn Source>,
cipher: SectorCipher,
data_offset: u64,
len: u64,
sector: u64,
) -> Self {
let sector = sector.max(512);
Self {
inner,
cipher,
data_offset,
len: len / sector * sector,
sector,
patches: vec![],
plain_from: u64::MAX,
iv_tweak: 0,
}
}
pub fn with_patches(mut self, p: Vec<Patch>) -> Self {
self.patches = p;
self
}
pub fn with_iv_tweak(mut self, t: u64) -> Self {
self.iv_tweak = t;
self
}
pub fn with_plain_from(mut self, off: u64) -> Self {
self.plain_from = off;
self
}
fn read_sector_plain(&self, rel: u64, out: &mut [u8]) -> io::Result<()> {
// `rel` alinhado ao setor; `out.len() == sector`.
for p in &self.patches {
if rel >= p.at && rel < p.at + p.len {
if p.zero {
out.fill(0);
return Ok(());
}
let src_off = p.from + (rel - p.at);
crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + src_off, out)?;
if let Some(pos) = p.decrypt_from {
let r = pos + (rel - p.at);
self.cipher.decrypt_sector(out, r / 512, r);
}
return Ok(());
}
}
crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + rel, out)?;
if rel < self.plain_from {
self.cipher
.decrypt_sector(out, rel / 512 + self.iv_tweak, rel);
}
Ok(())
}
}
impl Source for CryptSource {
fn size(&self) -> u64 {
self.len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.len || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((self.len - offset) as usize);
let s = self.sector;
let mut done = 0usize;
let mut tmp = vec![0u8; s as usize];
while done < want {
let pos = offset + done as u64;
let rel = pos / s * s;
let within = (pos - rel) as usize;
let n = (s as usize - within).min(want - done);
self.read_sector_plain(rel, &mut tmp)?;
buf[done..done + n].copy_from_slice(&tmp[within..within + n]);
done += n;
}
Ok(done)
}
}
/// Algo que o usuário fornece para abrir um volume.
#[derive(Clone)]
pub enum Secret {
Password(String),
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos).
RecoveryKey(String),
/// Conteúdo de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
KeyFile(Vec<u8>),
/// Chave do volume já conhecida (FVEK/master key).
VolumeKey(Vec<u8>),
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Secret::Password(_) => "Secret::Password(..)",
Secret::RecoveryKey(_) => "Secret::RecoveryKey(..)",
Secret::KeyFile(_) => "Secret::KeyFile(..)",
Secret::VolumeKey(_) => "Secret::VolumeKey(..)",
})
}
}
#[derive(Debug)]
pub enum UnlockError {
/// Nenhuma das senhas/chaves abriu o volume.
WrongSecret,
Unsupported(String),
Corrupt(String),
}
impl std::fmt::Display for UnlockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UnlockError::WrongSecret => write!(f, "senha ou chave incorreta"),
UnlockError::Unsupported(s) => write!(f, "não suportado: {s}"),
UnlockError::Corrupt(s) => write!(f, "cabeçalho inválido: {s}"),
}
}
}
impl From<UnlockError> for crate::Error {
fn from(e: UnlockError) -> Self {
crate::Error::msg(e.to_string())
}
}
/// Volume aberto.
pub struct Unlocked {
pub source: Arc<dyn Source>,
pub description: String,
/// Chave do volume (para reabrir sem a senha, se o usuário permitir guardá-la).
pub volume_key: Option<Vec<u8>>,
}
pub type UnlockResult = std::result::Result<Unlocked, UnlockError>;
pub fn to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub fn from_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim();
if s.len() % 2 != 0 {
return None;
}
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok())
.collect()
}
+398
View File
@@ -0,0 +1,398 @@
//! Recomposição de arquivos fragmentados no carving.
//!
//! Para JPEG: valida os bytes contíguos com o decodificador de entropia; se a imagem quebra,
//! procura entre os clusters "com cara de JPEG" próximos a continuação cujo DC de luminância
//! emenda sem salto, até três fragmentos. É o que dá para fazer sem tabela de alocação, e só
//! vale quando o sistema de arquivos não deixou metadados para o arquivo.
use crate::jpegscan::{self, Header, State, Stop};
use crate::source::{read_vec, Source};
use crate::types::Extent;
const MAX_FILE: usize = 48 << 20;
/// Janela de busca da continuação, em bytes, depois e antes do primeiro fragmento.
const WINDOW_AFTER: u64 = 512 << 20;
const WINDOW_BEFORE: u64 = 64 << 20;
const PROBE: usize = 8192;
pub const CLUSTER: u64 = 4096;
const MAX_PROBES: usize = 20_000;
/// Marca os clusters (de 4 KiB, alinhados ao início da origem) que parecem dados entropy-coded
/// de JPEG: sem marcadores no meio e com bytes bem distribuídos.
#[derive(Default, Clone)]
pub struct ClusterIndex {
bits: Vec<u64>,
}
impl ClusterIndex {
pub fn new(size: u64) -> Self {
Self {
bits: vec![0; (size / CLUSTER / 64 + 1) as usize],
}
}
pub fn set(&mut self, off: u64) {
let c = (off / CLUSTER) as usize;
if let Some(w) = self.bits.get_mut(c / 64) {
*w |= 1 << (c % 64);
}
}
pub fn get(&self, off: u64) -> bool {
let c = (off / CLUSTER) as usize;
self.bits
.get(c / 64)
.map(|w| w >> (c % 64) & 1 == 1)
.unwrap_or(false)
}
/// Examina um trecho lido e marca os clusters candidatos.
pub fn scan_chunk(&mut self, abs: u64, buf: &[u8]) {
let first = abs.div_ceil(CLUSTER) * CLUSTER;
let mut o = first;
while o + CLUSTER <= abs + buf.len() as u64 {
let c = &buf[(o - abs) as usize..(o - abs + CLUSTER) as usize];
if jpeg_like(c) {
self.set(o);
}
o += CLUSTER;
}
}
pub fn iter_range(&self, from: u64, to: u64) -> impl Iterator<Item = u64> + '_ {
let (a, b) = (from.div_ceil(CLUSTER), to / CLUSTER);
(a..b)
.filter(move |&c| {
self.bits
.get(c as usize / 64)
.map(|w| w >> (c % 64) & 1 == 1)
.unwrap_or(false)
})
.map(|c| c * CLUSTER)
}
}
pub fn jpeg_like(c: &[u8]) -> bool {
let mut seen = [false; 256];
let mut distinct = 0;
for (i, &b) in c.iter().enumerate() {
if !seen[b as usize] {
seen[b as usize] = true;
distinct += 1;
}
if b == 0xFF {
match c.get(i + 1) {
Some(&n) if n == 0x00 || (0xD0..=0xD7).contains(&n) => {}
Some(0xD9) if i + 2 >= c.len() - 16 => {}
None => {}
_ => return false,
}
}
}
distinct >= 190
}
pub struct Pool<'a> {
pub index: &'a ClusterIndex,
/// Extents já atribuídos a outros arquivos.
pub used: &'a [Extent],
}
impl Pool<'_> {
fn free_at(&self, off: u64, len: u64) -> bool {
!self
.used
.iter()
.any(|u| off < u.end() && off + len > u.offset)
}
}
#[derive(Debug)]
pub enum Analysis {
/// Contíguo e consistente; `len` inclui o EOI.
Intact { len: u64 },
/// Remontado a partir de fragmentos.
Rebuilt { extents: Vec<Extent>, size: u64 },
/// Só o início é confiável.
Broken { good_len: u64 },
/// Formato que não sabemos validar (progressivo etc.).
Unsupported,
}
/// Procura FF D9 logo depois do último MCU (até 64 bytes de preenchimento).
fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
let end = (pos + 64).min(data.len().saturating_sub(1));
(pos.saturating_sub(2)..end)
.find(|&i| data[i] == 0xFF && data[i + 1] == 0xD9)
.map(|i| i + 2)
}
/// Valida o JPEG que começa em `off` e, se estiver quebrado, tenta recompô-lo.
pub fn analyze_jpeg(
src: &dyn Source,
off: u64,
hit_len: u64,
pool: &Pool,
allow_rebuild: bool,
) -> Analysis {
let read_len = (hit_len as usize).clamp(4096, MAX_FILE);
let Ok(buf) = read_vec(src, off, read_len) else {
return Analysis::Unsupported;
};
let Some(h) = jpegscan::parse_header(&buf) else {
return Analysis::Unsupported;
};
let d = jpegscan::decode(&h, &buf, State::start(&h), u32::MAX, 512);
let disc = jpegscan::first_discontinuity(&h, &d.dc);
if d.stop == Stop::Done && disc.is_none() {
if let Some(end) = eoi_after(&buf, d.state.pos) {
return Analysis::Intact { len: end as u64 };
}
}
let mut stop_pos = match d.stop {
Stop::Marker(_, p) | Stop::Error(p) => p,
Stop::Truncated | Stop::Done => buf.len(),
};
if let Some(m) = disc {
let bad = d
.snaps
.iter()
.rev()
.find(|s| (s.mcu as usize) <= m)
.map(|s| s.pos)
.unwrap_or(h.scan_start);
stop_pos = stop_pos.min(bad);
}
let cl = CLUSTER as usize;
if off % CLUSTER != 0 || !allow_rebuild {
return Analysis::Broken {
good_len: (stop_pos / 512 * 512).max(512) as u64,
};
}
let mut pieces: Vec<(u64, usize)> = vec![];
let mut joined = buf;
let mut cur_base = off; // início absoluto do fragmento atual
let mut frag_start = 0usize; // posição em `joined` onde o fragmento atual começa
let mut snaps = d.snaps;
let mut suspect = stop_pos;
for _ in 0..3 {
let top = suspect.saturating_sub(frag_start) / cl * cl;
let mut best: Option<(usize, u64, State, f64)> = None;
let mut probes = 0usize;
for back in 0..4usize {
let Some(bc_rel) = top.checked_sub(back * cl).filter(|&b| b > 0) else {
break;
};
let bc = frag_start + bc_rel;
let Some(snap) = snaps
.iter()
.rev()
.find(|s| s.pos <= bc)
.cloned()
.or_else(|| (frag_start == 0).then(|| State::start(&h)))
else {
continue;
};
let mut plane = jpegscan::Plane::new(&h);
let _ =
jpegscan::decode_plane(&h, &joined[..bc], State::start(&h), u32::MAX, &mut plane);
let lo = off.saturating_sub(WINDOW_BEFORE);
let hi = cur_base + bc_rel as u64 + WINDOW_AFTER;
let pre = jpegscan::decode(&h, &joined[..bc], State::start(&h), u32::MAX, 0);
let bpm_prefix =
(pre.state.pos.saturating_sub(h.scan_start)) as f64 / pre.dc.len().max(1) as f64;
let mut ranked: Vec<(f64, u64)> = vec![];
for q in pool.index.iter_range(lo, hi) {
if q >= cur_base && q < cur_base + bc_rel as u64 + CLUSTER {
continue;
}
if !pool.free_at(q, CLUSTER)
|| pieces
.iter()
.any(|&(po, pl)| q < po + pl as u64 && q + CLUSTER > po)
{
continue;
}
probes += 1;
if probes > MAX_PROBES {
break;
}
if let Some(sc) =
probe_candidate(src, &h, &joined[..bc], &plane, &snap, q, bpm_prefix)
{
ranked.push((sc, q));
}
}
ranked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
ranked.truncate(120);
// Etapa 2: a continuação certa fecha a imagem exatamente no último MCU.
let mut longest: Option<(usize, u64, f64)> = None;
for (sc, q) in ranked {
let avail = (src.size() - q) as usize;
let more = read_vec(src, q, MAX_FILE.min(avail)).unwrap_or_default();
let mut j2 = joined[..bc].to_vec();
j2.extend_from_slice(&more);
let d2 = jpegscan::decode(&h, &j2, snap.clone(), u32::MAX, 0);
if d2.stop == Stop::Done && eoi_after(&j2, d2.state.pos).is_some() {
best = Some((bc, q, snap.clone(), 0.0));
break;
}
let reach = match d2.stop {
Stop::Marker(_, p) | Stop::Error(p) => p.saturating_sub(bc),
_ => j2.len() - bc,
};
if reach >= 3 * cl && longest.map_or(true, |l| sc < l.2) {
longest = Some((reach, q, sc));
}
}
if best.is_none() {
if let Some((_, q, sc)) = longest {
if sc < 25.0 {
let cand = (bc, q, snap.clone(), sc);
if best.as_ref().map_or(true, |b| sc < b.3) {
best = Some(cand);
}
}
}
}
if best.as_ref().is_some_and(|b| b.3 == 0.0) {
break;
}
}
let Some((bc, q, snap, _)) = best else { break };
pieces.push((cur_base, bc - frag_start));
let more = read_vec(src, q, MAX_FILE.min((src.size() - q) as usize)).unwrap_or_default();
joined.truncate(bc);
joined.extend_from_slice(&more);
let d2 = jpegscan::decode(&h, &joined, snap, u32::MAX, 512);
if d2.stop == Stop::Done {
if let Some(end) = eoi_after(&joined, d2.state.pos) {
pieces.push((q, end - bc));
return finish(pieces);
}
}
// Quebrou de novo: onde?
let mut sp = match d2.stop {
Stop::Marker(_, p) | Stop::Error(p) => p,
_ => joined.len(),
};
let all = jpegscan::decode(&h, &joined, State::start(&h), u32::MAX, 0);
if let Some(m) = jpegscan::first_discontinuity(&h, &all.dc) {
let bad = d2
.snaps
.iter()
.rev()
.find(|s| (s.mcu as usize) <= m)
.map(|s| s.pos)
.unwrap_or(bc);
if bad > bc {
sp = sp.min(bad);
}
}
snaps = d2.snaps;
cur_base = q;
frag_start = bc;
suspect = sp.max(bc + cl);
}
let good = pieces.iter().map(|p| p.1).sum::<usize>()
+ suspect.saturating_sub(frag_start).min(cl * 4) / 512 * 512;
Analysis::Broken {
good_len: (stop_pos.min(good.max(512)) / 512 * 512).max(512) as u64,
}
}
fn finish(pieces: Vec<(u64, usize)>) -> Analysis {
let mut extents: Vec<Extent> = vec![];
for (o, l) in &pieces {
match extents.last_mut() {
Some(last) if last.end() == *o => last.len += *l as u64,
_ => extents.push(Extent::new(*o, *l as u64)),
}
}
let size = pieces.iter().map(|p| p.1 as u64).sum();
if extents.len() == 1 {
return Analysis::Intact { len: size };
}
Analysis::Rebuilt { extents, size }
}
/// Pontuação (menor é melhor) de `q` como continuação de `prefix`: diferença média de pixels
/// nas bordas entre os MCUs novos e seus vizinhos. Natural: poucos níveis de cinza; lixo: dezenas.
fn probe_candidate(
src: &dyn Source,
h: &Header,
prefix: &[u8],
plane: &jpegscan::Plane,
snap: &State,
q: u64,
bpm_prefix: f64,
) -> Option<f64> {
let more = read_vec(src, q, PROBE).ok()?;
if more.len() < 1024 {
return None;
}
let mut joined = Vec::with_capacity(prefix.len() + more.len());
joined.extend_from_slice(prefix);
joined.extend_from_slice(&more);
let seam = plane.have;
const K: u32 = 36;
let mut news: std::collections::HashMap<u32, Vec<u8>> = std::collections::HashMap::new();
let mut hook = |mcu: u32, blocks: &[[i32; 64]]| {
if mcu >= seam {
news.insert(mcu, jpegscan::mcu_luma(h, blocks));
}
};
let want = seam.saturating_sub(snap.mcu) + K + 1;
let d = jpegscan::decode_hook(h, &joined, snap.clone(), want, 0, Some(&mut hook));
match d.stop {
Stop::Error(_) => return None,
Stop::Marker(m, _) if m != 0xD9 => return None,
_ => {}
}
let (mw, mh) = (plane.mw, plane.mh);
let mx = h.mcus_x;
let get = |idx: u32| -> Option<Vec<u8>> {
if idx < seam {
let (x, y) = ((idx % mx) as usize, (idx / mx) as usize);
let mut v = vec![0u8; mw * mh];
for r in 0..mh {
let o = (y * mh + r) * plane.w + x * mw;
v[r * mw..(r + 1) * mw].copy_from_slice(&plane.px[o..o + mw]);
}
Some(v)
} else {
news.get(&idx).cloned()
}
};
let (mut sum, mut n) = (0f64, 0u32);
for idx in seam + 1..seam + 1 + K {
let Some(cur) = news.get(&idx) else { break };
if idx % mx > 0 {
if let Some(l) = get(idx - 1) {
for y in 0..mh {
sum += (cur[y * mw] as i32 - l[y * mw + mw - 1] as i32).abs() as f64;
n += 1;
}
}
}
if idx >= mx {
if let Some(t) = get(idx - mx) {
for x in 0..mw {
sum += (cur[x] as i32 - t[(mh - 1) * mw + x] as i32).abs() as f64;
n += 1;
}
}
}
}
if n < 40 {
return None;
}
// Bytes por MCU depois da emenda, comparados com os do trecho anterior.
let after = d.dc.len() as f64 - (seam.saturating_sub(snap.mcu)) as f64;
let used = d.state.pos.saturating_sub(prefix.len()) as f64;
let ratio = if after > 4.0 && used > 0.0 {
(used / after) / bpm_prefix.max(0.5)
} else {
1.0
};
let sc = sum / n as f64 + 20.0 * ratio.ln().abs();
Some(sc)
}
File diff suppressed because it is too large. Load diff
+377
View File
@@ -0,0 +1,377 @@
//! Navegação estilo gerenciador de arquivos: filtros, ordenação, facetas
//! (tipos, datas, pastas, câmeras) e cache de miniaturas.
use crate::types::*;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::{Arc, Condvar, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Query {
#[serde(default)]
pub category: Option<Category>,
#[serde(default)]
pub min_confidence: Option<Confidence>,
#[serde(default)]
pub query: Option<String>,
#[serde(default)]
pub sort: Option<String>,
#[serde(default)]
pub offset: usize,
#[serde(default = "default_limit")]
pub limit: usize,
/// Pasta (prefixo), "@carved" (achados por assinatura) ou "@raiz".
#[serde(default)]
pub folder: Option<String>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub month: Option<u32>,
/// Só arquivos sem data conhecida.
#[serde(default)]
pub undated: Option<bool>,
#[serde(default)]
pub camera: Option<String>,
#[serde(default)]
pub origin: Option<Origin>,
#[serde(default)]
pub min_size: Option<u64>,
#[serde(default)]
pub max_size: Option<u64>,
/// Megapixels mínimos.
#[serde(default)]
pub min_mp: Option<f64>,
#[serde(default)]
pub has_gps: Option<bool>,
/// Mostra só a primeira ocorrência de cada conteúdo repetido.
#[serde(default)]
pub dedupe: Option<bool>,
}
fn default_limit() -> usize {
200
}
impl Default for Query {
fn default() -> Self {
serde_json::from_str("{}").unwrap()
}
}
impl Query {
pub fn needle(&self) -> Option<String> {
self.query
.as_deref()
.map(|x| x.to_lowercase())
.filter(|x| !x.is_empty())
}
/// Itens que atendem ao filtro (na ordem de achado), já sem duplicadas se pedido.
pub fn select<'a>(&self, found: &'a [Found]) -> Vec<&'a Found> {
let needle = self.needle();
let mut v: Vec<&Found> = found.iter().filter(|f| self.matches(f, &needle)).collect();
if self.dedupe == Some(true) {
let mut seen = std::collections::HashSet::new();
v.retain(|f| f.sig.map(|g| seen.insert((g, f.size))).unwrap_or(true));
}
v
}
pub fn matches(&self, f: &Found, needle: &Option<String>) -> bool {
if self.category.is_some_and(|c| f.category != c) {
return false;
}
if self.min_confidence.is_some_and(|c| f.confidence < c) {
return false;
}
if self.origin.is_some_and(|o| f.origin != o) {
return false;
}
if self.min_size.is_some_and(|m| f.size < m) || self.max_size.is_some_and(|m| f.size > m) {
return false;
}
if let Some(n) = needle {
let hay =
format!("{} {}", f.full_path(), f.camera.as_deref().unwrap_or("")).to_lowercase();
if !hay.contains(n.as_str()) {
return false;
}
}
if let Some(folder) = &self.folder {
let ok = match folder.as_str() {
"@carved" => f.origin == Origin::Carved,
"@raiz" => {
f.origin != Origin::Carved
&& f.dir.as_deref().map(str::is_empty).unwrap_or(true)
}
p => f
.dir
.as_deref()
.is_some_and(|d| d == p || d.starts_with(&format!("{p}/"))),
};
if !ok {
return false;
}
}
if self.year.is_some() || self.month.is_some() {
let Some(t) = f.when() else { return false };
let (y, m, _) = crate::meta::ymd(t);
if self.year.is_some_and(|yy| yy != y) || self.month.is_some_and(|mm| mm != m) {
return false;
}
}
if self.undated == Some(true) && f.when().is_some() {
return false;
}
if let Some(c) = &self.camera {
if f.camera.as_deref() != Some(c.as_str()) {
return false;
}
}
if let Some(mp) = self.min_mp {
match f.px {
Some((w, h)) if (w as f64 * h as f64) / 1e6 >= mp => {}
_ => return false,
}
}
if self.has_gps == Some(true) && !f.gps {
return false;
}
true
}
}
pub fn sort_items(items: &mut [&Found], sort: Option<&str>) {
match sort {
Some("name") => items.sort_by_key(|f| f.name.to_lowercase()),
Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)),
Some("date") => items.sort_by(|a, b| b.when().cmp(&a.when()).then(a.id.cmp(&b.id))),
Some("date_asc") => items.sort_by(|a, b| match (a.when(), b.when()) {
(Some(x), Some(y)) => x.cmp(&y).then(a.id.cmp(&b.id)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
_ => a.id.cmp(&b.id),
}),
Some("confidence") => {
items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size)))
}
_ => {}
}
}
pub fn item_json(f: &Found) -> Value {
json!({
"id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category,
"size": f.size, "origin": f.origin, "confidence": f.confidence,
"modified": f.modified, "created": f.created, "accessed": f.accessed,
"taken": f.taken, "when": f.when(), "camera": f.camera, "px": f.px, "gps": f.gps,
"note": f.note,
})
}
/// Contagens para a navegação lateral.
pub fn facets(found: &[Found]) -> Value {
let mut cats: HashMap<&'static str, usize> = HashMap::new();
let mut years: BTreeMap<i64, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
let mut cams: BTreeMap<String, usize> = BTreeMap::new();
let mut folders: BTreeMap<String, usize> = BTreeMap::new();
let (mut undated, mut carved, mut root, mut gps) = (0usize, 0usize, 0usize, 0usize);
let mut conf = [0usize; 3];
let mut seen_sig = std::collections::HashSet::new();
let mut dups = 0usize;
let (mut small, mut mp1) = (0usize, 0usize);
for f in found {
if f.size < 30 * 1024 {
small += 1;
}
if f.px.is_some_and(|(w, h)| w as u64 * h as u64 >= 1_000_000) {
mp1 += 1;
}
if let Some(g) = f.sig {
if !seen_sig.insert((g, f.size)) {
dups += 1;
}
}
*cats.entry(cat_key(f.category)).or_default() += 1;
conf[f.confidence as usize] += 1;
match f.when() {
Some(t) => {
let (y, m, _) = crate::meta::ymd(t);
let e = years.entry(y).or_default();
e.0 += 1;
*e.1.entry(m).or_default() += 1;
}
None => undated += 1,
}
if let Some(c) = &f.camera {
*cams.entry(c.clone()).or_default() += 1;
}
if f.gps {
gps += 1;
}
if f.origin == Origin::Carved {
carved += 1;
} else if let Some(d) = f.dir.as_deref().filter(|d| !d.is_empty()) {
let mut acc = String::new();
for part in d.split('/') {
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(part);
*folders.entry(acc.clone()).or_default() += 1;
}
} else {
root += 1;
}
}
let cams_v: Vec<Value> = {
let mut v: Vec<_> = cams.into_iter().collect();
v.sort_by_key(|x| std::cmp::Reverse(x.1));
v.into_iter()
.map(|(n, c)| json!({ "name": n, "count": c }))
.collect()
};
json!({
"total": found.len(),
"categories": cats,
"confidence": { "low": conf[0], "medium": conf[1], "high": conf[2] },
"years": years.iter().rev().map(|(y, (n, ms))| json!({
"year": y, "count": n,
"months": ms.iter().rev().map(|(m, c)| json!({"month": m, "count": c})).collect::<Vec<_>>()
})).collect::<Vec<_>>(),
"undated": undated,
"cameras": cams_v,
"folders": folders.iter().map(|(p, c)| json!({
"path": p, "name": p.rsplit('/').next().unwrap_or(p), "depth": p.matches('/').count(), "count": c
})).collect::<Vec<_>>(),
"carved": carved,
"root": root,
"gps": gps,
"duplicates": dups,
"small": small,
"mp1": mp1,
})
}
pub fn cat_key(c: Category) -> &'static str {
match c {
Category::Image => "image",
Category::Document => "document",
Category::Video => "video",
Category::Audio => "audio",
Category::Archive => "archive",
Category::Database => "database",
Category::Other => "other",
}
}
// ------------------------------------------------------------------ cache de miniaturas
type Key = (String, u64, u32);
pub struct ThumbCache {
map: HashMap<Key, Arc<Vec<u8>>>,
order: VecDeque<Key>,
bytes: usize,
}
impl ThumbCache {
const CAP: usize = 128 * 1024 * 1024;
pub fn new() -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
bytes: 0,
}
}
pub fn get(&self, k: &Key) -> Option<Arc<Vec<u8>>> {
self.map.get(k).cloned()
}
pub fn put(&mut self, k: Key, v: Arc<Vec<u8>>) {
self.bytes += v.len();
if self.map.insert(k.clone(), v).is_none() {
self.order.push_back(k);
}
while self.bytes > Self::CAP {
let Some(old) = self.order.pop_front() else {
break;
};
if let Some(v) = self.map.remove(&old) {
self.bytes -= v.len();
}
}
}
}
impl Default for ThumbCache {
fn default() -> Self {
Self::new()
}
}
/// Limita quantas miniaturas são decodificadas ao mesmo tempo (memória/CPU).
pub struct Gate {
n: Mutex<usize>,
cv: Condvar,
max: usize,
}
pub struct Permit<'a>(&'a Gate);
impl Gate {
pub fn new(max: usize) -> Self {
Self {
n: Mutex::new(0),
cv: Condvar::new(),
max: max.max(1),
}
}
pub fn acquire(&self) -> Permit<'_> {
let mut n = self.n.lock().unwrap();
while *n >= self.max {
n = self.cv.wait(n).unwrap();
}
*n += 1;
Permit(self)
}
}
impl Drop for Permit<'_> {
fn drop(&mut self) {
*self.0.n.lock().unwrap() -= 1;
self.0.cv.notify_one();
}
}
/// Assinatura rápida: tamanho + FNV-1a do início (32 KiB) e do fim (32 KiB).
pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
use crate::recover::read_found_at;
if f.size == 0 {
return None;
}
let mut h: u64 = 0xcbf29ce484222325 ^ f.size;
let mut feed = |off: u64| {
let mut buf = vec![0u8; 32 * 1024];
let mut got = 0;
while got < buf.len() {
match read_found_at(src, f, off + got as u64, &mut buf[got..]) {
Ok(0) | Err(_) => break,
Ok(n) => got += n,
}
}
for &b in &buf[..got] {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
got
};
let a = feed(0);
if a == 0 {
return None;
}
if f.size > 64 * 1024 {
feed(f.size - 32 * 1024);
}
Some(h)
}
+110
View File
@@ -0,0 +1,110 @@
//! Abre um arquivo achado no aplicativo padrão do sistema (player de vídeo, leitor
//! de PDF, etc.). Usado quando o WebView do app não consegue reproduzir a mídia:
//! o WebKitGTK empacotado no AppImage não traz os plugins de mídia do GStreamer e
//! o processo da página morre ao criar um `<video>`.
use crate::error::{Error, Result};
use crate::fs::sanitize_name;
use crate::types::Found;
use std::path::{Path, PathBuf};
/// Pasta temporária própria do usuário (nunca compartilhada).
fn temp_dir() -> PathBuf {
let uid = crate::owner::invoking_user()
.map(|u| u.uid.to_string())
.unwrap_or_else(|| {
#[cfg(unix)]
{
// SAFETY: getuid não tem pré-condições.
unsafe { libc::getuid() }.to_string()
}
#[cfg(not(unix))]
{
"user".to_string()
}
});
std::env::temp_dir().join(format!("guara-abrir-{uid}"))
}
/// Remove cópias com mais de um dia.
fn cleanup(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let limit = std::time::Duration::from_secs(24 * 3600);
for e in rd.flatten() {
if let Ok(age) = e
.metadata()
.and_then(|m| m.modified())
.and_then(|t| t.elapsed().map_err(std::io::Error::other))
{
if age > limit {
let _ = std::fs::remove_file(e.path());
}
}
}
}
/// Copia o arquivo achado para uma pasta temporária do usuário e o abre no aplicativo padrão.
pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf> {
const MAX: u64 = 8 * 1024 * 1024 * 1024;
if f.size > MAX {
return Err(Error::msg("Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos."));
}
let dir = temp_dir();
crate::owner::create_dir_all_owned(&dir)?;
cleanup(&dir);
let path = dir.join(format!("{}-{}", f.id, sanitize_name(&f.name)));
let _ = std::fs::remove_file(&path);
crate::recover::write_file(src, f, &path)?;
open_path(&path)
.map_err(|e| Error::msg(format!("Não foi possível abrir {}: {e}", path.display())))?;
Ok(path)
}
/// Abre o caminho com o aplicativo padrão, na sessão do usuário real mesmo se formos root.
pub fn open_path(path: &Path) -> std::io::Result<()> {
use std::process::{Command, Stdio};
#[cfg(target_os = "linux")]
let mut cmd = if let Some(u) = crate::owner::invoking_user() {
let mut c = Command::new("runuser");
c.args(["-u", &u.name, "--", "env"]);
for k in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY"] {
if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}"));
}
}
c.arg(format!("XDG_RUNTIME_DIR=/run/user/{}", u.uid));
c.arg(format!(
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus",
u.uid
));
c.arg("xdg-open").arg(path);
c
} else {
let mut c = Command::new("xdg-open");
c.arg(path);
c
};
#[cfg(target_os = "macos")]
let mut cmd = {
let mut c = Command::new("open");
c.arg(path);
c
};
#[cfg(windows)]
let mut cmd = {
let mut c = Command::new("cmd");
c.args(["/C", "start", ""]).arg(path);
c
};
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
File diff suppressed because it is too large. Load diff
+651
View File
@@ -0,0 +1,651 @@
//! APFS (volumes sem cifragem): lê o contêiner, o mapa de objetos e a árvore de arquivos de cada
//! volume; arquivos apagados vêm de duas fontes: as árvores dos instantâneos (snapshots) e os nós
//! antigos da árvore (copy-on-write) que ainda estão no disco. Volumes cifrados (FileVault) são
//! detectados e deixados para a varredura por assinatura.
use super::{make_found, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::{BTreeMap, HashMap, HashSet};
fn le16(b: &[u8], o: usize) -> u16 {
b.get(o..o + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
.unwrap_or(0)
}
fn le32(b: &[u8], o: usize) -> u32 {
b.get(o..o + 4)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
fn le64(b: &[u8], o: usize) -> u64 {
b.get(o..o + 8)
.map(|s| u64::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
const OBJ_TYPE_MASK: u32 = 0xFFFF;
const T_BTREE_ROOT: u32 = 2;
const T_BTREE_NODE: u32 = 3;
const SUBTYPE_FSTREE: u32 = 0x0E;
// tipos de registro da árvore de arquivos
const R_SNAP_METADATA: u8 = 1;
const R_INODE: u8 = 3;
const R_FILE_EXTENT: u8 = 8;
const R_DIR_REC: u8 = 9;
/// Soma de verificação Fletcher-64 do APFS (cobre o bloco menos os 8 bytes iniciais).
pub fn fletcher64(data: &[u8]) -> u64 {
let (mut s1, mut s2) = (0u64, 0u64);
for w in data.chunks_exact(4) {
s1 = (s1 + u32::from_le_bytes(w.try_into().unwrap()) as u64) % 0xFFFF_FFFF;
s2 = (s2 + s1) % 0xFFFF_FFFF;
}
let c1 = 0xFFFF_FFFF - ((s1 + s2) % 0xFFFF_FFFF);
let c2 = 0xFFFF_FFFF - ((s1 + c1) % 0xFFFF_FFFF);
(c2 << 32) | c1
}
struct Apfs<'a> {
src: &'a dyn Source,
base: u64,
bs: u64,
blocks: u64,
}
struct BNode {
flags: u16,
level: u16,
/// (chave, valor) por entrada
entries: Vec<(Vec<u8>, Vec<u8>)>,
oid: u64,
xid: u64,
}
impl<'a> Apfs<'a> {
fn block(&self, paddr: u64) -> Option<Vec<u8>> {
if paddr >= self.blocks {
return None;
}
let b = read_vec(self.src, self.base + paddr * self.bs, self.bs as usize).ok()?;
(b.len() as u64 == self.bs).then_some(b)
}
fn checked_block(&self, paddr: u64) -> Option<Vec<u8>> {
let b = self.block(paddr)?;
(fletcher64(&b[8..]) == le64(&b, 0)).then_some(b)
}
fn parse_node(&self, b: &[u8], fixed_k: usize, fixed_v: usize) -> Option<BNode> {
let flags = le16(b, 32);
let level = le16(b, 34);
let n = le32(b, 36) as usize;
let toc_off = le16(b, 40) as usize;
let toc_len = le16(b, 42) as usize;
let is_fixed = flags & 4 != 0;
let is_root = flags & 1 != 0;
let key_base = 56 + toc_off + toc_len;
let val_end = b.len() - if is_root { 40 } else { 0 };
let mut entries = Vec::with_capacity(n);
for i in 0..n {
let (koff, klen, voff, vlen);
if is_fixed {
let o = 56 + toc_off + i * 4;
koff = le16(b, o) as usize;
voff = le16(b, o + 2) as usize;
klen = fixed_k;
vlen = if level == 0 { fixed_v } else { 8 };
} else {
let o = 56 + toc_off + i * 8;
koff = le16(b, o) as usize;
klen = le16(b, o + 2) as usize;
voff = le16(b, o + 4) as usize;
vlen = le16(b, o + 6) as usize;
}
let ks = key_base + koff;
let k = b.get(ks..ks + klen)?.to_vec();
let v = if voff == 0xFFFF {
vec![]
} else {
b.get(val_end.checked_sub(voff)?..)?.get(..vlen)?.to_vec()
};
entries.push((k, v));
}
Some(BNode {
flags,
level,
entries,
oid: le64(b, 8),
xid: le64(b, 16),
})
}
/// Todas as entradas de uma árvore física (mapa de objetos): filhos são endereços físicos.
fn walk_physical(
&self,
root: u64,
fixed_k: usize,
fixed_v: usize,
f: &mut dyn FnMut(&[u8], &[u8]),
) {
let mut stack = vec![(root, 0u32)];
let mut seen = HashSet::new();
while let Some((p, depth)) = stack.pop() {
if depth > 8 || !seen.insert(p) {
continue;
}
let Some(b) = self.checked_block(p) else {
continue;
};
let Some(n) = self.parse_node(&b, fixed_k, fixed_v) else {
continue;
};
if n.level == 0 {
for (k, v) in &n.entries {
f(k, v);
}
} else {
for (_, v) in n.entries.iter().rev() {
stack.push((le64(v, 0), depth + 1));
}
}
}
}
/// Mapa de objetos: oid -> [(xid, paddr)] ordenado por xid.
fn omap(&self, omap_paddr: u64) -> HashMap<u64, Vec<(u64, u64)>> {
let mut m: HashMap<u64, Vec<(u64, u64)>> = HashMap::new();
let Some(ob) = self.checked_block(omap_paddr) else {
return m;
};
let tree = le64(&ob, 48);
self.walk_physical(tree, 16, 16, &mut |k, v| {
m.entry(le64(k, 0))
.or_default()
.push((le64(k, 8), le64(v, 8)));
});
for v in m.values_mut() {
v.sort();
}
m
}
}
fn lookup(m: &HashMap<u64, Vec<(u64, u64)>>, oid: u64, max_xid: u64) -> Option<u64> {
m.get(&oid)?
.iter()
.rev()
.find(|(x, _)| *x <= max_xid)
.map(|(_, p)| *p)
}
// ---------------------------------------------------------------- registros
#[derive(Clone, Debug, Default)]
struct Inode {
parent: u64,
private_id: u64,
mtime: i64,
ctime: i64,
atime: i64,
mode: u16,
size: u64,
name: Option<String>,
has: bool,
compressed: bool,
}
#[derive(Clone, Debug, Default)]
struct FileRec {
inode: Inode,
names: BTreeMap<u64, String>, // pai -> nome
}
type Files = HashMap<u64, FileRec>;
type Extents = HashMap<u64, BTreeMap<u64, (u64, u64, u64)>>; // private_id -> logical -> (len, paddr, crypto)
fn ns(t: u64) -> i64 {
(t / 1_000_000_000) as i64
}
fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
let hdr = le64(k, 0);
let (id, ty) = (hdr & 0x0FFF_FFFF_FFFF_FFFF, (hdr >> 60) as u8);
match ty {
R_INODE if v.len() >= 92 => {
let e = files.entry(id).or_default();
let mut ino = Inode {
parent: le64(v, 0),
private_id: le64(v, 8),
ctime: ns(le64(v, 16)), // criação
mtime: ns(le64(v, 24)),
atime: ns(le64(v, 40)),
mode: le16(v, 80),
size: le64(v, 84),
has: true,
..Default::default()
};
let flags = le64(v, 48);
ino.compressed = le32(v, 68) & 0x20 != 0;
let _ = flags;
// campos estendidos
let xf = &v[92..];
if xf.len() >= 4 {
let n = le16(xf, 0) as usize;
let mut p = 4 + n * 4;
for i in 0..n {
let (xt, xs) = (xf[4 + i * 4], le16(xf, 6 + i * 4) as usize);
let data = xf.get(p..p + xs).unwrap_or(&[]);
match xt {
4 => {
ino.name = Some(
String::from_utf8_lossy(
data.split(|&c| c == 0).next().unwrap_or(&[]),
)
.to_string(),
)
}
8 if data.len() >= 8 => ino.size = le64(data, 0),
_ => {}
}
p += xs.div_ceil(8) * 8;
}
}
e.inode = ino;
}
R_DIR_REC if k.len() > 12 && v.len() >= 18 => {
let nl = (le32(k, 8) & 0x3FF) as usize;
if let Some(name) = k.get(12..12 + nl) {
let name = String::from_utf8_lossy(name.split(|&c| c == 0).next().unwrap_or(&[]))
.to_string();
files.entry(le64(v, 0)).or_default().names.insert(id, name);
}
}
R_FILE_EXTENT if k.len() >= 16 && v.len() >= 24 => {
let len = le64(v, 0) & 0x00FF_FFFF_FFFF_FFFF;
extents
.entry(id)
.or_default()
.insert(le64(k, 8), (len, le64(v, 8), le64(v, 16)));
}
_ => {}
}
}
impl Apfs<'_> {
/// Percorre a árvore de arquivos a partir de uma raiz virtual.
fn walk_fstree(
&self,
root_oid: u64,
omap: &HashMap<u64, Vec<(u64, u64)>>,
xid: u64,
f: &mut dyn FnMut(&[u8], &[u8], u64),
) {
let Some(rp) = lookup(omap, root_oid, xid) else {
return;
};
let mut stack = vec![(rp, 0u32)];
let mut seen = HashSet::new();
while let Some((p, depth)) = stack.pop() {
if depth > 10 || !seen.insert(p) {
continue;
}
let Some(b) = self.checked_block(p) else {
continue;
};
let Some(n) = self.parse_node(&b, 16, 16) else {
continue;
};
if n.level == 0 {
for (k, v) in &n.entries {
f(k, v, p);
}
} else {
for (_, v) in n.entries.iter().rev() {
if let Some(cp) = lookup(omap, le64(v, 0), xid) {
stack.push((cp, depth + 1));
}
}
}
}
}
}
fn path_of(files: &Files, mut id: u64) -> String {
let mut parts = vec![];
for _ in 0..64 {
if id == 2 || id == 1 {
break;
}
match files
.get(&id)
.and_then(|f| f.names.iter().next().map(|(p, n)| (*p, n.clone())))
{
Some((p, n)) => {
parts.push(n);
id = p;
}
None => break,
}
}
parts.reverse();
parts.join("/")
}
struct Volume {
name: String,
omap_paddr: u64,
root_oid: u64,
snap_tree_oid: u64,
encrypted: bool,
}
fn parse_volume(b: &[u8]) -> Option<Volume> {
if &b[32..36] != b"APSB" {
return None;
}
let name = String::from_utf8_lossy(&b[704..960])
.split('\0')
.next()
.unwrap_or("")
.to_string();
Some(Volume {
name,
omap_paddr: le64(b, 128),
root_oid: le64(b, 136),
snap_tree_oid: le64(b, 152),
encrypted: le64(b, 264) & 1 == 0,
})
}
fn open_container<'a>(src: &'a dyn Source, base: u64) -> Option<(Apfs<'a>, Vec<u8>)> {
let b0 = read_vec(src, base, 4096).ok()?;
if b0.len() < 4096 || &b0[32..36] != b"NXSB" {
return None;
}
let bs = le32(&b0, 36) as u64;
let blocks = le64(&b0, 40);
if !(4096..=65536).contains(&bs) || !bs.is_power_of_two() {
return None;
}
let a = Apfs {
src,
base,
bs,
blocks,
};
// O superbloco mais recente está na área de descritores de checkpoint.
let desc_blocks = le32(&b0, 104) as u64;
let desc_base = le64(&b0, 112);
let mut best = b0.clone();
for i in 0..desc_blocks {
if let Some(b) = a.checked_block(desc_base as u64 + i) {
if &b[32..36] == b"NXSB" && le64(&b, 16) > le64(&best, 16) {
best = b;
}
}
}
Some((a, best))
}
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let _ = part_len;
let mut res = VolumeScan::default();
let Some((a, nx)) = open_container(ctx.src, base) else {
return res;
};
let Some(cont_omap_block) = a.checked_block(le64(&nx, 160)) else {
return res;
};
let _ = cont_omap_block;
let container_omap = a.omap(le64(&nx, 160));
let max_fs = le32(&nx, 180).min(100) as usize;
let mut vols: Vec<Volume> = vec![];
for i in 0..max_fs {
let oid = le64(&nx, 184 + i * 8);
if oid == 0 {
continue;
}
if let Some(p) = lookup(&container_omap, oid, u64::MAX) {
if let Some(b) = a.checked_block(p) {
if let Some(v) = parse_volume(&b) {
vols.push(v);
}
}
}
}
let nvols = vols.len();
for v in &vols {
if v.encrypted {
res.warnings.push(format!("APFS: o volume \"{}\" está cifrado (FileVault); os arquivos só são legíveis com a chave, então use a varredura por assinatura apenas se o volume já estiver aberto.", v.name));
continue;
}
scan_volume(ctx, &a, v, nvols == 1, &mut res);
if (ctx.cancelled)() {
return res;
}
}
res
}
fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res: &mut VolumeScan) {
let omap = a.omap(v.omap_paddr);
let current_xid = u64::MAX;
// Árvore atual.
let mut live: Files = HashMap::new();
let mut live_ext: Extents = HashMap::new();
let mut live_nodes: HashSet<u64> = HashSet::new();
a.walk_fstree(v.root_oid, &omap, current_xid, &mut |k, val, node| {
live_nodes.insert(node);
add_record(&mut live, &mut live_ext, k, val);
});
// Instantâneos.
let mut stale: Files = HashMap::new();
let mut stale_ext: Extents = HashMap::new();
if v.snap_tree_oid != 0 {
if let Some(sp) = lookup(&omap, v.snap_tree_oid, current_xid).or(Some(v.snap_tree_oid)) {
let mut snaps: Vec<(u64, u64)> = vec![]; // (xid, sblock paddr)
let mut stack = vec![sp];
let mut seen = HashSet::new();
while let Some(p) = stack.pop() {
if !seen.insert(p) {
continue;
}
let Some(b) = a.checked_block(p) else {
continue;
};
let Some(n) = a.parse_node(&b, 0, 0) else {
continue;
};
if n.level == 0 {
for (k, val) in &n.entries {
let hdr = le64(k, 0);
if (hdr >> 60) as u8 == R_SNAP_METADATA && val.len() >= 40 {
snaps.push((hdr & 0x0FFF_FFFF_FFFF_FFFF, le64(val, 8)));
}
}
} else {
for (_, val) in &n.entries {
// árvore de metadados de snapshot: filhos físicos ou virtuais
let c = le64(val, 0);
stack.push(lookup(&omap, c, current_xid).unwrap_or(c));
}
}
}
for (xid, sblock) in snaps {
let Some(sb) = a.checked_block(sblock) else {
continue;
};
let Some(sv) = parse_volume(&sb) else {
continue;
};
a.walk_fstree(sv.root_oid, &omap, xid, &mut |k, val, _| {
add_record(&mut stale, &mut stale_ext, k, val);
});
}
}
}
// Nós antigos da árvore de arquivos.
if allow_stale_scan {
let total = a.blocks;
for p in 0..total {
if p % 4096 == 0 {
if (ctx.cancelled)() {
return;
}
(ctx.progress)(p, total);
}
let Some(b) = a.block(p) else { continue };
let ty = le32(&b, 24) & OBJ_TYPE_MASK;
if (ty != T_BTREE_NODE && ty != T_BTREE_ROOT)
|| le32(&b, 28) != SUBTYPE_FSTREE
|| le16(&b, 34) != 0
{
continue;
}
if live_nodes.contains(&p) || fletcher64(&b[8..]) != le64(&b, 0) {
continue;
}
if let Some(n) = a.parse_node(&b, 16, 16) {
let _ = (n.oid, n.xid, n.flags);
for (k, val) in &n.entries {
add_record(&mut stale, &mut stale_ext, k, val);
}
}
}
}
// Arquivos que só existem nas versões antigas.
let mut ids: Vec<&u64> = stale.keys().collect();
ids.sort();
let mut seen_first: HashSet<u64> = HashSet::new();
for id in ids {
if live.get(id).is_some_and(|f| f.inode.has) {
continue;
}
let rec = &stale[id];
let ino = &rec.inode;
if !ino.has || ino.mode & 0o170000 != 0o100000 || ino.size == 0 {
continue;
}
let Some(ex) = stale_ext.get(&ino.private_id) else {
continue;
};
if ex.values().any(|e| e.2 != 0) {
continue; // extensão cifrada
}
// nome e pasta: da entrada de diretório, senão do próprio inode
let (name, parent) = match rec.names.iter().next() {
Some((p, n)) => (n.clone(), *p),
None => match &ino.name {
Some(n) => (n.clone(), ino.parent),
None => (format!("arquivo_{id}"), ino.parent),
},
};
let merged: Files = stale
.iter()
.map(|(k, v)| (*k, v.clone()))
.chain(live.iter().map(|(k, v)| (*k, v.clone())))
.collect();
let dir = Some(format!("/{}", path_of(&merged, parent)).replace("//", "/"));
let mut extents: Vec<Extent> = vec![];
let mut pos = 0u64;
let mut ok = true;
for (&lo, &(len, paddr, _)) in ex {
if lo > pos {
extents.push(Extent {
offset: 0,
len: lo - pos,
sparse: true,
});
}
if paddr == 0 {
extents.push(Extent {
offset: 0,
len,
sparse: true,
});
} else if paddr < a.blocks {
extents.push(Extent::new(a.base + paddr * a.bs, len));
} else {
ok = false;
}
pos = lo + len;
}
if !ok || pos < ino.size.min(1) {
continue;
}
let extents = super::fat::trim_extents(extents, ino.size);
let Some(first) = extents.iter().find(|e| !e.sparse) else {
continue;
};
if !seen_first.insert(first.offset) {
continue;
}
let note = if ino.compressed {
"arquivo comprimido pelo macOS (decmpfs): o conteúdo pode estar incompleto"
} else {
"versão antiga da árvore ou instantâneo do APFS"
};
let ts = |t: i64| (t > 0).then_some(t);
let nf = NewFound {
name,
dir,
size: ino.size,
origin: Origin::Apfs,
data: DataLoc::Extents { extents },
modified: ts(ino.mtime),
created: ts(ino.ctime),
accessed: ts(ino.atime),
base: if ino.compressed {
Confidence::Low
} else {
Confidence::Medium
},
note: Some(note),
};
let f = make_found(ctx, nf);
(ctx.emit)(f);
}
let _ = res;
}
/// Arquivos e pastas atuais do primeiro volume sem cifragem (caminho, tamanho, é pasta).
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some((a, nx)) = open_container(src, base) else {
return vec![];
};
let cont = a.omap(le64(&nx, 160));
let mut out = vec![];
for i in 0..le32(&nx, 180).min(100) as usize {
let oid = le64(&nx, 184 + i * 8);
let Some(p) = lookup(&cont, oid, u64::MAX) else {
continue;
};
let Some(vb) = a.checked_block(p) else {
continue;
};
let Some(v) = parse_volume(&vb) else { continue };
if v.encrypted {
continue;
}
let omap = a.omap(v.omap_paddr);
let mut files: Files = HashMap::new();
let mut ext: Extents = HashMap::new();
a.walk_fstree(v.root_oid, &omap, u64::MAX, &mut |k, val, _| {
add_record(&mut files, &mut ext, k, val)
});
for (&id, f) in &files {
if !f.inode.has || id <= 2 {
continue;
}
out.push((
path_of(&files, id),
f.inode.size,
f.inode.mode & 0o170000 == 0o040000,
));
}
}
out.sort();
out
}
+944
View File
@@ -0,0 +1,944 @@
//! Btrfs (um dispositivo; perfis single, DUP e RAID 1): árvores atuais para nomes e mapa de
//! espaço livre, e arquivos apagados achados em nós antigos das árvores. Como o Btrfs nunca
//! sobrescreve metadados no lugar (copy-on-write), as versões anteriores dos nós continuam no
//! disco até o espaço ser reaproveitado: varremos os blocos de metadata em busca delas.
//! Extensões comprimidas (zlib, zstd, lzo) são descompactadas na recuperação.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin, Packed, PackedCodec, Part};
use std::collections::{BTreeMap, HashMap, HashSet};
const MAGIC: &[u8; 8] = b"_BHRfS_M";
const SB_OFF: u64 = 0x10000;
// chaves
const INODE_ITEM: u8 = 1;
const INODE_REF: u8 = 12;
const EXTENT_DATA: u8 = 108;
const ROOT_ITEM: u8 = 132;
const EXTENT_ITEM: u8 = 168;
const METADATA_ITEM: u8 = 169;
const CHUNK_ITEM: u8 = 228;
fn le16(b: &[u8], o: usize) -> u16 {
b.get(o..o + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
.unwrap_or(0)
}
fn le32(b: &[u8], o: usize) -> u32 {
b.get(o..o + 4)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
fn le64(b: &[u8], o: usize) -> u64 {
b.get(o..o + 8)
.map(|s| u64::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
pub fn crc32c(data: &[u8]) -> u32 {
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
let t = TABLE.get_or_init(|| {
let mut t = [0u32; 256];
for i in 0..256u32 {
let mut c = i;
for _ in 0..8 {
c = if c & 1 != 0 {
(c >> 1) ^ 0x82F6_3B78
} else {
c >> 1
};
}
t[i as usize] = c;
}
t
});
let mut c = !0u32;
for &b in data {
c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
}
!c
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Key {
pub objectid: u64,
pub ty: u8,
pub offset: u64,
}
fn key_at(b: &[u8], o: usize) -> Key {
Key {
objectid: le64(b, o),
ty: b.get(o + 8).copied().unwrap_or(0),
offset: le64(b, o + 9),
}
}
#[derive(Clone, Debug)]
struct Chunk {
logical: u64,
len: u64,
ty: u64,
/// (offset físico do primeiro stripe) por cópia
phys: Vec<u64>,
}
#[derive(Clone, Debug)]
enum NodeKind {
Leaf(Vec<(Key, Vec<u8>)>),
Internal(Vec<(Key, u64)>),
}
#[derive(Clone, Debug)]
struct Node {
bytenr: u64,
generation: u64,
owner: u64,
kind: NodeKind,
}
struct Fs<'a> {
src: &'a dyn Source,
base: u64,
nodesize: u64,
sectorsize: u64,
fsid: [u8; 16],
chunks: Vec<Chunk>,
root: u64,
chunk_root: u64,
}
impl<'a> Fs<'a> {
fn open(src: &'a dyn Source, base: u64) -> Option<Fs<'a>> {
let sb = read_vec(src, base + SB_OFF, 4096).ok()?;
if sb.len() < 4096 || &sb[64..72] != MAGIC {
return None;
}
let csum_type = le16(&sb, 196);
if csum_type == 0 && crc32c(&sb[32..4096]) != le32(&sb, 0) {
return None;
}
let nodesize = le32(&sb, 148) as u64;
let sectorsize = le32(&sb, 144) as u64;
if !nodesize.is_power_of_two()
|| !(4096..=65536).contains(&nodesize)
|| !sectorsize.is_power_of_two()
{
return None;
}
let mut fsid = [0u8; 16];
fsid.copy_from_slice(&sb[32..48]);
let mut fs = Fs {
src,
base,
nodesize,
sectorsize,
fsid,
chunks: vec![],
root: le64(&sb, 80),
chunk_root: le64(&sb, 88),
};
// sys_chunk_array
let n = le32(&sb, 160) as usize;
let arr = sb.get(0x32B..0x32B + n.min(2048))?;
let mut p = 0;
while p + 17 + 48 <= arr.len() {
let k = key_at(arr, p);
if k.ty != CHUNK_ITEM {
break;
}
let (ch, used) = parse_chunk(&arr[p + 17..], k.offset)?;
fs.chunks.push(ch);
p += 17 + used;
}
// chunk tree completa
let mut more = vec![];
let cr = fs.chunk_root;
fs.walk(cr, &mut |k, d| {
if k.ty == CHUNK_ITEM {
if let Some((ch, _)) = parse_chunk(d, k.offset) {
more.push(ch);
}
}
});
for c in more {
if !fs.chunks.iter().any(|x| x.logical == c.logical) {
fs.chunks.push(c);
}
}
fs.chunks.sort_by_key(|c| c.logical);
Some(fs)
}
fn phys(&self, logical: u64) -> Option<u64> {
let i = self
.chunks
.partition_point(|c| c.logical + c.len <= logical);
let c = self.chunks.get(i)?;
(c.logical <= logical)
.then(|| {
c.phys
.first()
.map(|p| self.base + p + (logical - c.logical))
})
.flatten()
}
fn read_node(&self, logical: u64) -> Option<Node> {
let p = self.phys(logical)?;
let raw = read_vec(self.src, p, self.nodesize as usize).ok()?;
self.parse_node(&raw, Some(logical))
}
fn parse_node(&self, raw: &[u8], expect: Option<u64>) -> Option<Node> {
if raw.len() as u64 != self.nodesize || raw[32..48] != self.fsid {
return None;
}
let bytenr = le64(raw, 48);
if expect.is_some_and(|e| e != bytenr) {
return None;
}
if crc32c(&raw[32..]) != le32(raw, 0) {
return None;
}
let nritems = le32(raw, 96) as usize;
let level = raw[100];
if nritems > (self.nodesize as usize) / 25 {
return None;
}
let hdr = 101usize;
let kind = if level == 0 {
let mut items = Vec::with_capacity(nritems);
for i in 0..nritems {
let o = hdr + i * 25;
let k = key_at(raw, o);
let (off, size) = (le32(raw, o + 17) as usize, le32(raw, o + 21) as usize);
let s = hdr + off;
if s + size > raw.len() {
return None;
}
items.push((k, raw[s..s + size].to_vec()));
}
NodeKind::Leaf(items)
} else {
let mut ptrs = Vec::with_capacity(nritems);
for i in 0..nritems {
let o = hdr + i * 33;
if o + 33 > raw.len() {
return None;
}
ptrs.push((key_at(raw, o), le64(raw, o + 17)));
}
NodeKind::Internal(ptrs)
};
Some(Node {
bytenr,
generation: le64(raw, 80),
owner: le64(raw, 88),
kind,
})
}
/// Percorre uma árvore em ordem, chamando `f` para cada item de folha.
fn walk(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8])) {
let mut seen = HashSet::new();
self.walk_inner(root, f, &mut seen, &mut |_| {}, 0);
}
fn walk_nodes(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8]), nodes: &mut dyn FnMut(&Node)) {
let mut seen = HashSet::new();
self.walk_inner(root, f, &mut seen, nodes, 0);
}
fn walk_inner(
&self,
at: u64,
f: &mut dyn FnMut(&Key, &[u8]),
seen: &mut HashSet<u64>,
nodes: &mut dyn FnMut(&Node),
depth: u32,
) {
if depth > 10 || !seen.insert(at) {
return;
}
let Some(n) = self.read_node(at) else { return };
nodes(&n);
match &n.kind {
NodeKind::Leaf(items) => {
for (k, d) in items {
f(k, d);
}
}
NodeKind::Internal(ptrs) => {
for (_, child) in ptrs {
self.walk_inner(*child, f, seen, nodes, depth + 1);
}
}
}
}
}
fn parse_chunk(d: &[u8], logical: u64) -> Option<(Chunk, usize)> {
if d.len() < 48 {
return None;
}
let len = le64(d, 0);
let ty = le64(d, 24);
let nstripes = le16(d, 44) as usize;
if nstripes == 0 || nstripes > 32 || d.len() < 48 + nstripes * 32 {
return None;
}
let phys = (0..nstripes).map(|i| le64(d, 48 + i * 32 + 8)).collect();
Some((
Chunk {
logical,
len,
ty,
phys,
},
48 + nstripes * 32,
))
}
// ---------------------------------------------------------------- inodes
#[derive(Clone, Debug, Default)]
struct InodeData {
gen: u64,
size: u64,
mode: u32,
mtime: i64,
ctime: i64,
atime: i64,
has_inode: bool,
refs: HashMap<u64, String>,
/// offset no arquivo -> (geração do nó, item)
extents: BTreeMap<u64, (u64, Vec<u8>)>,
}
type Files = HashMap<(u64, u64), InodeData>;
fn add_item(files: &mut Files, owner: u64, gen: u64, k: &Key, d: &[u8]) {
match k.ty {
INODE_ITEM if d.len() >= 160 => {
let e = files.entry((owner, k.objectid)).or_default();
let g = le64(d, 0);
if !e.has_inode || g >= e.gen {
e.has_inode = true;
e.gen = g;
e.size = le64(d, 16);
e.mode = le32(d, 52);
e.atime = le64(d, 112) as i64;
e.ctime = le64(d, 124) as i64;
e.mtime = le64(d, 136) as i64;
}
}
INODE_REF if d.len() >= 10 => {
let nlen = le16(d, 8) as usize;
if let Some(n) = d.get(10..10 + nlen) {
files
.entry((owner, k.objectid))
.or_default()
.refs
.insert(k.offset, String::from_utf8_lossy(n).to_string());
}
}
EXTENT_DATA if d.len() >= 21 => {
let e = files.entry((owner, k.objectid)).or_default();
match e.extents.get(&k.offset) {
Some((g, _)) if *g > gen => {}
_ => {
e.extents.insert(k.offset, (gen, d.to_vec()));
}
}
}
_ => {}
}
}
fn is_fs_tree(id: u64) -> bool {
id == 5 || (256..(1u64 << 48)).contains(&id)
}
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let _ = part_len;
let mut res = VolumeScan::default();
let Some(fs) = Fs::open(ctx.src, base) else {
return res;
};
if fs
.chunks
.iter()
.any(|c| c.ty & 0x1 << 3 != 0 || c.ty & 0x10 != 0 || c.ty & 0x100 != 0 || c.ty & 0x200 != 0)
{
res.warnings.push("Btrfs em RAID 0/10/5/6: a leitura de metadados ainda não suporta esses perfis; usando só assinaturas.".into());
return res;
}
// 1) Árvores atuais: raízes das subvolumes e extents alocados.
let mut roots: Vec<(u64, u64)> = vec![]; // (id da árvore, bytenr)
let mut extent_root = 0u64;
let rr = fs.root;
fs.walk(rr, &mut |k, d| {
if k.ty == ROOT_ITEM && d.len() >= 239 {
let bytenr = le64(d, 176);
if k.objectid == 2 {
extent_root = bytenr;
} else if is_fs_tree(k.objectid) {
roots.push((k.objectid, bytenr));
}
}
});
let mut live_nodes: HashSet<u64> = HashSet::new();
let mut live: Files = HashMap::new();
for &(id, bytenr) in &roots {
let mut tmp: Vec<(Key, Vec<u8>)> = vec![];
let mut gens: HashMap<u64, u64> = HashMap::new();
let mut seen_nodes: Vec<(u64, u64)> = vec![];
{
let mut items = |k: &Key, d: &[u8]| tmp.push((*k, d.to_vec()));
let mut nodes = |n: &Node| {
seen_nodes.push((n.bytenr, n.generation));
};
fs.walk_nodes(bytenr, &mut items, &mut nodes);
}
for (b, g) in seen_nodes {
live_nodes.insert(b);
gens.insert(b, g);
}
for (k, d) in &tmp {
add_item(&mut live, id, u64::MAX, k, d);
}
if (ctx.cancelled)() {
return res;
}
}
// Extents alocados (árvore de extents) e mapa livre dos blocos de dados.
let mut allocated: Vec<Extent> = vec![];
if extent_root != 0 {
let nodesize = fs.nodesize;
fs.walk(extent_root, &mut |k, _| {
if k.ty == EXTENT_ITEM {
allocated.push(Extent::new(k.objectid, k.offset));
} else if k.ty == METADATA_ITEM {
allocated.push(Extent::new(k.objectid, nodesize));
}
});
}
let allocated = merge_extents(allocated);
let mut free: Vec<Extent> = vec![];
for c in fs.chunks.iter().filter(|c| c.ty & 0x1 != 0) {
// blocos de dados: livres = chunk - alocado
let mut cur = c.logical;
let i = allocated.partition_point(|e| e.end() <= c.logical);
for e in &allocated[i..] {
if e.offset >= c.logical + c.len {
break;
}
if e.offset > cur {
if let Some(p) = fs.phys(cur) {
free.push(Extent::new(p, e.offset - cur));
}
}
cur = cur.max(e.end());
}
if cur < c.logical + c.len {
if let Some(p) = fs.phys(cur) {
free.push(Extent::new(p, c.logical + c.len - cur));
}
}
}
res.free = Some(merge_extents(free));
// 2) Varre os blocos de metadata atrás de versões antigas de folhas de árvores de arquivos.
let mut stale: Files = HashMap::new();
let ns = fs.nodesize as usize;
let step = fs.sectorsize as usize;
let md_chunks: Vec<Chunk> = fs
.chunks
.iter()
.filter(|c| c.ty & 0x4 != 0)
.cloned()
.collect();
let total_md: u64 = md_chunks.iter().map(|c| c.len).sum();
let mut done_md = 0u64;
for c in &md_chunks {
let Some(p0) = c.phys.first().copied() else {
continue;
};
let mut off = 0u64;
let block = 8usize << 20;
while off < c.len {
if (ctx.cancelled)() {
return res;
}
let n = ((c.len - off) as usize).min(block + ns);
let Ok(buf) = read_vec(ctx.src, fs.base + p0 + off, n) else {
break;
};
let mut i = 0usize;
while i + ns <= buf.len() && (off + i as u64) < c.len {
let logical = c.logical + off + i as u64;
// cabeçalho barato antes do CRC
if buf[i + 32..i + 48] == fs.fsid
&& le64(&buf[i..], 48) == logical
&& buf[i + 100] == 0
{
if let Some(node) = fs.parse_node(&buf[i..i + ns], Some(logical)) {
if is_fs_tree(node.owner) && !live_nodes.contains(&logical) {
if let NodeKind::Leaf(items) = &node.kind {
for (k, d) in items {
add_item(&mut stale, node.owner, node.generation, k, d);
}
}
}
}
}
i += step;
}
off += block as u64;
done_md += block as u64;
(ctx.progress)(done_md.min(total_md), total_md.max(1));
}
}
// 3) Arquivos que só existem nas folhas antigas.
let path_of = |owner: u64, mut ino: u64| -> String {
let mut parts: Vec<String> = vec![];
for _ in 0..64 {
if ino == 256 {
break;
}
let e = stale.get(&(owner, ino)).or_else(|| live.get(&(owner, ino)));
match e.and_then(|e| e.refs.iter().next()) {
Some((parent, name)) => {
parts.push(name.clone());
ino = *parent;
}
None => {
parts.push("(pasta perdida)".into());
break;
}
}
}
parts.reverse();
parts.join("/")
};
let mut keys: Vec<&(u64, u64)> = stale.keys().collect();
keys.sort();
for key in keys {
let (owner, ino) = *key;
if live.contains_key(&(owner, ino)) {
continue;
}
let e = &stale[&(owner, ino)];
if !e.has_inode || e.mode & 0o170000 != 0o100000 || e.size == 0 || e.extents.is_empty() {
continue;
}
let Some((name, parent)) = e.refs.iter().next().map(|(p, n)| (n.clone(), *p)) else {
continue;
};
let dir = {
let p = path_of(owner, parent);
Some(if p.is_empty() {
String::new()
} else {
format!("/{p}")
})
};
let Some((data, any_alloc, all_ok)) = build_data(&fs, e, &allocated) else {
continue;
};
let (conf, note) = if !all_ok {
(
Confidence::Low,
"faltam trechos do arquivo na versão antiga da árvore".to_string(),
)
} else if any_alloc {
(Confidence::Medium, "versão antiga do Btrfs; alguns blocos ainda constam como alocados (podem ser de outro arquivo)".to_string())
} else {
(
Confidence::High,
"versão antiga da árvore do Btrfs; blocos de dados livres".to_string(),
)
};
let ts = |t: i64| (t > 0).then_some(t);
let nf = NewFound {
name,
dir,
size: e.size,
origin: Origin::Btrfs,
data,
modified: ts(e.mtime),
created: ts(e.ctime),
accessed: ts(e.atime),
base: conf,
note: Some(&note),
};
let f = make_found(ctx, nf);
(ctx.emit)(f);
}
res
}
/// Converte os itens EXTENT_DATA em extents físicos ou partes (inline/comprimidas).
fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc, bool, bool)> {
let mut parts: Vec<Part> = vec![];
let mut pos = 0u64;
let mut any_alloc = false;
let mut all_ok = true;
let mut plain_only = true;
let mut hole = |parts: &mut Vec<Part>, len: u64| {
if len > 0 {
parts.push(Part::Run { offset: 0, len });
// marcador: offset 0 + sparse tratado abaixo
}
};
let _ = &mut hole;
let mut sparse_marks: Vec<usize> = vec![];
for (&foff, (_, d)) in &e.extents {
if foff >= e.size {
break;
}
if foff > pos {
sparse_marks.push(parts.len());
parts.push(Part::Run {
offset: 0,
len: foff - pos,
});
plain_only = false;
pos = foff;
}
let ty = d[20];
let comp = d[16];
if ty == 0 {
// inline
let raw = &d[21..];
let ram = le64(d, 8) as usize;
let bytes = match comp {
0 => raw.to_vec(),
c => decode(c, raw, ram)?,
};
let len = (bytes.len() as u64).min(e.size - foff);
parts.push(Part::Bytes {
bytes: bytes[..len as usize].to_vec(),
});
plain_only = false;
pos = foff + len;
continue;
}
if d.len() < 53 {
all_ok = false;
break;
}
let (disk, disk_len, offset, num) = (le64(d, 21), le64(d, 29), le64(d, 37), le64(d, 45));
let num = num.min(e.size.saturating_sub(foff));
if disk == 0 || ty == 2 {
sparse_marks.push(parts.len());
parts.push(Part::Run {
offset: 0,
len: num,
});
plain_only = false;
} else {
let Some(phys) = fs.phys(disk) else {
all_ok = false;
break;
};
let overlaps = {
let i = allocated.partition_point(|x| x.end() <= disk);
allocated.get(i).is_some_and(|x| x.offset < disk + disk_len)
};
any_alloc |= overlaps;
if comp == 0 {
parts.push(Part::Run {
offset: phys + offset,
len: num,
});
} else {
let codec = match comp {
1 => PackedCodec::Zlib,
2 => PackedCodec::Zstd,
3..=6 => PackedCodec::Lzo,
_ => {
all_ok = false;
break;
}
};
parts.push(Part::Packed(Packed {
offset: phys,
disk_len,
ram_len: le64(d, 8),
skip: offset,
len: num,
codec,
}));
plain_only = false;
}
}
pos = foff + num;
}
if pos < e.size {
all_ok = false;
}
if plain_only {
let ex: Vec<Extent> = parts
.iter()
.filter_map(|p| {
if let Part::Run { offset, len } = p {
Some(Extent::new(*offset, *len))
} else {
None
}
})
.collect();
return Some((DataLoc::Extents { extents: ex }, any_alloc, all_ok));
}
// Buracos viram trechos esparsos: Part::Run com offset 0 não serve; usa Bytes de zeros pequenos.
let mut out: Vec<Part> = vec![];
for (i, p) in parts.into_iter().enumerate() {
if sparse_marks.contains(&i) {
if let Part::Run { len, .. } = p {
out.push(Part::Zeros { len });
continue;
}
}
out.push(p);
}
Some((DataLoc::Parts { parts: out }, any_alloc, all_ok))
}
fn decode(codec: u8, raw: &[u8], ram: usize) -> Option<Vec<u8>> {
match codec {
1 => {
use std::io::Read;
let mut v = Vec::with_capacity(ram);
flate2::read::ZlibDecoder::new(raw)
.read_to_end(&mut v)
.ok()?;
Some(v)
}
2 => {
use std::io::Read;
let mut dec = ruzstd::StreamingDecoder::new(raw).ok()?;
let mut v = Vec::with_capacity(ram);
dec.read_to_end(&mut v).ok()?;
Some(v)
}
_ => lzo_btrfs(raw, ram),
}
}
/// Formato de extensão LZO do Btrfs: tamanho total (u32) + segmentos [tamanho (u32)][dados LZO1X]
/// de até um setor de 4 KiB descomprimido cada, sem cruzar fronteiras de setor.
pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
if raw.len() < 4 {
return None;
}
let total = le32(raw, 0) as usize;
let mut out = Vec::with_capacity(ram);
let mut p = 4usize;
while p + 4 <= raw.len().min(total) && out.len() < ram {
// pula o preenchimento até o próximo setor se sobrar menos de 4 bytes
let in_sector = 4096 - (p % 4096);
if in_sector < 4 {
p += in_sector;
continue;
}
let seg = le32(raw, p) as usize;
p += 4;
let chunk = raw.get(p..p + seg)?;
let want = (ram - out.len()).min(4096);
out.extend(lzo1x_decompress(chunk, want)?);
p += seg;
}
Some(out)
}
/// LZO1X (descompressão segura, no mesmo esquema de estados do kernel Linux).
pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(max_out);
let mut ip = 0usize;
let byte = |i: usize| src.get(i).copied().map(|b| b as usize);
let lits = |out: &mut Vec<u8>, ip: &mut usize, n: usize| -> Option<()> {
out.extend_from_slice(src.get(*ip..*ip + n)?);
*ip += n;
Some(())
};
let ext = |ip: &mut usize, base: usize| -> Option<usize> {
let mut extra = 0usize;
while byte(*ip)? == 0 {
extra += 255;
*ip += 1;
}
let v = base + extra + byte(*ip)?;
*ip += 1;
Some(v)
};
// estado: literais ao fim da instrução anterior (0..3) ou 4 depois de uma corrida de literais
let mut state;
if byte(0)? > 17 {
let t = byte(0)? - 17;
ip = 1;
lits(&mut out, &mut ip, t)?;
state = if t < 4 { t } else { 4 };
} else {
state = 0;
}
loop {
if out.len() > max_out + 4096 {
return None;
}
let t = byte(ip)?;
ip += 1;
if t < 16 {
if state == 0 {
let n = if t == 0 { ext(&mut ip, 15)? } else { t } + 3;
lits(&mut out, &mut ip, n)?;
state = 4;
continue;
}
let h = byte(ip)?;
ip += 1;
let (dist, len) = if state == 4 {
((t >> 2) + (h << 2) + 2049, 3)
} else {
((t >> 2) + (h << 2) + 1, 2)
};
copy_match(&mut out, dist, len)?;
let next = t & 3;
lits(&mut out, &mut ip, next)?;
state = next;
} else if t >= 64 {
let h = byte(ip)?;
ip += 1;
copy_match(&mut out, 1 + ((t >> 2) & 7) + (h << 3), (t >> 5) + 1)?;
let next = t & 3;
lits(&mut out, &mut ip, next)?;
state = next;
} else if t >= 32 {
let len = if t & 31 == 0 {
ext(&mut ip, 31)?
} else {
t & 31
} + 2;
let b = (byte(ip)?) | (byte(ip + 1)? << 8);
ip += 2;
copy_match(&mut out, 1 + (b >> 2), len)?;
let next = b & 3;
lits(&mut out, &mut ip, next)?;
state = next;
} else {
let len = if t & 7 == 0 { ext(&mut ip, 7)? } else { t & 7 } + 2;
let b = (byte(ip)?) | (byte(ip + 1)? << 8);
ip += 2;
let d = ((t & 8) << 11) + (b >> 2);
if d == 0 {
return Some(out);
}
copy_match(&mut out, d + 0x4000, len)?;
let next = b & 3;
lits(&mut out, &mut ip, next)?;
state = next;
}
}
}
fn copy_match(out: &mut Vec<u8>, dist: usize, len: usize) -> Option<()> {
if dist == 0 || dist > out.len() {
return None;
}
let start = out.len() - dist;
for i in 0..len {
let b = out[start + i];
out.push(b);
}
Some(())
}
/// Arquivos e pastas atuais com caminho completo e tamanho (usa só as árvores vivas).
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some(fs) = Fs::open(src, base) else {
return vec![];
};
let mut roots: Vec<(u64, u64)> = vec![];
fs.walk(fs.root, &mut |k, d| {
if k.ty == ROOT_ITEM && d.len() >= 239 && is_fs_tree(k.objectid) {
roots.push((k.objectid, le64(d, 176)));
}
});
let mut files: Files = HashMap::new();
for (id, b) in roots {
fs.walk(b, &mut |k, d| add_item(&mut files, id, u64::MAX, k, d));
}
let mut out = vec![];
for (&(owner, ino), e) in &files {
if !e.has_inode || ino == 256 {
continue;
}
let mut parts = vec![];
let mut cur = ino;
for _ in 0..64 {
if cur == 256 {
break;
}
match files.get(&(owner, cur)).and_then(|x| x.refs.iter().next()) {
Some((p, n)) => {
parts.push(n.clone());
cur = *p;
}
None => break,
}
}
parts.reverse();
out.push((parts.join("/"), e.size, e.mode & 0o170000 == 0o040000));
}
out.sort();
out
}
/// Posições das árvores principais, para ferramentas e testes que precisam editar a imagem.
pub struct TreeLocations {
pub nodesize: u64,
/// Endereço físico (absoluto na origem) do primeiro nó de cada árvore.
pub root_tree: u64,
pub fs_tree: u64,
pub extent_tree: u64,
/// Endereços lógicos correspondentes.
pub root_tree_logical: u64,
pub fs_tree_logical: u64,
pub extent_tree_logical: u64,
/// Converte endereço lógico em físico.
pub chunks: Vec<(u64, u64, u64)>,
}
pub fn locate_trees(src: &dyn Source, base: u64) -> Option<TreeLocations> {
let fs = Fs::open(src, base)?;
let (mut fs_t, mut ext_t) = (0u64, 0u64);
fs.walk(fs.root, &mut |k, d| {
if k.ty == ROOT_ITEM && d.len() >= 239 {
if k.objectid == 5 {
fs_t = le64(d, 176);
} else if k.objectid == 2 {
ext_t = le64(d, 176);
}
}
});
Some(TreeLocations {
nodesize: fs.nodesize,
root_tree: fs.phys(fs.root)?,
fs_tree: fs.phys(fs_t)?,
extent_tree: fs.phys(ext_t)?,
root_tree_logical: fs.root,
fs_tree_logical: fs_t,
extent_tree_logical: ext_t,
chunks: fs
.chunks
.iter()
.filter_map(|c| c.phys.first().map(|p| (c.logical, c.len, fs.base + p)))
.collect(),
})
}
+10
View File
@@ -174,6 +174,14 @@ fn walk(
let mdate = u16::from_le_bytes([e[14], e[15]]);
let mtime = u16::from_le_bytes([e[12], e[13]]);
let mod_unix = super::dos_to_unix(mdate, mtime);
let cre_unix = super::dos_to_unix(
u16::from_le_bytes([e[10], e[11]]),
u16::from_le_bytes([e[8], e[9]]),
);
let acc_unix = super::dos_to_unix(
u16::from_le_bytes([e[18], e[19]]),
u16::from_le_bytes([e[16], e[17]]),
);
let mut stream: Option<&[u8]> = None;
let mut name_u16: Vec<u8> = vec![];
for s in 1..=secondary.min(19) {
@@ -259,6 +267,8 @@ fn walk(
origin: Origin::Exfat,
data: DataLoc::Extents { extents: ex },
modified: mod_unix,
created: cre_unix,
accessed: acc_unix,
base: conf,
note,
},
File diff suppressed because it is too large. Load diff
+17 -3
View File
@@ -237,6 +237,11 @@ impl Walk<'_, '_, '_, '_> {
u16::from_le_bytes([e[24], e[25]]),
u16::from_le_bytes([e[22], e[23]]),
);
let ctime = super::dos_to_unix(
u16::from_le_bytes([e[16], e[17]]),
u16::from_le_bytes([e[14], e[15]]),
);
let atime = super::dos_to_unix(u16::from_le_bytes([e[18], e[19]]), 0);
self.count += 1;
if self.count % 512 == 0 {
(self.ctx.progress)(self.count, 0);
@@ -277,7 +282,7 @@ impl Walk<'_, '_, '_, '_> {
if size == 0 || !self.fat.valid(start) {
continue;
}
self.deleted_file(&name, path, start, size, mtime);
self.deleted_file(&name, path, start, size, (ctime, mtime, atime));
}
}
@@ -291,7 +296,14 @@ impl Walk<'_, '_, '_, '_> {
v
}
fn deleted_file(&mut self, name: &str, dir: &str, start: u32, size: u64, mtime: Option<i64>) {
fn deleted_file(
&mut self,
name: &str,
dir: &str,
start: u32,
size: u64,
times: (Option<i64>, Option<i64>, Option<i64>),
) {
let fat = self.fat;
let cb = fat.cluster_bytes();
let need = size.div_ceil(cb);
@@ -336,7 +348,9 @@ impl Walk<'_, '_, '_, '_> {
data: DataLoc::Extents {
extents: trim_extents(extents, size),
},
modified: mtime,
modified: times.1,
created: times.0,
accessed: times.2,
base: conf,
note,
};
+576
View File
@@ -0,0 +1,576 @@
//! HFS+ / HFSX (inclusive volumes embutidos num wrapper HFS clássico).
//!
//! Lê o catálogo (árvore B+) para nomes e caminhos, o bitmap de alocação para o mapa de
//! espaço livre e procura arquivos apagados em três lugares: sobras dentro dos nós folha do
//! catálogo, cópias antigas de nós no diário (journal) e registros de arquivo "soltos".
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::{HashMap, HashSet};
const HFS_EPOCH: i64 = 2_082_844_800;
fn be16(b: &[u8], o: usize) -> u16 {
b.get(o..o + 2)
.map(|s| u16::from_be_bytes([s[0], s[1]]))
.unwrap_or(0)
}
fn be32(b: &[u8], o: usize) -> u32 {
b.get(o..o + 4)
.map(|s| u32::from_be_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
fn be64(b: &[u8], o: usize) -> u64 {
b.get(o..o + 8)
.map(|s| u64::from_be_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
#[derive(Clone, Debug, Default)]
struct Fork {
size: u64,
blocks: u32,
extents: Vec<(u32, u32)>,
}
fn parse_fork(b: &[u8]) -> Fork {
let mut f = Fork {
size: be64(b, 0),
blocks: be32(b, 12),
extents: vec![],
};
for i in 0..8 {
let (s, c) = (be32(b, 16 + i * 8), be32(b, 20 + i * 8));
if c != 0 {
f.extents.push((s, c));
}
}
f
}
/// Posição do volume HFS+ dentro da partição (wrapper HFS clássico) ou `None` se não for HFS+.
pub fn locate(src: &dyn Source, base: u64) -> Option<u64> {
let h = read_vec(src, base + 1024, 512).ok()?;
if h.len() < 512 {
return None;
}
match &h[0..2] {
b"H+" | b"HX" => Some(base),
b"BD" if &h[124..126] == b"H+" => {
let alblk = be32(&h, 20) as u64;
let alblst = be16(&h, 28) as u64;
let start = be16(&h, 126) as u64;
Some(base + alblst * 512 + start * alblk)
}
_ => None,
}
}
struct Vol<'a> {
src: &'a dyn Source,
base: u64,
bs: u64,
total: u64,
catalog: Fork,
extents_file: Fork,
alloc: Fork,
next_cnid: u32,
journal_info: u32,
}
impl<'a> Vol<'a> {
fn open(src: &'a dyn Source, base: u64) -> Option<Vol<'a>> {
let h = read_vec(src, base + 1024, 512).ok()?;
if h.len() < 512 || !(&h[0..2] == b"H+" || &h[0..2] == b"HX") {
return None;
}
let bs = be32(&h, 40) as u64;
if bs < 512 || !bs.is_power_of_two() {
return None;
}
Some(Vol {
src,
base,
bs,
total: be32(&h, 44) as u64,
alloc: parse_fork(&h[112..192]),
extents_file: parse_fork(&h[192..272]),
catalog: parse_fork(&h[272..352]),
next_cnid: be32(&h, 64),
journal_info: be32(&h, 12),
})
}
fn block_off(&self, b: u64) -> u64 {
self.base + b * self.bs
}
/// Todas as extensões de um fork (inclusive as do arquivo de overflow).
fn fork_extents(&self, f: &Fork, file_id: u32, fork_type: u8) -> Vec<Extent> {
let mut ex: Vec<(u32, u32)> = f.extents.clone();
let mut have: u64 = ex.iter().map(|e| e.1 as u64).sum();
if have < f.blocks as u64 && file_id >= 2 {
ex.extend(self.overflow_extents(file_id, fork_type, have as u32));
have = ex.iter().map(|e| e.1 as u64).sum();
let _ = have;
}
merge_extents(
ex.into_iter()
.map(|(s, c)| Extent::new(self.block_off(s as u64), c as u64 * self.bs))
.collect::<Vec<_>>(),
)
.into_iter()
.collect()
}
/// Extensões além das oito iniciais, procuradas no arquivo de overflow (árvore B+).
fn overflow_extents(&self, file_id: u32, fork_type: u8, mut start: u32) -> Vec<(u32, u32)> {
let mut out = vec![];
let node_size = match self.tree_node_size(&self.extents_file) {
Some(n) => n,
None => return out,
};
let total_nodes = self.extents_file.size / node_size as u64;
for n in 1..total_nodes.min(100_000) {
let Some(node) = self.read_tree_node(&self.extents_file, n as u32, node_size) else {
continue;
};
if node[8] != 0xFF {
continue;
}
for r in records(&node) {
// chave: keyLength u16, forkType u8, pad u8, fileID u32, startBlock u32
if r.len() < 12 + 64 || be16(r, 0) != 10 {
continue;
}
if r[2] == fork_type && be32(r, 4) == file_id && be32(r, 8) == start {
let mut got = 0;
for i in 0..8 {
let (s, c) = (be32(r, 12 + i * 8), be32(r, 16 + i * 8));
if c != 0 {
out.push((s, c));
got += c;
}
}
start += got;
}
}
}
out
}
fn tree_node_size(&self, f: &Fork) -> Option<usize> {
let first = self.read_fork_bytes(f, 0, 64)?;
let ns = be16(&first, 32) as usize;
(512..=65536).contains(&ns).then_some(ns)
}
fn read_tree_node(&self, f: &Fork, n: u32, ns: usize) -> Option<Vec<u8>> {
self.read_fork_bytes(f, n as u64 * ns as u64, ns)
}
/// Lê bytes de um fork que tem só extensões iniciais ou overflow já resolvido (para árvores).
fn read_fork_bytes(&self, f: &Fork, off: u64, len: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(len);
let mut pos = 0u64;
for &(s, c) in &f.extents {
let elen = c as u64 * self.bs;
if off + out.len() as u64 >= pos && off + (out.len() as u64) < pos + elen {
let within = off + out.len() as u64 - pos;
let n = ((elen - within) as usize).min(len - out.len());
out.extend(read_vec(self.src, self.block_off(s as u64) + within, n).ok()?);
if out.len() == len {
return Some(out);
}
}
pos += elen;
}
(out.len() == len).then_some(out)
}
}
/// Registros de um nó B+ (fatias, da chave em diante).
fn records(node: &[u8]) -> Vec<&[u8]> {
let n = be16(node, 10) as usize;
let mut offs = vec![];
for i in 0..=n {
let p = node.len().saturating_sub(2 * (i + 1));
offs.push(be16(node, p) as usize);
}
let mut out = vec![];
for i in 0..n {
let (a, b) = (offs[i], offs[i + 1]);
if a >= 14 && b > a && b <= node.len() {
out.push(&node[a..b]);
}
}
out
}
fn utf16be(b: &[u8]) -> String {
let u: Vec<u16> = b
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&u)
.replace('/', "_")
.replace(':', "/")
}
struct CatFile {
parent: u32,
name: String,
id: u32,
data: Fork,
created: u32,
modified: u32,
accessed: u32,
stale: bool,
}
fn date(d: u32) -> Option<i64> {
(d != 0).then(|| d as i64 - HFS_EPOCH)
}
/// Decodifica um registro do catálogo (chave + dados). `max_id` descarta lixo.
fn decode_record(
r: &[u8],
max_id: u32,
files: &mut Vec<CatFile>,
threads: &mut HashMap<u32, (u32, String)>,
folders: &mut HashMap<(u32, String), u32>,
stale: bool,
) {
let klen = be16(r, 0) as usize;
if klen < 6 || klen + 2 > r.len() {
return;
}
let parent = be32(r, 2);
let nlen = be16(r, 6) as usize;
if nlen > 255 || klen != 6 + nlen * 2 && klen != 6 {
return;
}
let name = utf16be(r.get(8..8 + nlen * 2).unwrap_or(&[]));
let mut d = 2 + klen;
if d % 2 == 1 {
d += 1;
}
let Some(rec) = r.get(d..) else { return };
match be16(rec, 0) {
1 if rec.len() >= 88 => {
let id = be32(rec, 8);
if id >= 2 && id < max_id.max(16) {
folders.entry((parent, name)).or_insert(id);
}
}
3 if rec.len() >= 10 => {
// thread de pasta: chave = (id da pasta, ""); dados = (pai, nome)
let pn = be16(rec, 8) as usize;
if pn <= 255 {
threads.entry(parent).or_insert((
be32(rec, 4),
utf16be(rec.get(10..10 + pn * 2).unwrap_or(&[])),
));
}
}
2 if rec.len() >= 248 => {
let id = be32(rec, 8);
if id < 16 || id >= max_id.max(16) {
return;
}
let data = parse_fork(&rec[88..168]);
files.push(CatFile {
parent,
name,
id,
data,
created: be32(rec, 12),
modified: be32(rec, 16),
accessed: be32(rec, 24),
stale,
});
}
4 if rec.len() >= 10 => {
let pn = be16(rec, 8) as usize;
if pn <= 255 {
threads.entry(parent).or_insert((
be32(rec, 4),
utf16be(rec.get(10..10 + pn * 2).unwrap_or(&[])),
));
}
}
_ => {}
}
}
/// Procura registros de arquivo nas sobras de um nó (entre os dados e a tabela de offsets).
fn scan_slack(
node: &[u8],
max_id: u32,
files: &mut Vec<CatFile>,
threads: &mut HashMap<u32, (u32, String)>,
folders: &mut HashMap<(u32, String), u32>,
) {
let n = be16(node, 10) as usize;
let table = node.len().saturating_sub(2 * (n + 1));
let used_end = (be16(node, table) as usize).max(14);
let mut p = used_end + (used_end % 2);
while p + 40 < table {
let klen = be16(node, p) as usize;
let nlen = be16(node, p + 6) as usize;
if klen == 6 + nlen * 2 && (1..=255).contains(&nlen) && p + 2 + klen + 2 < table {
let parent = be32(node, p + 2);
if parent >= 1 && parent < max_id.max(16) {
let d = (p + 2 + klen + 1) & !1;
let rt = be16(node, d);
if matches!(rt, 1..=4) {
let end = (d + 248).min(table);
decode_record(&node[p..end], max_id, files, threads, folders, true);
p = d + if rt == 2 { 248 } else { 16 };
continue;
}
}
}
p += 2;
}
}
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let mut res = VolumeScan::default();
let _ = part_len;
let Some(vbase) = locate(ctx.src, base) else {
return res;
};
let Some(vol) = Vol::open(ctx.src, vbase) else {
return res;
};
// Mapa de espaço livre.
if let Some(bm) = vol.read_fork_all(&vol.alloc, (vol.total / 8 + 1) as usize) {
let mut free = vec![];
let mut run: Option<u64> = None;
for b in 0..vol.total {
let used = bm
.get((b / 8) as usize)
.map(|x| x >> (7 - (b % 8)) & 1 == 1)
.unwrap_or(true);
match (used, run) {
(false, None) => run = Some(b),
(true, Some(s)) => {
free.push(Extent::new(vol.block_off(s), (b - s) * vol.bs));
run = None;
}
_ => {}
}
}
if let Some(s) = run {
free.push(Extent::new(vol.block_off(s), (vol.total - s) * vol.bs));
}
res.free = Some(merge_extents(free));
}
let Some(ns) = vol.tree_node_size(&vol.catalog) else {
res.warnings.push("HFS+: catálogo ilegível.".into());
return res;
};
let Some(hdr) = vol.read_tree_node(&vol.catalog, 0, ns) else {
return res;
};
let first_leaf = be32(&hdr, 24);
let max_id = vol.next_cnid.max(16);
let mut files: Vec<CatFile> = vec![];
let mut threads: HashMap<u32, (u32, String)> = HashMap::new();
let mut folders: HashMap<(u32, String), u32> = HashMap::new();
let mut live_ids: HashSet<u32> = HashSet::new();
let mut n = first_leaf;
let mut guard = 0;
while n != 0 && guard < 5_000_000 {
guard += 1;
if (ctx.cancelled)() {
return res;
}
let Some(node) = vol.read_tree_node(&vol.catalog, n, ns) else {
break;
};
if node[8] != 0xFF {
break;
}
let mut live_here: Vec<CatFile> = vec![];
for r in records(&node) {
decode_record(r, max_id, &mut live_here, &mut threads, &mut folders, false);
}
for f in &live_here {
live_ids.insert(f.id);
}
files.extend(live_here);
scan_slack(&node, max_id, &mut files, &mut threads, &mut folders);
n = be32(&node, 0);
}
// Diário: cópias antigas de nós folha do catálogo.
if vol.journal_info != 0 {
scan_journal(
&vol,
ns,
max_id,
&mut files,
&mut threads,
&mut folders,
ctx,
);
}
// Caminho de cada pasta a partir dos threads.
let path_of = |parent: u32| -> String {
let mut parts = vec![];
let mut cur = parent;
for _ in 0..64 {
if cur <= 2 {
break;
}
match threads.get(&cur) {
Some((p, name)) => {
parts.push(name.clone());
cur = *p;
}
None => break,
}
}
parts.reverse();
format!("/{}", parts.join("/"))
};
let free_set = res.free.clone().unwrap_or_default();
let mut seen: HashSet<(u32, u64)> = HashSet::new();
for f in files
.iter()
.filter(|f| f.stale && !live_ids.contains(&f.id))
{
if f.data.size == 0 || f.data.extents.is_empty() {
continue;
}
let ex = vol.fork_extents(&f.data, f.id, 0);
let total_len: u64 = ex.iter().map(|e| e.len).sum();
if ex.is_empty()
|| total_len < f.data.size
|| ex.iter().any(|e| e.end() > vol.base + vol.total * vol.bs)
{
continue;
}
if !seen.insert((f.id, ex[0].offset)) {
continue;
}
// Mantém só os bytes do arquivo.
let extents = super::fat::trim_extents(ex, f.data.size);
// Blocos já reaproveitados?
let reused = extents.iter().filter(|e| !covered_by(&free_set, e)).count();
let (conf, note) = if reused == 0 {
(
Confidence::High,
"registro apagado do catálogo HFS+; blocos ainda livres".to_string(),
)
} else {
(
Confidence::Low,
"alguns blocos do arquivo já foram reaproveitados".to_string(),
)
};
let nf = NewFound {
name: f.name.clone(),
dir: Some(path_of(f.parent)),
size: f.data.size,
origin: Origin::Hfs,
data: DataLoc::Extents { extents },
modified: date(f.modified),
created: date(f.created),
accessed: date(f.accessed),
base: conf,
note: Some(&note),
};
let found = make_found(ctx, nf);
(ctx.emit)(found);
}
res
}
fn covered_by(free: &[Extent], e: &Extent) -> bool {
let i = free.partition_point(|f| f.end() <= e.offset);
free.get(i)
.map(|f| f.offset <= e.offset && f.end() >= e.end())
.unwrap_or(false)
}
impl Vol<'_> {
fn read_fork_all(&self, f: &Fork, max: usize) -> Option<Vec<u8>> {
let mut out = vec![];
for &(s, c) in &f.extents {
let n = (c as u64 * self.bs) as usize;
out.extend(
read_vec(
self.src,
self.block_off(s as u64),
n.min(max.saturating_sub(out.len())),
)
.ok()?,
);
if out.len() >= max {
break;
}
}
Some(out)
}
}
/// Procura nós folha do catálogo nas transações guardadas no diário.
fn scan_journal(
vol: &Vol,
ns: usize,
max_id: u32,
files: &mut Vec<CatFile>,
threads: &mut HashMap<u32, (u32, String)>,
folders: &mut HashMap<(u32, String), u32>,
ctx: &Ctx,
) {
let Some(info) = read_vec(vol.src, vol.block_off(vol.journal_info as u64), 52)
.ok()
.filter(|b| b.len() == 52)
else {
return;
};
let (joff, jsize) = (be64(&info, 36), be64(&info, 44));
if jsize == 0 || jsize > 1 << 30 || vol.base + joff + jsize > vol.src.size() {
return;
}
let mut pos = 0u64;
let chunk = 4 << 20;
while pos < jsize {
if (ctx.cancelled)() {
return;
}
let n = chunk.min(jsize - pos) as usize;
let Ok(buf) = read_vec(vol.src, vol.base + joff + pos, n + ns) else {
break;
};
let mut i = 0;
while i + ns <= buf.len().min(n + ns) && i < n {
let nd = &buf[i..i + ns];
// nó folha do catálogo: kind = -1, altura 1, registros plausíveis
if nd[8] == 0xFF && nd[9] == 1 && be16(nd, 10) > 0 && (be16(nd, 10) as usize) < ns / 8 {
let table = ns - 2 * (be16(nd, 10) as usize + 1);
if (be16(nd, ns - 2) as usize) == 14
&& be16(nd, table) as usize > 14
&& (be16(nd, table) as usize) <= table
{
for r in records(nd) {
decode_record(r, max_id, files, threads, folders, true);
}
scan_slack(nd, max_id, files, threads, folders);
}
}
i += 512;
}
pos += n as u64;
}
}
+14
View File
@@ -4,10 +4,14 @@
//! devolve os extents ainda alocados ("live") para o carving pular e, quando
//! possível, os trechos livres.
pub mod apfs;
pub mod btrfs;
pub mod exfat;
pub mod ext;
pub mod fat;
pub mod hfsplus;
pub mod ntfs;
pub mod xfs;
use crate::signatures::{category_for_ext, matches_ext};
use crate::source::{read_vec, Source};
@@ -47,6 +51,8 @@ pub struct NewFound<'a> {
pub origin: Origin,
pub data: DataLoc,
pub modified: Option<i64>,
pub created: Option<i64>,
pub accessed: Option<i64>,
pub base: Confidence,
pub note: Option<&'a str>,
}
@@ -63,6 +69,7 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
let mut note = n.note.map(String::from);
let head: Vec<u8> = match &n.data {
DataLoc::Resident { bytes } => bytes.iter().take(32).copied().collect(),
DataLoc::Parts { .. } => vec![],
DataLoc::Extents { extents } => extents
.iter()
.find(|e| !e.sparse)
@@ -98,6 +105,13 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
confidence: conf,
data: n.data,
modified: n.modified,
created: n.created,
accessed: n.accessed,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note,
}
}
+10
View File
@@ -22,6 +22,8 @@ struct Rec {
parent: u64,
name: Option<(String, u8)>,
mtime: Option<i64>,
ctime: Option<i64>,
atime: Option<i64>,
data: Option<Data>,
has_attr_list: bool,
}
@@ -199,6 +201,8 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
origin: Origin::Ntfs,
data,
modified: rec.mtime,
created: rec.ctime,
accessed: rec.atime,
base: conf,
note: if rec.has_attr_list {
Some("registro com lista de atributos: pode estar incompleto")
@@ -320,6 +324,8 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
parent: 0,
name: None,
mtime: None,
ctime: None,
atime: None,
data: None,
has_attr_list: false,
};
@@ -341,7 +347,11 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
0x10 if !nonres => {
let vo = le16(r, p + 20) as usize;
if p + vo + 16 <= r.len() {
rec.ctime = filetime_to_unix(le64(r, p + vo));
rec.mtime = filetime_to_unix(le64(r, p + vo + 8));
if p + vo + 32 <= r.len() {
rec.atime = filetime_to_unix(le64(r, p + vo + 24));
}
}
}
0x20 => rec.has_attr_list = true,
+550
View File
@@ -0,0 +1,550 @@
//! XFS (v4 e v5): mapa de espaço livre pela árvore de blocos livres de cada grupo de alocação e
//! arquivos apagados. Ao liberar um inode, o XFS zera o modo e o tamanho mas não apaga a lista de
//! extents que ficou dentro do inode; e a entrada de diretório liberada perde só os 4 primeiros
//! bytes (o número do inode), preservando o nome. Juntamos as duas sobras.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::signatures;
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::{HashMap, HashSet};
fn be16(b: &[u8], o: usize) -> u16 {
b.get(o..o + 2)
.map(|s| u16::from_be_bytes([s[0], s[1]]))
.unwrap_or(0)
}
fn be32(b: &[u8], o: usize) -> u32 {
b.get(o..o + 4)
.map(|s| u32::from_be_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
fn be64(b: &[u8], o: usize) -> u64 {
b.get(o..o + 8)
.map(|s| u64::from_be_bytes(s.try_into().unwrap()))
.unwrap_or(0)
}
struct Xfs<'a> {
src: &'a dyn Source,
base: u64,
bs: u64,
agblocks: u64,
agcount: u64,
inodesize: u64,
inopblog: u32,
agblklog: u32,
sectsize: u64,
v5: bool,
ftype: bool,
root_ino: u64,
dblocks: u64,
}
impl<'a> Xfs<'a> {
fn open(src: &'a dyn Source, base: u64) -> Option<Xfs<'a>> {
let sb = read_vec(src, base, 512).ok()?;
if sb.len() < 512 || &sb[0..4] != b"XFSB" {
return None;
}
let ver = be16(&sb, 100) & 0xF;
let bs = be32(&sb, 4) as u64;
let agblocks = be32(&sb, 84) as u64;
let agcount = be32(&sb, 88) as u64;
let inodesize = be16(&sb, 104) as u64;
if !(512..=65536).contains(&bs)
|| !bs.is_power_of_two()
|| agblocks == 0
|| agcount == 0
|| !(256..=2048).contains(&inodesize)
{
return None;
}
let incompat = be32(&sb, 216);
let features2 = be32(&sb, 200);
let v5 = ver == 5;
Some(Xfs {
src,
base,
bs,
agblocks,
agcount,
inodesize,
inopblog: sb[123] as u32,
agblklog: sb[124] as u32,
sectsize: be16(&sb, 102) as u64,
v5,
ftype: if v5 {
incompat & 1 != 0
} else {
features2 & 0x200 != 0
},
root_ino: be64(&sb, 56),
dblocks: be64(&sb, 8),
})
}
fn ag_off(&self, agno: u64) -> u64 {
self.base + agno * self.agblocks * self.bs
}
/// Endereço físico de um bloco "fsblock" (agno << agblklog | agbno).
fn fsb_off(&self, fsb: u64) -> Option<u64> {
let (agno, agbno) = (fsb >> self.agblklog, fsb & ((1 << self.agblklog) - 1));
(agno < self.agcount && agbno < self.agblocks).then(|| self.ag_off(agno) + agbno * self.bs)
}
fn ino_off(&self, ino: u64) -> Option<u64> {
let shift = self.agblklog + self.inopblog;
let agno = ino >> shift;
let agbno = (ino >> self.inopblog) & ((1 << self.agblklog) - 1);
let idx = ino & ((1 << self.inopblog) - 1);
(agno < self.agcount && agbno < self.agblocks)
.then(|| self.ag_off(agno) + agbno * self.bs + idx * self.inodesize)
}
fn read_ino(&self, ino: u64) -> Option<Vec<u8>> {
let b = read_vec(self.src, self.ino_off(ino)?, self.inodesize as usize).ok()?;
(b.len() as u64 == self.inodesize && &b[0..2] == b"IN").then_some(b)
}
fn core_size(&self, ino: &[u8]) -> usize {
if ino[4] >= 3 {
176
} else {
100
}
}
/// Extents de um inode com a lista no próprio inode (formato 2) ou em árvore (formato 3).
fn extents_of(&self, ino: &[u8], max_blocks: u64) -> Vec<(u64, u64, u64)> {
let fmt = ino[5];
let start = self.core_size(ino);
let fork_end = if ino[82] != 0 {
start.max(100 + ino[82] as usize * 8)
} else {
self.inodesize as usize
};
let lit = &ino[start..fork_end.min(ino.len())];
let mut out = vec![];
match fmt {
2 => {
let n = (be32(ino, 76) as usize).min(lit.len() / 16);
for i in 0..n {
out.push(unpack_extent(&lit[i * 16..i * 16 + 16]));
}
}
3 => {
// raiz da árvore de extents no inode: nível, nregistros, chaves e ponteiros
let level = be16(lit, 0) as usize;
let nrecs = be16(lit, 2) as usize;
let maxrecs = (lit.len() - 4) / 16;
let ptrs = 4 + maxrecs * 8;
for i in 0..nrecs.min(maxrecs) {
let fsb = be64(lit, ptrs + i * 8);
self.walk_bmbt(fsb, level - 1, &mut out, 0);
}
}
_ => {}
}
out.retain(|e| e.2 > 0 && e.2 <= max_blocks);
out
}
fn walk_bmbt(&self, fsb: u64, level: usize, out: &mut Vec<(u64, u64, u64)>, depth: u32) {
if depth > 6 || out.len() > 1_000_000 {
return;
}
let Some(off) = self.fsb_off(fsb) else { return };
let Ok(b) = read_vec(self.src, off, self.bs as usize) else {
return;
};
if b.len() < 24 || !(&b[0..4] == b"BMAP" || &b[0..4] == b"BMA3") {
return;
}
let hdr = if self.v5 { 72 } else { 24 };
let numrecs = be16(&b, 6) as usize;
if level == 0 {
for i in 0..numrecs {
if let Some(r) = b.get(hdr + i * 16..hdr + i * 16 + 16) {
out.push(unpack_extent(r));
}
}
} else {
let maxrecs = (self.bs as usize - hdr) / 16;
let ptrs = hdr + maxrecs * 8;
for i in 0..numrecs {
self.walk_bmbt(be64(&b, ptrs + i * 8), level - 1, out, depth + 1);
}
}
}
}
/// Registro de extent empacotado em 128 bits: flag(1) | início no arquivo(54) | bloco(52) | quantidade(21).
fn unpack_extent(r: &[u8]) -> (u64, u64, u64) {
let hi = u64::from_be_bytes(r[0..8].try_into().unwrap());
let lo = u64::from_be_bytes(r[8..16].try_into().unwrap());
let startoff = (hi >> 9) & ((1 << 54) - 1);
let startblock = ((hi & 0x1FF) << 43) | (lo >> 21);
let count = lo & 0x1F_FFFF;
(startoff, startblock, count)
}
/// Percorre as folhas de uma árvore B+ curta, entregando cada registro de 16 (inobt) ou 8 (bnobt) bytes.
fn walk_short_btree(
x: &Xfs,
agno: u64,
root: u64,
levels: u32,
rec: usize,
key_size: usize,
f: &mut dyn FnMut(&[u8]),
) {
let mut cur = root;
// desce pela esquerda até a folha
for lvl in (1..levels).rev() {
let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else {
return;
};
if b.len() < 24 {
return;
}
let hdr = if x.v5 { 56 } else { 16 };
let maxrecs = (x.bs as usize - hdr) / (key_size + 4);
let ptrs = hdr + maxrecs * key_size;
let _ = lvl;
cur = be32(&b, ptrs) as u64;
}
let mut seen = HashSet::new();
while cur != 0 && cur != u32::MAX as u64 && seen.insert(cur) {
let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else {
return;
};
if b.len() < 24 {
return;
}
let hdr = if x.v5 { 56 } else { 16 };
let n = be16(&b, 6) as usize;
for i in 0..n {
if let Some(r) = b.get(hdr + i * rec..hdr + (i + 1) * rec) {
f(r);
}
}
cur = be32(&b, 12) as u64; // rightsib
}
}
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let _ = part_len;
let mut res = VolumeScan::default();
let Some(x) = Xfs::open(ctx.src, base) else {
return res;
};
// Espaço livre por grupo e inodes livres.
let mut free: Vec<Extent> = vec![];
let mut free_set: Vec<(u64, u64)> = vec![]; // (fsblock inicial, quantidade) para checar blocos
let mut chunks: Vec<(u64, u64, u64)> = vec![]; // (agno, startino, máscara livre)
for agno in 0..x.agcount {
if (ctx.cancelled)() {
return res;
}
let agf = read_vec(ctx.src, x.ag_off(agno) + x.sectsize, 64).unwrap_or_default();
if agf.len() >= 64 && &agf[0..4] == b"XAGF" {
let (root, levels) = (be32(&agf, 16) as u64, be32(&agf, 28));
walk_short_btree(&x, agno, root, levels, 8, 8, &mut |r| {
let (s, c) = (be32(r, 0) as u64, be32(r, 4) as u64);
free.push(Extent::new(x.ag_off(agno) + s * x.bs, c * x.bs));
free_set.push(((agno << x.agblklog) | s, c));
});
}
let agi = read_vec(ctx.src, x.ag_off(agno) + 2 * x.sectsize, 64).unwrap_or_default();
if agi.len() >= 64 && &agi[0..4] == b"XAGI" {
let (root, levels) = (be32(&agi, 20) as u64, be32(&agi, 24));
walk_short_btree(&x, agno, root, levels, 16, 4, &mut |r| {
chunks.push((agno, be32(r, 0) as u64, be64(r, 8)));
});
}
(ctx.progress)(agno, x.agcount);
}
free.sort_by_key(|e| e.offset);
let free = merge_extents(free);
res.free = Some(free.clone());
free_set.sort();
let is_free = |fsb: u64, count: u64| -> bool {
let i = free_set.partition_point(|&(s, c)| s + c <= fsb);
free_set
.get(i)
.is_some_and(|&(s, c)| s <= fsb && s + c >= fsb + count)
};
// Nomes: entradas de diretório liberadas (guardam o nome e os 4 bytes baixos do inode).
let (live_dirs, stale_names) = collect_names(&x, ctx);
// Inodes livres com a lista de extents ainda dentro do inode.
let max_blocks = x.dblocks;
let mut seen_first: HashSet<u64> = HashSet::new();
for (agno, startino, mask) in &chunks {
if (ctx.cancelled)() {
return res;
}
for i in 0..64u64 {
if mask >> i & 1 == 0 {
continue; // em uso
}
let ino = (agno << (x.agblklog + x.inopblog)) | (startino + i);
let Some(raw) = x.read_ino(ino) else { continue };
let mode = be16(&raw, 2);
if mode != 0 && be32(&raw, 16) != 0 {
continue;
}
// Só inodes que foram arquivos: o formato de extents continua gravado e há sobras.
if raw[5] != 2 {
continue;
}
let start = x.core_size(&raw);
let lit = &raw[start..];
// lê registros plausíveis em sequência
let mut ext: Vec<(u64, u64, u64)> = vec![];
let mut next_off = 0u64;
for k in 0..lit.len() / 16 {
let e = unpack_extent(&lit[k * 16..k * 16 + 16]);
let valid = e.2 > 0
&& e.2 < 1 << 21
&& e.0 == next_off
&& x.fsb_off(e.1).is_some()
&& (e.1 & ((1 << x.agblklog) - 1)) + e.2 <= x.agblocks;
if !valid {
break;
}
next_off = e.0 + e.2;
ext.push(e);
}
if ext.is_empty() {
continue;
}
let total_blocks: u64 = ext.iter().map(|e| e.2).sum();
if total_blocks > max_blocks {
continue;
}
let all_free = ext.iter().all(|e| is_free(e.1, e.2));
let extents: Vec<Extent> = ext
.iter()
.filter_map(|e| x.fsb_off(e.1).map(|o| Extent::new(o, e.2 * x.bs)))
.collect();
if extents.is_empty() || !seen_first.insert(extents[0].offset) {
continue;
}
let mut size = total_blocks * x.bs;
let mut conf = if all_free {
Confidence::High
} else {
Confidence::Low
};
let mut note = if all_free {
"inode XFS liberado; lista de extents recuperada do próprio inode".to_string()
} else {
"blocos do arquivo já reaproveitados".to_string()
};
// tamanho exato: tenta achar o fim real pelo conteúdo
let mut est = true;
if let Some(h) = read_vec(ctx.src, extents[0].offset, 128)
.ok()
.and_then(|head| signatures::detect(ctx.src, extents[0].offset, &head, size))
{
if h.len > 0 && h.len <= size {
size = h.len;
est = false;
}
}
if est {
conf = conf.min(Confidence::Medium);
note.push_str("; tamanho exato perdido: usando todos os blocos");
}
let extents = super::fat::trim_extents(extents, size);
let low = ino & 0xFFFF_FFFF;
let (dir, name) = match stale_names.get(&low) {
Some((parent, n)) => (
Some(live_dirs.get(parent).cloned().unwrap_or_default()),
n.clone(),
),
None => (None, format!("inode_{ino}")),
};
let ts = |s: u32| (s != 0).then_some(s as i64);
let nf = NewFound {
name,
dir,
size,
origin: Origin::Xfs,
data: DataLoc::Extents { extents },
modified: ts(be32(&raw, 40)),
created: if raw[4] >= 3 {
ts(be32(&raw, 144))
} else {
None
},
accessed: ts(be32(&raw, 32)),
base: conf,
note: Some(&note),
};
let f = make_found(ctx, nf);
(ctx.emit)(f);
}
}
res
}
/// Percorre os diretórios vivos; devolve (caminho de cada diretório por inode, nome liberado por inode baixo).
fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64, String)>) {
let mut paths: HashMap<u64, String> = HashMap::new();
let mut stale: HashMap<u64, (u64, String)> = HashMap::new();
let mut queue: Vec<(u64, String)> = vec![(x.root_ino, String::new())];
let mut seen: HashSet<u64> = HashSet::new();
while let Some((dino, path)) = queue.pop() {
if !seen.insert(dino) || (ctx.cancelled)() || seen.len() > 200_000 {
continue;
}
let Some(raw) = x.read_ino(dino) else {
continue;
};
if be16(&raw, 2) & 0o170000 != 0o040000 {
continue;
}
paths.insert(dino, path.clone());
let start = x.core_size(&raw);
match raw[5] {
1 => {
// pasta curta guardada no inode
let lit = &raw[start..];
let count = lit[0] as usize;
let i8 = lit[1] != 0;
let isz = if i8 { 8 } else { 4 };
let mut p = 2 + isz;
for _ in 0..count.max(lit[1] as usize) {
if p + 3 > lit.len() {
break;
}
let nl = lit[p] as usize;
p += 3;
let name =
String::from_utf8_lossy(lit.get(p..p + nl).unwrap_or(&[])).to_string();
p += nl + if x.ftype { 1 } else { 0 };
let ino = if i8 {
be64(lit, p)
} else {
be32(lit, p) as u64
};
p += isz;
queue.push((ino, format!("{path}/{name}")));
}
}
2 | 3 => {
let size = be64(&raw, 56).min(64 << 20);
let ex = x.extents_of(&raw, 1 << 20);
let _ = size;
for (off, fsb, cnt) in ex {
// blocos de dados de diretório ficam antes do offset de 32 GiB (depois vêm folhas e índice)
if off * x.bs >= 32u64 << 30 {
continue;
}
for k in 0..cnt.min(256) {
let Some(o) = x.fsb_off(fsb + k) else {
continue;
};
let Ok(b) = read_vec(x.src, o, x.bs as usize) else {
continue;
};
parse_dir_block(x, &b, dino, &path, &mut queue, &mut stale);
}
}
}
_ => {}
}
}
(paths, stale)
}
fn parse_dir_block(
x: &Xfs,
b: &[u8],
dino: u64,
path: &str,
queue: &mut Vec<(u64, String)>,
stale: &mut HashMap<u64, (u64, String)>,
) {
let magic = &b[0..4];
let hdr = match magic {
b"XD2B" | b"XD2D" => 16,
b"XDB3" | b"XDD3" => 64,
_ => return,
};
let is_block = magic == b"XD2B" || magic == b"XDB3";
// em blocos únicos, o final tem a tabela de folhas
let end = if is_block {
let cnt = be32(b, b.len() - 8) as usize;
b.len().saturating_sub(8 + cnt * 8)
} else {
b.len()
};
let mut p = hdr;
while p + 8 <= end {
if be16(b, p) == 0xFFFF {
let len = be16(b, p + 2) as usize;
if len < 8 || p + len > end {
break;
}
// entrada liberada: sobraram (inode baixo, nome); outras ainda podem estar dentro da região
scan_free_region(x, &b[p..p + len], dino, stale);
p += len;
continue;
}
let ino = be64(b, p);
let nl = b[p + 8] as usize;
if nl == 0 {
break;
}
let ft = if x.ftype { 1 } else { 0 };
let size = (8 + 1 + nl + ft + 2).div_ceil(8) * 8;
if p + size > end {
break;
}
let name = String::from_utf8_lossy(&b[p + 9..p + 9 + nl]).to_string();
if name != "." && name != ".." {
queue.push((ino, format!("{path}/{name}")));
}
p += size;
}
}
fn scan_free_region(x: &Xfs, r: &[u8], dir_ino: u64, stale: &mut HashMap<u64, (u64, String)>) {
// 1) a primeira entrada perdeu só os 4 primeiros bytes do número do inode
let try_at = |p: usize, low: u64, stale: &mut HashMap<u64, (u64, String)>| -> Option<usize> {
let nl = *r.get(p + 8)? as usize;
let name = r.get(p + 9..p + 9 + nl)?;
if nl == 0 || name.iter().any(|&c| c < 0x20 || c == b'/' || c == 0x7F) {
return None;
}
stale
.entry(low)
.or_insert((dir_ino, String::from_utf8_lossy(name).to_string()));
Some((8 + 1 + nl + if x.ftype { 1 } else { 0 } + 2).div_ceil(8) * 8)
};
if r.len() >= 16 {
let low = be32(r, 4) as u64;
if let Some(sz) = try_at(0, low, stale) {
// entradas seguintes dentro da mesma região, com o inode inteiro
let mut p = sz;
while p + 16 <= r.len() {
let ino = be64(r, p);
if ino == 0 || ino > 1 << 56 {
p += 8;
continue;
}
match try_at(p, ino & 0xFFFF_FFFF, stale) {
Some(s) => p += s,
None => p += 8,
}
}
}
}
}
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more