Compare commits
No files matched your search
@@ -2,6 +2,93 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -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.2.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.2.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",
|
||||
]
|
||||
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.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
|
||||
@@ -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,9 +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, confiança por arquivo, pausar/continuar e salvar sessão. Tema **Auto / Claro / Escuro** à sua escolha (lembrado também ao reiniciar como administrador) 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:
|
||||
|
||||
**Pré-visualização antes de recuperar** (lida direto do disco, sem gravar nada): imagens (com zoom), **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. Use ↑/↓ para percorrer a lista, Espaço/Enter ou duplo clique para abrir em tela cheia. 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`).
|
||||
- **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)
|
||||
|
||||
@@ -70,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
|
||||
|
||||
@@ -91,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,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.
|
||||
@@ -3,7 +3,7 @@
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "0.2.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 66 KiB |
@@ -101,6 +101,28 @@ fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||
/// 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()));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guara",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost https://tile.openstreetmap.org; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -323,7 +323,7 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
|
||||
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
|
||||
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
|
||||
|
||||
dialog.lightbox { width: min(1180px, 96vw); height: min(90vh, 920px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||
dialog.lightbox { width: min(1760px, 98vw); height: min(95vh, 1100px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||
dialog.lightbox[open] { display: grid; }
|
||||
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
|
||||
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
|
||||
@@ -332,3 +332,187 @@ dialog.lightbox[open] { display: grid; }
|
||||
|
||||
.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); }
|
||||
@@ -1,7 +1,8 @@
|
||||
// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`:
|
||||
// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`:
|
||||
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
|
||||
|
||||
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
|
||||
import { createExplorer } from './explorer.js';
|
||||
import { setLang, LOC } from './i18n.js';
|
||||
|
||||
const tauri = window.__TAURI__;
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -25,6 +26,7 @@ async function rpc(method, p = {}) {
|
||||
|
||||
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).
|
||||
@@ -46,9 +48,17 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
|
||||
const t = b.dataset.themePick;
|
||||
applyTheme(t);
|
||||
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
|
||||
try { await rpc('settings_set', { theme: t }); } catch { /* segue só com o armazenamento local */ }
|
||||
saveSettings({ theme: t });
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------------------ configurações
|
||||
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
|
||||
const SETTINGS = { theme: 'auto', lang: 'auto', 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
@@ -63,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'],
|
||||
@@ -103,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>`;
|
||||
@@ -153,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');
|
||||
@@ -162,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 () => {
|
||||
@@ -189,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"><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>
|
||||
<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>
|
||||
@@ -214,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) {
|
||||
@@ -252,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('')}
|
||||
@@ -272,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 };
|
||||
@@ -300,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;
|
||||
@@ -312,80 +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());
|
||||
$('#progressArea').onclick = onProgressClick;
|
||||
$('#chips').onclick = (e) => { const c = e.target.closest('.chip'); if (c) { S.filters.category = c.dataset.c || null; resetSel(); tick(); } };
|
||||
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(); };
|
||||
wireTable();
|
||||
paintPreview();
|
||||
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;
|
||||
@@ -397,26 +587,63 @@ 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;
|
||||
setHTML($('#progressArea'), `<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" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : ''}
|
||||
${!running && st.found ? '<button class="btn sm" data-act="save">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>`);
|
||||
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>`);
|
||||
}
|
||||
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>` : ''));
|
||||
// chips
|
||||
const total = st.found;
|
||||
setHTML($('#chips'), [`<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(''));
|
||||
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
|
||||
paintBar();
|
||||
}
|
||||
|
||||
async function chooseSource() {
|
||||
let cands = [];
|
||||
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
|
||||
const st = S.status || {};
|
||||
const size = st.layout?.size;
|
||||
const d = $('#dlg');
|
||||
const badge = (c) => (c.same_content === true ? '<span class="pill ok">mesmo disco</span>'
|
||||
: c.same_content === false ? '<span class="pill warn">tamanho igual, conteúdo diferente</span>'
|
||||
: c.same_size ? '<span class="pill accent">mesmo tamanho (precisa de administrador para conferir)</span>' : '');
|
||||
d.innerHTML = `<form method="dialog"><h2>Escolher a origem</h2>
|
||||
<p>Esta sessão foi feita sobre <code>${esc(st.target || '')}</code>${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.</p>
|
||||
<div class="cand">${cands.map((c, i) => `<button type="button" class="dev" data-c="${i}"><span class="ico">${icon(c.device.kind)}</span><span><span class="t">${esc(c.device.name)}</span><span class="s">${esc(c.device.path)} · ${human(c.device.size)} ${badge(c)}</span></span></button>`).join('') || '<p class="sub">Nenhum disco encontrado. Informe o caminho abaixo.</p>'}</div>
|
||||
<label class="fld"><span>Ou o caminho de um dispositivo ou imagem</span><input class="input" id="srcPath" name="path" placeholder="/dev/sdb ou /caminho/imagem.img" value="${esc(st.source_path || st.target || '')}"></label>
|
||||
<label class="check"><input type="checkbox" name="force"><span>Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)</span></label>
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Usar esta origem</button></menu></form>`;
|
||||
d.querySelectorAll('.cand .dev').forEach((b) => (b.onclick = () => {
|
||||
d.querySelectorAll('.cand .dev').forEach((x) => x.setAttribute('aria-current', 'false'));
|
||||
b.setAttribute('aria-current', 'true');
|
||||
$('#srcPath', d).value = cands[+b.dataset.c].device.path;
|
||||
}));
|
||||
d.onclose = async () => {
|
||||
if (d.returnValue !== 'ok') return;
|
||||
const path = $('#srcPath', d).value.trim();
|
||||
const force = d.querySelector('[name=force]').checked;
|
||||
if (!path) return;
|
||||
try {
|
||||
await rpc('session_relink', { job: S.job, path, force });
|
||||
toast('Origem reconectada');
|
||||
const cur = { job: S.job, target: st.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
|
||||
openJob(cur);
|
||||
} catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||
};
|
||||
d.showModal();
|
||||
}
|
||||
|
||||
async function onProgressClick(e) {
|
||||
@@ -424,189 +651,60 @@ async function onProgressClick(e) {
|
||||
if (!act) return;
|
||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
||||
else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
else if (act === 'save') {
|
||||
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 rowHtml(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>`;
|
||||
}
|
||||
|
||||
function paintList() {
|
||||
const el = $('#table');
|
||||
if (!el) return;
|
||||
const st = S.status;
|
||||
if (!S.items.length) {
|
||||
setHTML(el, `<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>`);
|
||||
paintBar();
|
||||
return;
|
||||
}
|
||||
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
|
||||
setHTML(el, `<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(rowHtml).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>` : ''));
|
||||
paintBar();
|
||||
}
|
||||
|
||||
// Eventos da tabela por delegação (o HTML é recriado, o listener não).
|
||||
function wireTable() {
|
||||
const el = $('#table');
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'moreBtn') return loadResults(false);
|
||||
const row = e.target.closest('.tr');
|
||||
if (e.target.id === 'selAll') {
|
||||
if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel();
|
||||
el._h = null; paintList();
|
||||
return;
|
||||
}
|
||||
if (!row) return;
|
||||
const id = +row.dataset.id;
|
||||
if (e.target.matches('input[type=checkbox]')) {
|
||||
if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); }
|
||||
e.target.checked ? S.selected.add(id) : S.selected.delete(id);
|
||||
row.setAttribute('aria-selected', e.target.checked);
|
||||
el._h = null;
|
||||
paintBar();
|
||||
return;
|
||||
}
|
||||
focusItem(S.items.find((i) => i.id === id));
|
||||
});
|
||||
el.addEventListener('dblclick', (e) => { if (e.target.closest('.tr')) openLightbox(); });
|
||||
}
|
||||
|
||||
let stopPv = null;
|
||||
function focusItem(item, { scroll = false } = {}) {
|
||||
if (!item) return;
|
||||
S.current = item;
|
||||
document.querySelectorAll('.tr').forEach((r) => {
|
||||
const on = +r.dataset.id === item.id || S.selected.has(+r.dataset.id) || S.allFiltered;
|
||||
r.setAttribute('aria-selected', on);
|
||||
if (scroll && +r.dataset.id === item.id) r.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
function detailsHtml(i) {
|
||||
const [cl, cc] = CONF[i.confidence];
|
||||
return `<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>`;
|
||||
}
|
||||
|
||||
function paintPreview() {
|
||||
const el = $('#preview');
|
||||
if (!el) return;
|
||||
stopPv?.(); stopPv = null;
|
||||
const i = S.current;
|
||||
if (!i) { el.innerHTML = '<div class="viewer"><div class="pv-note">Selecione um arquivo para pré-visualizar</div></div>'; return; }
|
||||
el.innerHTML = `<div class="viewer" id="pvHost"></div><div class="sub" style="font-size:12.5px">${esc(KIND_LABEL[kindOf(i)])}</div>${detailsHtml(i)}
|
||||
<div class="row"><button class="btn sm" id="pvFull">Tela cheia</button><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
|
||||
stopPv = renderPreview($('#pvHost'), i, { url: fileUrl(S.job, i.id) });
|
||||
$('#pvFull').onclick = openLightbox;
|
||||
$('#recOne').onclick = () => recover({ ids: [i.id] });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tela cheia
|
||||
let stopLb = null;
|
||||
function openLightbox() {
|
||||
const i = S.current;
|
||||
if (!i) return;
|
||||
const d = $('#lightbox');
|
||||
const idx = S.items.findIndex((x) => x.id === i.id);
|
||||
d.innerHTML = `<div class="lb-head"><button class="icon-btn" id="lbPrev" aria-label="Anterior" ${idx <= 0 ? 'disabled' : ''}>‹</button><button class="icon-btn" id="lbNext" aria-label="Próximo" ${idx >= S.items.length - 1 ? 'disabled' : ''}>›</button>
|
||||
<div class="ttl"><b>${esc(i.name)}</b><small>${esc(KIND_LABEL[kindOf(i)])} · ${human(i.size)} · confiança ${CONF[i.confidence][0]}</small></div>
|
||||
<button class="icon-btn" id="lbClose" aria-label="Fechar"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button></div>
|
||||
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||
<div class="lb-foot"><span class="sub">${i.note ? esc(i.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||
stopLb?.();
|
||||
stopLb = renderPreview($('#lbHost', d), i, { url: fileUrl(S.job, i.id), large: true });
|
||||
const go = (n) => { const t = S.items[idx + n]; if (t) { focusItem(t, { scroll: true }); openLightbox(); } };
|
||||
$('#lbPrev', d).onclick = () => go(-1);
|
||||
$('#lbNext', d).onclick = () => go(1);
|
||||
$('#lbClose', d).onclick = () => d.close();
|
||||
$('#lbRec', d).onclick = () => { d.close(); recover({ ids: [i.id] }); };
|
||||
d.onclose = () => { stopLb?.(); stopLb = null; };
|
||||
d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); };
|
||||
if (!d.open) d.showModal();
|
||||
}
|
||||
|
||||
// Teclado: ↑/↓ percorre a lista; Espaço/Enter abre a tela cheia.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!S.job || document.querySelector('dialog[open]') || /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName) && document.activeElement.type !== 'checkbox') return;
|
||||
const idx = S.items.findIndex((x) => x.id === S.current?.id);
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const t = S.items[Math.min(S.items.length - 1, Math.max(0, idx + (e.key === 'ArrowDown' ? 1 : -1)))];
|
||||
focusItem(t, { scroll: true });
|
||||
} else if ((e.key === ' ' || e.key === 'Enter') && S.current && !e.target.closest?.('button')) {
|
||||
e.preventDefault();
|
||||
openLightbox();
|
||||
}
|
||||
});
|
||||
|
||||
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 = ''; host._h = null; return; }
|
||||
const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size
|
||||
? '<button class="btn sm" data-bar="all">Selecionar todos os ' + S.total.toLocaleString('pt-BR') + '</button>' : '';
|
||||
const inner = `<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" data-bar="sel">Recuperar selecionados</button>' : '<button class="btn primary" data-bar="rec">Recuperar todos da lista</button>'}`;
|
||||
let bar = host.firstElementChild;
|
||||
if (!bar) {
|
||||
// A barra é criada uma vez (com animação de entrada) e depois só tem o texto atualizado.
|
||||
host.innerHTML = '<div class="actionbar enter"></div>';
|
||||
bar = host.firstElementChild;
|
||||
bar.onclick = (e) => {
|
||||
const act = e.target.closest('[data-bar]')?.dataset.bar;
|
||||
if (act === 'all') { S.allFiltered = true; $('#table')._h = null; paintList(); }
|
||||
else if (act === 'sel') recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] });
|
||||
else if (act === 'rec') recover({ filter: currentFilter() });
|
||||
};
|
||||
}
|
||||
setHTML(bar, inner);
|
||||
}
|
||||
|
||||
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>` }); }
|
||||
}
|
||||
@@ -640,18 +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'); if (st?.theme) { applyTheme(st.theme); try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } } } catch { /* usa o local */ }
|
||||
try {
|
||||
const st = await rpc('settings_get');
|
||||
Object.assign(SETTINGS, st);
|
||||
applyTheme(st.theme);
|
||||
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
|
||||
} catch { /* usa o local */ }
|
||||
setLang(SETTINGS.lang);
|
||||
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();
|
||||
})();
|
||||
@@ -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 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100" role="img" aria-label="Guará"><rect x="1.66" y="1.00" width="2.52" height="2.52" rx="0.30" fill="#67e8f9" opacity="0.50"/><rect x="5.59" y="0.44" width="2.16" height="2.16" rx="0.26" fill="#67e8f9" opacity="0.50"/><rect x="15.38" y="-1.68" width="1.95" height="1.95" rx="0.23" fill="#67e8f9" opacity="0.50"/><rect x="34.96" y="-2.08" width="3.04" height="3.04" rx="0.37" fill="#61e0ef" opacity="0.58"/><rect x="116.00" y="0.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="45.74" y="8.23" width="2.65" height="2.65" rx="0.32" fill="#48c0c8" opacity="0.63"/><rect x="58.59" y="2.22" width="2.04" height="2.04" rx="0.25" fill="#42b8bf" opacity="0.75"/><rect x="116.00" y="4.00" width="4.00" height="4.00" rx="0.48" fill="#127a73" opacity="1.00"/><rect x="22.44" y="11.12" width="3.15" height="3.15" rx="0.38" fill="#5fddec" opacity="0.51"/><rect x="29.61" y="5.65" width="2.48" height="2.48" rx="0.30" fill="#63e3f2" opacity="0.56"/><rect x="33.91" y="5.53" width="1.74" height="1.74" rx="0.21" fill="#60dfee" opacity="0.58"/><rect x="37.69" y="11.80" width="1.80" height="1.80" rx="0.22" fill="#52ccd7" opacity="0.60"/><rect x="80.66" y="8.60" width="2.43" height="2.43" rx="0.29" fill="#17807a" opacity="0.90"/><rect x="116.00" y="8.00" width="4.00" height="4.00" rx="0.48" fill="#10776f" opacity="1.00"/><rect x="23.94" y="11.79" width="0.95" height="0.95" rx="0.11" fill="#5fdeec" opacity="0.52"/><rect x="80.55" y="11.98" width="3.38" height="3.38" rx="0.41" fill="#1a8580" opacity="0.90"/><rect x="116.00" y="12.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="12.93" y="18.62" width="0.92" height="0.92" rx="0.11" fill="#66e7f8" opacity="0.50"/><rect x="80.08" y="16.11" width="3.84" height="3.84" rx="0.46" fill="#1d8884" opacity="0.90"/><rect x="84.27" y="16.17" width="3.12" height="3.12" rx="0.37" fill="#137b74" opacity="0.92"/><rect x="112.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="116.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="36.99" y="18.33" width="1.19" height="1.19" rx="0.14" fill="#5edceb" opacity="0.58"/><rect x="42.76" y="23.58" width="2.57" height="2.57" rx="0.31" fill="#5bd9e7" opacity="0.60"/><rect x="84.65" y="20.70" width="2.62" height="2.62" rx="0.31" fill="#1c8783" opacity="0.92"/><rect x="88.57" y="20.34" width="3.00" height="3.00" rx="0.36" fill="#147c76" opacity="0.94"/><rect x="112.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="116.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="2.84" y="24.95" width="1.02" height="1.02" rx="0.12" fill="#67e8f9" opacity="0.50"/><rect x="13.36" y="27.09" width="3.04" height="3.04" rx="0.36" fill="#67e8f9" opacity="0.50"/><rect x="35.01" y="22.08" width="2.21" height="2.21" rx="0.26" fill="#60dfed" opacity="0.58"/><rect x="39.95" y="23.90" width="1.39" height="1.39" rx="0.17" fill="#4fc8d2" opacity="0.60"/><rect x="57.75" y="26.92" width="1.51" height="1.51" rx="0.18" fill="#38abae" opacity="0.75"/><rect x="84.57" y="24.33" width="3.22" height="3.22" rx="0.39" fill="#19837e" opacity="0.92"/><rect x="87.85" y="24.01" width="4.20" height="4.20" rx="0.50" fill="#117871" opacity="0.94"/><rect x="112.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#127a72" opacity="1.00"/><rect x="116.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="16.90" y="29.10" width="1.84" height="1.84" rx="0.22" fill="#67e8f9" opacity="0.50"/><rect x="22.27" y="28.50" width="1.55" height="1.55" rx="0.19" fill="#67e8f9" opacity="0.52"/><rect x="27.25" y="29.79" width="1.65" height="1.65" rx="0.20" fill="#60deed" opacity="0.54"/><rect x="75.33" y="29.42" width="2.98" height="2.98" rx="0.36" fill="#23908d" opacity="0.84"/><rect x="80.43" y="28.69" width="3.16" height="3.16" rx="0.38" fill="#17817b" opacity="0.90"/><rect x="84.52" y="28.41" width="3.38" height="3.38" rx="0.41" fill="#157e77" opacity="0.92"/><rect x="88.64" y="28.72" width="2.47" height="2.47" rx="0.30" fill="#1d8985" opacity="0.94"/><rect x="92.11" y="28.12" width="3.81" height="3.81" rx="0.46" fill="#0f766e" opacity="1.00"/><rect x="112.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="116.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#147c75" opacity="1.00"/><rect x="120.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#0f766e" opacity="1.00"/><rect x="2.99" y="31.83" width="1.52" height="1.52" rx="0.18" fill="#66e6f7" opacity="0.50"/><rect x="3.82" y="34.72" width="1.03" height="1.03" rx="0.12" fill="#63e3f3" opacity="0.50"/><rect x="26.27" y="35.88" width="3.13" height="3.13" rx="0.38" fill="#57d3e0" opacity="0.54"/><rect x="35.28" y="36.35" width="2.04" height="2.04" rx="0.24" fill="#5edcea" opacity="0.56"/><rect x="84.84" y="3Line truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará"><style>.a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}</style><defs><clipPath id="g-head"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -1 +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="#14b8a6"/><stop offset="1" stop-color="#0c4a6e"/></linearGradient></defs><rect width="128" height="128" rx="29" fill="url(#g)"/><g transform="translate(-19 20) scale(0.88)"><rect x="116.00" y="0.00" width="4.00" height="4.00" rx="0.48" fill="#effdfa" opacity="1.00"/><rect x="55.88" y="7.09" width="2.81" height="2.81" rx="0.34" fill="#bff9ee" opacity="0.72"/><rect x="72.25" y="5.08" width="1.41" height="1.41" rx="0.17" fill="#dcfbf5" opacity="0.81"/><rect x="116.00" y="4.00" width="4.00" height="4.00" rx="0.48" fill="#eefdf9" opacity="1.00"/><rect x="80.28" y="8.30" width="2.76" height="2.76" rx="0.33" fill="#e3fcf7" opacity="0.90"/><rect x="116.00" y="8.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="26.46" y="13.82" width="3.31" height="3.31" rx="0.40" fill="#9bf6e4" opacity="0.54"/><rect x="53.70" y="15.81" width="1.46" height="1.46" rx="0.17" fill="#bef9ed" opacity="0.69"/><rect x="68.83" y="10.06" width="1.56" height="1.56" rx="0.19" fill="#cbfaf1" opacity="0.81"/><rect x="80.87" y="12.94" width="2.88" height="2.88" rx="0.35" fill="#e5fcf7" opacity="0.90"/><rect x="84.12" y="12.06" width="3.38" height="3.38" rx="0.41" fill="#dcfbf5" opacity="0.92"/><rect x="116.00" y="12.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="64.31" y="19.49" width="2.49" height="2.49" rx="0.30" fill="#cbfaf1" opacity="0.75"/><rect x="83.91" y="16.21" width="3.68" height="3.68" rx="0.44" fill="#e6fcf7" opacity="0.92"/><rect x="112.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#eefdfa" opacity="1.00"/><rect x="116.00" y="16.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="35.36" y="23.45" width="2.13" height="2.13" rx="0.26" fill="#a8f7e8" opacity="0.56"/><rect x="84.51" y="20.97" width="2.47" height="2.47" rx="0.30" fill="#ddfbf5" opacity="0.92"/><rect x="88.20" y="20.36" width="3.47" height="3.47" rx="0.42" fill="#e0fcf6" opacity="0.94"/><rect x="112.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="116.00" y="20.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="57.99" y="21.72" width="3.23" height="3.23" rx="0.39" fill="#c0f9ee" opacity="0.72"/><rect x="88.09" y="24.27" width="3.62" height="3.62" rx="0.43" fill="#f0fdfa" opacity="0.94"/><rect x="112.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="116.00" y="24.00" width="4.00" height="4.00" rx="0.48" fill="#ecfdf9" opacity="1.00"/><rect x="33.49" y="30.98" width="1.25" height="1.25" rx="0.15" fill="#aaf7e8" opacity="0.56"/><rect x="53.47" y="27.77" width="1.44" height="1.44" rx="0.17" fill="#c0f9ee" opacity="0.69"/><rect x="88.16" y="28.01" width="3.99" height="3.99" rx="0.48" fill="#ecfdf9" opacity="0.94"/><rect x="92.10" y="28.10" width="3.81" height="3.81" rx="0.46" fill="#f0fdfa" opacity="1.00"/><rect x="112.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="116.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="120.00" y="28.00" width="4.00" height="4.00" rx="0.48" fill="#edfdf9" opacity="1.00"/><rect x="31.35" y="34.66" width="1.37" height="1.37" rx="0.16" fill="#a0f7e6" opacity="0.56"/><rect x="41.92" y="32.71" width="2.72" height="2.72" rx="0.33" fill="#abf7e9" opacity="0.63"/><rect x="80.35" y="32.60" width="3.35" height="3.35" rx="0.40" fill="#e4fcf7" opacity="0.90"/><rect x="88.21" y="32.24" width="3.78" height="3.78" rx="0.45" fill="#e6fcf8" opacity="0.94"/><rect x="92.18" y="32.04" width="3.81" height="3.81" rx="0.46" fill="#f0fdfa" opacity="1.00"/><rect x="96.02" y="32.03" width="3.89" height="3.89" rx="0.47" fill="#f0fdfa" opacity="1.00"/><rect x="108.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#edfdf9" opacity="1.00"/><rect x="112.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#eefdfa" opacity="1.00"/><rect x="116.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#ecfdf9" opacity="1.00"/><rect x="120.00" y="32.00" width="4.00" height="4.00" rx="0.48" fill="#f0fdfa" opacity="1.00"/><rect x="48.73" y="37.21" width="1.04" height="1.04" rx="0.12" fill="#b6f8eb" opacity="0.63"/><rect x="71.16" y="34.38" width="2.65" height="2.65" rx="0.32" fill="#d1fbf2" opacity="0.81"/><rect x="80.74" y="36.35" width="2.54" height="2.54" rx="0.30" fill="#eafcf8" opacity="0.90"/><rect x="84.39" y="36.27" width="3.60" height="3.60" rx="0.43" fill="#e4fcf7" opacity="0.92"/><rect x="88.49" y="36.43" width="3.34" height="3.34" rx="0.40" fill="#e5fcf7" opacity="0.94"/><rect x="92.14" y="36.15" width="3.81" height="3.81" rx="0.46" fill="#f0fdfa" opacity="1.00"/><rect x="96.09" y="36.09" width="3.89" height="3.89" rx="0.47" fill="#f0fdfa" opacity="1.00"/><rect x="108.00" y="36.00" widLine truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará"><style>.a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}</style><defs><clipPath id="g-head"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -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.',
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -14,19 +14,21 @@
|
||||
<button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
|
||||
</button>
|
||||
<img src="guara.svg" alt="" width="24" height="24"><strong>Guará</strong>
|
||||
<svg class="logo" width="24" height="24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-top"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-top)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><strong>Guará</strong>
|
||||
</header>
|
||||
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
|
||||
<div class="brand"><img src="guara.svg" alt="" width="30" height="30"><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
|
||||
<div class="brand"><svg class="logo" width="30" height="30" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-side"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-side)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
|
||||
<nav>
|
||||
<div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
|
||||
<ul id="deviceList" class="list"></ul>
|
||||
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
|
||||
<div class="side-title">Sessões</div>
|
||||
<ul id="sessionList" class="list"></ul>
|
||||
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
|
||||
<ul id="recentList" class="list"></ul>
|
||||
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
||||
</nav>
|
||||
<footer class="side-foot"><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
<footer class="side-foot"><button class="icon-btn small" id="settingsBtn" title="Configurações" aria-label="Configurações"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
</aside>
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<main class="main" id="main" tabindex="-1"></main>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LOC } from './i18n.js';
|
||||
// Pré-visualização de arquivos recuperados.
|
||||
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
|
||||
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
|
||||
@@ -9,6 +10,7 @@ const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
|
||||
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
|
||||
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
|
||||
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
|
||||
const DBS = new Set(['db', 'sqlite', 'sqlite3', 'db3', 'sdb', 'sqlitedb', 'storedata']);
|
||||
|
||||
export function kindOf(item) {
|
||||
const e = (item.ext || '').toLowerCase();
|
||||
@@ -16,6 +18,7 @@ export function kindOf(item) {
|
||||
if (VID.has(e)) return 'video';
|
||||
if (AUD.has(e)) return 'audio';
|
||||
if (e === 'pdf') return 'pdf';
|
||||
if (DBS.has(e)) return 'database';
|
||||
if (OFFICE.has(e)) return 'office';
|
||||
if (ZIPS.has(e)) return 'zip';
|
||||
if (TXT.has(e)) return 'text';
|
||||
@@ -24,7 +27,7 @@ export function kindOf(item) {
|
||||
|
||||
export const KIND_LABEL = {
|
||||
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
|
||||
zip: 'Conteúdo do arquivo compactado', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
|
||||
zip: 'Conteúdo do arquivo compactado', database: 'Banco de dados SQLite', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
|
||||
};
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
@@ -90,7 +93,7 @@ function note(host, html, cls = '') {
|
||||
|
||||
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 — isso também indica que a chance de recuperação é menor.`, 'bad');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -121,7 +124,7 @@ async function text(host, item, url, large) {
|
||||
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) {
|
||||
async function image(host, item, url, state, thumb) {
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.alt = `Pré-visualização de ${item.name}`;
|
||||
@@ -131,9 +134,11 @@ async function image(host, item, url, state) {
|
||||
if (state.dead) return resolve();
|
||||
host.innerHTML = '';
|
||||
host.appendChild(img);
|
||||
host.classList.add('zoomable');
|
||||
img.onclick = () => host.classList.toggle('actual');
|
||||
note(host, `${img.naturalWidth} × ${img.naturalHeight} px — clique para alternar entre ajustar e tamanho real.`);
|
||||
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(); };
|
||||
@@ -270,21 +275,130 @@ async function zip(host, item, url, state) {
|
||||
note(host, `${ents.length} item(ns) no arquivo compactado.`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ bancos de dados (SQLite)
|
||||
|
||||
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
|
||||
function tsHint(v) {
|
||||
if (!Number.isInteger(v)) return null;
|
||||
let ms = null, kind = '';
|
||||
if (v > 9.4e8 && v < 4.2e9) { ms = v * 1000; kind = 'Unix (s)'; }
|
||||
else if (v > 9.4e11 && v < 4.2e12) { ms = v; kind = 'Unix (ms)'; }
|
||||
else if (v > 9.4e14 && v < 4.2e15) { ms = v / 1000; kind = 'Unix (µs)'; }
|
||||
else if (v > 1.2e16 && v < 1.4e16) { ms = v / 1000 - 11644473600000; kind = 'Chrome/WebKit'; }
|
||||
else if (v > 3e8 && v < 9.4e8) { ms = (v + 978307200) * 1000; kind = 'macOS/CoreData'; }
|
||||
if (ms == null) return null;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : { kind, text: d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' };
|
||||
}
|
||||
|
||||
function cellHtml(c) {
|
||||
switch (c.t) {
|
||||
case 'null': return '<span class="db-null">NULL</span>';
|
||||
case 'int': { const h = tsHint(c.v); return h ? `<span title="${esc(h.kind)}: ${esc(h.text)}">${c.v}<i class="db-ts">${esc(h.text.slice(0, 10))}</i></span>` : String(c.v); }
|
||||
case 'real': return String(c.v);
|
||||
case 'text': return `<span title="${esc(c.v.slice(0, 600))}">${esc(c.v.length > 240 ? c.v.slice(0, 240) + '…' : c.v)}</span>`;
|
||||
default: return `<span class="db-blob" title="${esc(c.v.head)}">BLOB ${c.v.mime ? esc(c.v.mime) + ' ' : ''}${human(c.v.len)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function database(host, item, state, large, db, onOpen) {
|
||||
if (!db) throw new Error('visualizador indisponível');
|
||||
const info = await db.info();
|
||||
if (state.dead) return;
|
||||
const tables = info.tables;
|
||||
const warn = (info.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
|
||||
if (!large) {
|
||||
const total = tables.reduce((a, t) => a + (t.rows || 0), 0);
|
||||
host.innerHTML = `<div class="db-sum"><b>Banco SQLite</b><span class="sub">${tables.length} tabela(s) · ${total.toLocaleString(LOC())} linhas · páginas de ${human(info.page_size)}</span>
|
||||
<ul>${tables.slice(0, 10).map((t) => `<li><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></li>`).join('')}</ul>
|
||||
${tables.length > 10 ? `<span class="sub">e mais ${tables.length - 10}…</span>` : ''}${warn}
|
||||
<button class="btn primary sm" data-pv="open">Abrir o visualizador</button></div>`;
|
||||
host.onclick = (e) => { if (e.target.closest('[data-pv]')) onOpen?.(); };
|
||||
return;
|
||||
}
|
||||
const S = { table: tables.find((t) => t.rows > 0)?.name || tables[0]?.name, offset: 0, limit: 100, deleted: false, q: '', schema: false };
|
||||
host.classList.add('db-large');
|
||||
host.innerHTML = `<div class="db-wrap"><nav class="db-tables" aria-label="Tabelas"></nav><section class="db-main"><div class="db-bar"></div><div class="db-grid"></div><div class="db-foot"></div></section></div>`;
|
||||
const $ = (sel) => host.querySelector(sel);
|
||||
const nav = $('.db-tables'), bar = $('.db-bar'), grid = $('.db-grid'), foot = $('.db-foot');
|
||||
nav.innerHTML = tables.map((t) => `<button class="db-t" data-t="${esc(t.name)}"><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></button>`).join('')
|
||||
+ (info.indexes.length || info.views.length ? `<div class="db-meta">${info.views.length ? `<b>Views</b>${info.views.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}${info.indexes.length ? `<b>Índices</b>${info.indexes.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}</div>` : '')
|
||||
+ warn;
|
||||
|
||||
async function load() {
|
||||
if (state.dead) return;
|
||||
nav.querySelectorAll('.db-t').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.t === S.table)));
|
||||
const t = tables.find((x) => x.name === S.table);
|
||||
if (!t) { grid.innerHTML = '<div class="pv-note">Nenhuma tabela.</div>'; return; }
|
||||
bar.innerHTML = `<b>${esc(t.name)}</b><span class="sub">${(t.rows || 0).toLocaleString(LOC())} linhas · ${t.columns.length} colunas</span>
|
||||
<input class="input" id="dbq" type="search" placeholder="Filtrar esta página" value="${esc(S.q)}" aria-label="Filtrar">
|
||||
<label class="check" style="padding:0"><input type="checkbox" id="dbdel" ${S.deleted ? 'checked' : ''}><span>Registros apagados (prováveis)</span></label>
|
||||
<button class="btn sm" id="dbsch">${S.schema ? 'Ver dados' : 'Ver esquema'}</button>`;
|
||||
if (S.schema) { grid.innerHTML = `<pre class="pv-text">${esc(t.sql)}</pre>`; foot.innerHTML = ''; wire(); return; }
|
||||
grid.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||
let page;
|
||||
try { page = await db.rows(S.table, S.offset, S.limit, S.deleted); } catch (e) { grid.innerHTML = `<div class="pv-note bad">${esc(e.message || e)}</div>`; wire(); return; }
|
||||
if (state.dead) return;
|
||||
const q = S.q.toLowerCase();
|
||||
const rows = q ? page.rows.filter((r) => r.cells.some((c) => String(c.t === 'blob' ? '' : c.v).toLowerCase().includes(q))) : page.rows;
|
||||
const notes = (page.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
|
||||
grid.innerHTML = notes + (rows.length ? `<table class="db-table"><thead><tr><th>#</th>${page.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r, i) => `<tr class="${r.deleted ? 'del' : ''}"><th>${r.deleted ? 'apagado' : (r.rowid ?? S.offset + i + 1)}</th>${r.cells.map((c) => `<td>${cellHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`
|
||||
: `<div class="pv-note">${S.deleted ? 'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.' : 'Nenhuma linha.'}</div>`);
|
||||
const total = S.deleted ? page.total : (t.rows || page.total);
|
||||
foot.innerHTML = `<span class="sub">${S.deleted ? `${total.toLocaleString(LOC())} registro(s) provável(is) apagado(s)` : `${total ? S.offset + 1 : 0} a ${Math.min(S.offset + S.limit, total)} de ${total.toLocaleString(LOC())}`}</span>
|
||||
<span class="row"><button class="btn sm" id="dbprev" ${S.offset <= 0 ? 'disabled' : ''}>‹ Anterior</button><button class="btn sm" id="dbnext" ${S.offset + S.limit >= total ? 'disabled' : ''}>Próximas ›</button></span>`;
|
||||
wire();
|
||||
}
|
||||
function wire() {
|
||||
$('#dbq')?.addEventListener('input', (e) => { S.q = e.target.value; clearTimeout(S.t); S.t = setTimeout(load, 200); });
|
||||
$('#dbdel')?.addEventListener('change', (e) => { S.deleted = e.target.checked; S.offset = 0; load(); });
|
||||
$('#dbsch')?.addEventListener('click', () => { S.schema = !S.schema; load(); });
|
||||
$('#dbprev')?.addEventListener('click', () => { S.offset = Math.max(0, S.offset - S.limit); load(); });
|
||||
$('#dbnext')?.addEventListener('click', () => { S.offset += S.limit; load(); });
|
||||
}
|
||||
nav.addEventListener('click', (e) => { const b = e.target.closest('.db-t'); if (b) { S.table = b.dataset.t; S.offset = 0; S.q = ''; S.deleted = false; S.schema = false; load(); } });
|
||||
load();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ API
|
||||
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
|
||||
export function renderPreview(host, item, { url, large = false }) {
|
||||
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);
|
||||
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); }
|
||||
|
||||
@@ -18,3 +18,4 @@ serde_json = "1"
|
||||
tiny_http = "0.12"
|
||||
getrandom = "0.2"
|
||||
ctrlc = "3"
|
||||
rpassword = "7"
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,16 @@ use guara_core::service::Engine;
|
||||
use serde_json::json;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
use tiny_http::{Header, Method, Request, Response, Server};
|
||||
|
||||
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
|
||||
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
|
||||
const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
|
||||
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js");
|
||||
const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg");
|
||||
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
|
||||
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
|
||||
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");
|
||||
@@ -51,7 +54,7 @@ fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
let host_ok = req
|
||||
.headers()
|
||||
.iter()
|
||||
@@ -60,8 +63,11 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
||||
return;
|
||||
}
|
||||
let Some(mut req) = guara_core::http::handle_media(eng, token, req) else {
|
||||
return;
|
||||
};
|
||||
let full = req.url().to_string();
|
||||
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let (path, _query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let ct = |v: &str| header("Content-Type", v);
|
||||
match (req.method().clone(), path) {
|
||||
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
||||
@@ -82,9 +88,25 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/i18n.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(I18N_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/i18n-en.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/guara-logo.svg") => {
|
||||
let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml")));
|
||||
}
|
||||
(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")));
|
||||
}
|
||||
@@ -98,43 +120,6 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, p) if p.starts_with("/file/") => {
|
||||
let tok_ok = query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token));
|
||||
if !tok_ok {
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return;
|
||||
}
|
||||
let mut it = p.trim_start_matches("/file/").split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let range = req
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return;
|
||||
};
|
||||
match eng.file_slice(job, id, range.as_deref()) {
|
||||
Ok(slice) => {
|
||||
let (status, len) = (slice.status(), slice.len() as usize);
|
||||
let headers: Vec<Header> =
|
||||
slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||
let resp =
|
||||
Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ =
|
||||
req.respond(Response::from_string("indisponível").with_status_code(code));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Method::Post, p) if p.starts_with("/rpc/") => {
|
||||
let ok = req
|
||||
.headers()
|
||||
|
||||
@@ -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"
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(§or_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(§or_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(§or_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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¬e),
|
||||
};
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(¬e),
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¬e),
|
||||
};
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Hashes de integridade usados em perícia: MD5, SHA-1 e SHA-256 numa única passada.
|
||||
|
||||
use md5::Md5;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Hashes {
|
||||
pub md5: String,
|
||||
pub sha1: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MultiHasher {
|
||||
md5: Md5,
|
||||
sha1: Sha1,
|
||||
sha256: Sha256,
|
||||
}
|
||||
|
||||
impl MultiHasher {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn update(&mut self, data: &[u8]) {
|
||||
self.md5.update(data);
|
||||
self.sha1.update(data);
|
||||
self.sha256.update(data);
|
||||
}
|
||||
pub fn finalize(self) -> Hashes {
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
Hashes {
|
||||
md5: hex(&self.md5.finalize()),
|
||||
sha1: hex(&self.sha1.finalize()),
|
||||
sha256: hex(&self.sha256.finalize()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_bytes(data: &[u8]) -> Hashes {
|
||||
let mut h = MultiHasher::new();
|
||||
h.update(data);
|
||||
h.finalize()
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
//! Saúde do disco antes de varrer: lê o SMART (via `smartctl`, quando instalado) e
|
||||
//! os contadores de erro do kernel. Um disco com defeito deve ser copiado para uma
|
||||
//! imagem primeiro: cada leitura extra pode piorar o estado dele.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Health {
|
||||
/// Foi possível avaliar? (imagens de disco e dispositivos sem SMART ficam sem avaliação)
|
||||
pub available: bool,
|
||||
/// "ok", "warning", "failing" ou "unknown".
|
||||
pub status: String,
|
||||
pub summary: String,
|
||||
pub details: Vec<(String, String)>,
|
||||
pub advice: Vec<String>,
|
||||
/// Ficha do dispositivo, no estilo do CrystalDiskInfo (modelo, firmware, interface...).
|
||||
pub info: Vec<(String, String)>,
|
||||
/// Saúde estimada em % (SSD: vida útil restante). `None` em HD, que não informa isso.
|
||||
pub health_pct: Option<u8>,
|
||||
pub temperature_c: Option<i64>,
|
||||
pub power_on_hours: Option<i64>,
|
||||
pub power_cycles: Option<i64>,
|
||||
/// Total lido e gravado pelo computador, em bytes, quando o disco informa.
|
||||
pub host_reads: Option<u64>,
|
||||
pub host_writes: Option<u64>,
|
||||
/// Tabela completa de atributos SMART.
|
||||
pub attributes: Vec<Attr>,
|
||||
/// Caminho avaliado de fato (o disco inteiro, quando `path` é uma partição).
|
||||
pub disk: String,
|
||||
/// Partição pedida, quando a avaliação foi feita no disco que a contém.
|
||||
pub partition: Option<String>,
|
||||
}
|
||||
|
||||
/// Uma linha da tabela de atributos SMART.
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Attr {
|
||||
/// Identificador em hexadecimal, como o CrystalDiskInfo mostra ("05", "C5").
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Valores normalizados (ATA): atual, pior já visto e limite de falha.
|
||||
pub value: Option<i64>,
|
||||
pub worst: Option<i64>,
|
||||
pub thresh: Option<i64>,
|
||||
pub raw: String,
|
||||
/// "ok", "warning" ou "failing".
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
fn unknown(msg: &str) -> Self {
|
||||
Health {
|
||||
available: false,
|
||||
status: "unknown".into(),
|
||||
summary: msg.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_ADVICE: &str = "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.";
|
||||
|
||||
/// Avalia o dispositivo `path`. Para arquivos comuns (imagens) devolve "sem avaliação".
|
||||
pub fn check(path: &str) -> Health {
|
||||
#[cfg(unix)]
|
||||
let is_dev = {
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.file_type().is_block_device() || m.file_type().is_char_device())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let is_dev = path.starts_with(r"\\.\");
|
||||
if !is_dev {
|
||||
return Health::unknown("Imagem de disco: não há SMART para avaliar.");
|
||||
}
|
||||
let disk = parent_disk(path);
|
||||
let mut h = match run_smartctl(&disk) {
|
||||
Some(v) => parse_smartctl(&v),
|
||||
None => Health::unknown("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."),
|
||||
};
|
||||
kernel_errors(path, &mut h);
|
||||
if disk != path {
|
||||
h.partition = Some(path.to_string());
|
||||
}
|
||||
h.disk = disk;
|
||||
h
|
||||
}
|
||||
|
||||
/// O SMART é do disco, não da partição: `/dev/sda1` vira `/dev/sda`,
|
||||
/// `/dev/nvme0n1p2` vira `/dev/nvme0n1` e `/dev/disk2s1` vira `/dev/disk2`.
|
||||
pub fn parent_disk(path: &str) -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let name = path.trim_start_matches("/dev/");
|
||||
let sys = format!("/sys/class/block/{name}");
|
||||
if std::path::Path::new(&format!("{sys}/partition")).exists() {
|
||||
if let Ok(real) = std::fs::canonicalize(&sys) {
|
||||
if let Some(p) = real.parent().and_then(|p| p.file_name()) {
|
||||
return format!("/dev/{}", p.to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
for pre in ["/dev/rdisk", "/dev/disk"] {
|
||||
if let Some(rest) = path.strip_prefix(pre) {
|
||||
if let Some(i) = rest.find('s') {
|
||||
return format!("{pre}{}", &rest[..i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn smartctl_name(path: &str) -> String {
|
||||
// Windows: \\.\PhysicalDriveN -> /dev/sdX (convenção do smartctl)
|
||||
if let Some(n) = path
|
||||
.strip_prefix(r"\\.\PhysicalDrive")
|
||||
.and_then(|n| n.parse::<u8>().ok())
|
||||
{
|
||||
return format!("/dev/sd{}", (b'a' + n.min(25)) as char);
|
||||
}
|
||||
// macOS: /dev/rdiskN -> /dev/diskN
|
||||
if let Some(rest) = path.strip_prefix("/dev/rdisk") {
|
||||
return format!("/dev/disk{rest}");
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn run_smartctl(path: &str) -> Option<Value> {
|
||||
let dev = smartctl_name(path);
|
||||
let out = Command::new("smartctl")
|
||||
.args(["--json=o", "-a", &dev])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
// smartctl usa bits do código de saída para avisos; o JSON vale mesmo assim
|
||||
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
// sem permissão ou dispositivo não suportado
|
||||
if v.pointer("/smartctl/messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.is_some_and(|m| m.iter().any(|x| x["severity"] == "error"))
|
||||
&& v.get("smart_status").is_none()
|
||||
&& v.get("ata_smart_attributes").is_none()
|
||||
&& v.get("nvme_smart_health_information_log").is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn attr(v: &Value, id: u64) -> Option<i64> {
|
||||
v.pointer("/ata_smart_attributes/table")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|a| a["id"] == id)?
|
||||
.pointer("/raw/value")?
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// Interpreta a saída JSON do `smartctl` (testável sem disco).
|
||||
pub fn parse_smartctl(v: &Value) -> Health {
|
||||
let mut h = Health {
|
||||
available: true,
|
||||
status: "ok".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut bad = 0u32; // 2 = falhando, 1 = atenção
|
||||
let mut note = |h: &mut Health, sev: u32, k: &str, val: String| {
|
||||
bad = bad.max(sev);
|
||||
h.details.push((k.to_string(), val));
|
||||
};
|
||||
fill_info(v, &mut h);
|
||||
fill_attributes(v, &mut h);
|
||||
if let Some(m) = v["model_name"].as_str() {
|
||||
h.details.push(("Modelo".into(), m.to_string()));
|
||||
}
|
||||
if let Some(s) = v["serial_number"].as_str() {
|
||||
h.details.push(("Nº de série".into(), s.to_string()));
|
||||
}
|
||||
if let Some(hrs) = v.pointer("/power_on_time/hours").and_then(|x| x.as_i64()) {
|
||||
h.details.push(("Horas ligado".into(), hrs.to_string()));
|
||||
}
|
||||
if let Some(t) = v.pointer("/temperature/current").and_then(|x| x.as_i64()) {
|
||||
if t >= 60 {
|
||||
note(&mut h, 1, "Temperatura", format!("{t} °C (alta)"));
|
||||
} else {
|
||||
h.details.push(("Temperatura".into(), format!("{t} °C")));
|
||||
}
|
||||
}
|
||||
if v.pointer("/smart_status/passed") == Some(&Value::Bool(false)) {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Autoavaliação SMART",
|
||||
"REPROVADO: o próprio disco prevê falha".into(),
|
||||
);
|
||||
} else if v.pointer("/smart_status/passed") == Some(&Value::Bool(true)) {
|
||||
h.details
|
||||
.push(("Autoavaliação SMART".into(), "aprovado".into()));
|
||||
}
|
||||
// ATA (HD e SSD SATA)
|
||||
if let Some(n) = attr(v, 5) {
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
if n >= 50 { 2 } else { 1 },
|
||||
"Setores realocados",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 197) {
|
||||
if n > 0 {
|
||||
note(&mut h, 2, "Setores pendentes de realocação", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 198) {
|
||||
if n > 0 {
|
||||
note(&mut h, 2, "Setores incorrigíveis offline", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 187) {
|
||||
if n > 0 {
|
||||
note(&mut h, 1, "Erros incorrigíveis reportados", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 199) {
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
1,
|
||||
"Erros de CRC na interface (cabo ou porta)",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(n) = v
|
||||
.pointer("/ata_smart_error_log/summary/count")
|
||||
.and_then(|x| x.as_i64())
|
||||
{
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
1,
|
||||
"Erros registrados no log do disco",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// NVMe
|
||||
if let Some(n) = v.get("nvme_smart_health_information_log") {
|
||||
if n["critical_warning"].as_i64().unwrap_or(0) != 0 {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Aviso crítico NVMe",
|
||||
n["critical_warning"].to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(u) = n["percentage_used"].as_i64() {
|
||||
if u >= 100 {
|
||||
note(&mut h, 2, "Vida útil usada", format!("{u}%"));
|
||||
} else if u >= 90 {
|
||||
note(&mut h, 1, "Vida útil usada", format!("{u}%"));
|
||||
} else {
|
||||
h.details.push(("Vida útil usada".into(), format!("{u}%")));
|
||||
}
|
||||
}
|
||||
if let Some(m) = n["media_errors"].as_i64() {
|
||||
if m > 0 {
|
||||
note(&mut h, 2, "Erros de mídia", m.to_string());
|
||||
}
|
||||
}
|
||||
if let (Some(s), Some(t)) = (
|
||||
n["available_spare"].as_i64(),
|
||||
n["available_spare_threshold"].as_i64(),
|
||||
) {
|
||||
if s < t {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Reserva de blocos abaixo do limite",
|
||||
format!("{s}% (limite {t}%)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.attributes.iter().any(|a| a.status == "failing") {
|
||||
bad = 2;
|
||||
}
|
||||
match bad {
|
||||
2 => {
|
||||
h.status = "failing".into();
|
||||
h.summary = "Este disco mostra sinais sérios de defeito.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
1 => {
|
||||
h.status = "warning".into();
|
||||
h.summary = "Este disco mostra sinais de desgaste ou erros.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
_ => h.summary = "Nenhum sinal de defeito encontrado pelo SMART.".into(),
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn s(v: &Value, p: &str) -> Option<String> {
|
||||
v.pointer(p)
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|x| x.trim().to_string())
|
||||
.filter(|x| !x.is_empty())
|
||||
}
|
||||
fn n(v: &Value, p: &str) -> Option<i64> {
|
||||
v.pointer(p).and_then(|x| x.as_i64())
|
||||
}
|
||||
|
||||
/// Ficha do dispositivo e métricas gerais (temperatura, horas, total lido/gravado, saúde %).
|
||||
fn fill_info(v: &Value, h: &mut Health) {
|
||||
let mut put = |k: &str, val: Option<String>| {
|
||||
if let Some(val) = val {
|
||||
h.info.push((k.to_string(), val));
|
||||
}
|
||||
};
|
||||
put(
|
||||
"Modelo",
|
||||
s(v, "/model_name").or_else(|| {
|
||||
let a = s(v, "/scsi_vendor")?;
|
||||
Some(format!("{a} {}", s(v, "/scsi_product").unwrap_or_default()))
|
||||
}),
|
||||
);
|
||||
put("Firmware", s(v, "/firmware_version"));
|
||||
put("Nº de série", s(v, "/serial_number"));
|
||||
put("Família", s(v, "/model_family"));
|
||||
let proto = s(v, "/device/protocol");
|
||||
let iface = s(v, "/sata_version/string").or_else(|| {
|
||||
let sp = v
|
||||
.pointer("/nvme_pci_vendor")
|
||||
.map(|_| "NVM Express".to_string());
|
||||
sp.or_else(|| proto.clone())
|
||||
});
|
||||
put("Interface", iface);
|
||||
let speed = |p: &str| s(v, &format!("/interface_speed/{p}/string"));
|
||||
put(
|
||||
"Modo de transferência",
|
||||
match (speed("current"), speed("max")) {
|
||||
(Some(c), Some(m)) if c != m => Some(format!("{c} (máx. {m})")),
|
||||
(Some(c), _) => Some(c),
|
||||
(None, Some(m)) => Some(m),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
put(
|
||||
"Capacidade",
|
||||
n(v, "/user_capacity/bytes")
|
||||
.or_else(|| n(v, "/nvme_total_capacity"))
|
||||
.map(|b| decimal_size(b as u64)),
|
||||
);
|
||||
put(
|
||||
"Rotação",
|
||||
n(v, "/rotation_rate").map(|r| {
|
||||
if r == 0 {
|
||||
"SSD (sem rotação)".into()
|
||||
} else {
|
||||
format!("{r} RPM")
|
||||
}
|
||||
}),
|
||||
);
|
||||
put("Formato", s(v, "/form_factor/name"));
|
||||
let mut feats = Vec::new();
|
||||
if v.pointer("/smart_support/enabled") == Some(&Value::Bool(true))
|
||||
|| v.get("nvme_smart_health_information_log").is_some()
|
||||
{
|
||||
feats.push("S.M.A.R.T.");
|
||||
}
|
||||
if v.pointer("/trim/supported") == Some(&Value::Bool(true)) {
|
||||
feats.push("TRIM");
|
||||
}
|
||||
if v.pointer("/write_cache/enabled") == Some(&Value::Bool(true)) {
|
||||
feats.push("Cache de gravação");
|
||||
}
|
||||
if v.get("ata_security").and_then(|x| x.get("enabled")) == Some(&Value::Bool(true)) {
|
||||
feats.push("Segurança ATA");
|
||||
}
|
||||
if !feats.is_empty() {
|
||||
put("Recursos", Some(feats.join(", ")));
|
||||
}
|
||||
|
||||
h.temperature_c = n(v, "/temperature/current");
|
||||
h.power_on_hours = n(v, "/power_on_time/hours");
|
||||
h.power_cycles = n(v, "/power_cycle_count");
|
||||
if let Some(l) = v.get("nvme_smart_health_information_log") {
|
||||
// a unidade NVMe vale 1000 setores de 512 bytes
|
||||
h.host_reads = l["data_units_read"].as_u64().map(|u| u * 512_000);
|
||||
h.host_writes = l["data_units_written"].as_u64().map(|u| u * 512_000);
|
||||
h.health_pct = l["percentage_used"]
|
||||
.as_i64()
|
||||
.map(|u| (100 - u.clamp(0, 100)) as u8);
|
||||
h.power_cycles = h.power_cycles.or(l["power_cycles"].as_i64());
|
||||
} else {
|
||||
let lba = n(v, "/logical_block_size").unwrap_or(512) as u64;
|
||||
h.host_reads = attr(v, 242).map(|x| x as u64 * lba);
|
||||
h.host_writes = attr(v, 241).map(|x| x as u64 * lba);
|
||||
// SSD SATA: vida útil restante no valor normalizado de um destes atributos
|
||||
// (231 SSD Life Left, 233 Media Wearout, 177 Wear Leveling, 202 Percent Lifetime Remain)
|
||||
h.health_pct = [231u64, 233, 177, 202, 169]
|
||||
.iter()
|
||||
.find_map(|&id| attr_field(v, id, "value"))
|
||||
.filter(|_| n(v, "/rotation_rate") == Some(0))
|
||||
.map(|x| x.clamp(0, 100) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
/// Capacidade como o fabricante anuncia (base 1000): "500,1 GB".
|
||||
fn decimal_size(b: u64) -> String {
|
||||
let units = ["B", "kB", "MB", "GB", "TB", "PB"];
|
||||
let mut x = b as f64;
|
||||
let mut i = 0;
|
||||
while x >= 1000.0 && i < units.len() - 1 {
|
||||
x /= 1000.0;
|
||||
i += 1;
|
||||
}
|
||||
format!("{x:.1} {}", units[i]).replace('.', ",")
|
||||
}
|
||||
|
||||
fn attr_field(v: &Value, id: u64, f: &str) -> Option<i64> {
|
||||
v.pointer("/ata_smart_attributes/table")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|a| a["id"] == id)?
|
||||
.get(f)?
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// Atributos cujo valor bruto acima de zero já é motivo de atenção.
|
||||
const RAW_WARN: [u64; 5] = [5, 196, 197, 198, 187];
|
||||
|
||||
fn fill_attributes(v: &Value, h: &mut Health) {
|
||||
if let Some(t) = v
|
||||
.pointer("/ata_smart_attributes/table")
|
||||
.and_then(|t| t.as_array())
|
||||
{
|
||||
for a in t {
|
||||
let id = a["id"].as_u64().unwrap_or(0);
|
||||
let value = a["value"].as_i64();
|
||||
let thresh = a["thresh"].as_i64();
|
||||
let raw_n = a
|
||||
.pointer("/raw/value")
|
||||
.and_then(|x| x.as_i64())
|
||||
.unwrap_or(0);
|
||||
let failing = a
|
||||
.pointer("/when_failed")
|
||||
.and_then(|x| x.as_str())
|
||||
.is_some_and(|w| w == "now")
|
||||
|| matches!((value, thresh), (Some(v), Some(t)) if t > 0 && v <= t);
|
||||
let status = if failing {
|
||||
"failing"
|
||||
} else if RAW_WARN.contains(&id) && raw_n > 0 {
|
||||
"warning"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
h.attributes.push(Attr {
|
||||
id: format!("{id:02X}"),
|
||||
name: a["name"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown_Attribute")
|
||||
.replace('_', " "),
|
||||
value,
|
||||
worst: a["worst"].as_i64(),
|
||||
thresh,
|
||||
raw: a
|
||||
.pointer("/raw/string")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| raw_n.to_string()),
|
||||
status: status.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(l) = v.get("nvme_smart_health_information_log") {
|
||||
// mesma ordem e nomes que o CrystalDiskInfo usa para NVMe
|
||||
let rows: [(u8, &str, &str); 15] = [
|
||||
(1, "Critical Warning", "critical_warning"),
|
||||
(2, "Composite Temperature", "temperature"),
|
||||
(3, "Available Spare", "available_spare"),
|
||||
(4, "Available Spare Threshold", "available_spare_threshold"),
|
||||
(5, "Percentage Used", "percentage_used"),
|
||||
(6, "Data Units Read", "data_units_read"),
|
||||
(7, "Data Units Written", "data_units_written"),
|
||||
(8, "Host Read Commands", "host_reads"),
|
||||
(9, "Host Write Commands", "host_writes"),
|
||||
(10, "Controller Busy Time", "controller_busy_time"),
|
||||
(11, "Power Cycles", "power_cycles"),
|
||||
(12, "Power On Hours", "power_on_hours"),
|
||||
(13, "Unsafe Shutdowns", "unsafe_shutdowns"),
|
||||
(14, "Media and Data Integrity Errors", "media_errors"),
|
||||
(
|
||||
15,
|
||||
"Number of Error Information Log Entries",
|
||||
"num_err_log_entries",
|
||||
),
|
||||
];
|
||||
let spare_low = matches!((l["available_spare"].as_i64(), l["available_spare_threshold"].as_i64()), (Some(s), Some(t)) if s < t);
|
||||
for (id, name, key) in rows {
|
||||
let Some(x) = l.get(key).and_then(|x| x.as_i64()) else {
|
||||
continue;
|
||||
};
|
||||
let status = match key {
|
||||
"critical_warning" | "media_errors" if x > 0 => "failing",
|
||||
"available_spare" if spare_low => "failing",
|
||||
"percentage_used" if x >= 100 => "failing",
|
||||
"percentage_used" if x >= 90 => "warning",
|
||||
_ => "ok",
|
||||
};
|
||||
h.attributes.push(Attr {
|
||||
id: format!("{id:02X}"),
|
||||
name: name.into(),
|
||||
raw: x.to_string(),
|
||||
status: status.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contadores de erro de E/S do kernel (Linux): reforçam o aviso do SMART.
|
||||
fn kernel_errors(path: &str, h: &mut Health) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let name = path.trim_start_matches("/dev/");
|
||||
let base = name.trim_end_matches(|c: char| c.is_ascii_digit());
|
||||
for n in [name, base] {
|
||||
if let Ok(s) = std::fs::read_to_string(format!("/sys/block/{n}/device/ioerr_cnt")) {
|
||||
if let Ok(c) = u64::from_str_radix(s.trim().trim_start_matches("0x"), 16) {
|
||||
if c > 0 {
|
||||
h.details.push((
|
||||
"Erros de leitura/gravação vistos pelo sistema".into(),
|
||||
c.to_string(),
|
||||
));
|
||||
if h.status == "ok" || h.status == "unknown" {
|
||||
h.available = true;
|
||||
h.status = "warning".into();
|
||||
h.summary = "O sistema já registrou erros de E/S neste disco.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = (path, h);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Rotas HTTP de mídia (`/file` com Range e `/thumb`) compartilhadas pelo modo `guara ui`.
|
||||
|
||||
use crate::service::Engine;
|
||||
use tiny_http::{Header, Method, Request, Response, StatusCode};
|
||||
|
||||
pub fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// O cabeçalho Host bate com um dos endereços esperados (defesa contra DNS rebinding)?
|
||||
pub fn host_ok(req: &Request, hosts: &[String]) -> bool {
|
||||
req.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()))
|
||||
}
|
||||
|
||||
/// Responde `/file/<job>/<id>` (com Range) e `/thumb/<job>/<id>?s=NNN`.
|
||||
/// Devolve a requisição quando ela não é uma rota de mídia.
|
||||
pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> {
|
||||
let full = req.url().to_string();
|
||||
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let is_file = path.starts_with("/file/");
|
||||
let is_thumb = path.starts_with("/thumb/");
|
||||
if !(is_file || is_thumb) {
|
||||
return Some(req);
|
||||
}
|
||||
if req.method() == &Method::Options {
|
||||
let r = Response::empty(204)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"))
|
||||
.with_header(header("Access-Control-Allow-Headers", "Range"))
|
||||
.with_header(header("Access-Control-Allow-Methods", "GET, OPTIONS"));
|
||||
let _ = req.respond(r);
|
||||
return None;
|
||||
}
|
||||
if req.method() != &Method::Get {
|
||||
let _ = req.respond(Response::from_string("método não permitido").with_status_code(405));
|
||||
return None;
|
||||
}
|
||||
if !query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token))
|
||||
{
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return None;
|
||||
}
|
||||
let mut it = path
|
||||
.trim_start_matches("/file/")
|
||||
.trim_start_matches("/thumb/")
|
||||
.split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return None;
|
||||
};
|
||||
if is_thumb {
|
||||
let size = query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("s="))
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(256);
|
||||
match eng.thumb(job, id, size) {
|
||||
Ok(bytes) => {
|
||||
let r = Response::from_data(bytes.as_ref().clone())
|
||||
.with_header(header("Content-Type", "image/jpeg"))
|
||||
.with_header(header("Cache-Control", "private, max-age=3600"))
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"));
|
||||
let _ = req.respond(r);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("sem miniatura")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let range = req
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
match eng.file_slice(job, id, range.as_deref()) {
|
||||
Ok(slice) => {
|
||||
let (status, len) = (slice.status(), slice.len() as usize);
|
||||
let headers: Vec<Header> = slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||
let resp = Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("indisponível")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::hashing::MultiHasher;
|
||||
use crate::source::Source;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -12,6 +12,10 @@ use std::path::Path;
|
||||
pub struct ImageReport {
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
#[serde(default)]
|
||||
pub md5: String,
|
||||
#[serde(default)]
|
||||
pub sha1: String,
|
||||
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
|
||||
pub bad_ranges: Vec<(u64, u64)>,
|
||||
pub cancelled: bool,
|
||||
@@ -20,6 +24,111 @@ pub struct ImageReport {
|
||||
const BLOCK: usize = 1 << 20;
|
||||
const SECTOR: usize = 512;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum ImageFormat {
|
||||
#[default]
|
||||
Raw,
|
||||
/// Expert Witness (EnCase 6): `.E01`, dividido em segmentos.
|
||||
E01 {
|
||||
#[serde(default)]
|
||||
case_number: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
evidence_number: String,
|
||||
#[serde(default)]
|
||||
examiner: String,
|
||||
#[serde(default)]
|
||||
notes: String,
|
||||
/// Tamanho máximo de cada segmento em MiB (0 = um arquivo só).
|
||||
#[serde(default = "default_segment_mib")]
|
||||
segment_mib: u64,
|
||||
/// 0 = sem compressão, 1 a 9 = deflate.
|
||||
#[serde(default = "default_compression")]
|
||||
compression: u32,
|
||||
},
|
||||
/// AFF4 (padrão v1.0, blocos snappy).
|
||||
Aff4 {
|
||||
#[serde(default)]
|
||||
case_number: String,
|
||||
#[serde(default)]
|
||||
examiner: String,
|
||||
#[serde(default)]
|
||||
evidence_number: String,
|
||||
#[serde(default)]
|
||||
notes: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_segment_mib() -> u64 {
|
||||
1500
|
||||
}
|
||||
fn default_compression() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn create_image_as(
|
||||
src: &dyn Source,
|
||||
dest: &Path,
|
||||
format: &ImageFormat,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<ImageReport> {
|
||||
match format {
|
||||
ImageFormat::Raw => create_image(src, dest, cancelled, progress),
|
||||
ImageFormat::Aff4 {
|
||||
case_number,
|
||||
examiner,
|
||||
evidence_number,
|
||||
notes,
|
||||
} => {
|
||||
let opts = crate::aff4::Aff4Options {
|
||||
case_name: case_number.clone(),
|
||||
examiner: examiner.clone(),
|
||||
notes: notes.clone(),
|
||||
evidence_number: evidence_number.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let dest = if dest
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("aff4"))
|
||||
{
|
||||
dest.to_path_buf()
|
||||
} else {
|
||||
dest.with_extension("aff4")
|
||||
};
|
||||
crate::aff4::write_aff4(src, &dest, &opts, cancelled, progress)
|
||||
}
|
||||
ImageFormat::E01 {
|
||||
case_number,
|
||||
description,
|
||||
evidence_number,
|
||||
examiner,
|
||||
notes,
|
||||
segment_mib,
|
||||
compression,
|
||||
} => {
|
||||
let opts = crate::ewf::E01Options {
|
||||
case_number: case_number.clone(),
|
||||
description: description.clone(),
|
||||
evidence_number: evidence_number.clone(),
|
||||
examiner: examiner.clone(),
|
||||
notes: notes.clone(),
|
||||
segment_size: segment_mib * 1024 * 1024,
|
||||
compression: *compression,
|
||||
..Default::default()
|
||||
};
|
||||
// O destino pode vir com ou sem a extensão .E01.
|
||||
let base = match dest.extension().and_then(|e| e.to_str()) {
|
||||
Some(e) if e.eq_ignore_ascii_case("e01") => dest.with_extension(""),
|
||||
_ => dest.to_path_buf(),
|
||||
};
|
||||
crate::ewf::write_e01(src, &base, &opts, cancelled, progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_image(
|
||||
src: &dyn Source,
|
||||
dest: &Path,
|
||||
@@ -36,9 +145,10 @@ pub fn create_image(
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
crate::owner::give_to_user(dest);
|
||||
let size = src.size();
|
||||
let mut rep = ImageReport::default();
|
||||
let mut hasher = Sha256::new();
|
||||
let mut hasher = MultiHasher::new();
|
||||
let mut buf = vec![0u8; BLOCK];
|
||||
let mut pos = 0u64;
|
||||
while pos < size {
|
||||
@@ -72,13 +182,12 @@ pub fn create_image(
|
||||
}
|
||||
out.flush()?;
|
||||
rep.bytes = pos;
|
||||
rep.sha256 = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
let h = hasher.finalize();
|
||||
rep.sha256 = h.sha256;
|
||||
rep.md5 = h.md5;
|
||||
rep.sha1 = h.sha1;
|
||||
let map = dest.with_extension("map.json");
|
||||
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
crate::owner::write_owned(&map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
|
||||