Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb38ad401a | ||
|
|
3caa9d805a | ||
|
|
cde0dd45f8 | ||
|
|
58940965bb | ||
|
|
26e5ebb592 | ||
|
|
c1c4f442d7 | ||
|
|
213a5a3df5 | ||
|
|
c6db7c7d2a | ||
|
|
a75cc6aeba | ||
|
|
cd0e647922 | ||
|
|
b4a85722a6 |
No files matched your search
@@ -1,11 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule: { interval: weekly }
|
||||
- package-ecosystem: cargo
|
||||
directory: /apps/gui/src-tauri
|
||||
schedule: { interval: weekly }
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule: { interval: weekly }
|
||||
@@ -23,6 +23,11 @@ jobs:
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Cobertura da tradução da interface
|
||||
run: node scripts/check-ui-i18n.mjs
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ jobs:
|
||||
id: notes
|
||||
run: |
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
v="${v%%-*}"
|
||||
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||
- uses: actions/github-script@v9
|
||||
@@ -112,7 +113,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Gera os ícones
|
||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -125,10 +126,21 @@ jobs:
|
||||
needs: [create-release, cli, gui]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Notas da versão (do CHANGELOG)
|
||||
run: |
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
v="${v%%-*}"
|
||||
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}, draft: false,
|
||||
release_id: ${{ needs.create-release.outputs.release_id }},
|
||||
name: `Guará ${context.ref.replace('refs/tags/', '')}`,
|
||||
body: fs.readFileSync('notes.md', 'utf8'),
|
||||
draft: false,
|
||||
});
|
||||
@@ -4,6 +4,47 @@
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||
|
||||
## [1.2.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
- **Virtual disk containers (read-only):** QCOW2 (v2 and v3, compressed clusters with deflate or zstd), VMDK (sparse, `streamOptimized`, and text descriptors with flat and zero extents), VHD and VHDX (fixed, dynamic and differencing, following the parent chain) and DMG (UDIF with zero, raw, ADC, zlib and bzip2 chunks). They open through the same path as E01 and AFF4, so partitions, file systems and encrypted volumes work inside them. Images that would read wrong are refused with the reason: QCOW2 with a backing file or encryption, VMDK with a parent or in SEsparse, password-protected DMG, DMG with LZFSE chunks. The VHDX log is not replayed.
|
||||
- **Audit trail** (`auditoria.jsonl`, `guara audit`): every action on evidence (`inspect`, `scan`, `recover`, `image`, `hash`, `search`, `verify`, exports and sessions) is recorded with UTC time, system user, host, version and details, chained by SHA-256. `guara audit --verify` reports the entry where the chain breaks. Set `GUARA_EXAMINER` to record the examiner; passwords and keys are never written.
|
||||
- **PDF report:** `guara report out.pdf` (and the interface's report button, by file extension) writes an A4 PDF with the same sections as the HTML, using a built-in writer with the standard fonts and no new dependency. Both formats now show the audit trail status and the hash of its last entry.
|
||||
- **APFS free-space map:** the container's space manager (spaceman), chunk-info blocks and bitmaps are read, so deep scans of an APFS volume cover only unallocated blocks. Containers that use chunk-info address blocks fall back to the whole volume, with a warning.
|
||||
- `scripts/fetch-testdata.sh` also downloads the dfvfs virtual disk images used by the container tests.
|
||||
|
||||
### Changed
|
||||
- The APFS partition warning no longer says metadata is unsupported; it says the free-space map is read and that encrypted (FileVault) volumes need the key.
|
||||
- The interface's report dialog explains that the file extension chooses PDF or HTML.
|
||||
|
||||
### Notes
|
||||
- Containers were checked by hash against an independent reader (7-Zip) on real images from dfvfs and VirtualBox, including differencing VHD and VHDX; the small fixtures in `tests/fixtures/vdisk` run in CI.
|
||||
- APFS deletion is still tested only in simulation, so APFS stays 🟡 in the roadmap.
|
||||
- CLI output remains in Portuguese.
|
||||
|
||||
## [1.1.0] - 2026-10-20
|
||||
|
||||
### Added
|
||||
- **Automated translation check** (`scripts/check-ui-i18n.mjs`): scans the interface JS, HTML and the labels emitted by the Rust core, fails on Portuguese text without an English entry and on duplicate dictionary keys. It runs in the CI lint job.
|
||||
- **Dependabot group for RustCrypto:** `sha2`, `sha1`, `md-5`, `hmac`, `pbkdf2`, `ripemd`, `aes`, `argon2`, `cipher` and `digest` now arrive in one update, because their traits are only compatible when the versions move together.
|
||||
|
||||
### Changed
|
||||
- **English interface for core messages:** the most common errors from the core now have `{n}` patterns in the dictionary, so paths and sizes are translated too.
|
||||
- **Notifications in the chosen language:** the core formats scan/recovery notifications using the saved interface language (`ui_lang` follows `lang` or the system locale when unset).
|
||||
- **ISO 8601 dates:** EXIF, PDF and MP4 metadata dates are emitted as `YYYY-MM-DDTHH:MM:SS` (MP4 with `Z`) and formatted by the interface in the selected locale, instead of a pre-formatted Brazilian date.
|
||||
- **RustCrypto on digest 0.11 / cipher 0.5:** `aes` 0.9, `sha2` 0.11, `sha1` 0.11, `md-5` 0.11, `hmac` 0.13, `pbkdf2` 0.13, `ripemd` 0.2 and `argon2` 0.6. BitLocker, LUKS and FileVault 2 were checked against the reference images.
|
||||
- **Release workflow:** `tauri-action` updated to v1; the publish job restores the release name and the CHANGELOG notes after the desktop builds, so v1 cannot overwrite them.
|
||||
- **Docker documentation:** `--device=...:r` was verified with a loop device (`inspect`, `scan`, `recover` read; writes are refused). The SMART/`SYS_RAWIO` wording now states what was and was not verified.
|
||||
- **Superset workspace:** setup, Run and teardown were verified in real workspaces; two interfaces get different ports, and deleting a workspace stops its `guara ui`.
|
||||
|
||||
### Fixed
|
||||
- Eight duplicated keys in `apps/gui/ui/i18n-en.js` (the last one won silently).
|
||||
- Untranslated fragments split by inline tags, metadata labels and preview messages that the previous manual pass missed.
|
||||
|
||||
### Notes
|
||||
- No change to scanning or recovery logic. The encrypted-volume tests pass with `GUARA_REQUIRE_FIXTURES=1`.
|
||||
- CLI output remains in Portuguese.
|
||||
|
||||
## [1.0.1] - 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,6 +4,47 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [1.2.0] - 2026-10-22
|
||||
|
||||
### Adicionado
|
||||
- **Contêineres de disco virtual (somente leitura):** QCOW2 (v2 e v3, clusters comprimidos com deflate ou zstd), VMDK (esparso, `streamOptimized` e descritores de texto com extensões flat e zero), VHD e VHDX (fixo, dinâmico e diferencial, seguindo a cadeia de pais) e DMG (UDIF com chunks zerados, brutos, ADC, zlib e bzip2). Abrem pelo mesmo caminho do E01 e do AFF4, então partições, sistemas de arquivos e volumes cifrados funcionam dentro deles. Imagens que seriam lidas errado são recusadas com o motivo: QCOW2 com arquivo de apoio ou cifrado, VMDK com pai ou em SEsparse, DMG protegido por senha, DMG com chunks LZFSE. O log do VHDX não é reproduzido.
|
||||
- **Trilha de auditoria** (`auditoria.jsonl`, `guara audit`): toda ação sobre a evidência (`inspect`, `scan`, `recover`, `image`, `hash`, `search`, `verify`, exportações e sessões) é registrada com hora UTC, usuário do sistema, máquina, versão e detalhes, encadeada por SHA-256. `guara audit --verify` aponta a entrada onde a cadeia quebra. Defina `GUARA_EXAMINER` para registrar o perito; senhas e chaves nunca são gravadas.
|
||||
- **Relatório em PDF:** `guara report saida.pdf` (e o botão de relatório da interface, pela extensão do arquivo) gera um PDF A4 com as mesmas seções do HTML, com gerador próprio, fontes padrão e nenhuma dependência nova. Os dois formatos passam a mostrar a situação da trilha de auditoria e o hash da última entrada.
|
||||
- **Mapa de espaço livre do APFS:** o gerenciador de espaço do contêiner (spaceman), os chunk-info blocks e os bitmaps são lidos, e a varredura profunda de um volume APFS cobre só os blocos não alocados. Contêineres que usam blocos de endereço de chunk-info voltam a varrer o volume todo, com aviso.
|
||||
- `scripts/fetch-testdata.sh` também baixa as imagens de disco virtual do dfvfs usadas nos testes dos contêineres.
|
||||
|
||||
### Alterado
|
||||
- O aviso da partição APFS deixa de dizer que não há suporte a metadados; informa que o mapa de espaço livre é lido e que volumes cifrados (FileVault) precisam da chave.
|
||||
- O diálogo de relatório da interface explica que a extensão do arquivo escolhe PDF ou HTML.
|
||||
|
||||
### Notas
|
||||
- Os contêineres foram conferidos por hash contra um leitor independente (7-Zip) em imagens reais do dfvfs e do VirtualBox, inclusive VHD e VHDX diferenciais; os fixtures pequenos de `tests/fixtures/vdisk` rodam no CI.
|
||||
- A exclusão no APFS continua testada só em simulação, por isso o APFS segue 🟡 no roadmap.
|
||||
- A saída da CLI continua em português.
|
||||
|
||||
## [1.1.0] - 2026-10-20
|
||||
|
||||
### Adicionado
|
||||
- **Verificação automática da tradução** (`scripts/check-ui-i18n.mjs`): varre o JS e o HTML da interface e os rótulos emitidos pelo núcleo em Rust, falha quando há texto em português sem entrada em inglês e quando há chave duplicada no dicionário. Roda no job de lint do CI.
|
||||
- **Grupo RustCrypto no Dependabot:** `sha2`, `sha1`, `md-5`, `hmac`, `pbkdf2`, `ripemd`, `aes`, `argon2`, `cipher` e `digest` passam a chegar numa única atualização, porque as traits só são compatíveis quando as versões andam juntas.
|
||||
|
||||
### Alterado
|
||||
- **Interface em inglês para mensagens do núcleo:** os erros mais comuns do núcleo agora têm padrões com `{n}` no dicionário, então caminhos e tamanhos também são traduzidos.
|
||||
- **Notificações no idioma escolhido:** o núcleo monta as notificações de varredura e recuperação usando o idioma salvo da interface (`ui_lang` segue `lang` ou o locale do sistema quando não definido).
|
||||
- **Datas em ISO 8601:** as datas de metadados EXIF, PDF e MP4 saem como `YYYY-MM-DDTHH:MM:SS` (MP4 com `Z`) e são formatadas pela interface no locale escolhido, em vez de uma data brasileira já pronta.
|
||||
- **RustCrypto em digest 0.11 / cipher 0.5:** `aes` 0.9, `sha2` 0.11, `sha1` 0.11, `md-5` 0.11, `hmac` 0.13, `pbkdf2` 0.13, `ripemd` 0.2 e `argon2` 0.6. BitLocker, LUKS e FileVault 2 foram conferidos contra as imagens de referência.
|
||||
- **Fluxo de release:** `tauri-action` atualizado para v1; o job de publicação restaura o nome e as notas do CHANGELOG depois dos builds do desktop, para a v1 não sobrescrevê-las.
|
||||
- **Documentação do Docker:** `--device=...:r` foi verificado com um dispositivo de loop (`inspect`, `scan`, `recover` leem; escritas são recusadas). O texto sobre SMART/`SYS_RAWIO` agora diz o que foi e o que não foi verificado.
|
||||
- **Workspace do Superset:** setup, Run e teardown foram verificados em workspaces reais; duas interfaces recebem portas diferentes, e apagar um workspace encerra o `guara ui`.
|
||||
|
||||
### Corrigido
|
||||
- Oito chaves duplicadas em `apps/gui/ui/i18n-en.js` (a última vencia em silêncio).
|
||||
- Trechos sem tradução separados por tags inline, rótulos de metadados e mensagens da pré-visualização que a passada manual anterior não pegou.
|
||||
|
||||
### Notas
|
||||
- Nenhuma mudança na lógica de varredura ou recuperação. Os testes de volumes cifrados passam com `GUARA_REQUIRE_FIXTURES=1`.
|
||||
- A saída do terminal continua em português.
|
||||
|
||||
## [1.0.1] - 2026-10-20
|
||||
|
||||
### Adicionado
|
||||
|
||||
Generated
+99
-81
@@ -10,12 +10,12 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
@@ -71,9 +71,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
@@ -107,20 +107,20 @@ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -191,9 +191,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
@@ -239,6 +239,12 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
@@ -252,31 +258,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
version = "1.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -291,14 +308,24 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -355,16 +382,6 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
@@ -388,7 +405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-cli"
|
||||
version = "1.0.1"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -401,7 +418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "1.0.1"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"argon2",
|
||||
@@ -444,9 +461,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
@@ -457,6 +474,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -495,11 +521,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -522,9 +548,9 @@ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-encoder"
|
||||
version = "0.6.1"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
|
||||
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
@@ -552,15 +578,15 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.6"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
@@ -697,25 +723,35 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
"getrandom",
|
||||
"phc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phc"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"ctutils",
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
@@ -765,17 +801,11 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "ripemd"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
|
||||
checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
@@ -868,9 +898,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
@@ -879,9 +909,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
@@ -906,12 +936,6 @@ 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"
|
||||
@@ -1007,9 +1031,9 @@ checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
@@ -1029,12 +1053,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.1"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -17,7 +17,7 @@ In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Dis
|
||||
- **Encrypted volumes:** opens BitLocker, LUKS and FileVault 2 with your password or key and scans inside.
|
||||
- **RAID and LVM:** reassembles arrays from loose disks (RAID 0, 1, 4, 5, 6, 10, LVM2), working out level, stripe size and order by itself when there is no metadata.
|
||||
- **Fragmented files:** reassembles JPEGs stored in pieces and rebuilds the index of MP4/MOV videos that lost their `moov`.
|
||||
- **Forensic images:** reads and writes raw images, **E01/Ex01** and **AFF4**.
|
||||
- **Forensic images:** reads and writes raw images, **E01/Ex01** and **AFF4**, and reads **QCOW2, VMDK, VHD, VHDX** and **DMG** virtual disks.
|
||||
- **Lost partitions:** reads MBR/GPT and searches for volumes by signature where the table failed.
|
||||
- **Merge without duplicates:** a file found by more than one method appears once, with the best evidence.
|
||||
- **Per-file confidence** (high, medium, low): intact structure, reused clusters, zeroed content (TRIM) or content that does not match the extension.
|
||||
@@ -111,8 +111,8 @@ Summary of the [ROADMAP](ROADMAP.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART |
|
||||
| **Next** | Audit log, signed and PDF report, APFS free map, tests on Windows and macOS |
|
||||
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, APFS free map |
|
||||
| **Next** | Digitally signed report, tests on Windows and macOS |
|
||||
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
|
||||
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
|
||||
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec,
|
||||
- **Volumes cifrados:** abre BitLocker, LUKS e FileVault 2 com a sua senha ou chave e varre por dentro.
|
||||
- **RAID e LVM:** remonta arrays a partir de discos soltos (RAID 0, 1, 4, 5, 6, 10, LVM2), descobrindo nível, faixa e ordem sozinho quando não há metadados.
|
||||
- **Arquivos fragmentados:** remonta JPEG em pedaços e refaz o índice de vídeos MP4/MOV sem `moov`.
|
||||
- **Imagens forenses:** lê e grava imagem bruta, **E01/Ex01** e **AFF4**.
|
||||
- **Imagens forenses:** lê e grava imagem bruta, **E01/Ex01** e **AFF4**, e lê discos virtuais **QCOW2, VMDK, VHD, VHDX** e **DMG**.
|
||||
- **Partições perdidas:** lê MBR/GPT e procura volumes por assinatura onde a tabela falhou.
|
||||
- **Fusão sem duplicatas:** um arquivo achado por mais de um método aparece uma vez, com a melhor evidência.
|
||||
- **Confiança por arquivo** (alta, média, baixa): estrutura íntegra, clusters reutilizados, conteúdo zerado (TRIM) ou que não confere com a extensão.
|
||||
@@ -111,8 +111,8 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART |
|
||||
| **Próximo** | Registro de auditoria, relatório assinado e em PDF, mapa livre do APFS, testes em Windows e macOS |
|
||||
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa livre do APFS |
|
||||
| **Próximo** | Relatório assinado digitalmente, testes em Windows e macOS |
|
||||
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
|
||||
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
|
||||
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
|
||||
|
||||
+9
-6
@@ -25,10 +25,12 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o
|
||||
| 0.6.0 | 2026-10-18 | Full disk health (ATA and NVMe SMART), `guara health`, new logo |
|
||||
| 1.0.0 | 2026-10-19 | ext4, HFS+, Btrfs, XFS, APFS; BitLocker, LUKS, FileVault 2; RAID and LVM; E01, Ex01, AFF4; fragmented JPEG; MP4 without moov |
|
||||
| 1.0.1 | 2026-10-20 | Documentation in English with Portuguese translations, complete English interface, screenshots, Docker image of the CLI |
|
||||
| 1.1.0 | 2026-10-20 | Core messages and dates in English, automated translation check, RustCrypto on digest 0.11, verified Docker device access and Superset workspaces |
|
||||
| 1.2.0 | 2026-10-22 | QCOW2, VMDK, VHD, VHDX and DMG virtual disks, hash-chained audit trail, PDF report, APFS free-space map |
|
||||
|
||||
Details of each version in the [CHANGELOG](CHANGELOG.md).
|
||||
|
||||
## Current status (1.0.1) against what is expected from a forensic suite
|
||||
## Current status (1.2.0) against what is expected from a forensic suite
|
||||
|
||||
### 1. File systems with metadata
|
||||
|
||||
@@ -39,7 +41,7 @@ Details of each version in the [CHANGELOG](CHANGELOG.md).
|
||||
| HFS+ | ✅ | Tree node leftovers and journal |
|
||||
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents |
|
||||
| XFS | ✅ | Freed inodes and names of freed entries. Tested only with simulated deletion |
|
||||
| APFS | 🟡 | Container, object map, snapshots and old nodes. **Free-space map is missing**; deletion tested only in simulation |
|
||||
| APFS | 🟡 | Container, object map, free-space map (spaceman and bitmaps), snapshots and old nodes. Deletion tested only in simulation; containers with chunk-info address blocks (large ones) fall back to scanning the whole volume |
|
||||
| ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
|
||||
| Storage Spaces | 🔜 | Not done. Reference image missing |
|
||||
| NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today |
|
||||
@@ -65,7 +67,8 @@ Today Guará opens volumes when you **already have** the password or key. Passwo
|
||||
| Raw image (dd) tolerant of bad sectors, with an error map | ✅ |
|
||||
| E01 and Ex01: read, write and `guara verify` | ✅ |
|
||||
| AFF4: read and write | ✅ |
|
||||
| Other containers (VMDK, VHD/VHDX, QCOW2, DMG) | 🔜 |
|
||||
| Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ |
|
||||
| QCOW2 with a backing file, VMDK snapshots, DMG with LZFSE or a password | 🔜 |
|
||||
| Mobile device image (see item 7) | 🔭 |
|
||||
|
||||
### 4. Assembling loose disks
|
||||
@@ -125,10 +128,10 @@ Physical access is the differentiator of commercial tools like Cellebrite. It de
|
||||
| `guara-manifesto.json` manifest in every recovery | ✅ |
|
||||
| HTML report with case, examiner, source, partitions, summary and hashes | ✅ |
|
||||
| Verification of an E01/Ex01 image against the stored hash | ✅ |
|
||||
| **Audit log** of every examiner action (who, when, what) | 🔜 |
|
||||
| **Audit trail** of every examiner action (who, when, what), chained by hash, with `guara audit --verify` | ✅ |
|
||||
| **Digitally signed reports** and a timestamped manifest | 🔜 |
|
||||
| **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
|
||||
| PDF report | 🔜 |
|
||||
| PDF report (A4, built in) | ✅ |
|
||||
| **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 |
|
||||
| Signed binaries (Windows and macOS) | 🔜 |
|
||||
|
||||
@@ -162,7 +165,7 @@ External validation is what makes a report accepted. Without it, Guará is good
|
||||
No dates, in the order that makes the most sense.
|
||||
|
||||
**1.1: trust and custody**
|
||||
Audit log, PDF report, signed manifest, APFS free-space map, XFS and APFS validation with real deletions, tests with real images on Windows and macOS.
|
||||
Signed manifest, XFS and APFS validation with real deletions, tests with real images on Windows and macOS.
|
||||
|
||||
**1.2: analysis**
|
||||
NSRL and hash lists, search in file content, unified timeline.
|
||||
|
||||
+9
-6
@@ -25,10 +25,12 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo
|
||||
| 0.6.0 | 2026-10-18 | Saúde do disco completa (SMART ATA e NVMe), `guara health`, nova logo |
|
||||
| 1.0.0 | 2026-10-19 | ext4, HFS+, Btrfs, XFS, APFS; BitLocker, LUKS, FileVault 2; RAID e LVM; E01, Ex01, AFF4; JPEG fragmentado; MP4 sem moov |
|
||||
| 1.0.1 | 2026-10-20 | Documentação em inglês com tradução em português, interface completa em inglês, telas, imagem Docker do terminal |
|
||||
| 1.1.0 | 2026-10-20 | Mensagens do núcleo e datas em inglês, verificação automática da tradução, RustCrypto em digest 0.11, acesso a dispositivo no Docker e workspaces do Superset verificados |
|
||||
| 1.2.0 | 2026-10-22 | Discos virtuais QCOW2, VMDK, VHD, VHDX e DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa de espaço livre do APFS |
|
||||
|
||||
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
|
||||
|
||||
## Situação atual (1.0.1) contra o que se espera de uma suíte forense
|
||||
## Situação atual (1.2.0) contra o que se espera de uma suíte forense
|
||||
|
||||
### 1. Sistemas de arquivos com metadados
|
||||
|
||||
@@ -39,7 +41,7 @@ Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
|
||||
| HFS+ | ✅ | Sobras de nós da árvore e diário |
|
||||
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo |
|
||||
| XFS | ✅ | Inodes liberados e nomes de entradas liberadas. Testado só com exclusão simulada |
|
||||
| APFS | 🟡 | Contêiner, mapa de objetos, instantâneos e nós antigos. **Falta o mapa de espaço livre**; exclusão testada só em simulação |
|
||||
| APFS | 🟡 | Contêiner, mapa de objetos, mapa de espaço livre (spaceman e bitmaps), instantâneos e nós antigos. Exclusão testada só em simulação; contêineres com blocos de endereço de chunk-info (grandes) voltam a varrer o volume inteiro |
|
||||
| ReFS | 🔜 | Hoje só é detectado; a recuperação depende do carving. Falta imagem de referência |
|
||||
| Storage Spaces | 🔜 | Não feito. Falta imagem de referência |
|
||||
| NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos |
|
||||
@@ -65,7 +67,8 @@ Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recupera
|
||||
| Imagem bruta (dd) tolerante a setores ruins, com mapa de erros | ✅ |
|
||||
| E01 e Ex01: leitura, gravação e `guara verify` | ✅ |
|
||||
| AFF4: leitura e gravação | ✅ |
|
||||
| Outros contêineres (VMDK, VHD/VHDX, QCOW2, DMG) | 🔜 |
|
||||
| Contêineres de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG (leitura) | ✅ |
|
||||
| QCOW2 com arquivo de apoio, snapshots de VMDK, DMG com LZFSE ou com senha | 🔜 |
|
||||
| Imagem de dispositivo móvel (ver item 7) | 🔭 |
|
||||
|
||||
### 4. Montagem de discos soltos
|
||||
@@ -125,10 +128,10 @@ O acesso físico é o diferencial de ferramentas comerciais como a Cellebrite. D
|
||||
| Manifesto `guara-manifesto.json` em toda recuperação | ✅ |
|
||||
| Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ |
|
||||
| Verificação de imagem E01/Ex01 contra o hash gravado | ✅ |
|
||||
| **Registro de auditoria** de cada ação do perito (quem, quando, o quê) | 🔜 |
|
||||
| **Trilha de auditoria** de cada ação do perito (quem, quando, o quê), encadeada por hash, com `guara audit --verify` | ✅ |
|
||||
| **Relatórios assinados** digitalmente e manifesto com carimbo de tempo | 🔜 |
|
||||
| **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
|
||||
| Relatório em PDF | 🔜 |
|
||||
| Relatório em PDF (A4, próprio) | ✅ |
|
||||
| **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 |
|
||||
| Binários assinados (Windows e macOS) | 🔜 |
|
||||
|
||||
@@ -162,7 +165,7 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve
|
||||
Sem datas, na ordem em que fazem mais sentido.
|
||||
|
||||
**1.1: confiança e custódia**
|
||||
Registro de auditoria, relatório em PDF, manifesto assinado, mapa de espaço livre do APFS, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS.
|
||||
Manifesto assinado, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS.
|
||||
|
||||
**1.2: análise**
|
||||
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "1.0.1"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guara",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.0",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
+6
-4
@@ -2,7 +2,7 @@
|
||||
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
|
||||
|
||||
import { createExplorer } from './explorer.js';
|
||||
import { setLang, LOC } from './i18n.js';
|
||||
import { setLang, LOC, currentLang } from './i18n.js';
|
||||
|
||||
const tauri = window.__TAURI__;
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
|
||||
|
||||
// ------------------------------------------------------------------ configurações
|
||||
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
|
||||
const SETTINGS = { theme: 'auto', lang: 'auto', notify: true, maps: false };
|
||||
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false };
|
||||
async function saveSettings(patch) {
|
||||
Object.assign(SETTINGS, patch);
|
||||
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
|
||||
@@ -667,7 +667,7 @@ async function onProgressClick(e) {
|
||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
} else if (act === 'report') {
|
||||
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [
|
||||
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.', ok: 'Gerar relatório', fields: [
|
||||
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
|
||||
{ name: 'case_id', label: 'Número do caso' }, { name: 'examiner', label: 'Responsável pelo exame' }, { name: 'organization', label: 'Organização' },
|
||||
{ name: 'notes', label: 'Observações', multiline: true } ] });
|
||||
@@ -750,8 +750,9 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
||||
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);
|
||||
patch.ui_lang = currentLang();
|
||||
saveSettings(patch);
|
||||
EX?.relang?.();
|
||||
toast('Salvo');
|
||||
};
|
||||
@@ -804,6 +805,7 @@ function watchClose() {
|
||||
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
|
||||
} catch { /* usa o local */ }
|
||||
setLang(SETTINGS.lang);
|
||||
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
|
||||
watchClose();
|
||||
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
|
||||
} catch (e) {
|
||||
|
||||
@@ -150,6 +150,9 @@ export function createExplorer(deps) {
|
||||
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
|
||||
};
|
||||
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
|
||||
// Metadados do núcleo trazem datas em ISO 8601; o idioma decide a ordem.
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
|
||||
const fmtMeta = (v) => ISO_DATE.test(v) ? new Date(v.endsWith('Z') ? v : v + 'Z').toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'medium' }) : v;
|
||||
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
|
||||
|
||||
function geometry() {
|
||||
@@ -591,7 +594,7 @@ export function createExplorer(deps) {
|
||||
if (my !== metaToken || !$('#xpMeta')) return;
|
||||
const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k));
|
||||
if (!rows.length) return;
|
||||
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`
|
||||
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(fmtMeta(v))}</dd>`).join('')}</dl>`
|
||||
+ (m.gps ? gpsHtml(m.gps) : '');
|
||||
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
|
||||
}).catch(() => {});
|
||||
|
||||
+81
-11
@@ -1,7 +1,6 @@
|
||||
// Dicionário português -> inglês da interface. {n} marca um valor variável.
|
||||
export const EN = {
|
||||
// saúde do disco (SMART)
|
||||
'Saúde do disco': 'Disk health',
|
||||
'O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco.': 'SMART could not be read. Install the smartmontools package (smartctl command) and run as administrator to see the disk health.',
|
||||
'Setores realocados': 'Reallocated sectors',
|
||||
'Setores pendentes de realocação': 'Pending sectors',
|
||||
@@ -13,7 +12,6 @@ export const EN = {
|
||||
'Erros de mídia': 'Media errors',
|
||||
'Reserva de blocos abaixo do limite': 'Spare blocks below threshold',
|
||||
'Erros de leitura/gravação vistos pelo sistema': 'Read/write errors seen by the system',
|
||||
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
|
||||
'Estado': 'Status',
|
||||
'Bom': 'Good',
|
||||
'Atenção': 'Caution',
|
||||
@@ -40,9 +38,6 @@ export const EN = {
|
||||
'O SMART é do disco inteiro: os dados abaixo são de': 'SMART belongs to the whole disk: the data below is for',
|
||||
', que contém': ', which contains',
|
||||
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.',
|
||||
'Nenhum sinal de defeito encontrado pelo SMART.': 'SMART found no signs of failure.',
|
||||
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
|
||||
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
|
||||
// barra lateral e estrutura
|
||||
'Recuperação de arquivos': 'File recovery',
|
||||
'Dispositivos': 'Devices',
|
||||
@@ -50,9 +45,7 @@ export const EN = {
|
||||
'Atualizar lista': 'Refresh list',
|
||||
'Lista atualizada': 'List refreshed',
|
||||
'Abrir arquivo de imagem…': 'Open disk image file…',
|
||||
'Abrir arquivo de imagem': 'Open disk image file',
|
||||
'Abrir sessão salva…': 'Open saved session…',
|
||||
'Abrir sessão salva': 'Open saved session',
|
||||
'Sessões': 'Sessions',
|
||||
'Sessão': 'Session',
|
||||
'Sessão:': 'Session:',
|
||||
@@ -181,7 +174,6 @@ export const EN = {
|
||||
'Por assinatura': 'By signature',
|
||||
'Sem data': 'No date',
|
||||
'sem data': 'no date',
|
||||
'Confiança {n}': 'Confidence {n}',
|
||||
'Com localização (GPS)': 'With location (GPS)',
|
||||
'Com GPS': 'With GPS',
|
||||
'Ocultar duplicadas': 'Hide duplicates',
|
||||
@@ -308,7 +300,6 @@ export const EN = {
|
||||
// recuperar
|
||||
'Recuperar para…': 'Recover to…',
|
||||
'Recuperar': 'Recover',
|
||||
'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.': 'Choose a folder on <b>another disk</b>. Guará never writes to the source disk, so it does not overwrite what can still be saved.',
|
||||
'Pasta de destino': 'Destination folder',
|
||||
'Escolher…': 'Choose…',
|
||||
'Organizar os arquivos': 'Organize the files',
|
||||
@@ -329,7 +320,7 @@ export const EN = {
|
||||
'{n} linhas gravadas': '{n} rows written',
|
||||
'Relatório de recuperação': 'Recovery report',
|
||||
'Gerar relatório': 'Generate report',
|
||||
'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.': 'Generates a print-ready HTML report with the examined source, the findings and the hashes of recovered files.',
|
||||
'Gera um relatório pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados. Termine o nome em .pdf para gerar PDF; com .html sai uma página web.': 'Generates a print-ready report with the examined source, the findings and the hashes of recovered files. End the name in .pdf for a PDF; with .html you get a web page.',
|
||||
'Salvar em': 'Save to',
|
||||
'Número do caso': 'Case number',
|
||||
'Responsável pelo exame': 'Examiner',
|
||||
@@ -346,7 +337,6 @@ export const EN = {
|
||||
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
|
||||
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
|
||||
// imagem, hash, busca
|
||||
'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.': 'Where to save the image file? Use <b>another disk</b> with enough space and a name that does not exist yet.',
|
||||
'Criando imagem do disco': 'Creating disk image',
|
||||
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
|
||||
'Calculando hashes da origem': 'Computing source hashes',
|
||||
@@ -505,4 +495,84 @@ export const EN = {
|
||||
'Tabela virtual: o conteúdo não fica armazenado como linhas comuns.': 'Virtual table: the content is not stored as ordinary rows.',
|
||||
'Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida.': 'WITHOUT ROWID table: the rows live in an index tree, not shown yet.',
|
||||
'Arquivo truncado: o cabeçalho indica {n} páginas, mas só há {n}. Parte dos dados pode estar faltando.': 'Truncated file: the header says {n} pages, but there are only {n}. Part of the data may be missing.',
|
||||
|
||||
// mensagens de erro e avisos do núcleo (valores variáveis usam {n})
|
||||
'job {n} não existe': 'job {n} does not exist',
|
||||
'a varredura não está pausada': 'the scan is not paused',
|
||||
'A origem desta sessão não está disponível ({n}). Reconecte o disco ou escolha a origem para continuar.': 'The source of this session is unavailable ({n}). Reconnect the disk or choose the source to continue.',
|
||||
'arquivo não encontrado': 'file not found',
|
||||
'nenhum arquivo selecionado': 'no file selected',
|
||||
'informe pelo menos um termo': 'enter at least one term',
|
||||
'Espaço insuficiente no destino: os arquivos somam {n} e há {n} livres. Escolha outro disco ou recupere menos arquivos.': 'Not enough space at the destination: the files total {n} and {n} are free. Choose another disk or recover fewer files.',
|
||||
'Sem permissão para ler a sessão {n}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta.': 'Permission denied reading the session {n}. If it was created as administrator (for example in /root), open Guará as administrator or copy the file to your folder.',
|
||||
'Não foi possível ler a sessão {n}: {n}': 'Could not read the session {n}: {n}',
|
||||
'O arquivo {n} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {n}': 'The file {n} is not a valid Guará session (or was created by an incompatible version): {n}',
|
||||
'disco não encontrado': 'disk not found',
|
||||
'Essa origem tem {n} bytes, mas a sessão foi feita sobre um disco de {n} bytes. Escolha o mesmo disco ou uma imagem dele.': 'This source has {n} bytes, but the session was made on a {n}-byte disk. Choose the same disk or an image of it.',
|
||||
'O conteúdo inicial dessa origem é diferente do disco da sessão (outra tabela de partições ou outro disco). Se tem certeza, use a opção de usar assim mesmo.': 'The initial content of this source differs from the session disk (another partition table or another disk). If you are sure, use the \'use anyway\' option.',
|
||||
'só sessões automáticas podem ser apagadas por aqui': 'only automatic sessions can be deleted here',
|
||||
'parâmetro \'{n}\' ausente': 'missing parameter \'{n}\'',
|
||||
'endereço não permitido': 'address not allowed',
|
||||
'id ausente': 'missing id',
|
||||
'método desconhecido: {n}': 'unknown method: {n}',
|
||||
'A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.': 'The source of this session is unavailable. Reconnect the disk or choose the source to preview and recover.',
|
||||
'Sem permissão para ler {n}. Discos exigem privilégios de administrador: no Linux/macOS rode com sudo; no Windows, execute como administrador.': 'Permission denied reading {n}. Disks need administrator privileges: on Linux/macOS run with sudo; on Windows, run as administrator.',
|
||||
'Não foi possível abrir {n}: {n}': 'Could not open {n}: {n}',
|
||||
'O destino {n} fica em {n} (montado a partir da origem {n}). Escolha outro disco para não sobrescrever dados recuperáveis.': 'The destination {n} is on {n} (mounted from the source {n}). Choose another disk so you do not overwrite recoverable data.',
|
||||
'A origem {n} contém o sistema (/). Recupere para um disco externo ou use uma imagem.': 'The source {n} contains the system (/). Recover to an external disk or use an image.',
|
||||
'leitura além do fim da origem': 'read beyond the end of the source',
|
||||
'senha ou chave incorreta': 'wrong password or key',
|
||||
'não suportado: {n}': 'not supported: {n}',
|
||||
'cabeçalho inválido: {n}': 'invalid header: {n}',
|
||||
'Não é um banco SQLite (cabeçalho ausente). Se o arquivo é um banco, ele pode estar criptografado (SQLCipher) ou ter o início sobrescrito.': 'Not an SQLite database (missing header). If the file is a database, it may be encrypted (SQLCipher) or have its beginning overwritten.',
|
||||
'Tamanho de página inválido no cabeçalho do banco.': 'Invalid page size in the database header.',
|
||||
'página {n} fora do arquivo (banco truncado)': 'page {n} outside the file (truncated database)',
|
||||
'página {n} incompleta': 'page {n} incomplete',
|
||||
'página 0 inválida': 'invalid page 0',
|
||||
'tabela {n} não encontrada': 'table {n} not found',
|
||||
'célula ilegível na página {n}': 'unreadable cell on page {n}',
|
||||
'página {n} de tipo inesperado (0x{n})': 'page {n} of unexpected type (0x{n})',
|
||||
'Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos.': 'File too large to open directly. Recover it and open it with your file manager.',
|
||||
'não foi possível criar {n}: {n} (o arquivo não pode existir)': 'could not create {n}: {n} (the file must not exist)',
|
||||
'não foi possível ler o arquivo de chave {n}: {n}': 'could not read the key file {n}: {n}',
|
||||
'chave do volume em hexadecimal inválida': 'invalid hexadecimal volume key',
|
||||
'não foi possível descobrir os parâmetros do RAID (nenhuma combinação produziu um sistema de arquivos coerente)': 'could not discover the RAID parameters (no combination produced a coherent file system)',
|
||||
'não é uma imagem EWF (E01/Ex01)': 'not an EWF (E01/Ex01) image',
|
||||
'imagem EWF sem tabela de chunks': 'EWF image without a chunk table',
|
||||
'tabela EWF inconsistente': 'inconsistent EWF table',
|
||||
'cabeçalho de volume EWF ausente': 'missing EWF volume header',
|
||||
'segmento Ex01 ausente: sequência de chunks com lacuna': 'missing Ex01 segment: chunk sequence has a gap',
|
||||
'tamanho de chunk EWF inválido': 'invalid EWF chunk size',
|
||||
'imagem grande demais para o formato E01': 'image too large for the E01 format',
|
||||
'não foi possível criar {n}: {n}': 'could not create {n}: {n}',
|
||||
'AFF4 inválido (ZIP): {n}': 'invalid AFF4 (ZIP): {n}',
|
||||
'AFF4: entrada sem início de dados: {n}': 'AFF4: entry without a data start: {n}',
|
||||
'AFF4 sem information.turtle': 'AFF4 without information.turtle',
|
||||
'AFF4 sem objeto de imagem (aff4:Image com aff4:dataStream)': 'AFF4 without an image object (aff4:Image with aff4:dataStream)',
|
||||
'AFF4: fluxo de dados da imagem não encontrado': 'AFF4: image data stream not found',
|
||||
'AFF4: fluxo simbólico inválido': 'AFF4: invalid symbolic stream',
|
||||
'AFF4: fluxo {n} não descrito': 'AFF4: stream {n} not described',
|
||||
'AFF4: entrada {n} ausente': 'AFF4: entry {n} missing',
|
||||
'AFF4/ZIP: {n}': 'AFF4/ZIP: {n}',
|
||||
'cifra {n} (apenas AES)': 'cipher {n} (only AES)',
|
||||
'chave inválida para {n}': 'invalid key for {n}',
|
||||
'IV {n}': 'IV {n}',
|
||||
'{n} arquivos · {n} · {n}': '{n} files · {n} · {n}',
|
||||
'Discos brutos exigem administrador. Rode': 'Raw disks require administrator. Run',
|
||||
'(Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.': '(Linux/macOS) or open Guará as administrator (Windows). You can still open an image file from the menu.',
|
||||
'(Linux/macOS) ou como administrador (Windows).': '(Linux/macOS) or as administrator (Windows).',
|
||||
'cifrada ({n}) · só detecção': 'encrypted ({n}) · detection only',
|
||||
'Onde salvar o arquivo de imagem? Use': 'Where to save the image file? Use',
|
||||
'outro disco': 'another disk',
|
||||
'com espaço suficiente e um nome que ainda não exista.': 'with enough space and a name that does not exist yet.',
|
||||
'Criar imagem': 'Create image',
|
||||
'{n} trecho(s) ilegível(is) foram preenchidos com zeros. Veja o mapa de erros ao lado da imagem.': '{n} unreadable chunk(s) were filled with zeros. See the error map next to the image.',
|
||||
'{n}. Escolha o disco que voltou ou uma imagem dele.': '{n}. Choose the disk that came back or an image of it.',
|
||||
'Escolha uma pasta em': 'Choose a folder on',
|
||||
'(todos desta vista)': '(all in this view)',
|
||||
'Recuperar {n}': 'Recover {n}',
|
||||
'Dimensões': 'Dimensions',
|
||||
'{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}',
|
||||
'e mais {n}…': 'and {n} more…',
|
||||
'abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'opens in your player: the embedded Linux browser does not include the codecs and would freeze the window.',
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { EN } from './i18n-en.js';
|
||||
|
||||
let lang = 'pt';
|
||||
export const isEn = () => lang === 'en';
|
||||
export const currentLang = () => lang;
|
||||
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
|
||||
|
||||
export function resolveLang(pref) {
|
||||
|
||||
+136
-45
@@ -88,6 +88,20 @@ enum Cmd {
|
||||
},
|
||||
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
|
||||
Verify { path: String },
|
||||
/// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash
|
||||
Audit {
|
||||
/// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG)
|
||||
#[arg(long)]
|
||||
log: Option<String>,
|
||||
/// Só confere a cadeia de hashes; sai com erro se ela estiver quebrada
|
||||
#[arg(long)]
|
||||
verify: bool,
|
||||
/// Mostra só as últimas N entradas
|
||||
#[arg(long)]
|
||||
tail: Option<usize>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Exporta os achados de uma sessão para CSV (abre no Excel)
|
||||
Export {
|
||||
session: String,
|
||||
@@ -96,10 +110,10 @@ enum Cmd {
|
||||
#[command(flatten)]
|
||||
filter: FilterArgs,
|
||||
},
|
||||
/// Gera o relatório HTML de recuperação (identificação do caso, origem, achados, hashes)
|
||||
/// Gera o relatório de recuperação (identificação do caso, origem, achados, hashes): PDF se o arquivo terminar em .pdf, senão HTML
|
||||
Report {
|
||||
session: String,
|
||||
/// Arquivo HTML de saída
|
||||
/// Arquivo de saída (.pdf ou .html)
|
||||
out: String,
|
||||
#[arg(long, default_value = "")]
|
||||
case: String,
|
||||
@@ -427,52 +441,20 @@ fn run(cli: Cli) -> Result<(), String> {
|
||||
eng.save_session(&job, &session).map_err(s)?;
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Verify { path } if path.to_ascii_lowercase().ends_with(".aff4") => {
|
||||
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(&path)).map_err(s)?;
|
||||
println!("Formato: AFF4");
|
||||
for (k, v) in &a.info {
|
||||
println!("{k}: {v}");
|
||||
}
|
||||
eprintln!("Recalculando o hash...");
|
||||
match a.verify_stream_md5().map_err(s)? {
|
||||
Some(true) => {
|
||||
println!("MD5 gravado e recalculado conferem.");
|
||||
Ok(())
|
||||
}
|
||||
Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()),
|
||||
None => {
|
||||
println!("Esta imagem não traz MD5 para conferir.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Verify { path } => {
|
||||
let e = guara_core::ewf::EwfSource::open(std::path::Path::new(&path)).map_err(s)?;
|
||||
println!(
|
||||
"Formato: {}",
|
||||
if e.info.version == 2 {
|
||||
"Ex01 (EnCase 7)"
|
||||
} else {
|
||||
"E01 (EnCase)"
|
||||
}
|
||||
let r = verify_image(&path);
|
||||
guara_core::audit::record(
|
||||
"verify",
|
||||
json!({ "path": path, "result": match &r { Ok(Some(true)) => "conferem", Ok(Some(false)) => "diferem", Ok(None) => "sem hash gravado", Err(_) => "erro" } }),
|
||||
);
|
||||
println!("Segmentos: {}", e.info.segments);
|
||||
for (k, v) in &e.info.fields {
|
||||
println!("{k}: {v}");
|
||||
}
|
||||
eprintln!("Recalculando o hash...");
|
||||
match e.verify().map_err(s)? {
|
||||
Some(true) => {
|
||||
println!("MD5 gravado e recalculado conferem: {}", e.info.md5.clone().unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
Some(false) => Err("O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada.".into()),
|
||||
None => {
|
||||
println!("Esta imagem não traz hash gravado para conferir.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
r.map(|_| ())
|
||||
}
|
||||
Cmd::Audit {
|
||||
log,
|
||||
verify,
|
||||
tail,
|
||||
json,
|
||||
} => audit(log, verify, tail, json),
|
||||
Cmd::Image {
|
||||
path,
|
||||
dest,
|
||||
@@ -790,6 +772,115 @@ fn recover_job(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confere o hash gravado numa imagem E01/Ex01 ou AFF4. `Some(true)` = confere.
|
||||
fn verify_image(path: &str) -> Result<Option<bool>, String> {
|
||||
if path.to_ascii_lowercase().ends_with(".aff4") {
|
||||
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(path)).map_err(s)?;
|
||||
println!("Formato: AFF4");
|
||||
for (k, v) in &a.info {
|
||||
println!("{k}: {v}");
|
||||
}
|
||||
eprintln!("Recalculando o hash...");
|
||||
return match a.verify_stream_md5().map_err(s)? {
|
||||
Some(true) => {
|
||||
println!("MD5 gravado e recalculado conferem.");
|
||||
Ok(Some(true))
|
||||
}
|
||||
Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()),
|
||||
None => {
|
||||
println!("Esta imagem não traz MD5 para conferir.");
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
}
|
||||
let e = guara_core::ewf::EwfSource::open(std::path::Path::new(path)).map_err(s)?;
|
||||
println!(
|
||||
"Formato: {}",
|
||||
if e.info.version == 2 {
|
||||
"Ex01 (EnCase 7)"
|
||||
} else {
|
||||
"E01 (EnCase)"
|
||||
}
|
||||
);
|
||||
println!("Segmentos: {}", e.info.segments);
|
||||
for (k, v) in &e.info.fields {
|
||||
println!("{k}: {v}");
|
||||
}
|
||||
eprintln!("Recalculando o hash...");
|
||||
match e.verify().map_err(s)? {
|
||||
Some(true) => {
|
||||
println!(
|
||||
"MD5 gravado e recalculado conferem: {}",
|
||||
e.info.md5.clone().unwrap_or_default()
|
||||
);
|
||||
Ok(Some(true))
|
||||
}
|
||||
Some(false) => Err(
|
||||
"O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada."
|
||||
.into(),
|
||||
),
|
||||
None => {
|
||||
println!("Esta imagem não traz hash gravado para conferir.");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `guara audit`: lista a trilha ou confere a cadeia de hashes.
|
||||
fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) -> Result<(), String> {
|
||||
let log = match log {
|
||||
Some(p) => guara_core::audit::Log::at(p),
|
||||
None => guara_core::audit::Log::open_default(),
|
||||
};
|
||||
let v = log.verify().map_err(s)?;
|
||||
if verify {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap());
|
||||
} else if v.ok {
|
||||
println!("Cadeia íntegra: {} entradas.", v.entries);
|
||||
if v.entries > 0 {
|
||||
println!(
|
||||
"Hash da última entrada (guarde-o fora desta máquina): {}",
|
||||
v.head
|
||||
);
|
||||
}
|
||||
}
|
||||
return match v.broken_at {
|
||||
None => Ok(()),
|
||||
Some((n, why)) => Err(format!("cadeia quebrada na entrada {n}: {why}")),
|
||||
};
|
||||
}
|
||||
let mut entries = log.entries().map_err(s)?;
|
||||
if let Some(n) = tail {
|
||||
let skip = entries.len().saturating_sub(n);
|
||||
entries.drain(..skip);
|
||||
}
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&entries).unwrap());
|
||||
} else if entries.is_empty() {
|
||||
println!("Trilha vazia ({}).", log.path().display());
|
||||
} else {
|
||||
for e in &entries {
|
||||
let who = match &e.examiner {
|
||||
Some(x) => format!("{}/{x}", e.user),
|
||||
None => e.user.clone(),
|
||||
};
|
||||
println!(
|
||||
"#{:<4} {} {who}@{} {} {}",
|
||||
e.seq, e.time, e.host, e.action, e.detail
|
||||
);
|
||||
}
|
||||
}
|
||||
if !v.ok {
|
||||
let (n, why) = v.broken_at.unwrap_or_default();
|
||||
eprintln!(
|
||||
"{} cadeia quebrada na entrada {n}: {why}",
|
||||
paint("31", "atenção:")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String> {
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
let flag = interrupted.clone();
|
||||
|
||||
@@ -10,11 +10,11 @@ rust-version.workspace = true
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
md-5 = "0.10"
|
||||
sha2 = "0.11"
|
||||
sha1 = "0.11"
|
||||
md-5 = "0.11"
|
||||
jpeg-decoder = { version = "0.3", default-features = false }
|
||||
jpeg-encoder = "0.6"
|
||||
jpeg-encoder = "0.7"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
thiserror = "2"
|
||||
@@ -23,12 +23,12 @@ bzip2 = "0.6"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
snap = "1"
|
||||
ruzstd = "0.9"
|
||||
lz4_flex = { version = "0.11", default-features = false, features = ["safe-decode", "safe-encode"] }
|
||||
aes = "0.8"
|
||||
hmac = "0.12"
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
ripemd = "0.1"
|
||||
argon2 = "0.5"
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["alloc", "safe-decode", "safe-encode"] }
|
||||
aes = "0.9"
|
||||
hmac = "0.13"
|
||||
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
|
||||
ripemd = "0.2"
|
||||
argon2 = "0.6"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Trilha de auditoria: cada ação do examinador (abrir uma origem, varrer, recuperar, criar
|
||||
//! imagem, exportar) vira uma linha JSON com quem, quando e o quê.
|
||||
//!
|
||||
//! As linhas são encadeadas por hash: o `hash` de cada uma é o SHA-256 do `hash` da anterior
|
||||
//! seguido do corpo da própria linha. Alterar ou apagar uma linha do meio quebra a cadeia e
|
||||
//! `guara audit --verify` aponta onde. Cortar o fim do arquivo não é detectável sozinho; para
|
||||
//! isso, guarde o hash da última entrada (a "cabeça") fora da máquina, por exemplo no relatório.
|
||||
//!
|
||||
//! O arquivo fica na pasta de configuração (`auditoria.jsonl`) ou onde `GUARA_AUDIT_LOG`
|
||||
//! apontar. Segredos (senhas, chaves) nunca entram no registro.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
seq: u64,
|
||||
time: &'a str,
|
||||
user: &'a str,
|
||||
host: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
examiner: Option<&'a str>,
|
||||
version: &'a str,
|
||||
action: &'a str,
|
||||
detail: &'a Value,
|
||||
prev: &'a str,
|
||||
}
|
||||
|
||||
/// Uma linha da trilha.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub seq: u64,
|
||||
/// UTC, ISO 8601.
|
||||
pub time: String,
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub examiner: Option<String>,
|
||||
pub version: String,
|
||||
pub action: String,
|
||||
pub detail: Value,
|
||||
pub prev: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Resultado de conferir a cadeia.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Verification {
|
||||
pub entries: usize,
|
||||
pub ok: bool,
|
||||
/// Entrada (1, 2, ...) onde a cadeia quebra e o motivo.
|
||||
pub broken_at: Option<(u64, String)>,
|
||||
/// Hash da última entrada; guarde-o fora da máquina para detectar truncamento.
|
||||
pub head: String,
|
||||
}
|
||||
|
||||
pub struct Log {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
fn sha_hex(prev: &str, body: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(prev.as_bytes());
|
||||
h.update(b"\n");
|
||||
h.update(body.as_bytes());
|
||||
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// UTC em ISO 8601 a partir de segundos Unix (conversão civil de Howard Hinnant).
|
||||
pub fn utc_iso(secs: i64) -> String {
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z.rem_euclid(146097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
|
||||
rem / 3600,
|
||||
rem % 3600 / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
utc_iso(secs)
|
||||
}
|
||||
|
||||
fn env(k: &str) -> Option<String> {
|
||||
std::env::var(k).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Usuário do sistema por trás da ação (o que chamou sudo, quando houver).
|
||||
fn current_user() -> String {
|
||||
env("SUDO_USER")
|
||||
.or_else(|| env("USER"))
|
||||
.or_else(|| env("USERNAME"))
|
||||
.or_else(|| env("LOGNAME"))
|
||||
.unwrap_or_else(|| "?".into())
|
||||
}
|
||||
|
||||
fn host_name() -> String {
|
||||
env("HOSTNAME")
|
||||
.or_else(|| env("COMPUTERNAME"))
|
||||
.or_else(|| {
|
||||
std::fs::read_to_string("/etc/hostname")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "?".into())
|
||||
}
|
||||
|
||||
/// Última linha não vazia do arquivo, lendo só o fim.
|
||||
fn last_line(path: &Path) -> std::io::Result<Option<String>> {
|
||||
let mut f = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let len = f.metadata()?.len();
|
||||
let mut want = 8192u64;
|
||||
loop {
|
||||
let start = len.saturating_sub(want);
|
||||
f.seek(SeekFrom::Start(start))?;
|
||||
let mut buf = vec![];
|
||||
f.read_to_end(&mut buf)?;
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let trimmed = text.trim_end();
|
||||
match trimmed.rfind('\n') {
|
||||
Some(i) => return Ok(Some(trimmed[i + 1..].to_string())),
|
||||
None if start == 0 => {
|
||||
return Ok((!trimmed.is_empty()).then(|| trimmed.to_string()));
|
||||
}
|
||||
None => want *= 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Log {
|
||||
pub fn at(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// `GUARA_AUDIT_LOG` ou `auditoria.jsonl` na pasta de configuração.
|
||||
pub fn default_path() -> PathBuf {
|
||||
match env("GUARA_AUDIT_LOG") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => crate::settings::config_dir().join("auditoria.jsonl"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_default() -> Self {
|
||||
Self::at(Self::default_path())
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Acrescenta uma entrada ao fim da cadeia.
|
||||
pub fn append(&self, action: &str, detail: Value) -> Result<Entry> {
|
||||
let (seq, prev) = match last_line(&self.path)? {
|
||||
None => (1, GENESIS.to_string()),
|
||||
Some(l) => {
|
||||
let e: Entry = serde_json::from_str(&l)
|
||||
.map_err(|e| Error::msg(format!("última linha da trilha inválida: {e}")))?;
|
||||
(e.seq + 1, e.hash)
|
||||
}
|
||||
};
|
||||
let (time, user, host) = (now_iso(), current_user(), host_name());
|
||||
let examiner = env("GUARA_EXAMINER");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let body = serde_json::to_string(&Body {
|
||||
seq,
|
||||
time: &time,
|
||||
user: &user,
|
||||
host: &host,
|
||||
examiner: examiner.as_deref(),
|
||||
version,
|
||||
action,
|
||||
detail: &detail,
|
||||
prev: &prev,
|
||||
})?;
|
||||
let hash = sha_hex(&prev, &body);
|
||||
let line = format!("{},\"hash\":\"{hash}\"}}\n", &body[..body.len() - 1]);
|
||||
if let Some(dir) = self.path.parent() {
|
||||
crate::owner::create_dir_all_owned(dir)?;
|
||||
}
|
||||
let existed = self.path.exists();
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
f.write_all(line.as_bytes())?;
|
||||
f.flush()?;
|
||||
if !existed {
|
||||
crate::owner::give_to_user(&self.path);
|
||||
}
|
||||
Ok(Entry {
|
||||
seq,
|
||||
time,
|
||||
user,
|
||||
host,
|
||||
examiner,
|
||||
version: version.into(),
|
||||
action: action.into(),
|
||||
detail,
|
||||
prev,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Todas as entradas, na ordem. Linha ilegível vira erro (use [`Log::verify`] para saber onde).
|
||||
pub fn entries(&self) -> Result<Vec<Entry>> {
|
||||
let text = match std::fs::read_to_string(&self.path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
serde_json::from_str(l)
|
||||
.map_err(|e| Error::msg(format!("linha {} da trilha inválida: {e}", i + 1)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Confere a numeração, o encadeamento e o hash de cada linha.
|
||||
pub fn verify(&self) -> Result<Verification> {
|
||||
let text = match std::fs::read_to_string(&self.path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut prev = GENESIS.to_string();
|
||||
let mut n = 0usize;
|
||||
let broken = |n: usize, why: &str, entries: usize, head: &str| Verification {
|
||||
entries,
|
||||
ok: false,
|
||||
broken_at: Some((n as u64, why.to_string())),
|
||||
head: head.to_string(),
|
||||
};
|
||||
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
||||
n += 1;
|
||||
let Ok(e) = serde_json::from_str::<Entry>(line) else {
|
||||
return Ok(broken(n, "linha ilegível", n - 1, &prev));
|
||||
};
|
||||
if e.seq != n as u64 {
|
||||
return Ok(broken(
|
||||
n,
|
||||
"numeração fora de ordem (entrada removida ou inserida)",
|
||||
n - 1,
|
||||
&prev,
|
||||
));
|
||||
}
|
||||
if e.prev != prev {
|
||||
return Ok(broken(n, "não continua a entrada anterior", n - 1, &prev));
|
||||
}
|
||||
let Some(i) = line.rfind(",\"hash\":\"") else {
|
||||
return Ok(broken(n, "linha sem hash", n - 1, &prev));
|
||||
};
|
||||
let body = format!("{}}}", &line[..i]);
|
||||
if sha_hex(&prev, &body) != e.hash {
|
||||
return Ok(broken(
|
||||
n,
|
||||
"conteúdo alterado (o hash não confere)",
|
||||
n - 1,
|
||||
&prev,
|
||||
));
|
||||
}
|
||||
prev = e.hash;
|
||||
}
|
||||
Ok(Verification {
|
||||
entries: n,
|
||||
ok: true,
|
||||
broken_at: None,
|
||||
head: prev,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Registra uma ação na trilha padrão. Falha ao gravar não interrompe a operação, mas é
|
||||
/// avisada no stderr: uma ação sem registro precisa ser notada.
|
||||
pub fn record(action: &str, detail: Value) {
|
||||
if let Err(e) = Log::open_default().append(action, detail) {
|
||||
eprintln!("aviso: não foi possível gravar a trilha de auditoria ({action}): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Atalho para detalhes de um caminho de origem.
|
||||
pub fn path_detail(path: &str) -> Value {
|
||||
json!({ "path": path })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_de_datas_conhecidas() {
|
||||
assert_eq!(utc_iso(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(utc_iso(951_782_400), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(utc_iso(1_792_000_000 + 3661), "2026-10-14T18:47:41Z");
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ pub mod filevault;
|
||||
pub mod luks;
|
||||
|
||||
use crate::source::Source;
|
||||
use aes::cipher::generic_array::GenericArray;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use aes::cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
use aes::{Aes128, Aes192, Aes256};
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
@@ -33,7 +32,7 @@ impl AesAny {
|
||||
})
|
||||
}
|
||||
pub fn encrypt(&self, b: &mut [u8; 16]) {
|
||||
let g = GenericArray::from_mut_slice(b);
|
||||
let g: &mut Block<Aes128> = (&mut b[..]).try_into().expect("bloco AES de 16 bytes");
|
||||
match self {
|
||||
AesAny::A128(c) => c.encrypt_block(g),
|
||||
AesAny::A192(c) => c.encrypt_block(g),
|
||||
@@ -41,7 +40,7 @@ impl AesAny {
|
||||
}
|
||||
}
|
||||
pub fn decrypt(&self, b: &mut [u8; 16]) {
|
||||
let g = GenericArray::from_mut_slice(b);
|
||||
let g: &mut Block<Aes128> = (&mut b[..]).try_into().expect("bloco AES de 16 bytes");
|
||||
match self {
|
||||
AesAny::A128(c) => c.decrypt_block(g),
|
||||
AesAny::A192(c) => c.decrypt_block(g),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! antigos da árvore (copy-on-write) que ainda estão no disco. Volumes cifrados (FileVault) são
|
||||
//! detectados e deixados para a varredura por assinatura.
|
||||
|
||||
use super::{make_found, Ctx, NewFound, VolumeScan};
|
||||
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
|
||||
use crate::source::{read_vec, Source};
|
||||
use crate::types::{Confidence, DataLoc, Extent, Origin};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -27,6 +27,9 @@ fn le64(b: &[u8], o: usize) -> u64 {
|
||||
const OBJ_TYPE_MASK: u32 = 0xFFFF;
|
||||
const T_BTREE_ROOT: u32 = 2;
|
||||
const T_BTREE_NODE: u32 = 3;
|
||||
const T_SPACEMAN: u32 = 5;
|
||||
const T_CIB: u32 = 7;
|
||||
const T_CHECKPOINT_MAP: u32 = 0x0C;
|
||||
const SUBTYPE_FSTREE: u32 = 0x0E;
|
||||
|
||||
// tipos de registro da árvore de arquivos
|
||||
@@ -170,6 +173,112 @@ impl<'a> Apfs<'a> {
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Endereço físico de um objeto ephemeral (spaceman), achado no mapa de checkpoint mais novo.
|
||||
fn ephemeral_paddr(&self, nx: &[u8], oid: u64) -> Option<u64> {
|
||||
let desc_blocks = le32(nx, 104) as u64;
|
||||
let desc_base = le64(nx, 112);
|
||||
let max_entries = (self.bs as usize).saturating_sub(40) / 40;
|
||||
let mut best: Option<(u64, u64)> = None;
|
||||
for i in 0..desc_blocks {
|
||||
let Some(b) = self.checked_block(desc_base + i) else {
|
||||
continue;
|
||||
};
|
||||
if le32(&b, 24) & OBJ_TYPE_MASK != T_CHECKPOINT_MAP {
|
||||
continue;
|
||||
}
|
||||
let xid = le64(&b, 16);
|
||||
if best.is_some_and(|(x, _)| x >= xid) {
|
||||
continue;
|
||||
}
|
||||
let count = (le32(&b, 36) as usize).min(max_entries);
|
||||
for j in 0..count {
|
||||
let o = 40 + j * 40;
|
||||
if le64(&b, o + 24) == oid {
|
||||
best = Some((xid, le64(&b, o + 32)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// Mapa de espaço livre do contêiner: percorre o spaceman, os chunk-info blocks e os bitmaps
|
||||
/// (bit 1 = bloco em uso).
|
||||
fn free_map(&self, nx: &[u8]) -> Option<Vec<Extent>> {
|
||||
let sm_oid = le64(nx, 152);
|
||||
if sm_oid == 0 {
|
||||
return None;
|
||||
}
|
||||
let sm = self.checked_block(self.ephemeral_paddr(nx, sm_oid)?)?;
|
||||
if le32(&sm, 24) & OBJ_TYPE_MASK != T_SPACEMAN {
|
||||
return None;
|
||||
}
|
||||
// Com CABs (contêineres grandes) os endereços dos CIBs não ficam no spaceman.
|
||||
if le32(&sm, 68) != 0 {
|
||||
return None;
|
||||
}
|
||||
let cib_count = le32(&sm, 64) as usize;
|
||||
let addr_off = le32(&sm, 80) as usize;
|
||||
let end = addr_off.checked_add(cib_count.checked_mul(8)?)?;
|
||||
if cib_count == 0 || end > sm.len() {
|
||||
return None;
|
||||
}
|
||||
let per_cib = (self.bs as usize).saturating_sub(40) / 32;
|
||||
let mut free = vec![];
|
||||
for ci in 0..cib_count {
|
||||
let cp = le64(&sm, addr_off + ci * 8);
|
||||
let Some(cib) = self.checked_block(cp) else {
|
||||
continue;
|
||||
};
|
||||
if le32(&cib, 24) & OBJ_TYPE_MASK != T_CIB {
|
||||
continue;
|
||||
}
|
||||
let count = (le32(&cib, 36) as usize).min(per_cib);
|
||||
for j in 0..count {
|
||||
let o = 40 + j * 32;
|
||||
let addr = le64(&cib, o + 8);
|
||||
let n = le32(&cib, o + 16) as u64;
|
||||
let bm = le64(&cib, o + 24);
|
||||
if n == 0 || addr >= self.blocks {
|
||||
continue;
|
||||
}
|
||||
let n = n.min(self.blocks - addr);
|
||||
if bm == 0 {
|
||||
free.push(Extent::new(self.base + addr * self.bs, n * self.bs));
|
||||
continue;
|
||||
}
|
||||
let Some(b) = self.block(bm) else {
|
||||
continue;
|
||||
};
|
||||
let mut run: Option<u64> = None;
|
||||
for k in 0..n {
|
||||
let used = b
|
||||
.get((k / 8) as usize)
|
||||
.map(|x| x >> (k % 8) & 1 == 1)
|
||||
.unwrap_or(true);
|
||||
match (used, run) {
|
||||
(false, None) => run = Some(k),
|
||||
(true, Some(s)) => {
|
||||
free.push(Extent::new(
|
||||
self.base + (addr + s) * self.bs,
|
||||
(k - s) * self.bs,
|
||||
));
|
||||
run = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(s) = run {
|
||||
free.push(Extent::new(
|
||||
self.base + (addr + s) * self.bs,
|
||||
(n - s) * self.bs,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(merge_extents(free))
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(m: &HashMap<u64, Vec<(u64, u64)>>, oid: u64, max_xid: u64) -> Option<u64> {
|
||||
@@ -399,6 +508,13 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||
return res;
|
||||
};
|
||||
let _ = cont_omap_block;
|
||||
res.free = a.free_map(&nx);
|
||||
if res.free.is_none() {
|
||||
res.warnings.push(
|
||||
"APFS: não foi possível ler o mapa de espaço livre; a varredura profunda usará todo o volume."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let container_omap = a.omap(le64(&nx, 160));
|
||||
let max_fs = le32(&nx, 180).min(100) as usize;
|
||||
let mut vols: Vec<Volume> = vec![];
|
||||
@@ -610,6 +726,12 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
|
||||
let _ = res;
|
||||
}
|
||||
|
||||
/// Trechos livres (absolutos) do contêiner, segundo o spaceman.
|
||||
pub fn free_extents(src: &dyn Source, base: u64) -> Option<Vec<Extent>> {
|
||||
let (a, nx) = open_container(src, base)?;
|
||||
a.free_map(&nx)
|
||||
}
|
||||
|
||||
/// Arquivos e pastas atuais do primeiro volume sem cifragem (caminho, tamanho, é pasta).
|
||||
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
|
||||
let Some((a, nx)) = open_container(src, base) else {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod aff4;
|
||||
pub mod assemble;
|
||||
pub mod audit;
|
||||
pub mod carve;
|
||||
pub mod crypt;
|
||||
pub mod defrag;
|
||||
@@ -33,6 +34,7 @@ pub mod mp4rebuild;
|
||||
pub mod notify;
|
||||
pub mod owner;
|
||||
pub mod partition;
|
||||
pub mod pdf;
|
||||
pub mod raid;
|
||||
pub mod raid_detect;
|
||||
pub mod reconnect;
|
||||
@@ -47,6 +49,7 @@ pub mod source;
|
||||
pub mod sqlite;
|
||||
pub mod thumb;
|
||||
pub mod types;
|
||||
pub mod vdisk;
|
||||
pub mod volume;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
@@ -444,11 +444,11 @@ fn exif_epoch(s: &str) -> Option<i64> {
|
||||
Some(crate::fs::days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se)
|
||||
}
|
||||
|
||||
/// "2023:07:14 18:22:05" -> "14/07/2023 18:22:05"
|
||||
/// "2023:07:14 18:22:05" -> "2023-07-14T18:22:05" (a interface formata por idioma)
|
||||
fn exif_date(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
if b.len() >= 19 && b[4] == b':' && b[7] == b':' {
|
||||
format!("{}/{}/{} {}", &s[8..10], &s[5..7], &s[0..4], &s[11..19])
|
||||
format!("{}-{}-{}T{}", &s[0..4], &s[5..7], &s[8..10], &s[11..19])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
@@ -613,17 +613,17 @@ fn pdf_text(b: &[u8]) -> String {
|
||||
b.iter().map(|&c| c as char).collect()
|
||||
}
|
||||
|
||||
/// "D:20230714182205-03'00'" -> "14/07/2023 18:22:05"
|
||||
/// "D:20230714182205-03'00'" -> "2023-07-14T18:22:05" (a interface formata por idioma)
|
||||
fn pdf_date(s: &str) -> String {
|
||||
let t = s.trim_start_matches("D:");
|
||||
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if digits.len() >= 8 {
|
||||
let g = |a: usize, b: usize| digits.get(a..b).unwrap_or("00");
|
||||
return format!(
|
||||
"{}/{}/{} {}:{}:{}",
|
||||
g(6, 8),
|
||||
g(4, 6),
|
||||
"{}-{}-{}T{}:{}:{}",
|
||||
g(0, 4),
|
||||
g(4, 6),
|
||||
g(6, 8),
|
||||
g(8, 10),
|
||||
g(10, 12),
|
||||
g(12, 14)
|
||||
@@ -733,21 +733,18 @@ fn unescape(s: &str) -> String {
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// "2023-07-14T18:22:05Z" -> "14/07/2023 18:22:05 (UTC)"
|
||||
/// Datas ISO 8601 ficam como estão; a interface as formata no idioma escolhido.
|
||||
fn iso_date(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' {
|
||||
let utc = if s.ends_with('Z') { " (UTC)" } else { "" };
|
||||
return format!(
|
||||
"{}/{}/{} {}{}",
|
||||
&s[8..10],
|
||||
&s[5..7],
|
||||
&s[0..4],
|
||||
&s[11..19],
|
||||
utc
|
||||
);
|
||||
let mut out = s[..19].to_string();
|
||||
if s.len() == 20 && s.ends_with('Z') {
|
||||
out.push('Z');
|
||||
}
|
||||
out
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
fn ooxml(fr: &Fr, m: &mut Meta) {
|
||||
@@ -890,7 +887,7 @@ fn moov(d: &[u8], m: &mut Meta) {
|
||||
if created > EPOCH_1904 {
|
||||
m.add(
|
||||
"Gravado em (metadado)",
|
||||
fmt_unix((created - EPOCH_1904) as i64),
|
||||
iso_unix((created - EPOCH_1904) as i64),
|
||||
);
|
||||
}
|
||||
if ts > 0 && dur > 0 {
|
||||
@@ -919,6 +916,19 @@ fn moov(d: &[u8], m: &mut Meta) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Instante em ISO 8601 UTC ("2023-07-14T18:22:05Z"), para a interface formatar.
|
||||
pub fn iso_unix(t: i64) -> String {
|
||||
let days = t.div_euclid(86400);
|
||||
let rem = t.rem_euclid(86400);
|
||||
let (y, mo, d) = civil_from_days(days);
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z",
|
||||
rem / 3600,
|
||||
rem % 3600 / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fmt_unix(t: i64) -> String {
|
||||
let days = t.div_euclid(86400);
|
||||
let rem = t.rem_euclid(86400);
|
||||
|
||||
@@ -3,9 +3,59 @@
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Avisos que o núcleo envia ao sistema operacional. O texto é montado no
|
||||
/// idioma efetivo da interface (`settings::ui_lang`), porque notificações não
|
||||
/// passam pelo dicionário do DOM.
|
||||
pub enum Notice {
|
||||
ScanDone { count: usize, target: String },
|
||||
RecoverDone { count: usize, destination: String },
|
||||
DiskLost,
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
fn text(&self, lang: &str) -> (String, String) {
|
||||
let en = lang == "en";
|
||||
match self {
|
||||
Notice::ScanDone { count, target } => (
|
||||
"Guará: scan complete".into(),
|
||||
if en {
|
||||
format!("{count} file(s) found in {target}.")
|
||||
} else {
|
||||
format!("{count} arquivo(s) encontrados em {target}.")
|
||||
},
|
||||
),
|
||||
Notice::RecoverDone { count, destination } => (
|
||||
if en {
|
||||
"Guará: recovery complete".into()
|
||||
} else {
|
||||
"Guará: recuperação concluída".into()
|
||||
},
|
||||
if en {
|
||||
format!("{count} file(s) saved to {destination}")
|
||||
} else {
|
||||
format!("{count} arquivo(s) salvos em {destination}")
|
||||
},
|
||||
),
|
||||
Notice::DiskLost => (
|
||||
if en {
|
||||
"Guará: disk disconnected?".into()
|
||||
} else {
|
||||
"Guará: disco desconectado?".into()
|
||||
},
|
||||
if en {
|
||||
"The scan was paused without losing anything. Reconnect the disk and continue."
|
||||
.into()
|
||||
} else {
|
||||
"A varredura foi pausada sem perder nada. Reconecte o disco e continue.".into()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
|
||||
pub fn send(title: &str, body: &str) {
|
||||
let (title, body) = (title.to_string(), body.to_string());
|
||||
pub fn send(notice: Notice) {
|
||||
let (title, body) = notice.text(&crate::settings::ui_lang());
|
||||
std::thread::spawn(move || {
|
||||
if let Some(mut c) = command(&title, &body) {
|
||||
if let Ok(mut child) = c
|
||||
|
||||
@@ -261,7 +261,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
|
||||
}
|
||||
if p.fs == FsKind::Apfs {
|
||||
layout.warnings.push(format!(
|
||||
"Partição {} é APFS: leitura de metadados ainda não suportada; usando apenas varredura por assinatura. Se estiver cifrada (FileVault), nada será encontrado.",
|
||||
"Partição {} é APFS: o mapa de espaço livre é lido do spaceman; se o volume estiver cifrado (FileVault), nada será encontrado sem a chave.",
|
||||
p.index + 1
|
||||
));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! Gerador mínimo de PDF (A4, texto e tabelas) para o relatório de recuperação.
|
||||
//!
|
||||
//! Usa só as fontes padrão do PDF (Helvetica, Helvetica-Bold e Courier) com codificação
|
||||
//! WinAnsi, então o arquivo é autocontido e não depende de fontes instaladas. Caracteres fora
|
||||
//! do Latin-1/WinAnsi viram `?`.
|
||||
|
||||
use flate2::write::ZlibEncoder;
|
||||
use flate2::Compression;
|
||||
use std::io::Write;
|
||||
|
||||
const W: f64 = 595.28;
|
||||
const H: f64 = 841.89;
|
||||
const MARGIN: f64 = 40.0;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Font {
|
||||
Regular,
|
||||
Bold,
|
||||
Mono,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
fn res(self) -> &'static str {
|
||||
match self {
|
||||
Font::Regular => "F1",
|
||||
Font::Bold => "F2",
|
||||
Font::Mono => "F3",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Larguras AFM (milésimos de em) dos caracteres ASCII 32..=126.
|
||||
const HELV: [u16; 95] = [
|
||||
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
|
||||
556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667,
|
||||
611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
|
||||
667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500,
|
||||
222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
|
||||
];
|
||||
const HELV_B: [u16; 95] = [
|
||||
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
|
||||
556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667,
|
||||
611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
|
||||
667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556,
|
||||
278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
|
||||
];
|
||||
|
||||
/// Letra base de um caractere acentuado do Latin-1 (a largura é a da letra sem acento).
|
||||
fn base_letter(c: char) -> char {
|
||||
match c {
|
||||
'À'..='Å' => 'A',
|
||||
'Ç' => 'C',
|
||||
'È'..='Ë' => 'E',
|
||||
'Ì'..='Ï' => 'I',
|
||||
'Ñ' => 'N',
|
||||
'Ò'..='Ö' | 'Ø' => 'O',
|
||||
'Ù'..='Ü' => 'U',
|
||||
'Ý' => 'Y',
|
||||
'à'..='å' => 'a',
|
||||
'ç' => 'c',
|
||||
'è'..='ë' => 'e',
|
||||
'ì'..='ï' => 'i',
|
||||
'ñ' => 'n',
|
||||
'ò'..='ö' | 'ø' => 'o',
|
||||
'ù'..='ü' => 'u',
|
||||
'ý' | 'ÿ' => 'y',
|
||||
_ => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn char_width(c: char, f: Font) -> f64 {
|
||||
if f == Font::Mono {
|
||||
return 0.6;
|
||||
}
|
||||
let t = if f == Font::Bold { &HELV_B } else { &HELV };
|
||||
let c = if (' '..='~').contains(&c) {
|
||||
c
|
||||
} else {
|
||||
base_letter(c)
|
||||
};
|
||||
match c {
|
||||
' '..='~' => t[c as usize - 32] as f64 / 1000.0,
|
||||
_ => 0.556,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_width(s: &str, size: f64, f: Font) -> f64 {
|
||||
s.chars().map(|c| char_width(c, f)).sum::<f64>() * size
|
||||
}
|
||||
|
||||
/// Byte WinAnsi de um caractere (ou `?`).
|
||||
fn win_ansi(c: char) -> u8 {
|
||||
let n = c as u32;
|
||||
match c {
|
||||
'€' => 0x80,
|
||||
'‘' => 0x91,
|
||||
'’' => 0x92,
|
||||
'“' => 0x93,
|
||||
'”' => 0x94,
|
||||
'•' => 0x95,
|
||||
'–' => 0x96,
|
||||
'—' => 0x97,
|
||||
'…' => 0x85,
|
||||
_ if (0x20..=0x7E).contains(&n) || (0xA0..=0xFF).contains(&n) => n as u8,
|
||||
_ => b'?',
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
let mut o = String::new();
|
||||
for c in s.chars() {
|
||||
match win_ansi(c) {
|
||||
b'(' => o.push_str("\\("),
|
||||
b')' => o.push_str("\\)"),
|
||||
b'\\' => o.push_str("\\\\"),
|
||||
b if (0x20..0x7F).contains(&b) => o.push(b as char),
|
||||
b => o.push_str(&format!("\\{b:03o}")),
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Quebra `s` em linhas de no máximo `width` pontos. Sem espaço onde quebrar (caminhos,
|
||||
/// hashes), corta no meio da palavra.
|
||||
pub fn wrap(s: &str, size: f64, f: Font, width: f64) -> Vec<String> {
|
||||
let mut lines = vec![];
|
||||
for para in s.split('\n') {
|
||||
let mut cur = String::new();
|
||||
let mut cur_w = 0.0;
|
||||
for word in para.split(' ') {
|
||||
let ww = text_width(word, size, f);
|
||||
let sp = if cur.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
char_width(' ', f) * size
|
||||
};
|
||||
if cur_w + sp + ww <= width {
|
||||
if !cur.is_empty() {
|
||||
cur.push(' ');
|
||||
}
|
||||
cur.push_str(word);
|
||||
cur_w += sp + ww;
|
||||
continue;
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur_w = 0.0;
|
||||
}
|
||||
if ww <= width {
|
||||
cur = word.to_string();
|
||||
cur_w = ww;
|
||||
} else {
|
||||
for c in word.chars() {
|
||||
let cw = char_width(c, f) * size;
|
||||
if cur_w + cw > width && !cur.is_empty() {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur_w = 0.0;
|
||||
}
|
||||
cur.push(c);
|
||||
cur_w += cw;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(cur);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub struct Col {
|
||||
pub title: &'static str,
|
||||
pub width: f64,
|
||||
pub font: Font,
|
||||
pub right: bool,
|
||||
}
|
||||
|
||||
pub struct Pdf {
|
||||
title: String,
|
||||
footer: String,
|
||||
pages: Vec<String>,
|
||||
cur: String,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
type Rgb = (f64, f64, f64);
|
||||
pub const TEAL: Rgb = (0.059, 0.463, 0.431);
|
||||
pub const GRAY: Rgb = (0.36, 0.43, 0.42);
|
||||
pub const BLACK: Rgb = (0.06, 0.12, 0.12);
|
||||
|
||||
impl Pdf {
|
||||
pub fn new(title: &str, footer: &str) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
footer: footer.into(),
|
||||
pages: vec![],
|
||||
cur: String::new(),
|
||||
y: H - MARGIN,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content_width() -> f64 {
|
||||
W - 2.0 * MARGIN
|
||||
}
|
||||
|
||||
fn new_page(&mut self) {
|
||||
if !self.cur.is_empty() || self.y != H - MARGIN {
|
||||
self.pages.push(std::mem::take(&mut self.cur));
|
||||
}
|
||||
self.y = H - MARGIN;
|
||||
}
|
||||
|
||||
fn ensure(&mut self, h: f64) {
|
||||
if self.y - h < MARGIN + 14.0 {
|
||||
self.new_page();
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&mut self, x: f64, y: f64, s: &str, size: f64, f: Font, c: Rgb) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.cur.push_str(&format!(
|
||||
"BT /{} {size:.1} Tf {:.3} {:.3} {:.3} rg {x:.2} {y:.2} Td ({}) Tj ET\n",
|
||||
f.res(),
|
||||
c.0,
|
||||
c.1,
|
||||
c.2,
|
||||
escape(s)
|
||||
));
|
||||
}
|
||||
|
||||
fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Rgb) {
|
||||
self.cur.push_str(&format!(
|
||||
"{:.3} {:.3} {:.3} rg {x:.2} {y:.2} {w:.2} {h:.2} re f\n",
|
||||
c.0, c.1, c.2
|
||||
));
|
||||
}
|
||||
|
||||
fn line(&mut self, x1: f64, x2: f64, y: f64, c: Rgb, w: f64) {
|
||||
self.cur.push_str(&format!(
|
||||
"{:.3} {:.3} {:.3} RG {w} w {x1:.2} {y:.2} m {x2:.2} {y:.2} l S\n",
|
||||
c.0, c.1, c.2
|
||||
));
|
||||
}
|
||||
|
||||
pub fn space(&mut self, h: f64) {
|
||||
self.y -= h;
|
||||
}
|
||||
|
||||
pub fn title(&mut self, s: &str, sub: &str) {
|
||||
self.ensure(60.0);
|
||||
self.put(MARGIN, self.y - 20.0, s, 20.0, Font::Bold, BLACK);
|
||||
self.put(MARGIN, self.y - 36.0, sub, 9.0, Font::Regular, GRAY);
|
||||
self.line(MARGIN, W - MARGIN, self.y - 44.0, TEAL, 2.0);
|
||||
self.y -= 58.0;
|
||||
}
|
||||
|
||||
pub fn heading(&mut self, s: &str) {
|
||||
self.ensure(50.0);
|
||||
self.y -= 14.0;
|
||||
self.put(MARGIN, self.y - 11.0, s, 12.0, Font::Bold, TEAL);
|
||||
self.y -= 20.0;
|
||||
}
|
||||
|
||||
pub fn paragraph(&mut self, s: &str, size: f64, f: Font, c: Rgb) {
|
||||
for l in wrap(s, size, f, Self::content_width()) {
|
||||
self.ensure(size * 1.4);
|
||||
self.put(MARGIN, self.y - size, &l, size, f, c);
|
||||
self.y -= size * 1.4;
|
||||
}
|
||||
self.y -= 3.0;
|
||||
}
|
||||
|
||||
/// Aviso destacado com barra lateral.
|
||||
pub fn note(&mut self, s: &str) {
|
||||
let size = 9.0;
|
||||
let lines = wrap(s, size, Font::Regular, Self::content_width() - 16.0);
|
||||
let h = lines.len() as f64 * size * 1.4 + 8.0;
|
||||
self.ensure(h + 4.0);
|
||||
let top = self.y;
|
||||
self.rect(MARGIN, top - h, Self::content_width(), h, (1.0, 0.97, 0.88));
|
||||
self.rect(MARGIN, top - h, 3.0, h, (0.88, 0.63, 0.0));
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
self.put(
|
||||
MARGIN + 10.0,
|
||||
top - 4.0 - size * 1.4 * (i as f64 + 0.8),
|
||||
l,
|
||||
size,
|
||||
Font::Regular,
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
self.y -= h + 6.0;
|
||||
}
|
||||
|
||||
/// Linhas rótulo/valor; o valor pode ser monoespaçado.
|
||||
pub fn kv(&mut self, rows: &[(&str, &str, Font)]) {
|
||||
let lw = 140.0;
|
||||
let size = 9.5;
|
||||
for (k, v, f) in rows {
|
||||
let lines = wrap(v, size, *f, Self::content_width() - lw - 8.0);
|
||||
let h = lines.len() as f64 * size * 1.4 + 6.0;
|
||||
self.ensure(h);
|
||||
let top = self.y;
|
||||
self.put(MARGIN + 4.0, top - size - 3.0, k, size, Font::Regular, GRAY);
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
self.put(
|
||||
MARGIN + lw,
|
||||
top - size - 3.0 - i as f64 * size * 1.4,
|
||||
l,
|
||||
size,
|
||||
*f,
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
|
||||
self.y -= h;
|
||||
}
|
||||
self.y -= 4.0;
|
||||
}
|
||||
|
||||
/// Tabela com cabeçalho repetido a cada página e linhas que quebram texto.
|
||||
pub fn table(&mut self, cols: &[Col], rows: &[Vec<String>]) {
|
||||
let size = 8.0;
|
||||
let pad = 3.0;
|
||||
let header = |p: &mut Pdf| {
|
||||
let h = size * 1.4 + 2.0 * pad;
|
||||
p.ensure(h + 2.0 * size * 1.4);
|
||||
let top = p.y;
|
||||
p.rect(
|
||||
MARGIN,
|
||||
top - h,
|
||||
Self::content_width(),
|
||||
h,
|
||||
(0.906, 0.953, 0.949),
|
||||
);
|
||||
let mut x = MARGIN;
|
||||
for c in cols {
|
||||
let tx = if c.right {
|
||||
x + c.width - pad - text_width(c.title, size, Font::Bold)
|
||||
} else {
|
||||
x + pad
|
||||
};
|
||||
p.put(
|
||||
tx,
|
||||
top - pad - size,
|
||||
c.title,
|
||||
size,
|
||||
Font::Bold,
|
||||
(0.043, 0.31, 0.29),
|
||||
);
|
||||
x += c.width;
|
||||
}
|
||||
p.y -= h;
|
||||
};
|
||||
header(self);
|
||||
for row in rows {
|
||||
let cells: Vec<Vec<String>> = cols
|
||||
.iter()
|
||||
.zip(row)
|
||||
.map(|(c, t)| wrap(t, size, c.font, c.width - 2.0 * pad))
|
||||
.collect();
|
||||
let n = cells.iter().map(|c| c.len()).max().unwrap_or(1).max(1);
|
||||
let h = n as f64 * size * 1.35 + 2.0 * pad;
|
||||
if self.y - h < MARGIN + 14.0 {
|
||||
self.new_page();
|
||||
header(self);
|
||||
}
|
||||
let top = self.y;
|
||||
let mut x = MARGIN;
|
||||
for (c, lines) in cols.iter().zip(&cells) {
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let tx = if c.right {
|
||||
x + c.width - pad - text_width(l, size, c.font)
|
||||
} else {
|
||||
x + pad
|
||||
};
|
||||
self.put(
|
||||
tx,
|
||||
top - pad - size - i as f64 * size * 1.35,
|
||||
l,
|
||||
size,
|
||||
c.font,
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
x += c.width;
|
||||
}
|
||||
self.line(MARGIN, W - MARGIN, top - h, (0.89, 0.93, 0.93), 0.5);
|
||||
self.y -= h;
|
||||
}
|
||||
self.y -= 6.0;
|
||||
}
|
||||
|
||||
/// Fecha o documento. `date` é a data de criação em UTC (`AAAAMMDDHHMMSS`).
|
||||
pub fn finish(mut self, date: &str) -> Vec<u8> {
|
||||
if !self.cur.is_empty() || self.pages.is_empty() {
|
||||
self.pages.push(std::mem::take(&mut self.cur));
|
||||
}
|
||||
let total = self.pages.len();
|
||||
for (i, p) in self.pages.iter_mut().enumerate() {
|
||||
let label = format!("{} · página {} de {total}", self.footer, i + 1);
|
||||
let x = W - MARGIN - text_width(&label, 8.0, Font::Regular);
|
||||
p.push_str(&format!(
|
||||
"BT /F1 8 Tf {:.3} {:.3} {:.3} rg {x:.2} 24 Td ({}) Tj ET\n",
|
||||
GRAY.0,
|
||||
GRAY.1,
|
||||
GRAY.2,
|
||||
escape(&label)
|
||||
));
|
||||
}
|
||||
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
||||
let mut offsets: Vec<usize> = vec![];
|
||||
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
// 1 catálogo, 2 páginas, 3-5 fontes, 6 info, depois (página, conteúdo) por página
|
||||
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
let kids: String = (0..total)
|
||||
.map(|i| format!("{} 0 R", 7 + 2 * i))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
obj(
|
||||
&mut out,
|
||||
format!("<< /Type /Pages /Kids [{kids}] /Count {total} >>").as_bytes(),
|
||||
);
|
||||
for name in ["Helvetica", "Helvetica-Bold", "Courier"] {
|
||||
obj(
|
||||
&mut out,
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /{name} /Encoding /WinAnsiEncoding >>"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
let title: String = format!("\u{feff}{}", self.title)
|
||||
.encode_utf16()
|
||||
.map(|u| format!("{u:04X}"))
|
||||
.collect();
|
||||
obj(
|
||||
&mut out,
|
||||
format!(
|
||||
"<< /Title <{title}> /Producer (Guara {}) /CreationDate (D:{date}Z) >>",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for (i, p) in self.pages.iter().enumerate() {
|
||||
obj(
|
||||
&mut out,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents {} 0 R >>",
|
||||
8 + 2 * i
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let mut z = ZlibEncoder::new(vec![], Compression::default());
|
||||
z.write_all(p.as_bytes()).unwrap();
|
||||
let z = z.finish().unwrap();
|
||||
let mut body =
|
||||
format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", z.len()).into_bytes();
|
||||
body.extend_from_slice(&z);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(
|
||||
format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes(),
|
||||
);
|
||||
for o in &offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R /Info 6 0 R >>\nstartxref\n{xref}\n%%EOF\n",
|
||||
offsets.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@ pub struct CaseInfo {
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Situação da trilha de auditoria no momento do relatório.
|
||||
pub struct AuditNote {
|
||||
pub entries: usize,
|
||||
pub ok: bool,
|
||||
/// Hash da última entrada, para guardar fora da máquina.
|
||||
pub head: String,
|
||||
}
|
||||
|
||||
pub struct ReportInput<'a> {
|
||||
pub case: &'a CaseInfo,
|
||||
pub target: &'a str,
|
||||
@@ -30,6 +38,7 @@ pub struct ReportInput<'a> {
|
||||
pub elapsed: f64,
|
||||
pub recoveries: &'a [RecoverReport],
|
||||
pub source_hashes: Option<&'a Hashes>,
|
||||
pub audit: Option<AuditNote>,
|
||||
}
|
||||
|
||||
fn cell(s: &str) -> String {
|
||||
@@ -190,6 +199,15 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
||||
if r.options.include_allocated { ", incluindo áreas em uso" } else { "" }
|
||||
));
|
||||
|
||||
if let Some(a) = &r.audit {
|
||||
o.push_str(&format!(
|
||||
"<h2>Trilha de auditoria</h2><table class=\"kv\"><tr><td>Entradas</td><td>{}</td></tr><tr><td>Encadeamento</td><td>{}</td></tr><tr><td>Hash da última entrada</td><td class=\"mono\">{}</td></tr></table>",
|
||||
a.entries,
|
||||
if a.ok { "íntegro" } else { "QUEBRADO: confira com guara audit --verify" },
|
||||
esc(&a.head)
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(l) = r.layout {
|
||||
o.push_str("<h2>Partições</h2><table><tr><th>#</th><th>Sistema</th><th>Início (bytes)</th><th>Tamanho</th><th>Rótulo</th><th>Observação</th></tr>");
|
||||
for p in &l.partitions {
|
||||
@@ -299,3 +317,361 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
|
||||
o.push_str("</table><footer>Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.</footer></main></body></html>");
|
||||
o
|
||||
}
|
||||
|
||||
/// Relatório em PDF (A4), com as mesmas seções do HTML.
|
||||
pub fn pdf(r: &ReportInput) -> Vec<u8> {
|
||||
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let stamp = crate::audit::utc_iso(now);
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let mut d = Pdf::new(
|
||||
"Guará: relatório de recuperação",
|
||||
&format!("Guará {version}"),
|
||||
);
|
||||
d.title(
|
||||
"Relatório de recuperação de arquivos",
|
||||
&format!("Gerado pelo Guará {version} em {}", fmt_unix(now)),
|
||||
);
|
||||
|
||||
let nf = |v: &str| {
|
||||
if v.is_empty() {
|
||||
"não informado".to_string()
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
};
|
||||
d.heading("Identificação do caso");
|
||||
let mut rows = vec![
|
||||
("Número do caso", nf(&r.case.case_id), Font::Regular),
|
||||
(
|
||||
"Responsável pelo exame",
|
||||
nf(&r.case.examiner),
|
||||
Font::Regular,
|
||||
),
|
||||
("Organização", nf(&r.case.organization), Font::Regular),
|
||||
];
|
||||
if !r.case.notes.is_empty() {
|
||||
rows.push(("Observações", r.case.notes.clone(), Font::Regular));
|
||||
}
|
||||
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
|
||||
d.kv(&rows);
|
||||
|
||||
d.heading("Origem examinada");
|
||||
let mut rows: Vec<(&str, String, Font)> =
|
||||
vec![("Dispositivo / imagem", r.target.to_string(), Font::Mono)];
|
||||
if let Some(l) = r.layout {
|
||||
rows.push((
|
||||
"Tamanho",
|
||||
format!("{} ({} bytes)", human(l.size), l.size),
|
||||
Font::Regular,
|
||||
));
|
||||
rows.push(("Tabela de partições", l.table.to_uppercase(), Font::Regular));
|
||||
}
|
||||
rows.push((
|
||||
"Hash da origem",
|
||||
match r.source_hashes {
|
||||
Some(h) => format!("MD5 {}\nSHA-1 {}\nSHA-256 {}", h.md5, h.sha1, h.sha256),
|
||||
None => "não calculado nesta sessão (use guara hash ou guara image)".into(),
|
||||
},
|
||||
if r.source_hashes.is_some() {
|
||||
Font::Mono
|
||||
} else {
|
||||
Font::Regular
|
||||
},
|
||||
));
|
||||
if let Some(t) = r.started_unix {
|
||||
rows.push(("Início da varredura", fmt_unix(t), Font::Regular));
|
||||
}
|
||||
rows.push((
|
||||
"Duração da varredura",
|
||||
format!("{:.1} s", r.elapsed),
|
||||
Font::Regular,
|
||||
));
|
||||
rows.push((
|
||||
"Modo",
|
||||
format!(
|
||||
"{}{}",
|
||||
if r.options.mode == ScanMode::Deep {
|
||||
"profunda (metadados + assinatura)"
|
||||
} else {
|
||||
"rápida (metadados)"
|
||||
},
|
||||
if r.options.include_allocated {
|
||||
", incluindo áreas em uso"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
Font::Regular,
|
||||
));
|
||||
rows.push((
|
||||
"Garantia de leitura",
|
||||
"A origem foi aberta somente para leitura; nenhuma escrita foi feita nela.".into(),
|
||||
Font::Regular,
|
||||
));
|
||||
let rows: Vec<(&str, &str, Font)> = rows.iter().map(|(k, v, f)| (*k, v.as_str(), *f)).collect();
|
||||
d.kv(&rows);
|
||||
|
||||
if let Some(a) = &r.audit {
|
||||
d.heading("Trilha de auditoria");
|
||||
let ok = if a.ok {
|
||||
"íntegro"
|
||||
} else {
|
||||
"QUEBRADO: confira com guara audit --verify"
|
||||
};
|
||||
d.kv(&[
|
||||
("Entradas", &a.entries.to_string(), Font::Regular),
|
||||
("Encadeamento", ok, Font::Regular),
|
||||
("Hash da última entrada", &a.head, Font::Mono),
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(l) = r.layout {
|
||||
d.heading("Partições");
|
||||
let cols = [
|
||||
Col {
|
||||
title: "#",
|
||||
width: 22.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Sistema",
|
||||
width: 80.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Início (bytes)",
|
||||
width: 90.0,
|
||||
font: Font::Regular,
|
||||
right: true,
|
||||
},
|
||||
Col {
|
||||
title: "Tamanho",
|
||||
width: 65.0,
|
||||
font: Font::Regular,
|
||||
right: true,
|
||||
},
|
||||
Col {
|
||||
title: "Rótulo",
|
||||
width: 90.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Observação",
|
||||
width: 168.28,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
];
|
||||
let mut rows: Vec<Vec<String>> = l
|
||||
.partitions
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let obs = [
|
||||
p.lost.then_some("encontrada por varredura".to_string()),
|
||||
p.encrypted
|
||||
.as_ref()
|
||||
.map(|e| format!("cifrada ({e}), não examinada")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
vec![
|
||||
(p.index + 1).to_string(),
|
||||
p.fs.name().to_string(),
|
||||
p.start.to_string(),
|
||||
human(p.len),
|
||||
p.label.clone().unwrap_or_default(),
|
||||
obs,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
for g in &l.gaps {
|
||||
rows.push(vec![
|
||||
"-".into(),
|
||||
"espaço não particionado".into(),
|
||||
g.start.to_string(),
|
||||
human(g.len),
|
||||
String::new(),
|
||||
String::new(),
|
||||
]);
|
||||
}
|
||||
d.table(&cols, &rows);
|
||||
}
|
||||
|
||||
let mut by_cat = std::collections::BTreeMap::new();
|
||||
let mut conf = [0usize; 3];
|
||||
let mut bytes = 0u64;
|
||||
for f in r.found {
|
||||
*by_cat.entry(f.category.label_pt()).or_insert(0usize) += 1;
|
||||
conf[f.confidence as usize] += 1;
|
||||
bytes += f.size;
|
||||
}
|
||||
d.heading("Resumo dos achados");
|
||||
d.kv(&[
|
||||
(
|
||||
"Arquivos encontrados",
|
||||
&format!("{} ({})", r.found.len(), human(bytes)),
|
||||
Font::Bold,
|
||||
),
|
||||
(
|
||||
"Confiança",
|
||||
&format!(
|
||||
"alta {} · média {} · baixa {}",
|
||||
conf[2], conf[1], conf[0]
|
||||
),
|
||||
Font::Regular,
|
||||
),
|
||||
(
|
||||
"Por tipo",
|
||||
&by_cat
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
Font::Regular,
|
||||
),
|
||||
]);
|
||||
for w in r.warnings {
|
||||
d.note(w);
|
||||
}
|
||||
|
||||
if !r.recoveries.is_empty() {
|
||||
d.heading("Arquivos recuperados (integridade)");
|
||||
for rec in r.recoveries {
|
||||
d.paragraph(
|
||||
&format!(
|
||||
"Destino: {}, {} arquivo(s), {}{}",
|
||||
rec.destination,
|
||||
rec.recovered.len(),
|
||||
human(rec.total_bytes),
|
||||
if rec.failed.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} falha(s)", rec.failed.len())
|
||||
}
|
||||
),
|
||||
9.0,
|
||||
Font::Regular,
|
||||
BLACK,
|
||||
);
|
||||
let cols = [
|
||||
Col {
|
||||
title: "Arquivo",
|
||||
width: 140.0,
|
||||
font: Font::Mono,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Tamanho",
|
||||
width: 55.0,
|
||||
font: Font::Regular,
|
||||
right: true,
|
||||
},
|
||||
Col {
|
||||
title: "Hashes",
|
||||
width: 320.28,
|
||||
font: Font::Mono,
|
||||
right: false,
|
||||
},
|
||||
];
|
||||
let rows: Vec<Vec<String>> = rec
|
||||
.recovered
|
||||
.iter()
|
||||
.take(MAX_ROWS)
|
||||
.map(|f| {
|
||||
vec![
|
||||
f.path.clone(),
|
||||
human(f.size),
|
||||
format!("MD5 {}\nSHA-1 {}\nSHA-256 {}", f.md5, f.sha1, f.sha256),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
d.table(&cols, &rows);
|
||||
}
|
||||
}
|
||||
|
||||
d.heading("Relação de arquivos encontrados");
|
||||
if r.found.len() > MAX_ROWS {
|
||||
d.note(&format!(
|
||||
"Mostrando os primeiros {MAX_ROWS} de {}. Use a exportação CSV para a lista completa.",
|
||||
r.found.len()
|
||||
));
|
||||
}
|
||||
let cols = [
|
||||
Col {
|
||||
title: "Nome",
|
||||
width: 110.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Pasta",
|
||||
width: 110.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Tamanho",
|
||||
width: 50.0,
|
||||
font: Font::Regular,
|
||||
right: true,
|
||||
},
|
||||
Col {
|
||||
title: "Modificado (UTC)",
|
||||
width: 92.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Origem",
|
||||
width: 50.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Conf.",
|
||||
width: 32.0,
|
||||
font: Font::Regular,
|
||||
right: false,
|
||||
},
|
||||
Col {
|
||||
title: "Posição",
|
||||
width: 71.28,
|
||||
font: Font::Mono,
|
||||
right: true,
|
||||
},
|
||||
];
|
||||
|
||||
let rows: Vec<Vec<String>> = r
|
||||
.found
|
||||
.iter()
|
||||
.take(MAX_ROWS)
|
||||
.map(|f| {
|
||||
vec![
|
||||
f.name.clone(),
|
||||
f.dir.clone().unwrap_or_default(),
|
||||
human(f.size),
|
||||
iso(f.modified),
|
||||
f.origin.label().to_string(),
|
||||
f.confidence.label_pt().to_string(),
|
||||
f.first_offset().map(|o| o.to_string()).unwrap_or_default(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
d.table(&cols, &rows);
|
||||
d.paragraph(
|
||||
"Este relatório descreve o que o Guará encontrou e recuperou. Arquivos de confiança baixa podem estar incompletos ou sobrescritos. Datas dos metadados do sistema de arquivos estão em UTC. Guará é software livre, sem garantias.",
|
||||
8.0,
|
||||
Font::Regular,
|
||||
GRAY,
|
||||
);
|
||||
d.finish(&stamp.replace(['-', ':', 'T', 'Z'], ""))
|
||||
}
|
||||
@@ -247,6 +247,13 @@ impl Engine {
|
||||
}
|
||||
s.warnings.extend(asm.notes.iter().cloned());
|
||||
}
|
||||
crate::audit::record(
|
||||
"scan.start",
|
||||
json!({
|
||||
"job": job.id, "source": path, "size": asm.source.size(),
|
||||
"fingerprint": fp, "encrypted_volumes_opened": asm.unlocked.len(),
|
||||
}),
|
||||
);
|
||||
*job.fingerprint.lock().unwrap() = fp;
|
||||
*job.session_file.lock().unwrap() = Some(new_session_path(path));
|
||||
spawn_scan(job.clone(), None);
|
||||
@@ -487,6 +494,13 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?;
|
||||
crate::audit::record(
|
||||
"recover.start",
|
||||
json!({
|
||||
"scan_job": sj.id, "source": sj.target, "destination": dest,
|
||||
"files": files.len(), "bytes": total,
|
||||
}),
|
||||
);
|
||||
let job = self.register(
|
||||
JobKind::Recover,
|
||||
dest.to_string(),
|
||||
@@ -525,20 +539,25 @@ impl Engine {
|
||||
let mut s = j2.st.lock().unwrap();
|
||||
match res {
|
||||
Ok(rep) => {
|
||||
crate::audit::record(
|
||||
"recover.finish",
|
||||
json!({
|
||||
"scan_job": parent.id, "destination": rep.destination,
|
||||
"recovered": rep.recovered.len(), "failed": rep.failed.len(),
|
||||
"skipped": rep.skipped, "bytes": rep.total_bytes,
|
||||
"cancelled": rep.cancelled,
|
||||
}),
|
||||
);
|
||||
s.report = serde_json::to_value(&rep).ok();
|
||||
parent.st.lock().unwrap().recoveries.push(rep.clone());
|
||||
if !rep.cancelled
|
||||
&& s.started.elapsed().as_secs() >= 8
|
||||
&& crate::settings::load().notify
|
||||
{
|
||||
crate::notify::send(
|
||||
"Guará: recuperação concluída",
|
||||
&format!(
|
||||
"{} arquivo(s) salvos em {}",
|
||||
rep.recovered.len(),
|
||||
rep.destination
|
||||
),
|
||||
);
|
||||
crate::notify::send(crate::notify::Notice::RecoverDone {
|
||||
count: rep.recovered.len(),
|
||||
destination: rep.destination.clone(),
|
||||
});
|
||||
}
|
||||
s.status = if rep.cancelled {
|
||||
Status::Stopped
|
||||
@@ -575,6 +594,10 @@ impl Engine {
|
||||
Some(Path::new(path)),
|
||||
Path::new(dest).parent().unwrap_or(Path::new(".")),
|
||||
)?;
|
||||
crate::audit::record(
|
||||
"image.start",
|
||||
json!({ "source": path, "destination": dest, "format": format!("{format:?}").split([' ', '{']).next().unwrap_or("").to_lowercase() }),
|
||||
);
|
||||
let job = self.register(
|
||||
JobKind::Image,
|
||||
dest.to_string(),
|
||||
@@ -596,6 +619,14 @@ impl Engine {
|
||||
let mut s = j2.st.lock().unwrap();
|
||||
match res {
|
||||
Ok(rep) => {
|
||||
crate::audit::record(
|
||||
"image.finish",
|
||||
json!({
|
||||
"destination": dest, "bytes": rep.bytes, "md5": rep.md5,
|
||||
"sha1": rep.sha1, "sha256": rep.sha256,
|
||||
"unreadable_ranges": rep.bad_ranges.len(), "cancelled": rep.cancelled,
|
||||
}),
|
||||
);
|
||||
s.status = if rep.cancelled {
|
||||
Status::Stopped
|
||||
} else {
|
||||
@@ -624,6 +655,10 @@ impl Engine {
|
||||
None => s.found.iter().collect(),
|
||||
};
|
||||
crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?;
|
||||
crate::audit::record(
|
||||
"export.csv",
|
||||
json!({ "job": id, "source": j.target, "path": path, "rows": items.len() }),
|
||||
);
|
||||
Ok(items.len())
|
||||
}
|
||||
|
||||
@@ -640,7 +675,16 @@ impl Engine {
|
||||
let elapsed = s
|
||||
.finished_in
|
||||
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
|
||||
let html = crate::report::html(&crate::report::ReportInput {
|
||||
let audit = crate::audit::Log::open_default()
|
||||
.verify()
|
||||
.ok()
|
||||
.filter(|v| v.entries > 0)
|
||||
.map(|v| crate::report::AuditNote {
|
||||
entries: v.entries,
|
||||
ok: v.ok,
|
||||
head: v.head,
|
||||
});
|
||||
let input = crate::report::ReportInput {
|
||||
case: &case,
|
||||
target: &j.target,
|
||||
options: &j.opts,
|
||||
@@ -651,8 +695,21 @@ impl Engine {
|
||||
elapsed,
|
||||
recoveries: &s.recoveries,
|
||||
source_hashes: hashes.as_ref(),
|
||||
});
|
||||
crate::owner::write_owned(Path::new(path), html)?;
|
||||
audit,
|
||||
};
|
||||
let as_pdf = Path::new(path)
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"));
|
||||
let bytes = if as_pdf {
|
||||
crate::report::pdf(&input)
|
||||
} else {
|
||||
crate::report::html(&input).into_bytes()
|
||||
};
|
||||
crate::owner::write_owned(Path::new(path), bytes)?;
|
||||
crate::audit::record(
|
||||
"export.report",
|
||||
json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -663,6 +720,10 @@ impl Engine {
|
||||
return Err(Error::msg("informe pelo menos um termo"));
|
||||
}
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
crate::audit::record(
|
||||
"search.start",
|
||||
json!({ "source": path, "terms": opts.terms }),
|
||||
);
|
||||
let job = self.register(
|
||||
JobKind::Search,
|
||||
path.to_string(),
|
||||
@@ -755,7 +816,15 @@ impl Engine {
|
||||
} else if pos < size {
|
||||
s.status = Status::Stopped;
|
||||
} else {
|
||||
s.report = serde_json::to_value(h.finalize()).ok();
|
||||
let hashes = h.finalize();
|
||||
crate::audit::record(
|
||||
"hash.finish",
|
||||
json!({
|
||||
"source": j2.target, "bytes": size, "md5": hashes.md5,
|
||||
"sha1": hashes.sha1, "sha256": hashes.sha256,
|
||||
}),
|
||||
);
|
||||
s.report = serde_json::to_value(hashes).ok();
|
||||
s.status = Status::Done;
|
||||
}
|
||||
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||
@@ -767,7 +836,12 @@ impl Engine {
|
||||
|
||||
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
write_session(&j, Path::new(path))
|
||||
write_session(&j, Path::new(path))?;
|
||||
crate::audit::record(
|
||||
"session.save",
|
||||
json!({ "job": id, "source": j.target, "path": path }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_session(&self, path: &str) -> Result<String> {
|
||||
@@ -838,6 +912,10 @@ impl Engine {
|
||||
}
|
||||
// continua salvando automaticamente no mesmo arquivo se a varredura for retomada
|
||||
*job.session_file.lock().unwrap() = Some(PathBuf::from(path));
|
||||
crate::audit::record(
|
||||
"session.load",
|
||||
json!({ "job": job.id, "source": data.target, "path": path }),
|
||||
);
|
||||
if data.done {
|
||||
spawn_enrich(job.clone());
|
||||
}
|
||||
@@ -1025,6 +1103,10 @@ impl Engine {
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let raw = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?;
|
||||
crate::audit::record(
|
||||
"inspect",
|
||||
json!({ "source": path, "size": raw.size(), "fingerprint": crate::source::fingerprint(raw.as_ref()) }),
|
||||
);
|
||||
let a = crate::assemble::assemble_source(raw, &asm)?;
|
||||
let mut layout = partition::analyze(a.source.as_ref(), false);
|
||||
if a.skip_primary {
|
||||
@@ -1300,14 +1382,10 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
s.status = Status::Done;
|
||||
s.checkpoint = None;
|
||||
if s.started.elapsed().as_secs() >= 20 && crate::settings::load().notify {
|
||||
crate::notify::send(
|
||||
"Guará: varredura concluída",
|
||||
&format!(
|
||||
"{} arquivo(s) encontrados em {}.",
|
||||
s.found.len(),
|
||||
job.target
|
||||
),
|
||||
);
|
||||
crate::notify::send(crate::notify::Notice::ScanDone {
|
||||
count: s.found.len(),
|
||||
target: job.target.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Outcome::Interrupted(cp) => {
|
||||
@@ -1317,7 +1395,7 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
s.status = Status::Paused;
|
||||
if cp.io_lost {
|
||||
if crate::settings::load().notify {
|
||||
crate::notify::send("Guará: disco desconectado?", "A varredura foi pausada sem perder nada. Reconecte o disco e continue.");
|
||||
crate::notify::send(crate::notify::Notice::DiskLost);
|
||||
}
|
||||
s.warnings.push("A leitura do dispositivo falhou repetidamente: ele provavelmente foi desconectado. A varredura foi pausada sem perder nada. Reconecte o disco e clique em Continuar.".into());
|
||||
}
|
||||
@@ -1327,6 +1405,13 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
}
|
||||
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||
let done = s.status == Status::Done;
|
||||
crate::audit::record(
|
||||
"scan.finish",
|
||||
json!({
|
||||
"job": job.id, "source": job.target,
|
||||
"status": format!("{:?}", s.status).to_lowercase(), "found": s.found.len(),
|
||||
}),
|
||||
);
|
||||
drop(s);
|
||||
if done {
|
||||
spawn_enrich(job);
|
||||
|
||||
@@ -13,6 +13,10 @@ pub struct Settings {
|
||||
/// Idioma da interface: "auto", "pt" ou "en".
|
||||
#[serde(default = "auto")]
|
||||
pub lang: String,
|
||||
/// Idioma que a interface resolveu de fato ("pt" ou "en"), para as
|
||||
/// notificações do sistema e a saída do terminal acompanharem a tela.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_lang: Option<String>,
|
||||
/// Notificação do sistema quando uma tarefa longa termina.
|
||||
#[serde(default = "yes")]
|
||||
pub notify: bool,
|
||||
@@ -34,6 +38,7 @@ impl Default for Settings {
|
||||
Self {
|
||||
theme: auto(),
|
||||
lang: auto(),
|
||||
ui_lang: None,
|
||||
notify: true,
|
||||
maps: false,
|
||||
}
|
||||
@@ -101,6 +106,30 @@ pub fn load() -> Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Idioma efetivo para textos gerados fora da interface (notificações e CLI).
|
||||
/// Usa o que a interface informou; sem isso, a preferência e o ambiente.
|
||||
pub fn ui_lang() -> String {
|
||||
let s = load();
|
||||
if let Some(l) = s.ui_lang.as_deref() {
|
||||
if l == "pt" || l == "en" {
|
||||
return l.to_string();
|
||||
}
|
||||
}
|
||||
if s.lang == "pt" || s.lang == "en" {
|
||||
return s.lang;
|
||||
}
|
||||
let env = std::env::var("LC_ALL")
|
||||
.or_else(|_| std::env::var("LC_MESSAGES"))
|
||||
.or_else(|_| std::env::var("LANG"))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if env.starts_with("pt") {
|
||||
"pt".into()
|
||||
} else {
|
||||
"en".into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(s: &Settings) -> Result<()> {
|
||||
crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?;
|
||||
Ok(())
|
||||
|
||||
@@ -286,7 +286,13 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
return Ok(Arc::new(e));
|
||||
}
|
||||
Ok(Arc::new(file))
|
||||
drop(file);
|
||||
if let Some(v) = crate::vdisk::open(path)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?
|
||||
{
|
||||
return Ok(Arc::new(v));
|
||||
}
|
||||
Ok(Arc::new(FileSource::open(path)?))
|
||||
}
|
||||
|
||||
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
|
||||
//! zlib e bzip2. Imagens cifradas (`encrcdsa`) e chunks LZFSE (`ULFO`) são recusados com
|
||||
//! mensagem clara.
|
||||
|
||||
use super::{bad, be32, be64, read_exact, read_vec, Backend};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io::{self, Read};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const SECTOR: u64 = 512;
|
||||
|
||||
pub(super) fn is_dmg(file: &FileSource) -> bool {
|
||||
let size = file.size();
|
||||
if size < 1024 {
|
||||
return false;
|
||||
}
|
||||
let mut t = [0u8; 512];
|
||||
read_exact(file, size - SECTOR, &mut t).is_ok()
|
||||
&& &t[..4] == b"koly"
|
||||
&& be32(&t, 4) == 4
|
||||
&& be32(&t, 8) == 512
|
||||
}
|
||||
|
||||
/// Imagem cifrada pelo `hdiutil`: começa com `encrcdsa` ou `cdsaencr`.
|
||||
pub(super) fn is_encrypted(head: &[u8]) -> bool {
|
||||
head.starts_with(b"encrcdsa") || head.starts_with(b"cdsaencr")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Chunk {
|
||||
start: u64,
|
||||
len: u64,
|
||||
ty: u32,
|
||||
off: u64,
|
||||
comp_len: u64,
|
||||
}
|
||||
|
||||
pub(super) struct Dmg {
|
||||
file: FileSource,
|
||||
size: u64,
|
||||
chunks: Vec<Chunk>,
|
||||
cache: Mutex<Option<(usize, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
fn base64(s: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
let (mut acc, mut bits) = (0u32, 0);
|
||||
for c in s.bytes() {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => continue,
|
||||
};
|
||||
acc = acc << 6 | v as u32;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Descompacta ADC (Apple Data Compression).
|
||||
fn adc(src: &[u8], max: usize) -> io::Result<Vec<u8>> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(max);
|
||||
let mut i = 0;
|
||||
while i < src.len() && out.len() < max {
|
||||
let c = src[i];
|
||||
i += 1;
|
||||
if c & 0x80 != 0 {
|
||||
let n = (c & 0x7F) as usize + 1;
|
||||
let lit = src.get(i..i + n).ok_or_else(|| bad("chunk ADC truncado"))?;
|
||||
out.extend_from_slice(lit);
|
||||
i += n;
|
||||
} else {
|
||||
let (len, off) = if c & 0x40 != 0 {
|
||||
let b = src.get(i..i + 2).ok_or_else(|| bad("chunk ADC truncado"))?;
|
||||
i += 2;
|
||||
(
|
||||
(c & 0x3F) as usize + 4,
|
||||
(b[0] as usize) << 8 | b[1] as usize,
|
||||
)
|
||||
} else {
|
||||
let b = *src.get(i).ok_or_else(|| bad("chunk ADC truncado"))?;
|
||||
i += 1;
|
||||
((c >> 2) as usize + 3, ((c & 3) as usize) << 8 | b as usize)
|
||||
};
|
||||
let off = off + 1;
|
||||
if off > out.len() {
|
||||
return Err(bad("referência ADC inválida"));
|
||||
}
|
||||
for _ in 0..len {
|
||||
out.push(out[out.len() - off]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Dmg {
|
||||
pub(super) fn open(file: FileSource) -> io::Result<Self> {
|
||||
let mut head = [0u8; 8];
|
||||
let _ = file.read_at(0, &mut head);
|
||||
if is_encrypted(&head) {
|
||||
return Err(bad(
|
||||
"DMG cifrado (encrcdsa) não é suportado; desproteja a imagem antes",
|
||||
));
|
||||
}
|
||||
let size = file.size();
|
||||
let t = read_vec(&file, size - SECTOR, SECTOR as usize)?;
|
||||
let fork = be64(&t, 24);
|
||||
let xml_off = be64(&t, 216);
|
||||
let xml_len = be64(&t, 224);
|
||||
let sectors = be64(&t, 492);
|
||||
if xml_len == 0 || xml_len > 256 << 20 {
|
||||
return Err(bad("plist do DMG ausente ou grande demais"));
|
||||
}
|
||||
let xml = read_vec(&file, xml_off, xml_len as usize)?;
|
||||
let xml = String::from_utf8_lossy(&xml);
|
||||
let from = xml
|
||||
.find("<key>blkx</key>")
|
||||
.ok_or_else(|| bad("tabela blkx não encontrada no DMG"))?;
|
||||
let rest = &xml[from..];
|
||||
let rest = &rest[..rest.find("</array>").unwrap_or(rest.len())];
|
||||
let mut chunks: Vec<Chunk> = vec![];
|
||||
let mut cur = rest;
|
||||
while let Some(a) = cur.find("<data>") {
|
||||
let after = &cur[a + 6..];
|
||||
let Some(b) = after.find("</data>") else {
|
||||
break;
|
||||
};
|
||||
let mish = base64(&after[..b]);
|
||||
cur = &after[b + 7..];
|
||||
if mish.len() < 204 || &mish[..4] != b"mish" {
|
||||
continue;
|
||||
}
|
||||
let first = be64(&mish, 8);
|
||||
let data_off = be64(&mish, 24);
|
||||
let n = be32(&mish, 200) as usize;
|
||||
for i in 0..n {
|
||||
let Some(c) = mish.get(204 + i * 40..204 + (i + 1) * 40) else {
|
||||
break;
|
||||
};
|
||||
let ty = be32(c, 0);
|
||||
if ty == 0x7FFF_FFFE || ty == 0xFFFF_FFFF {
|
||||
continue;
|
||||
}
|
||||
chunks.push(Chunk {
|
||||
start: (first + be64(c, 8)) * SECTOR,
|
||||
len: be64(c, 16) * SECTOR,
|
||||
ty,
|
||||
off: fork + data_off + be64(c, 24),
|
||||
comp_len: be64(c, 32),
|
||||
});
|
||||
}
|
||||
}
|
||||
if chunks.is_empty() {
|
||||
return Err(bad("DMG sem chunks"));
|
||||
}
|
||||
chunks.sort_by_key(|c| c.start);
|
||||
let end = chunks.iter().map(|c| c.start + c.len).max().unwrap_or(0);
|
||||
Ok(Self {
|
||||
file,
|
||||
size: end.max(sectors * SECTOR),
|
||||
chunks,
|
||||
cache: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(&self, c: &Chunk) -> io::Result<Vec<u8>> {
|
||||
if c.len > 64 << 20 || c.comp_len > 64 << 20 {
|
||||
return Err(bad("chunk de DMG grande demais"));
|
||||
}
|
||||
let raw = read_vec(&self.file, c.off, c.comp_len as usize)?;
|
||||
let mut out = Vec::with_capacity(c.len as usize);
|
||||
match c.ty {
|
||||
0x8000_0004 => out = adc(&raw, c.len as usize)?,
|
||||
0x8000_0005 => {
|
||||
flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
||||
}
|
||||
0x8000_0006 => {
|
||||
bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
||||
}
|
||||
0x8000_0007 => {
|
||||
return Err(bad("chunk LZFSE (formato ULFO) ainda não é suportado"));
|
||||
}
|
||||
t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))),
|
||||
}
|
||||
out.resize(c.len as usize, 0);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Dmg {
|
||||
fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let i = self.chunks.partition_point(|c| c.start + c.len <= voff);
|
||||
let Some(c) = self.chunks.get(i).copied() else {
|
||||
let n = buf.len().min((self.size - voff) as usize);
|
||||
buf[..n].fill(0);
|
||||
return Ok(n);
|
||||
};
|
||||
if voff < c.start {
|
||||
// espaço que nenhuma tabela cobre
|
||||
let n = buf.len().min((c.start - voff) as usize);
|
||||
buf[..n].fill(0);
|
||||
return Ok(n);
|
||||
}
|
||||
let rel = voff - c.start;
|
||||
let n = buf.len().min((c.len - rel) as usize);
|
||||
match c.ty {
|
||||
0 | 2 => buf[..n].fill(0),
|
||||
1 => read_exact(&self.file, c.off + rel, &mut buf[..n])?,
|
||||
_ => {
|
||||
let mut cache = self.cache.lock().unwrap();
|
||||
if cache.as_ref().map(|x| x.0) != Some(i) {
|
||||
*cache = Some((i, self.decode(&c)?));
|
||||
}
|
||||
let data = &cache.as_ref().unwrap().1;
|
||||
buf[..n].copy_from_slice(&data[rel as usize..rel as usize + n]);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Imagens de disco virtual e contêineres: QCOW2, VMDK, VHD, VHDX e DMG (UDIF).
|
||||
//!
|
||||
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
|
||||
//! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos
|
||||
//! diferenciais (VHD e VHDX com pai) seguem a cadeia até o disco base; QCOW2 e VMDK com
|
||||
//! arquivo de apoio são recusados com mensagem clara, porque ler só a camada filha
|
||||
//! devolveria dados errados.
|
||||
|
||||
mod dmg;
|
||||
mod qcow2;
|
||||
mod vhd;
|
||||
mod vhdx;
|
||||
mod vmdk;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn be16(b: &[u8], o: usize) -> u16 {
|
||||
u16::from_be_bytes(b[o..o + 2].try_into().unwrap())
|
||||
}
|
||||
pub(crate) fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
pub(crate) fn be64(b: &[u8], o: usize) -> u64 {
|
||||
u64::from_be_bytes(b[o..o + 8].try_into().unwrap())
|
||||
}
|
||||
pub(crate) fn le16(b: &[u8], o: usize) -> u16 {
|
||||
u16::from_le_bytes(b[o..o + 2].try_into().unwrap())
|
||||
}
|
||||
pub(crate) fn le32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
pub(crate) fn le64(b: &[u8], o: usize) -> u64 {
|
||||
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Leitura dentro de um único bloco do formato; devolve quantos bytes entregou (pode ser
|
||||
/// menos que `buf.len()`, nunca zero antes do fim do disco).
|
||||
pub(crate) trait Backend: Send + Sync {
|
||||
fn size(&self) -> u64;
|
||||
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize>;
|
||||
}
|
||||
|
||||
/// Disco virtual aberto de uma imagem de contêiner.
|
||||
pub struct VDisk {
|
||||
inner: Box<dyn Backend>,
|
||||
format: &'static str,
|
||||
}
|
||||
|
||||
impl VDisk {
|
||||
/// Nome do formato (`QCOW2`, `VMDK`, `VHD`, `VHDX`, `DMG`).
|
||||
pub fn format(&self) -> &'static str {
|
||||
self.format
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for VDisk {
|
||||
fn size(&self) -> u64 {
|
||||
self.inner.size()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let size = self.inner.size();
|
||||
if offset >= size {
|
||||
return Ok(0);
|
||||
}
|
||||
let want = buf.len().min((size - offset) as usize);
|
||||
let mut done = 0;
|
||||
while done < want {
|
||||
let n = self
|
||||
.inner
|
||||
.read_chunk(offset + done as u64, &mut buf[done..want])?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bad(msg: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, msg.into())
|
||||
}
|
||||
|
||||
pub(crate) fn io_err(e: io::Error, what: &str) -> Error {
|
||||
Error::msg(format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
/// Cadeias de arquivos pai (VHD/VHDX diferenciais) não passam deste limite, o que também
|
||||
/// corta ciclos.
|
||||
const MAX_DEPTH: u32 = 16;
|
||||
|
||||
/// Formato de contêiner reconhecido pelos primeiros bytes (e, para VHD fixo e DMG, pelo
|
||||
/// fim do arquivo). `None` quando é um disco bruto.
|
||||
pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
|
||||
if head.starts_with(b"QFI\xfb") {
|
||||
return Some("QCOW2");
|
||||
}
|
||||
if head.starts_with(b"KDMV") || head.starts_with(b"# Disk DescriptorFile") {
|
||||
return Some("VMDK");
|
||||
}
|
||||
if head.starts_with(b"vhdxfile") {
|
||||
return Some("VHDX");
|
||||
}
|
||||
if head.starts_with(b"conectix") || vhd::is_fixed(file) {
|
||||
return Some("VHD");
|
||||
}
|
||||
if dmg::is_encrypted(head) || dmg::is_dmg(file) {
|
||||
return Some("DMG");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Abre `path` como disco virtual, ou `Ok(None)` se não for um contêiner conhecido.
|
||||
pub fn open(path: &Path) -> Result<Option<VDisk>> {
|
||||
open_depth(path, 0)
|
||||
}
|
||||
|
||||
pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::msg("cadeia de discos pai longa demais ou circular"));
|
||||
}
|
||||
let file = FileSource::open(path)?;
|
||||
let mut head = [0u8; 96];
|
||||
let got = file.read_at(0, &mut head).unwrap_or(0);
|
||||
let Some(format) = detect(&file, &head[..got]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let name = format!("{format} {}", path.display());
|
||||
let inner: Box<dyn Backend> = match format {
|
||||
"QCOW2" => Box::new(qcow2::Qcow2::open(file).map_err(|e| io_err(e, &name))?),
|
||||
"VMDK" => Box::new(vmdk::Vmdk::open(file, path).map_err(|e| io_err(e, &name))?),
|
||||
"VHD" => Box::new(vhd::Vhd::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
"VHDX" => Box::new(vhdx::Vhdx::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
_ => Box::new(dmg::Dmg::open(file).map_err(|e| io_err(e, &name))?),
|
||||
};
|
||||
Ok(Some(VDisk { inner, format }))
|
||||
}
|
||||
|
||||
/// Abre o disco pai de um VHD/VHDX diferencial.
|
||||
pub(crate) fn open_parent(
|
||||
candidates: &[std::path::PathBuf],
|
||||
depth: u32,
|
||||
) -> io::Result<Box<dyn Source>> {
|
||||
for p in candidates {
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
|
||||
return Ok(Box::new(v));
|
||||
}
|
||||
let f = FileSource::open(p)?;
|
||||
return Ok(Box::new(f));
|
||||
}
|
||||
Err(bad(format!(
|
||||
"disco pai não encontrado (procurado: {})",
|
||||
candidates
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)))
|
||||
}
|
||||
|
||||
/// Lê de um arquivo por posição e exige todos os bytes.
|
||||
pub(crate) fn read_exact(f: &FileSource, off: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
crate::source::read_exact_at(f, off, buf)
|
||||
}
|
||||
|
||||
pub(crate) fn read_vec(f: &FileSource, off: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut v = vec![0u8; len];
|
||||
read_exact(f, off, &mut v)?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Tabelas em memória têm teto: um cabeçalho adulterado não pode pedir gigabytes.
|
||||
pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Result<usize> {
|
||||
const MAX: u64 = 512 * 1024 * 1024;
|
||||
if entries.saturating_mul(entry_size) > MAX {
|
||||
return Err(bad(format!("{what} grande demais ({entries} entradas)")));
|
||||
}
|
||||
Ok(entries as usize)
|
||||
}
|
||||
|
||||
/// UTF-16 (big ou little endian) até o primeiro nulo.
|
||||
pub(crate) fn utf16(b: &[u8], big: bool) -> String {
|
||||
let units: Vec<u16> = b
|
||||
.chunks_exact(2)
|
||||
.map(|c| if big { be16(c, 0) } else { le16(c, 0) })
|
||||
.take_while(|&u| u != 0)
|
||||
.collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
|
||||
/// Caminhos onde procurar o disco pai: o caminho gravado (relativo ao arquivo filho, com `\`
|
||||
/// virando `/`) e, como alternativa, só o nome do arquivo na mesma pasta.
|
||||
pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::PathBuf> {
|
||||
let mut out: Vec<std::path::PathBuf> = vec![];
|
||||
for n in names {
|
||||
let n = n.trim().replace('\\', "/");
|
||||
let n = n.strip_prefix("file:///").unwrap_or(&n).to_string();
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rel = n.trim_start_matches("./");
|
||||
let full = std::path::PathBuf::from(rel);
|
||||
for p in [
|
||||
if full.is_absolute() {
|
||||
full.clone()
|
||||
} else {
|
||||
dir.join(rel)
|
||||
},
|
||||
dir.join(rel.rsplit('/').next().unwrap_or(rel)),
|
||||
] {
|
||||
if !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lê do disco pai (ou zeros, sem pai); o que o pai não cobre também é zero.
|
||||
pub(crate) fn read_parent(
|
||||
parent: &Option<Box<dyn Source>>,
|
||||
voff: u64,
|
||||
buf: &mut [u8],
|
||||
) -> io::Result<()> {
|
||||
let Some(p) = parent else {
|
||||
buf.fill(0);
|
||||
return Ok(());
|
||||
};
|
||||
let mut done = 0;
|
||||
while done < buf.len() {
|
||||
let n = p.read_at(voff + done as u64, &mut buf[done..])?;
|
||||
if n == 0 {
|
||||
buf[done..].fill(0);
|
||||
break;
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd) e
|
||||
//! clusters zerados. Imagens cifradas, com arquivo de apoio, com arquivo de dados externo ou
|
||||
//! com L2 estendido são recusadas.
|
||||
|
||||
use super::{bad, be32, be64, check_table, read_exact, read_vec, Backend};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io::{self, Read};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00;
|
||||
const COMPRESSED: u64 = 1 << 62;
|
||||
|
||||
pub(super) struct Qcow2 {
|
||||
file: FileSource,
|
||||
size: u64,
|
||||
cluster_bits: u32,
|
||||
l1: Vec<u64>,
|
||||
zstd: bool,
|
||||
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
|
||||
cache: Mutex<Option<(u64, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl Qcow2 {
|
||||
pub(super) fn open(file: FileSource) -> io::Result<Self> {
|
||||
let h = read_vec(&file, 0, 112.min(file.size() as usize))?;
|
||||
if h.len() < 72 {
|
||||
return Err(bad("cabeçalho QCOW2 truncado"));
|
||||
}
|
||||
let version = be32(&h, 4);
|
||||
if version != 2 && version != 3 {
|
||||
return Err(bad(format!("versão QCOW {version} não suportada")));
|
||||
}
|
||||
if be64(&h, 8) != 0 {
|
||||
return Err(bad(
|
||||
"a imagem QCOW2 usa arquivo de apoio (backing file); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let cluster_bits = be32(&h, 20);
|
||||
if !(9..=21).contains(&cluster_bits) {
|
||||
return Err(bad(format!(
|
||||
"tamanho de cluster inválido (2^{cluster_bits})"
|
||||
)));
|
||||
}
|
||||
let size = be64(&h, 24);
|
||||
match be32(&h, 32) {
|
||||
0 => {}
|
||||
1 => return Err(bad("imagem QCOW2 cifrada (AES legado) não é suportada")),
|
||||
_ => return Err(bad("imagem QCOW2 cifrada (LUKS) não é suportada; abra o volume LUKS depois de convertê-la")),
|
||||
}
|
||||
let mut zstd = false;
|
||||
if version == 3 && h.len() >= 108 {
|
||||
let incompat = be64(&h, 72);
|
||||
if incompat & (1 << 2) != 0 {
|
||||
return Err(bad("QCOW2 com arquivo de dados externo não é suportado"));
|
||||
}
|
||||
if incompat & (1 << 4) != 0 {
|
||||
return Err(bad("QCOW2 com L2 estendido não é suportado"));
|
||||
}
|
||||
if incompat & (1 << 3) != 0 {
|
||||
zstd = h[104] == 1;
|
||||
if h[104] > 1 {
|
||||
return Err(bad("tipo de compressão QCOW2 desconhecido"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let l1_size = be32(&h, 36) as u64;
|
||||
let l1_off = be64(&h, 40);
|
||||
let n = check_table(l1_size, 8, "tabela L1")?;
|
||||
let raw = read_vec(&file, l1_off, n * 8)?;
|
||||
let l1 = raw.chunks_exact(8).map(|c| be64(c, 0)).collect();
|
||||
Ok(Self {
|
||||
file,
|
||||
size,
|
||||
cluster_bits,
|
||||
l1,
|
||||
zstd,
|
||||
cache: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn cluster_size(&self) -> u64 {
|
||||
1 << self.cluster_bits
|
||||
}
|
||||
|
||||
fn l2_entry(&self, cluster_idx: u64) -> io::Result<u64> {
|
||||
let per_l2 = self.cluster_size() / 8;
|
||||
let l1_idx = (cluster_idx / per_l2) as usize;
|
||||
let l2_idx = cluster_idx % per_l2;
|
||||
let Some(&l1e) = self.l1.get(l1_idx) else {
|
||||
return Ok(0);
|
||||
};
|
||||
let l2_off = l1e & OFFSET_MASK;
|
||||
if l2_off == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut b = [0u8; 8];
|
||||
read_exact(&self.file, l2_off + l2_idx * 8, &mut b)?;
|
||||
Ok(u64::from_be_bytes(b))
|
||||
}
|
||||
|
||||
fn inflate(&self, entry: u64) -> io::Result<Vec<u8>> {
|
||||
let shift = 62 - (self.cluster_bits - 8);
|
||||
let host = entry & ((1u64 << shift) - 1);
|
||||
let nsect = ((entry >> shift) & ((1u64 << (self.cluster_bits - 8)) - 1)) + 1;
|
||||
let csize = (nsect * 512 - (host & 511)) as usize;
|
||||
let avail = self.file.size().saturating_sub(host) as usize;
|
||||
let raw = read_vec(&self.file, host, csize.min(avail))?;
|
||||
let mut out = Vec::with_capacity(self.cluster_size() as usize);
|
||||
if self.zstd {
|
||||
let mut d = ruzstd::decoding::StreamingDecoder::new(&raw[..])
|
||||
.map_err(|e| bad(format!("cluster zstd inválido: {e}")))?;
|
||||
d.read_to_end(&mut out)?;
|
||||
} else {
|
||||
flate2::read::DeflateDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
||||
}
|
||||
out.resize(self.cluster_size() as usize, 0);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Qcow2 {
|
||||
fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let cs = self.cluster_size();
|
||||
let idx = voff / cs;
|
||||
let within = voff % cs;
|
||||
let n = buf.len().min((cs - within) as usize);
|
||||
let entry = self.l2_entry(idx)?;
|
||||
if entry & COMPRESSED != 0 {
|
||||
let mut cache = self.cache.lock().unwrap();
|
||||
if cache.as_ref().map(|c| c.0) != Some(idx) {
|
||||
*cache = Some((idx, self.inflate(entry)?));
|
||||
}
|
||||
let data = &cache.as_ref().unwrap().1;
|
||||
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
|
||||
return Ok(n);
|
||||
}
|
||||
let off = entry & OFFSET_MASK;
|
||||
if off == 0 || entry & 1 != 0 {
|
||||
buf[..n].fill(0);
|
||||
} else {
|
||||
read_exact(&self.file, off + within, &mut buf[..n])?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! VHD (Microsoft Virtual PC e Hyper-V): discos fixos, dinâmicos e diferenciais. O disco
|
||||
//! diferencial lê o pai (achado pelos localizadores do cabeçalho) nos setores que a camada
|
||||
//! filha não preencheu.
|
||||
|
||||
use super::{
|
||||
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
|
||||
utf16, Backend,
|
||||
};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
const SECTOR: u64 = 512;
|
||||
const NONE: u32 = 0xFFFF_FFFF;
|
||||
|
||||
enum Kind {
|
||||
Fixed,
|
||||
Dynamic {
|
||||
bat: Vec<u32>,
|
||||
block_size: u64,
|
||||
bitmap_size: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct Vhd {
|
||||
file: FileSource,
|
||||
size: u64,
|
||||
kind: Kind,
|
||||
parent: Option<Box<dyn Source>>,
|
||||
}
|
||||
|
||||
fn footer_ok(f: &[u8]) -> bool {
|
||||
if f.len() < 512 || &f[..8] != b"conectix" {
|
||||
return false;
|
||||
}
|
||||
let mut sum = 0u32;
|
||||
for (i, &b) in f[..512].iter().enumerate() {
|
||||
if !(64..68).contains(&i) {
|
||||
sum = sum.wrapping_add(b as u32);
|
||||
}
|
||||
}
|
||||
!sum == be32(f, 64)
|
||||
}
|
||||
|
||||
/// VHD fixo: disco bruto seguido de um rodapé de 512 bytes.
|
||||
pub(super) fn is_fixed(file: &FileSource) -> bool {
|
||||
let size = file.size();
|
||||
if size < 2 * SECTOR {
|
||||
return false;
|
||||
}
|
||||
let mut f = [0u8; 512];
|
||||
read_exact(file, size - SECTOR, &mut f).is_ok()
|
||||
&& footer_ok(&f)
|
||||
&& be32(&f, 60) == 2
|
||||
&& be64(&f, 16) == u64::MAX
|
||||
}
|
||||
|
||||
impl Vhd {
|
||||
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||
let size = file.size();
|
||||
let mut footer = [0u8; 512];
|
||||
let tail_ok = size >= SECTOR && {
|
||||
read_exact(&file, size - SECTOR, &mut footer)?;
|
||||
footer_ok(&footer)
|
||||
};
|
||||
if !tail_ok {
|
||||
read_exact(&file, 0, &mut footer)?;
|
||||
if !footer_ok(&footer) {
|
||||
return Err(bad("rodapé VHD inválido"));
|
||||
}
|
||||
}
|
||||
let current = be64(&footer, 48);
|
||||
let disk_type = be32(&footer, 60);
|
||||
match disk_type {
|
||||
2 => {
|
||||
return Ok(Self {
|
||||
file,
|
||||
size: current,
|
||||
kind: Kind::Fixed,
|
||||
parent: None,
|
||||
});
|
||||
}
|
||||
3 | 4 => {}
|
||||
t => return Err(bad(format!("tipo de VHD {t} desconhecido"))),
|
||||
}
|
||||
let dh = read_vec(&file, be64(&footer, 16), 1024)?;
|
||||
if &dh[..8] != b"cxsparse" {
|
||||
return Err(bad("cabeçalho de disco dinâmico VHD inválido"));
|
||||
}
|
||||
let bat_off = be64(&dh, 16);
|
||||
let entries = be32(&dh, 28) as u64;
|
||||
let block_size = be32(&dh, 32) as u64;
|
||||
if block_size < SECTOR || !block_size.is_power_of_two() {
|
||||
return Err(bad("tamanho de bloco VHD inválido"));
|
||||
}
|
||||
let n = check_table(entries, 4, "tabela de alocação")?;
|
||||
let raw = read_vec(&file, bat_off, n * 4)?;
|
||||
let bat = raw.chunks_exact(4).map(|c| be32(c, 0)).collect();
|
||||
let bitmap_size = (block_size / SECTOR / 8).div_ceil(SECTOR) * SECTOR;
|
||||
let parent = if disk_type == 4 {
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
let mut names = vec![];
|
||||
for i in 0..8 {
|
||||
let o = 576 + i * 24;
|
||||
let code = &dh[o..o + 4];
|
||||
let len = be32(&dh, o + 8) as usize;
|
||||
let off = be64(&dh, o + 16);
|
||||
if matches!(code, b"W2ru" | b"W2ku" | b"Wi2r" | b"Wi2k") && len > 0 && len < 4096 {
|
||||
if let Ok(b) = read_vec(&file, off, len) {
|
||||
names.push(utf16(&b, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
names.push(utf16(&dh[64..64 + 512], true));
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
file,
|
||||
size: current,
|
||||
kind: Kind::Dynamic {
|
||||
bat,
|
||||
block_size,
|
||||
bitmap_size,
|
||||
},
|
||||
parent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Vhd {
|
||||
fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let Kind::Dynamic {
|
||||
bat,
|
||||
block_size,
|
||||
bitmap_size,
|
||||
} = &self.kind
|
||||
else {
|
||||
let n = buf.len().min((self.size - voff) as usize);
|
||||
read_exact(&self.file, voff, &mut buf[..n])?;
|
||||
return Ok(n);
|
||||
};
|
||||
let block = (voff / block_size) as usize;
|
||||
let within = voff % block_size;
|
||||
let mut n = buf.len().min((block_size - within) as usize);
|
||||
let entry = bat.get(block).copied().unwrap_or(NONE);
|
||||
if entry == NONE {
|
||||
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||
return Ok(n);
|
||||
}
|
||||
let base = entry as u64 * SECTOR;
|
||||
let first = within / SECTOR;
|
||||
let last = (within + n as u64 - 1) / SECTOR;
|
||||
let nbytes = (last / 8 - first / 8 + 1) as usize;
|
||||
let bm = read_vec(&self.file, base + first / 8, nbytes)?;
|
||||
let bit = |s: u64| bm[(s / 8 - first / 8) as usize] & (0x80 >> (s % 8)) != 0;
|
||||
let present = bit(first);
|
||||
let mut s = first;
|
||||
while s <= last && bit(s) == present {
|
||||
s += 1;
|
||||
}
|
||||
n = n.min((s * SECTOR - within) as usize);
|
||||
if present {
|
||||
read_exact(&self.file, base + bitmap_size + within, &mut buf[..n])?;
|
||||
} else {
|
||||
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! VHDX (Hyper-V): tabela de regiões, metadados, BAT com blocos de bitmap intercalados,
|
||||
//! discos fixos, dinâmicos e diferenciais (pai achado pelo localizador de pai).
|
||||
//!
|
||||
//! O log de transações não é reproduzido: abrir a imagem nunca a escreve. Se o disco foi
|
||||
//! desligado no meio de uma escrita, os dados ainda no log não aparecem.
|
||||
|
||||
use super::{
|
||||
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
|
||||
read_vec, utf16, Backend,
|
||||
};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
const MB: u64 = 1 << 20;
|
||||
|
||||
/// GUID em texto para os 16 bytes gravados em disco (três primeiros campos little-endian).
|
||||
fn guid(s: &str) -> [u8; 16] {
|
||||
let hex: Vec<u8> = s
|
||||
.replace('-', "")
|
||||
.as_bytes()
|
||||
.chunks(2)
|
||||
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
|
||||
.collect();
|
||||
let mut g = [0u8; 16];
|
||||
g[..4].copy_from_slice(&[hex[3], hex[2], hex[1], hex[0]]);
|
||||
g[4..6].copy_from_slice(&[hex[5], hex[4]]);
|
||||
g[6..8].copy_from_slice(&[hex[7], hex[6]]);
|
||||
g[8..].copy_from_slice(&hex[8..]);
|
||||
g
|
||||
}
|
||||
|
||||
pub(super) struct Vhdx {
|
||||
file: FileSource,
|
||||
size: u64,
|
||||
block_size: u64,
|
||||
sector_size: u64,
|
||||
chunk_ratio: u64,
|
||||
bat: Vec<u64>,
|
||||
parent: Option<Box<dyn Source>>,
|
||||
}
|
||||
|
||||
impl Vhdx {
|
||||
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||
let rt = read_vec(&file, 192 * 1024, 64 * 1024)?;
|
||||
if &rt[..4] != b"regi" {
|
||||
return Err(bad("tabela de regiões VHDX inválida"));
|
||||
}
|
||||
let count = le32(&rt, 8).min(2047) as usize;
|
||||
let (mut bat_at, mut meta_at) = (None, None);
|
||||
for i in 0..count {
|
||||
let e = &rt[16 + i * 32..16 + (i + 1) * 32];
|
||||
let region = (le64(e, 16), le32(e, 24) as u64);
|
||||
if e[..16] == guid("2DC27766-F623-4200-9D64-115E9BFD4A08") {
|
||||
bat_at = Some(region);
|
||||
} else if e[..16] == guid("8B7CA206-4790-4B9A-B8FE-575F050F886E") {
|
||||
meta_at = Some(region);
|
||||
}
|
||||
}
|
||||
let (Some((bat_off, bat_len)), Some((meta_off, meta_len))) = (bat_at, meta_at) else {
|
||||
return Err(bad("regiões BAT e metadados não encontradas"));
|
||||
};
|
||||
let meta = read_vec(&file, meta_off, meta_len.min(MB) as usize)?;
|
||||
if &meta[..8] != b"metadata" {
|
||||
return Err(bad("região de metadados VHDX inválida"));
|
||||
}
|
||||
let items = le16(&meta, 10) as usize;
|
||||
let find = |id: [u8; 16]| -> Option<&[u8]> {
|
||||
(0..items).find_map(|i| {
|
||||
let e = meta.get(32 + i * 32..32 + (i + 1) * 32)?;
|
||||
if e[..16] != id {
|
||||
return None;
|
||||
}
|
||||
let (o, l) = (le32(e, 16) as usize, le32(e, 20) as usize);
|
||||
meta.get(o..o + l)
|
||||
})
|
||||
};
|
||||
let params = find(guid("CAA16737-FA36-4D43-B3B6-33F0AA44E76B"))
|
||||
.filter(|p| p.len() >= 8)
|
||||
.ok_or_else(|| bad("parâmetros do arquivo VHDX ausentes"))?;
|
||||
let block_size = le32(params, 0) as u64;
|
||||
let has_parent = le32(params, 4) & 2 != 0;
|
||||
let size = find(guid("2FA54224-CD1B-4876-B211-5DBED83BF4B8"))
|
||||
.filter(|p| p.len() >= 8)
|
||||
.map(|p| le64(p, 0))
|
||||
.ok_or_else(|| bad("tamanho do disco VHDX ausente"))?;
|
||||
let sector_size = find(guid("8141BF1D-A96F-4709-BA47-F233A8FAAB5F"))
|
||||
.filter(|p| p.len() >= 4)
|
||||
.map(|p| le32(p, 0) as u64)
|
||||
.unwrap_or(512);
|
||||
if block_size < MB || !block_size.is_power_of_two() || !matches!(sector_size, 512 | 4096) {
|
||||
return Err(bad("parâmetros VHDX inválidos"));
|
||||
}
|
||||
let chunk_ratio = (1u64 << 23) * sector_size / block_size;
|
||||
let payload = size.div_ceil(block_size);
|
||||
// Com pai, cada trecho termina com a entrada do bitmap de setores, mesmo que o disco acabe antes.
|
||||
let want = if has_parent {
|
||||
payload.div_ceil(chunk_ratio) * (chunk_ratio + 1)
|
||||
} else {
|
||||
payload + payload.div_ceil(chunk_ratio)
|
||||
};
|
||||
let n = check_table(want.min(bat_len / 8), 8, "BAT")?;
|
||||
let raw = read_vec(&file, bat_off, n * 8)?;
|
||||
let bat = raw.chunks_exact(8).map(|c| le64(c, 0)).collect();
|
||||
|
||||
let parent = if has_parent {
|
||||
let loc = find(guid("A8D35F2D-B30B-454D-ABF7-D3D84834AB0C"))
|
||||
.ok_or_else(|| bad("localizador de pai VHDX ausente"))?;
|
||||
let count = if loc.len() >= 20 {
|
||||
le16(loc, 18) as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut names = vec![];
|
||||
for i in 0..count {
|
||||
let Some(e) = loc.get(20 + i * 12..20 + (i + 1) * 12) else {
|
||||
break;
|
||||
};
|
||||
let (ko, vo) = (le32(e, 0) as usize, le32(e, 4) as usize);
|
||||
let (ks, vs) = (le16(e, 8) as usize, le16(e, 10) as usize);
|
||||
let (Some(k), Some(v)) = (loc.get(ko..ko + ks), loc.get(vo..vo + vs)) else {
|
||||
continue;
|
||||
};
|
||||
let key = utf16(k, false);
|
||||
if matches!(
|
||||
key.as_str(),
|
||||
"relative_path" | "absolute_win32_path" | "volume_path"
|
||||
) {
|
||||
names.push(utf16(v, false));
|
||||
}
|
||||
}
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
file,
|
||||
size,
|
||||
block_size,
|
||||
sector_size,
|
||||
chunk_ratio,
|
||||
bat,
|
||||
parent,
|
||||
})
|
||||
}
|
||||
|
||||
fn bat(&self, i: u64) -> u64 {
|
||||
self.bat.get(i as usize).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Vhdx {
|
||||
fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let block = voff / self.block_size;
|
||||
let within = voff % self.block_size;
|
||||
let mut n = buf.len().min((self.block_size - within) as usize);
|
||||
let e = self.bat(block + block / self.chunk_ratio);
|
||||
let off = e & 0xFFFF_FFFF_FFF0_0000;
|
||||
match e & 7 {
|
||||
6 => {
|
||||
read_exact(&self.file, off + within, &mut buf[..n])?;
|
||||
}
|
||||
7 => {
|
||||
// Parcialmente presente: o bitmap do trecho diz, setor a setor, onde está o dado.
|
||||
let chunk = block / self.chunk_ratio;
|
||||
let bm_entry = self.bat(chunk * (self.chunk_ratio + 1) + self.chunk_ratio);
|
||||
let ss = self.sector_size;
|
||||
let per_block = self.block_size / ss;
|
||||
let first = within / ss;
|
||||
let last = (within + n as u64 - 1) / ss;
|
||||
let base_bit = (block % self.chunk_ratio) * per_block;
|
||||
let bm_off = bm_entry & 0xFFFF_FFFF_FFF0_0000;
|
||||
if bm_entry & 7 != 6 {
|
||||
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||
return Ok(n);
|
||||
}
|
||||
let b0 = (base_bit + first) / 8;
|
||||
let b1 = (base_bit + last) / 8;
|
||||
let bm = read_vec(&self.file, bm_off + b0, (b1 - b0 + 1) as usize)?;
|
||||
let bit = |s: u64| {
|
||||
let p = base_bit + s;
|
||||
bm[(p / 8 - b0) as usize] >> (p % 8) & 1 == 1
|
||||
};
|
||||
let present = bit(first);
|
||||
let mut s = first;
|
||||
while s <= last && bit(s) == present {
|
||||
s += 1;
|
||||
}
|
||||
n = n.min((s * ss - within) as usize);
|
||||
if present {
|
||||
read_exact(&self.file, off + within, &mut buf[..n])?;
|
||||
} else {
|
||||
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||
}
|
||||
}
|
||||
2 | 3 => buf[..n].fill(0),
|
||||
_ => read_parent(&self.parent, voff, &mut buf[..n])?,
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
|
||||
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
|
||||
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) e o formato SEsparse
|
||||
//! são recusados.
|
||||
|
||||
use super::{bad, check_table, le16, le32, le64, read_exact, read_vec, Backend};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const SECTOR: u64 = 512;
|
||||
const GD_AT_END: u64 = u64::MAX;
|
||||
|
||||
struct Sparse {
|
||||
file: FileSource,
|
||||
grain: u64,
|
||||
gtes: u64,
|
||||
gd: Vec<u32>,
|
||||
compressed: bool,
|
||||
cache: Mutex<Option<(u64, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
Zero,
|
||||
Flat { file: FileSource, offset: u64 },
|
||||
Sparse(Sparse),
|
||||
}
|
||||
|
||||
struct Extent {
|
||||
start: u64,
|
||||
len: u64,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
pub(super) struct Vmdk {
|
||||
extents: Vec<Extent>,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn parent_in(descriptor: &str) -> bool {
|
||||
descriptor.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.strip_prefix("parentCID")
|
||||
.and_then(|r| r.trim_start().strip_prefix('='))
|
||||
.is_some_and(|v| {
|
||||
let v = v.trim().trim_matches('"');
|
||||
!v.is_empty() && !v.eq_ignore_ascii_case("ffffffff")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl Sparse {
|
||||
fn open(file: FileSource) -> io::Result<(Self, u64, Option<String>)> {
|
||||
let mut h = read_vec(&file, 0, SECTOR as usize)?;
|
||||
if &h[..4] == b"SESp" || &h[..4] == b"seSp" {
|
||||
return Err(bad("VMDK no formato SEsparse (vSphere) não é suportado"));
|
||||
}
|
||||
if &h[..4] != b"KDMV" {
|
||||
return Err(bad("extensão VMDK sem assinatura KDMV"));
|
||||
}
|
||||
if le64(&h, 56) == GD_AT_END {
|
||||
let size = file.size();
|
||||
if size < 3 * SECTOR {
|
||||
return Err(bad("VMDK truncado"));
|
||||
}
|
||||
h = read_vec(&file, size - 2 * SECTOR, SECTOR as usize)?;
|
||||
if &h[..4] != b"KDMV" {
|
||||
return Err(bad("rodapé do VMDK streamOptimized ausente"));
|
||||
}
|
||||
}
|
||||
let flags = le32(&h, 8);
|
||||
let capacity = le64(&h, 12);
|
||||
let grain = le64(&h, 20);
|
||||
let desc_off = le64(&h, 28);
|
||||
let desc_len = le64(&h, 36);
|
||||
let gtes = le32(&h, 44) as u64;
|
||||
let gd_off = le64(&h, 56);
|
||||
let algo = le16(&h, 77);
|
||||
if grain == 0 || !grain.is_power_of_two() || gtes == 0 {
|
||||
return Err(bad("cabeçalho VMDK inválido"));
|
||||
}
|
||||
let compressed = flags & (1 << 16) != 0;
|
||||
if compressed && algo != 1 {
|
||||
return Err(bad("compressão de grão VMDK desconhecida"));
|
||||
}
|
||||
let descriptor = if desc_off != 0 && desc_len != 0 {
|
||||
let raw = read_vec(
|
||||
&file,
|
||||
desc_off * SECTOR,
|
||||
(desc_len * SECTOR).min(1 << 20) as usize,
|
||||
)?;
|
||||
let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
|
||||
Some(String::from_utf8_lossy(&raw[..end]).into_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if descriptor.as_deref().is_some_and(parent_in) {
|
||||
return Err(bad(
|
||||
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let gde = capacity.div_ceil(gtes * grain);
|
||||
let n = check_table(gde, 4, "diretório de grãos")?;
|
||||
let raw = read_vec(&file, gd_off * SECTOR, n * 4)?;
|
||||
let gd = raw.chunks_exact(4).map(|c| le32(c, 0)).collect();
|
||||
Ok((
|
||||
Self {
|
||||
file,
|
||||
grain: grain * SECTOR,
|
||||
gtes,
|
||||
gd,
|
||||
compressed,
|
||||
cache: Mutex::new(None),
|
||||
},
|
||||
capacity * SECTOR,
|
||||
descriptor,
|
||||
))
|
||||
}
|
||||
|
||||
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let g = off / self.grain;
|
||||
let within = off % self.grain;
|
||||
let n = buf.len().min((self.grain - within) as usize);
|
||||
let gt_sector = self.gd.get((g / self.gtes) as usize).copied().unwrap_or(0) as u64;
|
||||
let mut sector = 0u64;
|
||||
if gt_sector > 1 {
|
||||
let mut b = [0u8; 4];
|
||||
read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?;
|
||||
sector = u32::from_le_bytes(b) as u64;
|
||||
}
|
||||
if sector <= 1 {
|
||||
buf[..n].fill(0);
|
||||
return Ok(n);
|
||||
}
|
||||
if !self.compressed {
|
||||
read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?;
|
||||
return Ok(n);
|
||||
}
|
||||
let mut cache = self.cache.lock().unwrap();
|
||||
if cache.as_ref().map(|c| c.0) != Some(g) {
|
||||
let mut m = [0u8; 12];
|
||||
read_exact(&self.file, sector * SECTOR, &mut m)?;
|
||||
let csize = le32(&m, 8) as usize;
|
||||
if csize == 0 || csize > self.grain as usize * 2 {
|
||||
return Err(bad("grão VMDK comprimido com tamanho inválido"));
|
||||
}
|
||||
let raw = read_vec(&self.file, sector * SECTOR + 12, csize)?;
|
||||
let mut out = Vec::with_capacity(self.grain as usize);
|
||||
flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
||||
out.resize(self.grain as usize, 0);
|
||||
*cache = Some((g, out));
|
||||
}
|
||||
let data = &cache.as_ref().unwrap().1;
|
||||
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
|
||||
fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
|
||||
if parent_in(text) {
|
||||
return Err(bad(
|
||||
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut start = 0u64;
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
let mut it = l.split_whitespace();
|
||||
let (Some(access), Some(sectors), Some(kind)) = (it.next(), it.next(), it.next()) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(access, "RW" | "RDONLY" | "NOACCESS") {
|
||||
continue;
|
||||
}
|
||||
let Ok(sectors) = sectors.parse::<u64>() else {
|
||||
continue;
|
||||
};
|
||||
let len = sectors * SECTOR;
|
||||
let name = match (l.find('"'), l.rfind('"')) {
|
||||
(Some(a), Some(b)) if b > a => Some(&l[a + 1..b]),
|
||||
_ => None,
|
||||
};
|
||||
let kind = match kind {
|
||||
"ZERO" => Kind::Zero,
|
||||
"FLAT" | "VMFS" => {
|
||||
let name = name.ok_or_else(|| bad("extensão FLAT sem nome de arquivo"))?;
|
||||
let offset = l
|
||||
.rsplit_once('"')
|
||||
.and_then(|(_, r)| r.trim().parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
Kind::Flat {
|
||||
file: FileSource::open(&dir.join(name))?,
|
||||
offset: offset * SECTOR,
|
||||
}
|
||||
}
|
||||
"SPARSE" => {
|
||||
let name = name.ok_or_else(|| bad("extensão SPARSE sem nome de arquivo"))?;
|
||||
let (sp, cap, _) = Sparse::open(FileSource::open(&dir.join(name))?)?;
|
||||
if cap != len {
|
||||
return Err(bad("tamanho da extensão VMDK difere do descritor"));
|
||||
}
|
||||
Kind::Sparse(sp)
|
||||
}
|
||||
other => return Err(bad(format!("tipo de extensão VMDK {other} não suportado"))),
|
||||
};
|
||||
out.push(Extent { start, len, kind });
|
||||
start += len;
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad("descritor VMDK sem extensões"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Vmdk {
|
||||
pub(super) fn open(file: FileSource, path: &Path) -> io::Result<Self> {
|
||||
let mut head = [0u8; 4];
|
||||
let _ = file.read_at(0, &mut head);
|
||||
let extents = if &head == b"KDMV" {
|
||||
let (sp, size, _) = Sparse::open(file)?;
|
||||
vec![Extent {
|
||||
start: 0,
|
||||
len: size,
|
||||
kind: Kind::Sparse(sp),
|
||||
}]
|
||||
} else {
|
||||
if file.size() > 1 << 20 {
|
||||
return Err(bad("descritor VMDK grande demais"));
|
||||
}
|
||||
let raw = read_vec(&file, 0, file.size() as usize)?;
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
parse_descriptor(&text, dir)?
|
||||
};
|
||||
let size = extents.iter().map(|e| e.len).sum();
|
||||
Ok(Self { extents, size })
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Vmdk {
|
||||
fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let i = self.extents.partition_point(|e| e.start + e.len <= voff);
|
||||
let Some(e) = self.extents.get(i) else {
|
||||
return Ok(0);
|
||||
};
|
||||
let rel = voff - e.start;
|
||||
let n = buf.len().min((e.len - rel) as usize);
|
||||
match &e.kind {
|
||||
Kind::Zero => {
|
||||
buf[..n].fill(0);
|
||||
Ok(n)
|
||||
}
|
||||
Kind::Flat { file, offset } => {
|
||||
read_exact(file, offset + rel, &mut buf[..n])?;
|
||||
Ok(n)
|
||||
}
|
||||
Kind::Sparse(s) => s.read(rel, &mut buf[..n]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use guara_core::fs::apfs::{fletcher64, live_paths};
|
||||
use guara_core::source::FileSource;
|
||||
use guara_core::fs::apfs::{fletcher64, free_extents, live_paths};
|
||||
use guara_core::source::{FileSource, Source};
|
||||
use guara_core::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -26,6 +26,17 @@ fn reads_the_live_tree_of_the_reference_image() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_space_map_matches_the_spaceman() {
|
||||
let Some(p) = img() else { return };
|
||||
let src = FileSource::open(&p).unwrap();
|
||||
let free = free_extents(&src, 0).expect("mapa de espaço livre");
|
||||
let bytes: u64 = free.iter().map(|e| e.len).sum();
|
||||
assert_eq!(bytes, 904 * 4096);
|
||||
assert_eq!(free.first().unwrap().offset, 110 * 4096);
|
||||
assert!(free.iter().all(|e| e.end() <= src.size()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_matches_real_blocks() {
|
||||
let Some(p) = img() else { return };
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Trilha de auditoria: encadeamento por hash, detecção de adulteração e registro das ações do motor.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use guara_core::audit::Log;
|
||||
use guara_core::{service, ScanOptions};
|
||||
use serde_json::json;
|
||||
|
||||
fn log_with(n: usize) -> (tempfile::TempDir, Log) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = Log::at(dir.path().join("sub/auditoria.jsonl"));
|
||||
for i in 0..n {
|
||||
log.append("teste", json!({ "i": i, "caminho": "/dev/sdb" }))
|
||||
.unwrap();
|
||||
}
|
||||
(dir, log)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_are_numbered_and_chained() {
|
||||
let (_d, log) = log_with(4);
|
||||
let e = log.entries().unwrap();
|
||||
assert_eq!(e.iter().map(|x| x.seq).collect::<Vec<_>>(), [1, 2, 3, 4]);
|
||||
assert_eq!(e[0].prev, "0".repeat(64));
|
||||
for w in e.windows(2) {
|
||||
assert_eq!(w[1].prev, w[0].hash);
|
||||
}
|
||||
assert!(e[0].time.ends_with('Z') && e[0].time.len() == 20);
|
||||
let v = log.verify().unwrap();
|
||||
assert!(v.ok && v.entries == 4 && v.head == e[3].hash);
|
||||
// continua de onde parou, inclusive numa instância nova
|
||||
let again = Log::at(log.path());
|
||||
assert_eq!(again.append("mais", json!({})).unwrap().seq, 5);
|
||||
assert!(again.verify().unwrap().ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_missing_log_is_valid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = Log::at(dir.path().join("nada.jsonl"));
|
||||
let v = log.verify().unwrap();
|
||||
assert!(v.ok && v.entries == 0);
|
||||
assert!(log.entries().unwrap().is_empty());
|
||||
}
|
||||
|
||||
fn tamper(log: &Log, f: impl FnOnce(&mut Vec<String>)) {
|
||||
let text = std::fs::read_to_string(log.path()).unwrap();
|
||||
let mut lines: Vec<String> = text.lines().map(String::from).collect();
|
||||
f(&mut lines);
|
||||
std::fs::write(log.path(), lines.join("\n") + "\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_line_breaks_the_chain_at_that_line() {
|
||||
let (_d, log) = log_with(5);
|
||||
tamper(&log, |l| l[2] = l[2].replace("/dev/sdb", "/dev/sdc"));
|
||||
let v = log.verify().unwrap();
|
||||
assert!(!v.ok);
|
||||
let (n, why) = v.broken_at.unwrap();
|
||||
assert_eq!(n, 3, "{why}");
|
||||
assert!(why.contains("hash"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_or_reordering_lines_is_detected() {
|
||||
let (_d, log) = log_with(5);
|
||||
tamper(&log, |l| {
|
||||
l.remove(1);
|
||||
});
|
||||
let (n, why) = log.verify().unwrap().broken_at.unwrap();
|
||||
assert_eq!(n, 2, "{why}");
|
||||
|
||||
let (_d, log) = log_with(5);
|
||||
tamper(&log, |l| l.swap(1, 2));
|
||||
assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 2);
|
||||
|
||||
// trocar o hash por outro também não passa: o seguinte deixa de continuar
|
||||
let (_d, log) = log_with(3);
|
||||
tamper(&log, |l| {
|
||||
let h = l[0].rsplit('"').nth(1).unwrap().to_string();
|
||||
l[0] = l[0].replace(&h, &"a".repeat(64));
|
||||
});
|
||||
assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_line_is_reported_not_skipped() {
|
||||
let (_d, log) = log_with(3);
|
||||
tamper(&log, |l| l[1] = "isto não é json".into());
|
||||
let (n, why) = log.verify().unwrap().broken_at.unwrap();
|
||||
assert_eq!((n, why.as_str()), (2, "linha ilegível"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_actions_are_recorded_without_secrets() {
|
||||
init();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let img = dir.path().join("disco.img");
|
||||
std::fs::write(&img, vec![0u8; 2 << 20]).unwrap();
|
||||
let eng = service::Engine::new();
|
||||
let job = eng
|
||||
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||
.unwrap();
|
||||
wait(&eng, &job);
|
||||
let hash = eng.start_hash(img.to_str().unwrap()).unwrap();
|
||||
wait(&eng, &hash);
|
||||
let csv = dir.path().join("achados.csv");
|
||||
eng.export_csv(&job, csv.to_str().unwrap(), None).unwrap();
|
||||
eng.save_session(&job, dir.path().join("s.guara").to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let log = Log::open_default();
|
||||
let e = log.entries().unwrap();
|
||||
let actions: Vec<&str> = e.iter().map(|x| x.action.as_str()).collect();
|
||||
for want in [
|
||||
"scan.start",
|
||||
"scan.finish",
|
||||
"hash.finish",
|
||||
"export.csv",
|
||||
"session.save",
|
||||
] {
|
||||
assert!(actions.contains(&want), "{want} ausente em {actions:?}");
|
||||
}
|
||||
let start = e.iter().find(|x| x.action == "scan.start").unwrap();
|
||||
assert_eq!(start.detail["source"], img.to_str().unwrap());
|
||||
assert_eq!(start.detail["size"], 2 << 20);
|
||||
let h = e.iter().find(|x| x.action == "hash.finish").unwrap();
|
||||
assert_eq!(h.detail["sha256"].as_str().unwrap().len(), 64);
|
||||
assert!(log.verify().unwrap().ok);
|
||||
let all = std::fs::read_to_string(log.path()).unwrap();
|
||||
assert!(!all.contains("password") && !all.contains("senha"));
|
||||
}
|
||||
|
||||
fn wait(eng: &service::Engine, job: &str) {
|
||||
for _ in 0..1000 {
|
||||
if eng.status(job).unwrap()["status"] != "running" {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("job não terminou");
|
||||
}
|
||||
@@ -139,6 +139,7 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
|
||||
settings::save(&settings::Settings {
|
||||
theme: "light".into(),
|
||||
lang: "en".into(),
|
||||
ui_lang: Some("en".into()),
|
||||
notify: false,
|
||||
maps: true,
|
||||
})
|
||||
@@ -167,7 +168,10 @@ fn notificacao_chama_o_notify_send() {
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let old = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
|
||||
notify::send("Guará: varredura concluída", "7 arquivos");
|
||||
notify::send(notify::Notice::ScanDone {
|
||||
count: 7,
|
||||
target: "disco".into(),
|
||||
});
|
||||
for _ in 0..100 {
|
||||
if marker.exists() && !std::fs::read(&marker).unwrap().is_empty() {
|
||||
break;
|
||||
@@ -176,8 +180,10 @@ fn notificacao_chama_o_notify_send() {
|
||||
}
|
||||
std::env::set_var("PATH", old);
|
||||
let got = String::from_utf8(std::fs::read(&marker).unwrap()).unwrap();
|
||||
assert!(got.contains("Guará: "), "{got}");
|
||||
assert!(
|
||||
got.contains("Guará: varredura concluída") && got.contains("7 arquivos"),
|
||||
got.contains("7 arquivo(s) encontrados em disco.")
|
||||
|| got.contains("7 file(s) found in disco."),
|
||||
"{got}"
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -151,7 +151,7 @@ fn exif_camera_data_e_gps() {
|
||||
assert!(m
|
||||
.fields
|
||||
.iter()
|
||||
.any(|(k, v)| k == "Foto tirada em" && v == "14/07/2023 18:22:05"));
|
||||
.any(|(k, v)| k == "Foto tirada em" && v == "2023-07-14T18:22:05"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -199,7 +199,7 @@ fn metadados_de_pdf() {
|
||||
assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva"));
|
||||
assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer"));
|
||||
assert_eq!(get("Gerado por").as_deref(), Some("PDF12"));
|
||||
assert_eq!(get("Criado em").as_deref(), Some("14/07/2023 18:22:05"));
|
||||
assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05"));
|
||||
assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12"));
|
||||
}
|
||||
|
||||
@@ -279,10 +279,7 @@ fn metadados_de_documentos_office() {
|
||||
);
|
||||
assert_eq!(get("Autor").as_deref(), Some("Ana Souza"));
|
||||
assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima"));
|
||||
assert_eq!(
|
||||
get("Criado em").as_deref(),
|
||||
Some("14/07/2023 18:22:05 (UTC)")
|
||||
);
|
||||
assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05Z"));
|
||||
assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word"));
|
||||
assert_eq!(get("Empresa").as_deref(), Some("ACME"));
|
||||
}
|
||||
@@ -323,7 +320,7 @@ fn metadados_de_mp4() {
|
||||
assert_eq!(get("Duração").as_deref(), Some("0:02:05"));
|
||||
assert_eq!(
|
||||
get("Gravado em (metadado)").as_deref(),
|
||||
Some("14/07/2023 18:22:05 (UTC)")
|
||||
Some("2023-07-14T18:22:05Z")
|
||||
);
|
||||
assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
|
||||
}
|
||||
@@ -420,8 +417,14 @@ fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
|
||||
elapsed: 1.5,
|
||||
recoveries: &[],
|
||||
source_hashes: Some(&hashes),
|
||||
audit: Some(report::AuditNote {
|
||||
entries: 3,
|
||||
ok: true,
|
||||
head: "ab".repeat(32),
|
||||
}),
|
||||
});
|
||||
assert!(!html.contains("<script>alert"), "HTML não escapado");
|
||||
assert!(html.contains("Trilha de auditoria") && html.contains(&"ab".repeat(32)));
|
||||
assert!(html.contains("<script>alert(1)</script>.txt"));
|
||||
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
||||
assert!(html.contains(&hashes.sha256));
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Relatório em PDF: estrutura do arquivo (xref, páginas), texto com acentos e paginação.
|
||||
|
||||
use guara_core::recover::{RecoverReport, RecoveredFile};
|
||||
use guara_core::report::{self, AuditNote, CaseInfo, ReportInput};
|
||||
use guara_core::*;
|
||||
use std::io::Read;
|
||||
|
||||
fn found(i: u64) -> Found {
|
||||
Found {
|
||||
id: i,
|
||||
name: format!("fotografia_{i:04}.jpg"),
|
||||
dir: Some("/Usuários/João/Imagens (cópia)".into()),
|
||||
category: Category::Image,
|
||||
ext: "jpg".into(),
|
||||
size: 1000 + i,
|
||||
origin: Origin::Carved,
|
||||
confidence: Confidence::High,
|
||||
data: DataLoc::Resident { bytes: vec![0; 4] },
|
||||
modified: Some(1_689_358_925),
|
||||
created: None,
|
||||
accessed: None,
|
||||
taken: None,
|
||||
camera: None,
|
||||
px: None,
|
||||
gps: false,
|
||||
sig: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build(n: u64) -> Vec<u8> {
|
||||
let files: Vec<Found> = (0..n).map(found).collect();
|
||||
let rec = RecoverReport {
|
||||
destination: "/mnt/outro/Recuperados".into(),
|
||||
recovered: (0..3)
|
||||
.map(|i| RecoveredFile {
|
||||
id: i,
|
||||
path: format!("Fotos/fotografia_{i:04}.jpg"),
|
||||
size: 1000 + i,
|
||||
sha256: "ab".repeat(32),
|
||||
md5: "cd".repeat(16),
|
||||
sha1: "ef".repeat(20),
|
||||
confidence: Confidence::High,
|
||||
origin: Origin::Carved,
|
||||
partial: false,
|
||||
})
|
||||
.collect(),
|
||||
total_bytes: 3003,
|
||||
..Default::default()
|
||||
};
|
||||
let case = CaseInfo {
|
||||
case_id: "2026-001".into(),
|
||||
examiner: "Perita (Ana) \\ Souza".into(),
|
||||
organization: "Laboratório".into(),
|
||||
notes: "Observação com acentos: ção, ã, é, ü e “aspas”.".into(),
|
||||
};
|
||||
let opts = ScanOptions::default();
|
||||
report::pdf(&ReportInput {
|
||||
case: &case,
|
||||
target: "/dev/sdb",
|
||||
options: &opts,
|
||||
layout: None,
|
||||
found: &files,
|
||||
warnings: &["aviso importante sobre o disco".to_string()],
|
||||
started_unix: Some(1_689_358_925),
|
||||
elapsed: 1.5,
|
||||
recoveries: &[rec],
|
||||
source_hashes: None,
|
||||
audit: Some(AuditNote {
|
||||
entries: 7,
|
||||
ok: true,
|
||||
head: "12".repeat(32),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
hay[from..]
|
||||
.windows(needle.len())
|
||||
.position(|w| w == needle)
|
||||
.map(|p| p + from)
|
||||
}
|
||||
|
||||
/// Texto dos fluxos de conteúdo, já descomprimidos.
|
||||
fn streams(pdf: &[u8]) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut at = 0;
|
||||
while let Some(s) = find(pdf, b">>\nstream\n", at) {
|
||||
let start = s + 10;
|
||||
let end = find(pdf, b"\nendstream", start).unwrap();
|
||||
let mut text = String::new();
|
||||
flate2::read::ZlibDecoder::new(&pdf[start..end])
|
||||
.read_to_string(&mut text)
|
||||
.unwrap_or_else(|e| panic!("fluxo inválido: {e}"));
|
||||
out.push(text);
|
||||
at = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_has_a_valid_cross_reference_table() {
|
||||
let pdf = build(5);
|
||||
assert!(pdf.starts_with(b"%PDF-1.4"));
|
||||
assert!(pdf.ends_with(b"%%EOF\n"));
|
||||
let sx = find(&pdf, b"startxref\n", 0).unwrap() + 10;
|
||||
let nl = find(&pdf, b"\n", sx).unwrap();
|
||||
let xref: usize = std::str::from_utf8(&pdf[sx..nl]).unwrap().parse().unwrap();
|
||||
assert_eq!(&pdf[xref..xref + 4], b"xref");
|
||||
// cada entrada aponta exatamente para "N 0 obj"
|
||||
let table = &pdf[xref..];
|
||||
let header_end = find(table, b"\n", 5).unwrap();
|
||||
let count: usize = std::str::from_utf8(&table[5..header_end])
|
||||
.unwrap()
|
||||
.split(' ')
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
for n in 1..count {
|
||||
let e = &table[header_end + 1 + n * 20..header_end + 1 + (n + 1) * 20];
|
||||
let off: usize = std::str::from_utf8(&e[..10]).unwrap().parse().unwrap();
|
||||
let want = format!("{n} 0 obj");
|
||||
assert_eq!(&pdf[off..off + want.len()], want.as_bytes(), "objeto {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_text_survives_accents_and_escaping() {
|
||||
let all = streams(&build(5)).join("\n");
|
||||
// acentos viram bytes WinAnsi em octal; parênteses e barra são escapados
|
||||
assert!(all.contains("Identifica\\347\\343o do caso"), "{all}");
|
||||
assert!(all.contains("Perita \\(Ana\\) \\\\ Souza"));
|
||||
assert!(all.contains("Laborat\\363rio"));
|
||||
assert!(all.contains("fotografia_0003.jpg"));
|
||||
assert!(all.contains(&"ab".repeat(32)));
|
||||
assert!(all.contains(&"12".repeat(32)), "hash da trilha ausente");
|
||||
// aspas curvas viram os bytes WinAnsi 0x93/0x94
|
||||
assert!(all.contains("\\223aspas\\224"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lists_paginate_with_numbered_footers() {
|
||||
let pdf = build(400);
|
||||
let pages = streams(&pdf);
|
||||
assert!(pages.len() >= 5, "{} páginas", pages.len());
|
||||
let last = pages.len();
|
||||
assert!(pages[last - 1].contains(&format!("p\\341gina {last} de {last}")));
|
||||
assert!(pages[0].contains(&format!("p\\341gina 1 de {last}")));
|
||||
// o cabeçalho da tabela se repete nas páginas seguintes
|
||||
assert!(pages[2].contains("Modificado \\(UTC\\)"));
|
||||
// todas as linhas aparecem exatamente uma vez
|
||||
let all = pages.join("\n");
|
||||
assert_eq!(all.matches("fotografia_0399.jpg").count(), 1);
|
||||
// a primeira também consta da lista de recuperados
|
||||
assert_eq!(all.matches("fotografia_0000.jpg").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_wraps_instead_of_running_off_the_page() {
|
||||
use guara_core::pdf::{text_width, wrap, Font};
|
||||
let lines = wrap(&"a".repeat(200), 8.0, Font::Mono, 100.0);
|
||||
assert!(lines.len() > 5);
|
||||
assert!(lines
|
||||
.iter()
|
||||
.all(|l| text_width(l, 8.0, Font::Mono) <= 100.0 + 1e-6));
|
||||
let words = wrap(
|
||||
"uma frase comum com várias palavras curtas",
|
||||
10.0,
|
||||
Font::Regular,
|
||||
90.0,
|
||||
);
|
||||
assert!(
|
||||
words.len() >= 3
|
||||
&& words
|
||||
.iter()
|
||||
.all(|l| text_width(l, 10.0, Font::Regular) <= 90.0)
|
||||
);
|
||||
assert_eq!(wrap("a\nb", 10.0, Font::Regular, 100.0), ["a", "b"]);
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
//! Imagens de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG.
|
||||
//!
|
||||
//! Os arquivos de `tests/fixtures/vdisk` foram feitos por ferramentas reais (VirtualBox para
|
||||
//! VMDK/VHD, qemu para QCOW2, hdiutil para DMG) e conferidos contra o conteúdo bruto. VHDX, DMG
|
||||
//! sintético e discos diferenciais são montados aqui, byte a byte, a partir da especificação.
|
||||
//! As imagens do dfvfs em target/testdata (scripts/fetch-testdata.sh) são conferidas por hash,
|
||||
//! calculado com um leitor independente (7-Zip), e pulam quando ausentes.
|
||||
|
||||
use guara_core::source::{open_path, read_vec, Source};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn fx(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/vdisk")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
fn raw() -> Vec<u8> {
|
||||
std::fs::read(fx("disk.raw")).unwrap()
|
||||
}
|
||||
|
||||
fn dfvfs(name: &str) -> Option<PathBuf> {
|
||||
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/testdata/dfvfs")
|
||||
.join(name);
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
fn sha(src: &dyn Source) -> String {
|
||||
let mut h = Sha256::new();
|
||||
let mut off = 0;
|
||||
while off < src.size() {
|
||||
let n = (1 << 20).min((src.size() - off) as usize);
|
||||
h.update(read_vec(src, off, n).unwrap());
|
||||
off += n as u64;
|
||||
}
|
||||
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Lê o disco inteiro e também em pedaços que cruzam blocos, comparando com `want`.
|
||||
fn check_content(path: &Path, want: &[u8]) {
|
||||
let s = open_path(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
assert_eq!(s.size(), want.len() as u64, "{}", path.display());
|
||||
assert!(
|
||||
read_vec(s.as_ref(), 0, want.len()).unwrap() == want,
|
||||
"{}: conteúdo difere",
|
||||
path.display()
|
||||
);
|
||||
let mut x = 88172645463325252u64;
|
||||
for _ in 0..200 {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
let off = x % want.len() as u64;
|
||||
let len = 1 + (x >> 20) as usize % 70_000;
|
||||
let end = (off as usize + len).min(want.len());
|
||||
let got = read_vec(s.as_ref(), off, end - off as usize).unwrap();
|
||||
assert!(
|
||||
got == want[off as usize..end],
|
||||
"{}: @{off}+{len}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
// leitura além do fim devolve só o que existe
|
||||
let mut buf = [0u8; 64];
|
||||
assert_eq!(s.read_at(want.len() as u64 - 10, &mut buf).unwrap(), 10);
|
||||
assert_eq!(s.read_at(want.len() as u64, &mut buf).unwrap(), 0);
|
||||
}
|
||||
|
||||
// ---------- imagens feitas por ferramentas reais ----------
|
||||
|
||||
#[test]
|
||||
fn virtualbox_vmdk_sparse_and_stream_optimized() {
|
||||
let want = raw();
|
||||
check_content(&fx("disk.vmdk"), &want);
|
||||
// streamOptimized: grãos comprimidos com zlib e cabeçalho no rodapé
|
||||
check_content(&fx("disk-stream.vmdk"), &want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtualbox_vhd_dynamic() {
|
||||
check_content(&fx("disk.vhd"), &raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qemu_qcow2_and_hdiutil_dmg_match_a_reference_reader() {
|
||||
let s = open_path(&fx("ext2.qcow2")).unwrap();
|
||||
assert_eq!(s.size(), 4 << 20);
|
||||
assert_eq!(
|
||||
sha(s.as_ref()),
|
||||
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80"
|
||||
);
|
||||
// UDIF com chunks zlib, zerados e ignorados
|
||||
let d = open_path(&fx("hfsplus_zlib.dmg")).unwrap();
|
||||
assert_eq!(d.size(), 1_954_304);
|
||||
assert_eq!(
|
||||
sha(d.as_ref()),
|
||||
"c86d3616818e175caca2f6022f49e190e5b1cfe39b2e8f9373119e22aa7eaf58"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- montagem de imagens sintéticas ----------
|
||||
|
||||
fn be(v: &mut [u8], o: usize, x: u64, n: usize) {
|
||||
v[o..o + n].copy_from_slice(&x.to_be_bytes()[8 - n..]);
|
||||
}
|
||||
fn le(v: &mut [u8], o: usize, x: u64, n: usize) {
|
||||
v[o..o + n].copy_from_slice(&x.to_le_bytes()[..n]);
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> Vec<u8> {
|
||||
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
|
||||
e.write_all(data).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
fn zlib(data: &[u8]) -> Vec<u8> {
|
||||
let mut e = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default());
|
||||
e.write_all(data).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
/// QCOW2 v3 com clusters de 64 KiB: zerados (sem entrada e com a flag), brutos e comprimidos.
|
||||
fn build_qcow2(data: &[u8]) -> Vec<u8> {
|
||||
const CS: usize = 1 << 16;
|
||||
let clusters = data.len() / CS;
|
||||
let mut img = vec![0u8; 3 * CS];
|
||||
img[..4].copy_from_slice(b"QFI\xfb");
|
||||
be(&mut img, 4, 3, 4);
|
||||
be(&mut img, 20, 16, 4);
|
||||
be(&mut img, 24, data.len() as u64, 8);
|
||||
be(&mut img, 36, 1, 4);
|
||||
be(&mut img, 40, CS as u64, 8);
|
||||
be(&mut img, 100, 112, 4);
|
||||
be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8); // L1 -> L2 no cluster 2
|
||||
for i in 0..clusters {
|
||||
let c = &data[i * CS..(i + 1) * CS];
|
||||
let entry: u64 = if c.iter().all(|&b| b == 0) {
|
||||
if i % 2 == 0 {
|
||||
0
|
||||
} else {
|
||||
1 // zerado explícito
|
||||
}
|
||||
} else if i % 3 == 0 {
|
||||
let z = deflate(c);
|
||||
let host = img.len() as u64;
|
||||
let sectors = z.len().div_ceil(512);
|
||||
img.extend_from_slice(&z);
|
||||
img.resize(img.len().div_ceil(512) * 512, 0);
|
||||
1 << 62 | ((sectors as u64 - 1) << (62 - 8)) | host
|
||||
} else {
|
||||
let host = img.len() as u64;
|
||||
img.extend_from_slice(c);
|
||||
host | 1 << 63
|
||||
};
|
||||
be(&mut img, 2 * CS + i * 8, entry, 8);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn vhd_footer(size: u64, kind: u32, data_offset: u64) -> [u8; 512] {
|
||||
let mut f = [0u8; 512];
|
||||
f[..8].copy_from_slice(b"conectix");
|
||||
be(&mut f, 8, 2, 4);
|
||||
be(&mut f, 12, 0x0001_0000, 4);
|
||||
be(&mut f, 16, data_offset, 8);
|
||||
f[28..32].copy_from_slice(b"guar");
|
||||
be(&mut f, 40, size, 8);
|
||||
be(&mut f, 48, size, 8);
|
||||
be(&mut f, 60, kind as u64, 4);
|
||||
let sum = f.iter().fold(0u32, |a, &b| a.wrapping_add(b as u32));
|
||||
be(&mut f, 64, !sum as u64, 4);
|
||||
f
|
||||
}
|
||||
|
||||
fn base64(d: &[u8]) -> String {
|
||||
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in d.chunks(3) {
|
||||
let n = (c[0] as u32) << 16
|
||||
| (*c.get(1).unwrap_or(&0) as u32) << 8
|
||||
| *c.get(2).unwrap_or(&0) as u32;
|
||||
for i in 0..4 {
|
||||
if i <= c.len() {
|
||||
s.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
} else {
|
||||
s.push('=');
|
||||
}
|
||||
}
|
||||
if s.len() % 76 == 0 {
|
||||
s.push('\n');
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// ADC só com literais e cópias de 3 bytes (deslocamento 8), como o `hdiutil` produz.
|
||||
fn adc_pattern(len: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let pat = *b"ADC-test";
|
||||
let mut enc = vec![0x80 | 7];
|
||||
enc.extend_from_slice(&pat);
|
||||
let mut made = 8;
|
||||
while made < len {
|
||||
let rem = len - made;
|
||||
let mut n = rem.min(67);
|
||||
if rem - n > 0 && rem - n < 3 {
|
||||
n = rem - 3;
|
||||
}
|
||||
if n >= 4 {
|
||||
enc.extend_from_slice(&[0x40 | (n as u8 - 4), 0, 7]);
|
||||
} else {
|
||||
enc.extend_from_slice(&[((n as u8 - 3) << 2), 7]); // 2 bytes, comprimento 3
|
||||
}
|
||||
made += n;
|
||||
}
|
||||
let want: Vec<u8> = (0..len).map(|i| pat[i % 8]).collect();
|
||||
(enc, want)
|
||||
}
|
||||
|
||||
struct DmgChunk {
|
||||
ty: u32,
|
||||
sectors: u64,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
fn build_dmg(chunks: &[DmgChunk], total_sectors: u64) -> Vec<u8> {
|
||||
let mut fork = vec![];
|
||||
let mut table = vec![0u8; 204];
|
||||
table[..4].copy_from_slice(b"mish");
|
||||
be(&mut table, 4, 1, 4);
|
||||
be(&mut table, 16, total_sectors, 8);
|
||||
be(&mut table, 200, chunks.len() as u64 + 1, 4);
|
||||
let mut sector = 0;
|
||||
for c in chunks {
|
||||
let mut e = [0u8; 40];
|
||||
be(&mut e, 0, c.ty as u64, 4);
|
||||
be(&mut e, 8, sector, 8);
|
||||
be(&mut e, 16, c.sectors, 8);
|
||||
be(&mut e, 24, fork.len() as u64, 8);
|
||||
be(&mut e, 32, c.payload.len() as u64, 8);
|
||||
table.extend_from_slice(&e);
|
||||
fork.extend_from_slice(&c.payload);
|
||||
sector += c.sectors;
|
||||
}
|
||||
let mut end = [0u8; 40];
|
||||
be(&mut end, 0, 0xFFFF_FFFF, 4);
|
||||
be(&mut end, 8, sector, 8);
|
||||
table.extend_from_slice(&end);
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>resource-fork</key><dict>\
|
||||
<key>blkx</key><array><dict><key>Data</key><data>\n{}\n</data><key>Name</key><string>teste</string></dict></array>\
|
||||
<key>plst</key><array><dict><key>Data</key><data>AAAA</data></dict></array></dict></dict></plist>",
|
||||
base64(&table)
|
||||
);
|
||||
let mut img = fork;
|
||||
let xml_off = img.len() as u64;
|
||||
img.extend_from_slice(xml.as_bytes());
|
||||
let mut k = vec![0u8; 512];
|
||||
k[..4].copy_from_slice(b"koly");
|
||||
be(&mut k, 4, 4, 4);
|
||||
be(&mut k, 8, 512, 4);
|
||||
be(&mut k, 32, xml_off, 8);
|
||||
be(&mut k, 216, xml_off, 8);
|
||||
be(&mut k, 224, xml.len() as u64, 8);
|
||||
be(&mut k, 492, total_sectors, 8);
|
||||
img.extend_from_slice(&k);
|
||||
img
|
||||
}
|
||||
|
||||
// ---------- QCOW2 ----------
|
||||
|
||||
#[test]
|
||||
fn qcow2_with_zero_raw_and_compressed_clusters() {
|
||||
let want = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("t.qcow2");
|
||||
std::fs::write(&p, build_qcow2(&want)).unwrap();
|
||||
check_content(&p, &want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qcow2_with_backing_file_or_encryption_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut img = build_qcow2(&raw());
|
||||
let p = dir.path().join("b.qcow2");
|
||||
be(&mut img, 8, 4096, 8);
|
||||
be(&mut img, 16, 3, 4);
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("arquivo de apoio"), "{e}");
|
||||
|
||||
let mut img = build_qcow2(&raw());
|
||||
be(&mut img, 32, 2, 4);
|
||||
let p = dir.path().join("e.qcow2");
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("LUKS"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- VMDK ----------
|
||||
|
||||
#[test]
|
||||
fn vmdk_descriptor_with_flat_and_zero_extents() {
|
||||
let want = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let half = want.len() / 2;
|
||||
std::fs::write(dir.path().join("a-f001.vmdk"), &want[..half]).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("a-f002.vmdk"),
|
||||
&want[half..want.len() - 4096],
|
||||
)
|
||||
.unwrap();
|
||||
let desc = format!(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=fffffffe\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentFlat\"\n\n# Extent description\nRW {} FLAT \"a-f001.vmdk\" 0\nRW {} FLAT \"a-f002.vmdk\" 0\nRW 8 ZERO\n",
|
||||
half / 512,
|
||||
(want.len() - 4096 - half) / 512
|
||||
);
|
||||
let p = dir.path().join("a.vmdk");
|
||||
std::fs::write(&p, desc).unwrap();
|
||||
let mut expect = want.clone();
|
||||
expect[want.len() - 4096..].fill(0);
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmdk_with_a_parent_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("c.vmdk");
|
||||
std::fs::write(
|
||||
&p,
|
||||
"# Disk DescriptorFile\nversion=1\nparentCID=12345678\ncreateType=\"monolithicFlat\"\nRW 8 ZERO\n",
|
||||
)
|
||||
.unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("disco pai"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- VHD ----------
|
||||
|
||||
#[test]
|
||||
fn vhd_fixed_hides_the_footer() {
|
||||
let want = raw();
|
||||
let mut img = want.clone();
|
||||
img.extend_from_slice(&vhd_footer(want.len() as u64, 2, u64::MAX));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("f.vhd");
|
||||
std::fs::write(&p, img).unwrap();
|
||||
check_content(&p, &want);
|
||||
}
|
||||
|
||||
/// VHD diferencial: a filha só traz o setor 100 e o bloco inteiro do fim; o resto vem do pai.
|
||||
#[test]
|
||||
fn vhd_differencing_reads_the_parent_for_missing_sectors() {
|
||||
let want = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut parent = want.clone();
|
||||
parent.extend_from_slice(&vhd_footer(want.len() as u64, 2, u64::MAX));
|
||||
std::fs::write(dir.path().join("pai.vhd"), &parent).unwrap();
|
||||
|
||||
const BLOCK: usize = 1 << 20; // um bloco só
|
||||
let mut f = vec![0u8; 512 + 1024 + 512];
|
||||
let hdr = vhd_footer(want.len() as u64, 4, 512);
|
||||
f[..512].copy_from_slice(&hdr);
|
||||
let mut dh = [0u8; 1024];
|
||||
dh[..8].copy_from_slice(b"cxsparse");
|
||||
be(&mut dh, 8, u64::MAX, 8);
|
||||
be(&mut dh, 16, 1536, 8); // BAT
|
||||
be(&mut dh, 28, 1, 4);
|
||||
be(&mut dh, 32, BLOCK as u64, 4);
|
||||
let name = "pai.vhd"
|
||||
.encode_utf16()
|
||||
.flat_map(|u| u.to_be_bytes())
|
||||
.collect::<Vec<u8>>();
|
||||
dh[64..64 + name.len()].copy_from_slice(&name);
|
||||
f[512..1536].copy_from_slice(&dh);
|
||||
be(&mut f, 1536, 4, 4); // bloco 0 no setor 4
|
||||
f.resize(4 * 512, 0);
|
||||
// bitmap (4096 setores = 512 bytes) com o setor 100 presente, depois os dados
|
||||
let mut bitmap = vec![0u8; 512];
|
||||
bitmap[100 / 8] |= 0x80 >> (100 % 8);
|
||||
f.extend_from_slice(&bitmap);
|
||||
let mut block = vec![0u8; BLOCK];
|
||||
let marker = [0xA5u8; 512];
|
||||
block[100 * 512..101 * 512].copy_from_slice(&marker);
|
||||
f.extend_from_slice(&block);
|
||||
f.extend_from_slice(&hdr);
|
||||
let p = dir.path().join("filho.vhd");
|
||||
std::fs::write(&p, f).unwrap();
|
||||
|
||||
let mut expect = want.clone();
|
||||
expect[100 * 512..101 * 512].copy_from_slice(&marker);
|
||||
check_content(&p, &expect);
|
||||
|
||||
// sem o pai a abertura falha com o motivo
|
||||
std::fs::remove_file(dir.path().join("pai.vhd")).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("disco pai não encontrado"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- VHDX ----------
|
||||
|
||||
fn guid(s: &str) -> [u8; 16] {
|
||||
let h: Vec<u8> = s
|
||||
.replace('-', "")
|
||||
.as_bytes()
|
||||
.chunks(2)
|
||||
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
|
||||
.collect();
|
||||
let mut g = [0u8; 16];
|
||||
g[..4].copy_from_slice(&[h[3], h[2], h[1], h[0]]);
|
||||
g[4..6].copy_from_slice(&[h[5], h[4]]);
|
||||
g[6..8].copy_from_slice(&[h[7], h[6]]);
|
||||
g[8..].copy_from_slice(&h[8..]);
|
||||
g
|
||||
}
|
||||
|
||||
const MB: usize = 1 << 20;
|
||||
|
||||
/// VHDX com blocos de 1 MiB. `blocks[i]`: `None` = não presente, `Some((estado, dados))`.
|
||||
/// Com `parent`, acrescenta o localizador de pai e o bitmap de setores (`bitmap`).
|
||||
fn build_vhdx(
|
||||
size: u64,
|
||||
blocks: &[Option<(u64, Vec<u8>)>],
|
||||
parent: Option<&str>,
|
||||
bitmap: Option<Vec<u8>>,
|
||||
) -> Vec<u8> {
|
||||
let mut img = vec![0u8; 4 * MB];
|
||||
img[..8].copy_from_slice(b"vhdxfile");
|
||||
img[64 * 1024..64 * 1024 + 4].copy_from_slice(b"head");
|
||||
img[128 * 1024..128 * 1024 + 4].copy_from_slice(b"head");
|
||||
// tabela de regiões: BAT em 2 MiB, metadados em 1 MiB
|
||||
let rt = 192 * 1024;
|
||||
img[rt..rt + 4].copy_from_slice(b"regi");
|
||||
le(&mut img, rt + 8, 2, 4);
|
||||
img[rt + 16..rt + 32].copy_from_slice(&guid("2DC27766-F623-4200-9D64-115E9BFD4A08"));
|
||||
le(&mut img, rt + 32, 2 * MB as u64, 8);
|
||||
le(&mut img, rt + 40, MB as u64, 4);
|
||||
img[rt + 48..rt + 64].copy_from_slice(&guid("8B7CA206-4790-4B9A-B8FE-575F050F886E"));
|
||||
le(&mut img, rt + 64, MB as u64, 8);
|
||||
le(&mut img, rt + 72, MB as u64, 4);
|
||||
// metadados
|
||||
let m = MB;
|
||||
img[m..m + 8].copy_from_slice(b"metadata");
|
||||
let mut items: Vec<([u8; 16], Vec<u8>)> = vec![];
|
||||
let mut params = vec![0u8; 8];
|
||||
le(&mut params, 0, MB as u64, 4);
|
||||
le(&mut params, 4, if parent.is_some() { 2 } else { 0 }, 4);
|
||||
items.push((guid("CAA16737-FA36-4D43-B3B6-33F0AA44E76B"), params));
|
||||
items.push((
|
||||
guid("2FA54224-CD1B-4876-B211-5DBED83BF4B8"),
|
||||
size.to_le_bytes().to_vec(),
|
||||
));
|
||||
items.push((
|
||||
guid("8141BF1D-A96F-4709-BA47-F233A8FAAB5F"),
|
||||
512u32.to_le_bytes().to_vec(),
|
||||
));
|
||||
if let Some(p) = parent {
|
||||
let key: Vec<u8> = "relative_path"
|
||||
.encode_utf16()
|
||||
.flat_map(|u| u.to_le_bytes())
|
||||
.collect();
|
||||
let val: Vec<u8> = p.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
let mut loc = vec![0u8; 20 + 12];
|
||||
loc[..16].copy_from_slice(&guid("B04AEFB7-D19E-4A81-B789-25B8E9445913"));
|
||||
le(&mut loc, 18, 1, 2);
|
||||
let (ko, vo) = (32, 32 + key.len());
|
||||
le(&mut loc, 20, ko as u64, 4);
|
||||
le(&mut loc, 24, vo as u64, 4);
|
||||
le(&mut loc, 28, key.len() as u64, 2);
|
||||
le(&mut loc, 30, val.len() as u64, 2);
|
||||
loc.extend_from_slice(&key);
|
||||
loc.extend_from_slice(&val);
|
||||
items.push((guid("A8D35F2D-B30B-454D-ABF7-D3D84834AB0C"), loc));
|
||||
}
|
||||
le(&mut img, m + 10, items.len() as u64, 2);
|
||||
let mut at = 64 * 1024;
|
||||
for (i, (id, data)) in items.iter().enumerate() {
|
||||
let e = m + 32 + i * 32;
|
||||
img[e..e + 16].copy_from_slice(id);
|
||||
le(&mut img, e + 16, at as u64, 4);
|
||||
le(&mut img, e + 20, data.len() as u64, 4);
|
||||
img[m + at..m + at + data.len()].copy_from_slice(data);
|
||||
at += data.len().div_ceil(8) * 8;
|
||||
}
|
||||
// blocos de dados a partir de 4 MiB; o BAT guarda estado e deslocamento (em MiB)
|
||||
let ratio = (1usize << 23) * 512 / MB;
|
||||
let bat = 2 * MB;
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
let Some((state, data)) = b else { continue };
|
||||
let mut off = 0;
|
||||
if !data.is_empty() {
|
||||
off = img.len();
|
||||
let mut d = data.clone();
|
||||
d.resize(MB, 0);
|
||||
img.extend_from_slice(&d);
|
||||
}
|
||||
le(
|
||||
&mut img,
|
||||
bat + (i + i / ratio) * 8,
|
||||
state | (off as u64 >> 20) << 20,
|
||||
8,
|
||||
);
|
||||
}
|
||||
if let Some(bm) = bitmap {
|
||||
let off = img.len();
|
||||
let mut d = bm;
|
||||
d.resize(MB, 0);
|
||||
img.extend_from_slice(&d);
|
||||
le(&mut img, bat + ratio * 8, 6 | (off as u64 >> 20) << 20, 8);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vhdx_dynamic_with_zero_and_missing_blocks() {
|
||||
let want = raw(); // 1 MiB = um bloco
|
||||
let mut expect = want.clone();
|
||||
expect.extend_from_slice(&vec![0u8; 3 * MB]);
|
||||
let tail = vec![0x5Au8; 100_000];
|
||||
expect[3 * MB..3 * MB + tail.len()].copy_from_slice(&tail);
|
||||
let img = build_vhdx(
|
||||
4 * MB as u64,
|
||||
&[
|
||||
Some((6, want.clone())),
|
||||
Some((2, vec![])), // zerado
|
||||
None, // não presente
|
||||
Some((6, tail)),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("t.vhdx");
|
||||
std::fs::write(&p, img).unwrap();
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vhdx_differencing_uses_the_sector_bitmap() {
|
||||
let want = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pai.raw"), &want).unwrap();
|
||||
// filho: bloco 0 parcialmente presente, só com os setores 3 e 2000..2010 no filho
|
||||
let mut data = vec![0u8; MB];
|
||||
let mut bm = vec![0u8; 512];
|
||||
let mut expect = want.clone();
|
||||
for s in std::iter::once(3usize).chain(2000..2010) {
|
||||
data[s * 512..(s + 1) * 512].fill(0x77);
|
||||
expect[s * 512..(s + 1) * 512].fill(0x77);
|
||||
bm[s / 8] |= 1 << (s % 8);
|
||||
}
|
||||
let img = build_vhdx(MB as u64, &[Some((7, data))], Some("pai.raw"), Some(bm));
|
||||
let p = dir.path().join("filho.vhdx");
|
||||
std::fs::write(&p, img).unwrap();
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
// ---------- DMG ----------
|
||||
|
||||
#[test]
|
||||
fn dmg_with_every_chunk_type() {
|
||||
let want = raw();
|
||||
let sec = 512usize;
|
||||
// 8 chunks de 128 setores (64 KiB)
|
||||
let part = |i: usize| want[i * 128 * sec..(i + 1) * 128 * sec].to_vec();
|
||||
let (adc_enc, adc_want) = adc_pattern(128 * sec);
|
||||
let mut bz = bzip2::write::BzEncoder::new(vec![], bzip2::Compression::default());
|
||||
bz.write_all(&part(3)).unwrap();
|
||||
let chunks = vec![
|
||||
DmgChunk {
|
||||
ty: 1,
|
||||
sectors: 128,
|
||||
payload: part(0),
|
||||
}, // bruto
|
||||
DmgChunk {
|
||||
ty: 0,
|
||||
sectors: 128,
|
||||
payload: vec![],
|
||||
}, // zerado
|
||||
DmgChunk {
|
||||
ty: 0x8000_0005,
|
||||
sectors: 128,
|
||||
payload: zlib(&part(2)),
|
||||
},
|
||||
DmgChunk {
|
||||
ty: 0x8000_0006,
|
||||
sectors: 128,
|
||||
payload: bz.finish().unwrap(),
|
||||
},
|
||||
DmgChunk {
|
||||
ty: 0x8000_0004,
|
||||
sectors: 128,
|
||||
payload: adc_enc,
|
||||
},
|
||||
DmgChunk {
|
||||
ty: 2,
|
||||
sectors: 128,
|
||||
payload: vec![],
|
||||
}, // ignorado
|
||||
DmgChunk {
|
||||
ty: 0x8000_0005,
|
||||
sectors: 128,
|
||||
payload: zlib(&part(6)),
|
||||
},
|
||||
];
|
||||
let mut expect = vec![];
|
||||
expect.extend(part(0));
|
||||
expect.extend(vec![0u8; 128 * sec]);
|
||||
expect.extend(part(2));
|
||||
expect.extend(part(3));
|
||||
expect.extend(adc_want);
|
||||
expect.extend(vec![0u8; 128 * sec]);
|
||||
expect.extend(part(6));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("t.dmg");
|
||||
std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap();
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmg_lzfse_and_encrypted_are_refused_with_a_reason() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("l.dmg");
|
||||
let chunks = [DmgChunk {
|
||||
ty: 0x8000_0007,
|
||||
sectors: 8,
|
||||
payload: vec![1, 2, 3],
|
||||
}];
|
||||
std::fs::write(&p, build_dmg(&chunks, 8)).unwrap();
|
||||
let s = open_path(&p).unwrap();
|
||||
let e = read_vec(s.as_ref(), 0, 512).unwrap_err().to_string();
|
||||
assert!(e.contains("LZFSE"), "{e}");
|
||||
|
||||
let p = dir.path().join("e.dmg");
|
||||
let mut img = b"encrcdsa".to_vec();
|
||||
img.resize(8192, 0);
|
||||
std::fs::write(&p, img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("cifrado"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- imagens reais do dfvfs (target/testdata) ----------
|
||||
|
||||
#[test]
|
||||
fn dfvfs_images_match_a_reference_reader() {
|
||||
// hashes do disco virtual inteiro, conferidos com o 7-Zip (árvore de arquivos e partições)
|
||||
let table: &[(&str, &str)] = &[
|
||||
(
|
||||
"ext2.vhdx",
|
||||
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80",
|
||||
),
|
||||
(
|
||||
"ext2.vmdk",
|
||||
"a6c2f0e39afe6c6ab432ca5465349fcefe8dc944398e97b2d957d3f89dbb5d80",
|
||||
),
|
||||
(
|
||||
"ext2.vhd",
|
||||
"870be7ae16c1fa8faab05c6eb9205dc9a7ae35c5f552c5cf8a267c0bc6a5cb99",
|
||||
),
|
||||
(
|
||||
"fvdetest.qcow2",
|
||||
"732091e57632b7a2332f218d2d3a9ce1355a836ebacce91615a2c30a178e4ec3",
|
||||
),
|
||||
(
|
||||
"windows_volume.qcow2",
|
||||
"44abe9deca9658f331a2bdba2deb53192d04f35d157a1fb3ba0597b03f0524f2",
|
||||
),
|
||||
(
|
||||
"apm.dmg",
|
||||
"897f487f4971e72c762e00460935e3b25176cda35620b508b490f65a7e0118ff",
|
||||
),
|
||||
(
|
||||
"ntfs-dynamic.vhd",
|
||||
"8b10b52e3d1e76075d05a5101a6536b9fa8376a5b8f7c22f502d0987d165c876",
|
||||
),
|
||||
(
|
||||
"ntfs-fixed.vhd",
|
||||
"cd81fade3878f1b2957f1ece168201876bdf4e90c9b30f60c2f232eba5fea76c",
|
||||
),
|
||||
(
|
||||
"ntfs-dynamic.vhdx",
|
||||
"b92731f8e4bf3532418444d22a9086823a8122569500acd09cd8d2ce20eb98e9",
|
||||
),
|
||||
(
|
||||
"ntfs-fixed.vhdx",
|
||||
"5237dd895f38052344e2ef35a207fe9df08cd18d17b6ea857a1b93cc475e1f2e",
|
||||
),
|
||||
(
|
||||
"fat-differential.vhd",
|
||||
"f4b61a33ee2f7e8c19f737792a4107e46340026a9a829e4169b751901cc3ef02",
|
||||
),
|
||||
(
|
||||
"ntfs-differential.vhd",
|
||||
"b7922a46a6b08fa4f4fc0c51a25d5587d1d49f15cfade45ca740dd07110ae144",
|
||||
),
|
||||
(
|
||||
"fat-differential.vhdx",
|
||||
"a538161f34bc44c12bf9a542ce9218741c62728723ea18d0e4f6ea92e9f32e93",
|
||||
),
|
||||
(
|
||||
"ntfs-differential.vhdx",
|
||||
"1ef1fb5360247aa7d36a38415bc7bf6e1e52f62af5e6bd1283815e82584c7efc",
|
||||
),
|
||||
];
|
||||
for (name, hash) in table {
|
||||
let Some(p) = dfvfs(name) else { continue };
|
||||
let s = open_path(&p).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(&sha(s.as_ref()), hash, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_table_is_found_inside_a_qcow2() {
|
||||
let Some(p) = dfvfs("fvdetest.qcow2") else {
|
||||
return;
|
||||
};
|
||||
let s = open_path(&p).unwrap();
|
||||
let layout = guara_core::partition::analyze(s.as_ref(), false);
|
||||
assert_eq!(layout.table, "gpt");
|
||||
assert_eq!(layout.partitions.len(), 1);
|
||||
}
|
||||
@@ -43,6 +43,8 @@ recover copies with MD5, SHA-1 and SHA-256 and writes the
|
||||
| `source`, `device` | Open devices and images read-only; list disks |
|
||||
| `image` | Raw image tolerant of bad sectors, with an error map |
|
||||
| `ewf`, `aff4` | Read and write E01/Ex01 and AFF4 |
|
||||
| `audit` | Hash-chained audit trail of every examiner action |
|
||||
| `vdisk/` | Read QCOW2, VMDK, VHD, VHDX and DMG as the disk they contain |
|
||||
| `assemble` | Builds the virtual disk: image, RAID, LVM and encrypted volume |
|
||||
| `raid`, `raid_detect`, `lvm` | RAID reassembly (with automatic parameter discovery) and LVM2 |
|
||||
| `crypt/` | BitLocker, LUKS and FileVault 2 |
|
||||
@@ -63,7 +65,7 @@ recover copies with MD5, SHA-1 and SHA-256 and writes the
|
||||
|---|---|
|
||||
| `recover` | Copies the files, checks the hashes, writes the manifest, resumes recovery |
|
||||
| `hashing`, `search` | Hashes and text search across the whole disk |
|
||||
| `report`, `explorer` | HTML report, CSV, explorer filters and groupings |
|
||||
| `report`, `pdf`, `explorer` | HTML and PDF report (the PDF writer is built in, no external library), CSV, explorer filters and groupings |
|
||||
| `health`, `external` | SMART and external tools (`smartctl`) |
|
||||
|
||||
### Service
|
||||
|
||||
+15
-2
@@ -19,7 +19,8 @@ Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linu
|
||||
| `hash <source>` | MD5, SHA-1 and SHA-256 of the device |
|
||||
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
|
||||
| `export <session> <csv>` | Spreadsheet of the findings |
|
||||
| `report <session> <html>` | Printable HTML report |
|
||||
| `report <session> <file>` | Printable report, HTML or PDF (by the file extension) |
|
||||
| `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain |
|
||||
| `sessions` | Automatically saved sessions |
|
||||
| `ui` | Interface in the browser (127.0.0.1, with token) |
|
||||
|
||||
@@ -99,6 +100,18 @@ guara verify /mnt/other-disk/sdb.E01
|
||||
|
||||
For E01: `--segment-mib` (size of each segment, default 1500, 0 for a single file) and `--compression` (0 to 9, default 1).
|
||||
|
||||
## Audit trail
|
||||
|
||||
Every action that touches evidence is appended to `auditoria.jsonl` in the configuration folder (or to the file named by `GUARA_AUDIT_LOG`): opening a source (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exports and sessions. Each line has the time (UTC), the system user (the one behind `sudo`), the host, the Guará version and the details; set `GUARA_EXAMINER` to also record the examiner's name. Passwords and keys are never written.
|
||||
|
||||
```bash
|
||||
guara audit # lists the entries
|
||||
guara audit --tail 20 --json
|
||||
guara audit --verify # checks the chain; exits with an error if it is broken
|
||||
```
|
||||
|
||||
The lines are chained: each one carries the SHA-256 of the previous hash plus its own content, so editing, removing or reordering an entry is detected and `--verify` says which one. Cutting the end of the file cannot be detected by the file alone: keep the hash printed by `--verify` somewhere else (for example in the case notes).
|
||||
|
||||
## Video without an index (MP4 without moov)
|
||||
|
||||
Pass an **intact video from the same camera or drone** so Guará can rebuild the index:
|
||||
@@ -123,4 +136,4 @@ Searches in UTF-8 and UTF-16 (`--no-utf16` turns it off), including free space a
|
||||
guara report s.json report.html --case 2026-001 --examiner "Name" --hash-source
|
||||
```
|
||||
|
||||
Includes case identification, source, partitions, summary, hashes and the list of files. `--hash-source` adds the hash of the whole source.
|
||||
Includes case identification, source, partitions, summary, hashes, the audit trail status and the list of files. `--hash-source` adds the hash of the whole source. The format follows the extension: `report.pdf` writes an A4 PDF (standard fonts, nothing external), anything else writes HTML.
|
||||
+2
-1
@@ -52,7 +52,8 @@ docker run --rm \
|
||||
|
||||
- The destination must be on **another disk** than `/dev/sdb`.
|
||||
- Reading a raw device needs root inside the container, so do not pass `--user` here.
|
||||
- To read SMART, `--device` is not enough on some systems; `--cap-add=SYS_RAWIO` is the usual extra.
|
||||
- Verified locally with a loop device (Linux): `inspect`, `scan` and `recover` read through `--device=...:r`, and a write attempt (`dd of=/dev/loop0`) fails with `Operation not permitted`. Loop devices are hidden from `guara devices`, like other virtual devices, so pass the path explicitly; a real USB disk does appear in the list.
|
||||
- SMART depends on the host, the controller and the device. `--cap-add=SYS_RAWIO` is the usual addition when `--device` alone is not enough, but this was **not** verified with a loop device, which has no SMART data.
|
||||
- Avoid `--privileged`: it gives the container access to every device.
|
||||
|
||||
## Other commands
|
||||
|
||||
@@ -43,6 +43,8 @@ recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto
|
||||
| `source`, `device` | Abrir dispositivos e imagens somente para leitura; listar discos |
|
||||
| `image` | Imagem bruta tolerante a setores ruins, com mapa de erros |
|
||||
| `ewf`, `aff4` | Ler e gravar E01/Ex01 e AFF4 |
|
||||
| `audit` | Trilha de auditoria encadeada por hash de cada ação do perito |
|
||||
| `vdisk/` | Ler QCOW2, VMDK, VHD, VHDX e DMG como o disco que contêm |
|
||||
| `assemble` | Monta o disco virtual: imagem, RAID, LVM e volume cifrado |
|
||||
| `raid`, `raid_detect`, `lvm` | Remontagem de RAID (com descoberta automática de parâmetros) e LVM2 |
|
||||
| `crypt/` | BitLocker, LUKS e FileVault 2 |
|
||||
@@ -63,7 +65,7 @@ recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto
|
||||
|---|---|
|
||||
| `recover` | Copia os arquivos, confere os hashes, grava o manifesto, retoma recuperação |
|
||||
| `hashing`, `search` | Hashes e busca de texto no disco inteiro |
|
||||
| `report`, `explorer` | Relatório HTML, CSV, filtros e agrupamentos do explorador |
|
||||
| `report`, `pdf`, `explorer` | Relatório HTML e PDF (o gerador de PDF é próprio, sem biblioteca externa), CSV, filtros e agrupamentos do explorador |
|
||||
| `health`, `external` | SMART e ferramentas externas (`smartctl`) |
|
||||
|
||||
### Serviço
|
||||
|
||||
+15
-2
@@ -19,7 +19,8 @@ Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e
|
||||
| `hash <origem>` | MD5, SHA-1 e SHA-256 do dispositivo |
|
||||
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
|
||||
| `export <sessão> <csv>` | Planilha dos achados |
|
||||
| `report <sessão> <html>` | Relatório HTML imprimível |
|
||||
| `report <sessão> <arquivo>` | Relatório imprimível, HTML ou PDF (pela extensão do arquivo) |
|
||||
| `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash |
|
||||
| `sessions` | Sessões salvas automaticamente |
|
||||
| `ui` | Interface no navegador (127.0.0.1, com token) |
|
||||
|
||||
@@ -99,6 +100,18 @@ guara verify /mnt/outro-disco/sdb.E01
|
||||
|
||||
Para E01: `--segment-mib` (tamanho de cada segmento, padrão 1500, 0 para um arquivo só) e `--compression` (0 a 9, padrão 1).
|
||||
|
||||
## Trilha de auditoria
|
||||
|
||||
Toda ação que mexe na evidência é acrescentada a `auditoria.jsonl` na pasta de configuração (ou ao arquivo indicado em `GUARA_AUDIT_LOG`): abrir uma origem (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exportações e sessões. Cada linha traz a hora (UTC), o usuário do sistema (o que está por trás do `sudo`), a máquina, a versão do Guará e os detalhes; defina `GUARA_EXAMINER` para registrar também o nome do perito. Senhas e chaves nunca são gravadas.
|
||||
|
||||
```bash
|
||||
guara audit # lista as entradas
|
||||
guara audit --tail 20 --json
|
||||
guara audit --verify # confere a cadeia; sai com erro se estiver quebrada
|
||||
```
|
||||
|
||||
As linhas são encadeadas: cada uma leva o SHA-256 do hash anterior mais o próprio conteúdo, então editar, remover ou reordenar uma entrada é detectado e o `--verify` diz qual. Cortar o fim do arquivo não é detectável só pelo arquivo: guarde o hash que o `--verify` imprime em outro lugar (por exemplo nas notas do caso).
|
||||
|
||||
## Vídeo sem índice (MP4 sem moov)
|
||||
|
||||
Passe um vídeo **íntegro da mesma câmera ou drone** para o Guará refazer o índice:
|
||||
@@ -123,4 +136,4 @@ Procura em UTF-8 e UTF-16 (`--no-utf16` desliga), inclusive no espaço livre e f
|
||||
guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-source
|
||||
```
|
||||
|
||||
Traz identificação do caso, origem, partições, resumo, hashes e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira.
|
||||
Traz identificação do caso, origem, partições, resumo, hashes, a situação da trilha de auditoria e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira. O formato segue a extensão: `relatorio.pdf` gera um PDF A4 (fontes padrão, nada externo); qualquer outra gera HTML.
|
||||
@@ -52,7 +52,8 @@ docker run --rm \
|
||||
|
||||
- O destino precisa estar em **outro disco** que não seja o `/dev/sdb`.
|
||||
- Ler dispositivo bruto exige root dentro do container, então não use `--user` aqui.
|
||||
- Para ler o SMART, em alguns sistemas `--device` não basta; o complemento usual é `--cap-add=SYS_RAWIO`.
|
||||
- Verificado localmente com um dispositivo de loop (Linux): `inspect`, `scan` e `recover` leem através de `--device=...:r`, e uma tentativa de escrita (`dd of=/dev/loop0`) falha com `Operation not permitted`. Dispositivos de loop ficam ocultos no `guara devices`, como outros dispositivos virtuais, então informe o caminho explicitamente; um pendrive real aparece na lista.
|
||||
- O SMART depende do hospedeiro, do controlador e do dispositivo. `--cap-add=SYS_RAWIO` é o complemento usual quando só `--device` não basta, mas isso **não** foi verificado com um dispositivo de loop, que não tem dados SMART.
|
||||
- Evite `--privileged`: ele dá ao container acesso a todos os dispositivos.
|
||||
|
||||
## Outros comandos
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](../support.md) · **Português**
|
||||
|
||||
Matriz do que existe hoje (versão 1.0.1). Para o que vem a seguir, veja o [ROADMAP](../../ROADMAP.pt-BR.md).
|
||||
Matriz do que existe hoje (versão 1.1.0). Para o que vem a seguir, veja o [ROADMAP](../../ROADMAP.pt-BR.md).
|
||||
|
||||
## Tabelas de partição
|
||||
|
||||
@@ -55,6 +55,12 @@ As senhas **nunca** vão para o arquivo de sessão. Com `--remember-keys` a sess
|
||||
| Bruta (dd, `.img`) | ✅ | ✅ com mapa de erros de setores ruins |
|
||||
| E01 e Ex01 (Expert Witness) | ✅ | ✅ com metadados do caso e segmentação |
|
||||
| AFF4 | ✅ | ✅ |
|
||||
| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) | ❌ |
|
||||
| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized` e descritores com extensões flat/zero | ❌ |
|
||||
| VHD e VHDX (Hyper-V) | ✅ fixo, dinâmico e diferencial (segue a cadeia de pais) | ❌ |
|
||||
| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib e bzip2 | ❌ |
|
||||
|
||||
Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 com arquivo de apoio ou cifrado, VMDK com pai (snapshot) ou em SEsparse, DMG protegido por senha e DMG com chunks LZFSE. O log de transações do VHDX não é reproduzido (nada é gravado na imagem).
|
||||
|
||||
`guara verify` recalcula o MD5 de uma imagem E01 ou Ex01 e compara com o gravado nela.
|
||||
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](support.md) · [Português](pt-BR/suporte.md)
|
||||
|
||||
Matrix of what exists today (version 1.0.1). For what comes next, see the [ROADMAP](../ROADMAP.md).
|
||||
Matrix of what exists today (version 1.1.0). For what comes next, see the [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Partition tables
|
||||
|
||||
@@ -55,6 +55,12 @@ Passwords are **never** written to the session file. With `--remember-keys` the
|
||||
| Raw (dd, `.img`) | ✅ | ✅ with a bad sector error map |
|
||||
| E01 and Ex01 (Expert Witness) | ✅ | ✅ with case metadata and segmentation |
|
||||
| AFF4 | ✅ | ✅ |
|
||||
| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) | ❌ |
|
||||
| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized` and descriptors with flat/zero extents | ❌ |
|
||||
| VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ |
|
||||
| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib and bzip2 chunks | ❌ |
|
||||
|
||||
Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: QCOW2 with a backing file or encryption, VMDK with a parent (snapshot) or in SEsparse, password-protected DMG and DMG with LZFSE chunks. The VHDX transaction log is not replayed (nothing is ever written to the image).
|
||||
|
||||
`guara verify` recomputes the MD5 of an E01 or Ex01 image and compares it with the one stored in it.
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env node
|
||||
// Confere se o dicionário inglês cobre os textos da interface.
|
||||
//
|
||||
// A varredura é estática e sem dependências: lê os arquivos da interface
|
||||
// (JS e HTML), os rótulos que o núcleo em Rust envia para a tela (metadados,
|
||||
// tipos de arquivo e saúde do disco) e falha quando encontra uma string que
|
||||
// parece português e não tem tradução. Também recusa chaves duplicadas no
|
||||
// dicionário (num objeto JS a última vence em silêncio).
|
||||
//
|
||||
// Uso: node scripts/check-ui-i18n.mjs
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||
const rel = (p) => relative(root, p).replaceAll('\\', '/');
|
||||
|
||||
// ---------------------------------------------------------------- dicionário
|
||||
|
||||
const dictPath = join(root, 'apps/gui/ui/i18n-en.js');
|
||||
const dictSource = readFileSync(dictPath, 'utf8');
|
||||
|
||||
const keyRe = /^\s*(['"])((?:(?!\1).)*)\1\s*:/gm;
|
||||
const seen = new Map();
|
||||
const duplicates = [];
|
||||
let m;
|
||||
while ((m = keyRe.exec(dictSource))) {
|
||||
const key = m[2];
|
||||
const line = dictSource.slice(0, m.index).split('\n').length;
|
||||
if (seen.has(key)) duplicates.push({ key, first: seen.get(key), again: line });
|
||||
else seen.set(key, line);
|
||||
}
|
||||
|
||||
if (duplicates.length) {
|
||||
console.error('Chaves duplicadas em apps/gui/ui/i18n-en.js:');
|
||||
for (const d of duplicates) console.error(` ${JSON.stringify(d.key)} (linhas ${d.first} e ${d.again})`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const { EN } = await import(new URL('../apps/gui/ui/i18n-en.js', import.meta.url).href);
|
||||
|
||||
const exact = new Set(Object.keys(EN).filter((k) => !k.includes('{n}')));
|
||||
const patterns = Object.keys(EN)
|
||||
.filter((k) => k.includes('{n}'))
|
||||
.map((k) => {
|
||||
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
return new RegExp('^' + parts.join('(.+?)') + '$', 's');
|
||||
});
|
||||
|
||||
function translated(text) {
|
||||
if (exact.has(text)) return true;
|
||||
return patterns.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- heurística
|
||||
|
||||
const ACCENT = /[áàâãäéèêëíìîïóòôõöúùûüçÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇ]/;
|
||||
const WORDS = [
|
||||
'arquivo', 'arquivos', 'pasta', 'pastas', 'selecionado', 'selecionados', 'selecionada',
|
||||
'selecionadas', 'item', 'itens', 'abrir', 'abre', 'salvar', 'salvo', 'salvos', 'cancelar',
|
||||
'fechar', 'buscar', 'procurar', 'procurando', 'disco', 'discos', 'senha', 'chave', 'origem',
|
||||
'destino', 'tamanho', 'nome', 'nenhum', 'nenhuma', 'todos', 'todas', 'cada', 'esta', 'este',
|
||||
'essa', 'esse', 'para', 'com', 'sem', 'dos', 'das', 'por', 'que', 'não', 'sim', 'mais',
|
||||
'menos', 'uma', 'uns', 'umas', 'aqui', 'onde', 'quando', 'como', 'qual', 'confiança',
|
||||
'recuperar', 'recupera', 'recuperação', 'varredura', 'imagem', 'sessão', 'mês', 'dia',
|
||||
'hora', 'hoje', 'ontem', 'modificado', 'criado', 'acessado', 'tirada', 'foto', 'fotos',
|
||||
'documentos', 'vídeos', 'vídeo', 'áudio', 'compactados', 'banco', 'bancos', 'dados',
|
||||
];
|
||||
|
||||
const BRAND = /Guará|Português|English|OpenStreetMap|BitLocker|FileVault|LUKS|NO\u00c7\u00d5ES|S\u00c3O/gi;
|
||||
const isPortuguese = (raw) => {
|
||||
const s = raw.replace(BRAND, ' ');
|
||||
return ACCENT.test(s) || WORDS.some((w) => new RegExp(`(^|[^a-zà-ú])${w}([^a-zà-ú]|$)`, 'i').test(s));
|
||||
};
|
||||
|
||||
const ALLOW = new Set([
|
||||
'Guará', 'Português', 'English', 'Auto', 'SMART', 'NVMe', 'OpenStreetMap', 'SHA-256',
|
||||
'SHA-1', 'MD5', 'AES', 'LUKS', 'BitLocker', 'FileVault', 'APFS', 'HFS+', 'ext4', 'Btrfs',
|
||||
'XFS', 'NTFS', 'FAT32', 'exFAT', 'ReFS', 'RAID', 'LVM', 'SQLite', 'CSV', 'HTML', 'PDF',
|
||||
'JPEG', 'PNG', 'GIF', 'BMP', 'WebP', 'MP4', 'MOV', 'E01', 'Ex01', 'AFF4', 'UTF-8',
|
||||
'UTF-16', 'USB', 'SSD', 'ATA', 'CRC', 'GPS', 'EXIF', 'TRIM', 'ZIP', 'TAR', 'OK',
|
||||
]);
|
||||
const ALLOW_PREFIX = ['http://', 'https://', 'data:', 'file:', 'guara:', '#', '.', '/'];
|
||||
const ALLOW_EXACT = new Set([
|
||||
'sem data', 'no date', 'n/a', '—', '-', '·',
|
||||
]);
|
||||
|
||||
function suspicious(text) {
|
||||
const s = text.replace(/\s+/g, ' ').trim();
|
||||
if (!s || s.length < 2) return false;
|
||||
if (ALLOW.has(s) || ALLOW_EXACT.has(s)) return false;
|
||||
if (ALLOW_PREFIX.some((p) => s.startsWith(p))) return false;
|
||||
if (!isPortuguese(s)) return false;
|
||||
// técnico: seletores, classes, extensões, expressões
|
||||
if (/^[.#\w-]+(\s*[,>+~]\s*[.#\w-]+)+$/.test(s)) return false;
|
||||
if (/^[a-z0-9_.:/@%{}$-]+$/i.test(s) && !ACCENT.test(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- extratores
|
||||
|
||||
function jsStrings(source, file, baseLine = 1) {
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let line = baseLine;
|
||||
let prev = '';
|
||||
const skipRegex = () => {
|
||||
i++;
|
||||
let inClass = false;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (ch === '[') inClass = true;
|
||||
else if (ch === ']') inClass = false;
|
||||
else if (ch === '/' && !inClass) { i++; break; }
|
||||
else if (ch === '\n') line++;
|
||||
i++;
|
||||
}
|
||||
while (i < source.length && /[a-z]/i.test(source[i])) i++;
|
||||
};
|
||||
while (i < source.length) {
|
||||
const c = source[i];
|
||||
if (c === '\n') { line++; i++; prev = '\n'; continue; }
|
||||
if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
|
||||
if (c === '/' && source[i + 1] === '/') {
|
||||
while (i < source.length && source[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && source[i + 1] === '*') {
|
||||
i += 2;
|
||||
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { if (source[i] === '\n') line++; i++; }
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && (/[=(:,[!&|?{};+\-*%^~<>]/.test(prev) || prev === 'return')) {
|
||||
skipRegex();
|
||||
prev = ')';
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') {
|
||||
const quote = c;
|
||||
const startLine = line;
|
||||
let value = '';
|
||||
i++;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === '\\') {
|
||||
const n = source[i + 1];
|
||||
if (n === 'n') value += '\n';
|
||||
else if (n === 't') value += '\t';
|
||||
else if (n === 'r') value += '\r';
|
||||
else if (n === '\n') line++;
|
||||
else value += n ?? '';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) { i++; break; }
|
||||
if (ch === '\n') line++;
|
||||
value += ch;
|
||||
i++;
|
||||
}
|
||||
out.push({ text: value, file, line: startLine });
|
||||
prev = ')';
|
||||
continue;
|
||||
}
|
||||
if (c === '`') {
|
||||
const startLine = line;
|
||||
let value = '';
|
||||
i++;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === '\\') {
|
||||
const n = source[i + 1];
|
||||
if (n === 'n') value += '\n';
|
||||
else if (n === 't') value += '\t';
|
||||
else if (n === '\n') line++;
|
||||
else value += n ?? '';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') { i++; break; }
|
||||
if (ch === '$' && source[i + 1] === '{') {
|
||||
value += '{n}';
|
||||
i += 2;
|
||||
const exprStart = i;
|
||||
const exprLine = line;
|
||||
let depth = 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}') depth--;
|
||||
else if (source[i] === '\n') line++;
|
||||
i++;
|
||||
}
|
||||
out.push(...jsStrings(source.slice(exprStart, i - 1), file, exprLine));
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') line++;
|
||||
value += ch;
|
||||
i++;
|
||||
}
|
||||
out.push({ text: value, file, line: startLine });
|
||||
prev = ')';
|
||||
continue;
|
||||
}
|
||||
prev = c;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function htmlStrings(source, file, baseLine = 1) {
|
||||
const out = [];
|
||||
const lineAt = (idx) => baseLine + source.slice(0, idx).split('\n').length - 1;
|
||||
// todo texto entre tags (inclusive começo e fim) — nós de texto reais
|
||||
let last = 0;
|
||||
for (const m of source.matchAll(/<[^>]*>/g)) {
|
||||
out.push({ text: source.slice(last, m.index), file, line: lineAt(last) });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
out.push({ text: source.slice(last), file, line: lineAt(last) });
|
||||
// atributos traduzíveis (com aspas simples ou duplas)
|
||||
for (const m of source.matchAll(/\b(placeholder|title|aria-label|alt|data-empty)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
||||
out.push({ text: m[2] ?? m[3], file, line: lineAt(m.index) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rustLabels(source, file) {
|
||||
const out = [];
|
||||
const lineAt = (idx) => source.slice(0, idx).split('\n').length;
|
||||
// m.add("Rótulo", ...) e m.add(\n "Rótulo", ...)
|
||||
for (const m of source.matchAll(/\.add\(\s*"([^"]+)"/g)) out.push({ text: m[1], file, line: lineAt(m.index) });
|
||||
// m.kind = "Tipo".into()
|
||||
for (const m of source.matchAll(/\.kind\s*=\s*"([^"]+)"/g)) out.push({ text: m[1], file, line: lineAt(m.index) });
|
||||
// rótulos de listas (saúde do disco, tipos de arquivo) dentro de arrays de tuplas
|
||||
for (const m of source.matchAll(/\(\s*"([^"]{2,60})"\s*,\s*"(?:[^"]*)"\s*\)/g)) out.push({ text: m[1], file, line: lineAt(m.index) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- coleta
|
||||
|
||||
const uiFiles = readdirSync(join(root, 'apps/gui/ui'))
|
||||
.filter((f) => f.endsWith('.js') && f !== 'i18n-en.js')
|
||||
.map((f) => join(root, 'apps/gui/ui', f));
|
||||
uiFiles.push(join(root, 'apps/gui/ui/index.html'));
|
||||
|
||||
const rustFiles = [
|
||||
'crates/guara-core/src/meta.rs',
|
||||
'crates/guara-core/src/explorer.rs',
|
||||
'crates/guara-core/src/types.rs',
|
||||
'crates/guara-core/src/health.rs',
|
||||
].map((f) => join(root, f));
|
||||
|
||||
const found = [];
|
||||
for (const file of uiFiles) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
if (file.endsWith('.html')) found.push(...htmlStrings(source, file));
|
||||
else {
|
||||
for (const str of jsStrings(source, file)) {
|
||||
if (/<[a-z!/][^>]*>/i.test(str.text)) found.push(...htmlStrings(str.text, file, str.line));
|
||||
else found.push(str);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of rustFiles) found.push(...rustLabels(readFileSync(file, 'utf8'), file));
|
||||
|
||||
const missing = [];
|
||||
const seenMissing = new Set();
|
||||
for (const { text, file, line } of found) {
|
||||
if (!suspicious(text)) continue;
|
||||
const key = text.replace(/\s+/g, ' ').trim();
|
||||
if (translated(key)) continue;
|
||||
const id = `${key}\u0000${rel(file)}\u0000${line}`;
|
||||
if (seenMissing.has(id)) continue;
|
||||
seenMissing.add(id);
|
||||
missing.push({ text: key, file: rel(file), line });
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
console.error('Textos em português sem tradução em apps/gui/ui/i18n-en.js:\n');
|
||||
for (const x of missing) console.error(` ${x.file}:${x.line} ${JSON.stringify(x.text)}`);
|
||||
console.error(`\n${missing.length} texto(s) sem tradução.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
console.log(`i18n ok: ${found.length} textos verificados, ${Object.keys(EN).length} chaves no dicionário.`);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ for n in bitlk-images fvault2-images; do
|
||||
rm -rf "$tmp"
|
||||
done
|
||||
mkdir -p "$dest/dfvfs"
|
||||
for f in lvm.raw apfs.raw apfs_encrypted.dmg hfsplus.raw hfs.raw xfs.raw xfs-bigtime.raw ext4.raw ext2.raw fvdetest.qcow2 luks1.raw luks2.raw bdetogo.raw; do
|
||||
for f in lvm.raw apfs.raw apfs_encrypted.dmg hfsplus.raw hfs.raw xfs.raw xfs-bigtime.raw ext4.raw ext2.raw fvdetest.qcow2 ext2.vhd ext2.vhdx ext2.vmdk windows_volume.qcow2 apm.dmg ntfs-dynamic.vhd ntfs-fixed.vhd ntfs-dynamic.vhdx ntfs-fixed.vhdx ntfs-parent.vhd ntfs-differential.vhd ntfs-parent.vhdx ntfs-differential.vhdx fat-parent.vhd fat-differential.vhd fat-parent.vhdx fat-differential.vhdx luks1.raw luks2.raw bdetogo.raw; do
|
||||
[ -f "$dest/dfvfs/$f" ] || curl -fsSL "https://raw.githubusercontent.com/log2timeline/dfvfs/main/test_data/$f" -o "$dest/dfvfs/$f"
|
||||
done
|
||||
mkdir -p "$dest/aff4"
|
||||
|
||||
Reference in new issue
Block a user