Compare commits
No files matched your search
@@ -28,6 +28,8 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Cobertura da tradução da interface
|
- name: Cobertura da tradução da interface
|
||||||
run: node scripts/check-ui-i18n.mjs
|
run: node scripts/check-ui-i18n.mjs
|
||||||
|
- name: Script do latest.json do atualizador
|
||||||
|
run: python3 scripts/test_make_latest_json.py
|
||||||
- run: cargo fmt --all -- --check
|
- run: cargo fmt --all -- --check
|
||||||
- run: cargo clippy --workspace --all-targets
|
- run: cargo clippy --workspace --all-targets
|
||||||
|
|
||||||
@@ -42,12 +44,28 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Cache dos pacotes das ferramentas de teste (.deb)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/apt-cache-tools
|
||||||
|
key: apt-tools-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
|
||||||
- name: Ferramentas para imagens de teste (Linux)
|
- name: Ferramentas para imagens de teste (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
timeout-minutes: 25
|
||||||
sudo apt-get update
|
run: scripts/apt-install.sh ~/apt-cache-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
|
||||||
sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs imagemagick zip
|
- name: Cache das imagens de referência
|
||||||
- name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux)
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: target/testdata
|
||||||
|
key: testdata-${{ runner.os }}-${{ hashFiles('scripts/fetch-testdata.sh') }}
|
||||||
|
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
|
||||||
|
shell: bash
|
||||||
|
run: scripts/fetch-testdata.sh
|
||||||
|
- name: Imagem XFS com exclusão feita pelo kernel (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo scripts/make-xfs-real-deletion.sh
|
||||||
|
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
||||||
@@ -74,10 +92,14 @@ jobs:
|
|||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
- name: Cache dos pacotes do Tauri (.deb)
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/apt-cache
|
||||||
|
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
|
||||||
- name: Dependências do Tauri
|
- name: Dependências do Tauri
|
||||||
run: |
|
timeout-minutes: 25
|
||||||
sudo apt-get update
|
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
|
||||||
- name: Gera os ícones
|
- name: Gera os ícones
|
||||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||||
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ jobs:
|
|||||||
name: App desktop (${{ matrix.name }})
|
name: App desktop (${{ matrix.name }})
|
||||||
needs: create-release
|
needs: create-release
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
# Os segredos de assinatura do atualizador ficam no ambiente "release", que só aceita tags v*.
|
||||||
|
environment: release
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -106,20 +108,31 @@ jobs:
|
|||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
- name: Cache dos pacotes do Tauri (.deb)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/apt-cache
|
||||||
|
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
|
||||||
- name: Dependências do Tauri (Linux)
|
- name: Dependências do Tauri (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
timeout-minutes: 25
|
||||||
sudo apt-get update
|
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
|
||||||
- name: Gera os ícones
|
- name: Gera os ícones
|
||||||
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||||
|
# A chave privada de atualização fica só nos segredos do repositório. Ela assina os pacotes
|
||||||
|
# que o app baixa sozinho; a chave pública correspondente está em tauri.conf.json.
|
||||||
- uses: tauri-apps/tauri-action@v1
|
- uses: tauri-apps/tauri-action@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
projectPath: apps/gui/src-tauri
|
projectPath: apps/gui/src-tauri
|
||||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
# o latest.json é montado no job de publicação, de uma vez só (scripts/make-latest-json.py)
|
||||||
|
uploadUpdaterJson: false
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publica a release
|
name: Publica a release
|
||||||
@@ -144,3 +157,11 @@ jobs:
|
|||||||
body: fs.readFileSync('notes.md', 'utf8'),
|
body: fs.readFileSync('notes.md', 'utf8'),
|
||||||
draft: false,
|
draft: false,
|
||||||
});
|
});
|
||||||
|
# Depois de publicada (rascunho não é visível por tag), monta e sobe o latest.json.
|
||||||
|
- name: latest.json do atualizador do app
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
python3 scripts/make-latest-json.py "$GITHUB_REF_NAME" notes.md latest.json
|
||||||
|
gh release upload "$GITHUB_REF_NAME" latest.json --clobber
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
# Estado de ferramentas locais
|
# Estado de ferramentas locais
|
||||||
.wolf/
|
.wolf/
|
||||||
.claude/
|
.claude/
|
||||||
|
.superset/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
|
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"setup": ["./.superset/setup.sh"],
|
|
||||||
"teardown": ["./.superset/teardown.sh"],
|
|
||||||
"run": ["cargo run -p guara-cli -- ui --no-open"]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Runs once when a Superset workspace (git worktree) is created. Safe to run again.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Local files that are not tracked by git but that a new worktree needs.
|
|
||||||
# Nothing is overwritten if it already exists in the workspace.
|
|
||||||
if [ -n "${SUPERSET_ROOT_PATH:-}" ] && [ -d "$SUPERSET_ROOT_PATH" ]; then
|
|
||||||
for f in "$SUPERSET_ROOT_PATH"/.env "$SUPERSET_ROOT_PATH"/.env.* "$SUPERSET_ROOT_PATH"/CLAUDE.md; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
dest="./$(basename "$f")"
|
|
||||||
[ -e "$dest" ] || cp "$f" "$dest"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download the crates now so the first build and the first test run do not wait on the network.
|
|
||||||
cargo fetch --locked
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Runs when a Superset workspace is deleted.
|
|
||||||
# Setup starts no background service. The only long-running process is the Run command
|
|
||||||
# (`guara ui`), which this workspace builds into its own target/ directory, so stop any
|
|
||||||
# copy that is still alive before the worktree is removed.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
pkill -f -- "$PWD/target/.*/guara ui" 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
@@ -4,6 +4,26 @@
|
|||||||
|
|
||||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-10-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Update from inside the desktop app.** On startup the app asks GitHub for the release's `latest.json`; if there is a newer version, a card offers **Update now**. It downloads the package, verifies its minisign signature against the public key built into the app, installs and reopens (the AppImage is replaced in place; Windows and macOS use the installer). Installs from `.deb` or `.rpm` only show the notice. **Settings** can turn the startup check off and has a **Check now** button. The check is one HTTP request and sends nothing of yours.
|
||||||
|
- **Digital signatures (Ed25519):** `guara sign keygen | file | verify | recovery`. With a key configured, `recover` and `report` sign the manifest and the report by themselves. `guara sign recovery` checks the manifest signature and the hash of every recovered file (and refuses manifest paths that leave the folder). The signature is plain Ed25519 over a documented message, so `openssl` verifies it too. Comparing the key fingerprint (`--trust`) says whose key it is; the signed time is the local clock, not a third-party timestamp.
|
||||||
|
- **Virtual disks:** QCOW2 with a backing file, VMDK snapshots (parent by name or, in VirtualBox, by UUID; the child is refused if the parent's CID changed) and DMG with LZFSE chunks.
|
||||||
|
- **CI with real reference images** on Linux, macOS and Windows (they were skipped before), and an XFS image deleted by the real kernel generated with `sudo` on the Linux runner.
|
||||||
|
- Release pipeline: update packages are signed with a key kept in the repository's `release` environment (tags `v*` only), and `latest.json` is built in one step by `scripts/make-latest-json.py`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **XFS is now 🟡.** On a real XFS (v5, kernel deletion) the inode and extent list are wiped, so the metadata path finds nothing; the content of files with a recognizable structure comes back by carving, without name or folder. The earlier path only worked on simulated deletions. The name-and-folder test is kept `ignore`d until the journal is read.
|
||||||
|
- CI `apt` steps use cached `.deb`s, a time limit per attempt and retries (they took up to 28 minutes on a slow mirror day).
|
||||||
|
- Dependencies: `ctutils` 0.4.5 (RUSTSEC-2026-0385); new `ed25519-compact`, `lzfse_rust`, `tauri-plugin-updater`.
|
||||||
|
- The README and ROADMAP now say the update check is the only network use by default.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Versions before 1.3.0 do not have the updater: from 1.2.0, replace the AppImage by hand one last time. The first update inside the app is 1.3.0 to the next release.
|
||||||
|
- Still missing: password-protected DMG, an identity check for VHD, VHDX and QCOW2 parents, third-party timestamps (RFC 3161), XFS journal reading, and APFS validation with real deletion (needs an image from a real Mac).
|
||||||
|
- CLI output remains in Portuguese.
|
||||||
|
|
||||||
## [1.2.0] - 2026-10-22
|
## [1.2.0] - 2026-10-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,6 +4,26 @@
|
|||||||
|
|
||||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-10-23
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- **Atualização dentro do app desktop.** Ao abrir, o app pede ao GitHub o `latest.json` da release; havendo versão nova, um cartão oferece **Atualizar agora**. Ele baixa o pacote, confere a assinatura minisign com a chave pública embutida no app, instala e reabre (o AppImage é substituído no lugar; Windows e macOS usam o instalador). Instalações por `.deb` ou `.rpm` só mostram o aviso. **Configurações** desliga a consulta ao abrir e tem o botão **Verificar agora**. A consulta é uma requisição HTTP e não envia nada seu.
|
||||||
|
- **Assinatura digital (Ed25519):** `guara sign keygen | file | verify | recovery`. Com uma chave configurada, `recover` e `report` assinam sozinhos o manifesto e o relatório. `guara sign recovery` confere a assinatura do manifesto e o hash de cada arquivo recuperado (e recusa caminhos do manifesto que saem da pasta). É Ed25519 comum sobre uma mensagem documentada, então o `openssl` também confere. Comparar a impressão digital da chave (`--trust`) diz de quem ela é; a hora assinada é a do relógio local, não um carimbo de terceiros.
|
||||||
|
- **Discos virtuais:** QCOW2 com arquivo de apoio, snapshots de VMDK (pai por nome ou, no VirtualBox, por UUID; o filho é recusado se o CID do pai mudou) e DMG com chunks LZFSE.
|
||||||
|
- **CI com imagens de referência reais** em Linux, macOS e Windows (antes eram puladas) e uma imagem XFS apagada pelo kernel de verdade, gerada com `sudo` no runner Linux.
|
||||||
|
- Pipeline de release: os pacotes de atualização são assinados com uma chave guardada no ambiente `release` do repositório (só tags `v*`), e o `latest.json` é montado de uma vez por `scripts/make-latest-json.py`.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- **O XFS passa a 🟡.** Num XFS real (v5, exclusão do kernel) o inode e a lista de extensões são zerados, então o caminho por metadados não acha nada; o conteúdo de arquivos com estrutura reconhecível volta por carving, sem nome nem pasta. O caminho anterior só funcionava na exclusão simulada. O teste de nome e pasta fica `ignore`d até o diário ser lido.
|
||||||
|
- Os passos de `apt` do CI usam `.deb` em cache, tempo limite por tentativa e nova tentativa (levavam até 28 minutos num dia de espelho lento).
|
||||||
|
- Dependências: `ctutils` 0.4.5 (RUSTSEC-2026-0385); novas `ed25519-compact`, `lzfse_rust` e `tauri-plugin-updater`.
|
||||||
|
- O README e o ROADMAP passam a dizer que a verificação de atualização é o único uso da rede por padrão.
|
||||||
|
|
||||||
|
### Notas
|
||||||
|
- As versões anteriores à 1.3.0 não têm o atualizador: da 1.2.0, troque o AppImage à mão uma última vez. A primeira atualização dentro do app é da 1.3.0 para a próxima versão.
|
||||||
|
- Ainda faltam: DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2, carimbo de tempo de terceiros (RFC 3161), leitura do diário do XFS e validação de APFS com exclusão real (precisa de imagem de um Mac de verdade).
|
||||||
|
- A saída da CLI continua em português.
|
||||||
|
|
||||||
## [1.2.0] - 2026-10-22
|
## [1.2.0] - 2026-10-22
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ On Linux the tests build images with `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro
|
|||||||
|
|
||||||
For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `scripts/fetch-testdata.sh`; without them those tests are skipped. The BitLocker test takes about 75 s.
|
For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `scripts/fetch-testdata.sh`; without them those tests are skipped. The BitLocker test takes about 75 s.
|
||||||
|
|
||||||
|
The desktop app signs its update packages: `tauri.conf.json` has `createUpdaterArtifacts` on, so a local `tauri build` needs `TAURI_SIGNING_PRIVATE_KEY` (any key made with `tauri signer generate`; the release key lives only in the repository secrets). For a quick local bundle, pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
|
||||||
|
|
||||||
## Where to make changes
|
## Where to make changes
|
||||||
|
|
||||||
A map of the modules is in [docs/architecture.md](docs/architecture.md). What is missing and what is welcome is in the [ROADMAP](ROADMAP.md).
|
A map of the modules is in [docs/architecture.md](docs/architecture.md). What is missing and what is welcome is in the [ROADMAP](ROADMAP.md).
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro
|
|||||||
|
|
||||||
Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), rode `scripts/fetch-testdata.sh`; sem elas esses testes são pulados. O teste de BitLocker leva cerca de 75 s.
|
Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), rode `scripts/fetch-testdata.sh`; sem elas esses testes são pulados. O teste de BitLocker leva cerca de 75 s.
|
||||||
|
|
||||||
|
O app desktop assina os pacotes de atualização: o `tauri.conf.json` tem `createUpdaterArtifacts` ligado, então um `tauri build` local precisa de `TAURI_SIGNING_PRIVATE_KEY` (qualquer chave feita com `tauri signer generate`; a chave da release fica só nos segredos do repositório). Para um pacote local rápido, passe `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
|
||||||
|
|
||||||
## Onde mexer
|
## Onde mexer
|
||||||
|
|
||||||
Mapa dos módulos em [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). O que falta e o que é bem-vindo está no [ROADMAP](ROADMAP.pt-BR.md).
|
Mapa dos módulos em [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). O que falta e o que é bem-vindo está no [ROADMAP](ROADMAP.pt-BR.md).
|
||||||
|
|||||||
Generated
+84
-4
@@ -309,9 +309,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctutils"
|
name = "ctutils"
|
||||||
version = "0.4.3"
|
version = "0.4.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
@@ -340,6 +340,15 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-compact"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1454db4f2edb7f0e8fe0c5b375b0c978fc63244cc9f010d160e417eb10139aa8"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -389,8 +398,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -405,7 +416,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-cli"
|
name = "guara-cli"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -418,11 +429,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
|
"ed25519-compact",
|
||||||
"flate2",
|
"flate2",
|
||||||
"hmac",
|
"hmac",
|
||||||
"image",
|
"image",
|
||||||
@@ -430,6 +442,7 @@ dependencies = [
|
|||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
"libc",
|
"libc",
|
||||||
"lz4_flex",
|
"lz4_flex",
|
||||||
|
"lzfse_rust",
|
||||||
"md-5",
|
"md-5",
|
||||||
"mp4",
|
"mp4",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
@@ -552,6 +565,16 @@ version = "0.7.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
|
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libbz2-rs-sys"
|
name = "libbz2-rs-sys"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -582,6 +605,12 @@ version = "0.14.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
|
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lzfse_rust"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -844,6 +873,12 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruzstd"
|
name = "ruzstd"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -1053,6 +1088,51 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.129"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.129"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.129"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.129"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/guara-core", "crates/guara-cli"]
|
members = ["crates/guara-core", "crates/guara-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/guara"
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately:
|
In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately:
|
||||||
|
|
||||||
- **File system metadata:** finds deleted files **with name, folder and date** on NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs, XFS and APFS.
|
- **File system metadata:** finds deleted files **with name, folder and date** on NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs and APFS (on XFS only the content comes back, by carving, see below).
|
||||||
- **Structure-aware carving** in free space, damaged partitions and unpartitioned space. The detectors read the real structure of dozens of formats to learn the **exact size** and a **confidence** level.
|
- **Structure-aware carving** in free space, damaged partitions and unpartitioned space. The detectors read the real structure of dozens of formats to learn the **exact size** and a **confidence** level.
|
||||||
- **Encrypted volumes:** opens BitLocker, LUKS and FileVault 2 with your password or key and scans inside.
|
- **Encrypted volumes:** opens BitLocker, LUKS and FileVault 2 with your password or key and scans inside.
|
||||||
- **RAID and LVM:** reassembles arrays from loose disks (RAID 0, 1, 4, 5, 6, 10, LVM2), working out level, stripe size and order by itself when there is no metadata.
|
- **RAID and LVM:** reassembles arrays from loose disks (RAID 0, 1, 4, 5, 6, 10, LVM2), working out level, stripe size and order by itself when there is no metadata.
|
||||||
@@ -44,6 +44,8 @@ Download the package for your system from **[Releases](../../releases)**:
|
|||||||
|
|
||||||
Every file has a `.sha256` next to it. To build from source: `cargo build --release -p guara-cli` (Rust 1.80+).
|
Every file has a `.sha256` next to it. To build from source: `cargo build --release -p guara-cli` (Rust 1.80+).
|
||||||
|
|
||||||
|
**Updates:** the desktop app (AppImage, Windows and macOS) checks GitHub for a newer version when it opens; if there is one, a card offers **Update now**: it downloads, verifies the package signature, installs and reopens, so you no longer swap the AppImage by hand. The check is a single request for the release's `latest.json` and nothing of yours is sent; turn it off in **Settings**. Installs from `.deb`/`.rpm` only show the notice, because the package manager owns those files.
|
||||||
|
|
||||||
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256.
|
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256.
|
||||||
|
|
||||||
Reading raw disks requires privileges: **administrator** (Windows), `sudo` (Linux and macOS). Even as root, Guará writes sessions, reports and recovered files with the owner and permissions of the user who launched it.
|
Reading raw disks requires privileges: **administrator** (Windows), `sudo` (Linux and macOS). Even as root, Guará writes sessions, reports and recovered files with the owner and permissions of the user who launched it.
|
||||||
@@ -91,14 +93,14 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f
|
|||||||
- **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name.
|
- **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name.
|
||||||
- **Duplicate detection** by content signature.
|
- **Duplicate detection** by content signature.
|
||||||
|
|
||||||
> **Warning:** Guará has **not been externally validated** yet (for example, in the NIST CFTT tests) and has no audit log or signed report. It is well suited to recovery and triage; for use in court, check the results by other means. The path to get there is in the [ROADMAP](ROADMAP.md).
|
> **Warning:** Guará has **not been externally validated** yet (for example, in the NIST CFTT tests) and has no trusted timestamp (RFC 3161) on its signatures. It is well suited to recovery and triage; for use in court, check the results by other means. The path to get there is in the [ROADMAP](ROADMAP.md).
|
||||||
|
|
||||||
## Known limits
|
## Known limits
|
||||||
|
|
||||||
- **Overwritten data does not come back.** On an SSD with TRIM the disk may zero deleted blocks; Guará warns you, but it cannot work miracles.
|
- **Overwritten data does not come back.** On an SSD with TRIM the disk may zero deleted blocks; Guará warns you, but it cannot work miracles.
|
||||||
- **Fragments:** only JPEG (up to three pieces) and MP4 without an index are reassembled. Other formats, if fragmented, come out truncated with low confidence.
|
- **Fragments:** only JPEG (up to three pieces) and MP4 without an index are reassembled. Other formats, if fragmented, come out truncated with low confidence.
|
||||||
- **ReFS and Storage Spaces** are not supported (ReFS is only detected).
|
- **ReFS and Storage Spaces** are not supported (ReFS is only detected).
|
||||||
- **APFS:** no free-space map. **XFS and APFS:** deletion recovery tested only in simulation.
|
- **XFS:** on a real XFS (v5, kernel deletion) the inode and its extent list are wiped, so Guará recovers the **content** of files with a recognizable structure by carving, but not their name or folder; reading names needs the file system journal, which is not implemented. The old inode-based path only works on simulated deletions. **APFS:** deletion recovery tested only in simulation.
|
||||||
- **NTFS:** compressed files, EFS and heavily fragmented records are skipped or marked as incomplete.
|
- **NTFS:** compressed files, EFS and heavily fragmented records are skipped or marked as incomplete.
|
||||||
- **exFAT:** validated with a synthetic image; real media are still missing.
|
- **exFAT:** validated with a synthetic image; real media are still missing.
|
||||||
- **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords.
|
- **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords.
|
||||||
@@ -111,8 +113,8 @@ Summary of the [ROADMAP](ROADMAP.md):
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, APFS free map |
|
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, Ed25519 signatures, APFS free map |
|
||||||
| **Next** | Digitally signed report, tests on Windows and macOS |
|
| **Next** | Trusted timestamps (RFC 3161), tests on Windows and macOS |
|
||||||
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
|
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
|
||||||
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
|
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
|
||||||
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
|
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
|
||||||
|
|||||||
+5
-3
@@ -44,6 +44,8 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
|||||||
|
|
||||||
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+).
|
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+).
|
||||||
|
|
||||||
|
**Atualizações:** o app desktop (AppImage, Windows e macOS) consulta o GitHub ao abrir; havendo versão nova, um cartão oferece **Atualizar agora**: ele baixa, confere a assinatura do pacote, instala e reabre, e você deixa de trocar o AppImage na mão. A consulta é uma única requisição ao `latest.json` da release e nada seu é enviado; desligue em **Configurações**. Instalações por `.deb`/`.rpm` só mostram o aviso, porque quem manda nesses arquivos é o gerenciador de pacotes.
|
||||||
|
|
||||||
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
||||||
|
|
||||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux e macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
|
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux e macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
|
||||||
@@ -91,7 +93,7 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando
|
|||||||
- **Sessões automáticas** e reconexão: o Guará reconhece o mesmo disco que volta com outro nome.
|
- **Sessões automáticas** e reconexão: o Guará reconhece o mesmo disco que volta com outro nome.
|
||||||
- **Detecção de duplicadas** por assinatura de conteúdo.
|
- **Detecção de duplicadas** por assinatura de conteúdo.
|
||||||
|
|
||||||
> **Atenção:** o Guará ainda **não foi validado externamente** (por exemplo, nos testes do NIST CFTT) e não tem registro de auditoria nem relatório assinado. Serve bem para recuperação e triagem; para uso em juízo, confira os resultados por outro meio. O caminho para isso está no [ROADMAP](ROADMAP.pt-BR.md).
|
> **Atenção:** o Guará ainda **não foi validado externamente** (por exemplo, nos testes do NIST CFTT) e suas assinaturas não têm carimbo de tempo de terceiros (RFC 3161). Serve bem para recuperação e triagem; para uso em juízo, confira os resultados por outro meio. O caminho para isso está no [ROADMAP](ROADMAP.pt-BR.md).
|
||||||
|
|
||||||
## Limites conhecidos
|
## Limites conhecidos
|
||||||
|
|
||||||
@@ -111,8 +113,8 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa livre do APFS |
|
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, assinaturas Ed25519, mapa livre do APFS |
|
||||||
| **Próximo** | Relatório assinado digitalmente, testes em Windows e macOS |
|
| **Próximo** | Carimbo de tempo de terceiros (RFC 3161), testes em Windows e macOS |
|
||||||
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
|
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
|
||||||
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
|
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
|
||||||
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
|
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
|
||||||
|
|||||||
+18
-13
@@ -11,7 +11,7 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o
|
|||||||
1. **The source is read-only.** Nothing in the core writes to the device being recovered.
|
1. **The source is read-only.** Nothing in the core writes to the device being recovered.
|
||||||
2. **Do not ship without validating.** A feature only lands with a test against a real or reference image. That is why ReFS and Storage Spaces do not exist yet: there is no reference image to check against.
|
2. **Do not ship without validating.** A feature only lands with a test against a real or reference image. That is why ReFS and Storage Spaces do not exist yet: there is no reference image to check against.
|
||||||
3. **Honest confidence.** If the content could not be checked, confidence is low or medium, never high.
|
3. **Honest confidence.** If the content could not be checked, confidence is low or medium, never high.
|
||||||
4. **Free and auditable.** Open source (MIT or Apache-2.0), no telemetry, and every result can be reproduced.
|
4. **Free and auditable.** Open source (MIT or Apache-2.0), no telemetry (the only thing that touches the network by default is the update check, a single request you can turn off), and every result can be reproduced.
|
||||||
|
|
||||||
## History
|
## History
|
||||||
|
|
||||||
@@ -27,10 +27,11 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o
|
|||||||
| 1.0.1 | 2026-10-20 | Documentation in English with Portuguese translations, complete English interface, screenshots, Docker image of the CLI |
|
| 1.0.1 | 2026-10-20 | Documentation in English with Portuguese translations, complete English interface, screenshots, Docker image of the CLI |
|
||||||
| 1.1.0 | 2026-10-20 | Core messages and dates in English, automated translation check, RustCrypto on digest 0.11, verified Docker device access and Superset workspaces |
|
| 1.1.0 | 2026-10-20 | Core messages and dates in English, automated translation check, RustCrypto on digest 0.11, verified Docker device access and Superset workspaces |
|
||||||
| 1.2.0 | 2026-10-22 | QCOW2, VMDK, VHD, VHDX and DMG virtual disks, hash-chained audit trail, PDF report, APFS free-space map |
|
| 1.2.0 | 2026-10-22 | QCOW2, VMDK, VHD, VHDX and DMG virtual disks, hash-chained audit trail, PDF report, APFS free-space map |
|
||||||
|
| 1.3.0 | 2026-10-23 | Update from inside the app, Ed25519 signatures, differencing QCOW2/VMDK and LZFSE DMG, reference images tested on Linux, macOS and Windows, XFS real-deletion test |
|
||||||
|
|
||||||
Details of each version in the [CHANGELOG](CHANGELOG.md).
|
Details of each version in the [CHANGELOG](CHANGELOG.md).
|
||||||
|
|
||||||
## Current status (1.2.0) against what is expected from a forensic suite
|
## Current status (1.3.0) against what is expected from a forensic suite
|
||||||
|
|
||||||
### 1. File systems with metadata
|
### 1. File systems with metadata
|
||||||
|
|
||||||
@@ -40,12 +41,13 @@ Details of each version in the [CHANGELOG](CHANGELOG.md).
|
|||||||
| ext2/3/4 | ✅ | Deleted inodes and old copies in the journal |
|
| ext2/3/4 | ✅ | Deleted inodes and old copies in the journal |
|
||||||
| HFS+ | ✅ | Tree node leftovers and journal |
|
| HFS+ | ✅ | Tree node leftovers and journal |
|
||||||
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents |
|
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents |
|
||||||
| XFS | ✅ | Freed inodes and names of freed entries. Tested only with simulated deletion |
|
| XFS | 🟡 | Freed inodes and names of freed entries work on simulated deletion only. With real kernel deletion (tested in CI) the metadata is wiped: content comes back by carving, names do not. **Journal reading is missing** |
|
||||||
| APFS | 🟡 | Container, object map, 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 |
|
| APFS | 🟡 | Container, object map, free-space map (spaceman and bitmaps), snapshots and old nodes. Deletion tested only in simulation; containers with chunk-info address blocks (large ones) fall back to scanning the whole volume |
|
||||||
| ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
|
| ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
|
||||||
| Storage Spaces | 🔜 | Not done. Reference image missing |
|
| Storage Spaces | 🔜 | Not done. Reference image missing |
|
||||||
| NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today |
|
| NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today |
|
||||||
| XFS and APFS validation with real deletion | 🔜 | Replace the simulation with images from real devices |
|
| XFS: names and folders after real deletion (read the journal) | 🔜 | The CI test exists and is ignored until this lands |
|
||||||
|
| APFS validation with real deletion | 🔜 | Needs an image from a real Mac |
|
||||||
|
|
||||||
### 2. Encrypted volumes
|
### 2. Encrypted volumes
|
||||||
|
|
||||||
@@ -68,7 +70,8 @@ Today Guará opens volumes when you **already have** the password or key. Passwo
|
|||||||
| E01 and Ex01: read, write and `guara verify` | ✅ |
|
| E01 and Ex01: read, write and `guara verify` | ✅ |
|
||||||
| AFF4: read and write | ✅ |
|
| AFF4: read and write | ✅ |
|
||||||
| Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ |
|
| Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ |
|
||||||
| QCOW2 with a backing file, VMDK snapshots, DMG with LZFSE or a password | 🔜 |
|
| QCOW2 with a backing file, VMDK snapshots (by name or VirtualBox UUID), DMG with LZFSE | ✅ |
|
||||||
|
| Password-protected DMG, parent identity check for VHD, VHDX and QCOW2 | 🔜 |
|
||||||
| Mobile device image (see item 7) | 🔭 |
|
| Mobile device image (see item 7) | 🔭 |
|
||||||
|
|
||||||
### 4. Assembling loose disks
|
### 4. Assembling loose disks
|
||||||
@@ -129,7 +132,8 @@ Physical access is the differentiator of commercial tools like Cellebrite. It de
|
|||||||
| HTML report with case, examiner, source, partitions, summary and hashes | ✅ |
|
| HTML report with case, examiner, source, partitions, summary and hashes | ✅ |
|
||||||
| Verification of an E01/Ex01 image against the stored hash | ✅ |
|
| Verification of an E01/Ex01 image against the stored hash | ✅ |
|
||||||
| **Audit trail** of every examiner action (who, when, what), chained by hash, with `guara audit --verify` | ✅ |
|
| **Audit trail** of every examiner action (who, when, what), chained by hash, with `guara audit --verify` | ✅ |
|
||||||
| **Digitally signed reports** and a timestamped manifest | 🔜 |
|
| **Digital signatures** (Ed25519) of manifests, reports and any file; `guara sign recovery` checks a whole recovery folder | ✅ |
|
||||||
|
| Trusted timestamp (RFC 3161) on signatures | 🔜 |
|
||||||
| **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
|
| **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
|
||||||
| PDF report (A4, built in) | ✅ |
|
| PDF report (A4, built in) | ✅ |
|
||||||
| **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 |
|
| **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 |
|
||||||
@@ -155,7 +159,8 @@ External validation is what makes a report accepted. Without it, Guará is good
|
|||||||
|---|---|
|
|---|---|
|
||||||
| CLI and interface for Windows, Linux and macOS | ✅ |
|
| CLI and interface for Windows, Linux and macOS | ✅ |
|
||||||
| CI on three systems, automatic release by tag | ✅ |
|
| CI on three systems, automatic release by tag | ✅ |
|
||||||
| Tests with real images on Windows and macOS (Linux only today) | 🔜 |
|
| Tests with real reference images on Linux, macOS and Windows | ✅ |
|
||||||
|
| Update from inside the desktop app (signed packages, one click) | ✅ |
|
||||||
| GUI: privileged helper process instead of restarting the whole app as administrator | 🔜 |
|
| GUI: privileged helper process instead of restarting the whole app as administrator | 🔜 |
|
||||||
| Signed and notarized binaries | 🔜 |
|
| Signed and notarized binaries | 🔜 |
|
||||||
| Packages in package managers (winget, Homebrew, Flatpak) | 🔭 |
|
| Packages in package managers (winget, Homebrew, Flatpak) | 🔭 |
|
||||||
@@ -164,17 +169,17 @@ External validation is what makes a report accepted. Without it, Guará is good
|
|||||||
|
|
||||||
No dates, in the order that makes the most sense.
|
No dates, in the order that makes the most sense.
|
||||||
|
|
||||||
**1.1: trust and custody**
|
**1.4: trust and custody**
|
||||||
Signed manifest, XFS and APFS validation with real deletions, tests with real images on Windows and macOS.
|
Trusted timestamps, XFS journal reading, APFS validation with real deletions.
|
||||||
|
|
||||||
**1.2: analysis**
|
**1.5: analysis**
|
||||||
NSRL and hash lists, search in file content, unified timeline.
|
NSRL and hash lists, search in file content, unified timeline.
|
||||||
|
|
||||||
**1.3: artifacts**
|
**1.6: artifacts**
|
||||||
Windows registry, browsers, EVTX and the most common messaging apps, built on the SQLite reader.
|
Windows registry, browsers, EVTX and the most common messaging apps, built on the SQLite reader.
|
||||||
|
|
||||||
**1.4: more structure**
|
**1.7: more structure**
|
||||||
ReFS and Storage Spaces (once there is a reference image), other image containers (VMDK, VHD, QCOW2), expanded fragmented carving.
|
ReFS and Storage Spaces (once there is a reference image), password-protected DMG, identity check of VHD, VHDX and QCOW2 parents, expanded fragmented carving.
|
||||||
|
|
||||||
**2.0: mobile devices and validation**
|
**2.0: mobile devices and validation**
|
||||||
iTunes backups, ADB and Android backups; submission to the NIST CFTT tests.
|
iTunes backups, ADB and Android backups; submission to the NIST CFTT tests.
|
||||||
|
|||||||
+18
-13
@@ -11,7 +11,7 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo
|
|||||||
1. **A origem é somente leitura.** Nada no núcleo escreve no dispositivo recuperado.
|
1. **A origem é somente leitura.** Nada no núcleo escreve no dispositivo recuperado.
|
||||||
2. **Não entregar sem validar.** Um recurso só entra com teste contra imagem real ou de referência. Por isso ReFS e Storage Spaces ainda não existem: não há imagem de referência para conferir.
|
2. **Não entregar sem validar.** Um recurso só entra com teste contra imagem real ou de referência. Por isso ReFS e Storage Spaces ainda não existem: não há imagem de referência para conferir.
|
||||||
3. **Confiança honesta.** Se o conteúdo não pôde ser conferido, a confiança é baixa ou média, nunca alta.
|
3. **Confiança honesta.** Se o conteúdo não pôde ser conferido, a confiança é baixa ou média, nunca alta.
|
||||||
4. **Livre e auditável.** Código aberto (MIT ou Apache-2.0), sem telemetria, e cada resultado pode ser reproduzido.
|
4. **Livre e auditável.** Código aberto (MIT ou Apache-2.0), sem telemetria (a única coisa que usa a rede por padrão é a verificação de atualização, uma requisição só que você pode desligar), e cada resultado pode ser reproduzido.
|
||||||
|
|
||||||
## Histórico
|
## Histórico
|
||||||
|
|
||||||
@@ -27,10 +27,11 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo
|
|||||||
| 1.0.1 | 2026-10-20 | Documentação em inglês com tradução em português, interface completa em inglês, telas, imagem Docker do terminal |
|
| 1.0.1 | 2026-10-20 | Documentação em inglês com tradução em português, interface completa em inglês, telas, imagem Docker do terminal |
|
||||||
| 1.1.0 | 2026-10-20 | Mensagens do núcleo e datas em inglês, verificação automática da tradução, RustCrypto em digest 0.11, acesso a dispositivo no Docker e workspaces do Superset verificados |
|
| 1.1.0 | 2026-10-20 | Mensagens do núcleo e datas em inglês, verificação automática da tradução, RustCrypto em digest 0.11, acesso a dispositivo no Docker e workspaces do Superset verificados |
|
||||||
| 1.2.0 | 2026-10-22 | Discos virtuais QCOW2, VMDK, VHD, VHDX e DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa de espaço livre do APFS |
|
| 1.2.0 | 2026-10-22 | Discos virtuais QCOW2, VMDK, VHD, VHDX e DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa de espaço livre do APFS |
|
||||||
|
| 1.3.0 | 2026-10-23 | Atualização pelo próprio app, assinaturas Ed25519, QCOW2/VMDK diferenciais e DMG com LZFSE, imagens de referência testadas em Linux, macOS e Windows, teste de exclusão real do XFS |
|
||||||
|
|
||||||
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
|
Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
|
||||||
|
|
||||||
## Situação atual (1.2.0) contra o que se espera de uma suíte forense
|
## Situação atual (1.3.0) contra o que se espera de uma suíte forense
|
||||||
|
|
||||||
### 1. Sistemas de arquivos com metadados
|
### 1. Sistemas de arquivos com metadados
|
||||||
|
|
||||||
@@ -40,12 +41,13 @@ Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
|
|||||||
| ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário |
|
| ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário |
|
||||||
| HFS+ | ✅ | Sobras de nós da árvore e diário |
|
| HFS+ | ✅ | Sobras de nós da árvore e diário |
|
||||||
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo |
|
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo |
|
||||||
| XFS | ✅ | Inodes liberados e nomes de entradas liberadas. Testado só com exclusão simulada |
|
| XFS | 🟡 | Inodes liberados e nomes de entradas liberadas só funcionam na exclusão simulada. Com exclusão real do kernel (testada no CI) os metadados são zerados: o conteúdo volta por carving, o nome não. **Falta ler o diário** |
|
||||||
| APFS | 🟡 | Contêiner, mapa de objetos, 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 |
|
| APFS | 🟡 | Contêiner, mapa de objetos, mapa de espaço livre (spaceman e bitmaps), instantâneos e nós antigos. Exclusão testada só em simulação; contêineres com blocos de endereço de chunk-info (grandes) voltam a varrer o volume inteiro |
|
||||||
| ReFS | 🔜 | Hoje só é detectado; a recuperação depende do carving. Falta imagem de referência |
|
| ReFS | 🔜 | Hoje só é detectado; a recuperação depende do carving. Falta imagem de referência |
|
||||||
| Storage Spaces | 🔜 | Não feito. Falta imagem de referência |
|
| Storage Spaces | 🔜 | Não feito. Falta imagem de referência |
|
||||||
| NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos |
|
| NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos |
|
||||||
| Validação de XFS e APFS com exclusão real | 🔜 | Trocar a simulação por imagens de dispositivos reais |
|
| XFS: nomes e pastas após exclusão real (ler o diário) | 🔜 | O teste no CI existe e fica ignorado até isso entrar |
|
||||||
|
| Validação de APFS com exclusão real | 🔜 | Precisa de imagem de um Mac de verdade |
|
||||||
|
|
||||||
### 2. Volumes cifrados
|
### 2. Volumes cifrados
|
||||||
|
|
||||||
@@ -68,7 +70,8 @@ Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recupera
|
|||||||
| E01 e Ex01: leitura, gravação e `guara verify` | ✅ |
|
| E01 e Ex01: leitura, gravação e `guara verify` | ✅ |
|
||||||
| AFF4: leitura e gravação | ✅ |
|
| AFF4: leitura e gravação | ✅ |
|
||||||
| Contêineres de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG (leitura) | ✅ |
|
| 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 | 🔜 |
|
| QCOW2 com arquivo de apoio, snapshots de VMDK (por nome ou UUID do VirtualBox), DMG com LZFSE | ✅ |
|
||||||
|
| DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2 | 🔜 |
|
||||||
| Imagem de dispositivo móvel (ver item 7) | 🔭 |
|
| Imagem de dispositivo móvel (ver item 7) | 🔭 |
|
||||||
|
|
||||||
### 4. Montagem de discos soltos
|
### 4. Montagem de discos soltos
|
||||||
@@ -129,7 +132,8 @@ O acesso físico é o diferencial de ferramentas comerciais como a Cellebrite. D
|
|||||||
| Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ |
|
| Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ |
|
||||||
| Verificação de imagem E01/Ex01 contra o hash gravado | ✅ |
|
| Verificação de imagem E01/Ex01 contra o hash gravado | ✅ |
|
||||||
| **Trilha de auditoria** de cada ação do perito (quem, quando, o quê), encadeada por hash, com `guara audit --verify` | ✅ |
|
| **Trilha de auditoria** de cada ação do perito (quem, quando, o quê), encadeada por hash, com `guara audit --verify` | ✅ |
|
||||||
| **Relatórios assinados** digitalmente e manifesto com carimbo de tempo | 🔜 |
|
| **Assinatura digital** (Ed25519) de manifestos, relatórios e qualquer arquivo; `guara sign recovery` confere uma pasta de recuperação inteira | ✅ |
|
||||||
|
| Carimbo de tempo de terceiros (RFC 3161) nas assinaturas | 🔜 |
|
||||||
| **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
|
| **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
|
||||||
| Relatório em PDF (A4, próprio) | ✅ |
|
| Relatório em PDF (A4, próprio) | ✅ |
|
||||||
| **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 |
|
| **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 |
|
||||||
@@ -155,7 +159,8 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve
|
|||||||
|---|---|
|
|---|---|
|
||||||
| CLI e interface para Windows, Linux e macOS | ✅ |
|
| CLI e interface para Windows, Linux e macOS | ✅ |
|
||||||
| CI em três sistemas, release automática por tag | ✅ |
|
| CI em três sistemas, release automática por tag | ✅ |
|
||||||
| Testes com imagens reais em Windows e macOS (hoje só Linux) | 🔜 |
|
| Testes com imagens de referência reais em Linux, macOS e Windows | ✅ |
|
||||||
|
| Atualização dentro do app desktop (pacotes assinados, um clique) | ✅ |
|
||||||
| GUI: processo auxiliar privilegiado em vez de reiniciar o app inteiro como administrador | 🔜 |
|
| GUI: processo auxiliar privilegiado em vez de reiniciar o app inteiro como administrador | 🔜 |
|
||||||
| Binários assinados e notarizados | 🔜 |
|
| Binários assinados e notarizados | 🔜 |
|
||||||
| Pacotes em gerenciadores (winget, Homebrew, Flatpak) | 🔭 |
|
| Pacotes em gerenciadores (winget, Homebrew, Flatpak) | 🔭 |
|
||||||
@@ -164,17 +169,17 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve
|
|||||||
|
|
||||||
Sem datas, na ordem em que fazem mais sentido.
|
Sem datas, na ordem em que fazem mais sentido.
|
||||||
|
|
||||||
**1.1: confiança e custódia**
|
**1.4: confiança e custódia**
|
||||||
Manifesto assinado, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS.
|
Carimbo de tempo de terceiros, leitura do diário do XFS, validação de APFS com exclusões reais.
|
||||||
|
|
||||||
**1.2: análise**
|
**1.5: análise**
|
||||||
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
|
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
|
||||||
|
|
||||||
**1.3: artefatos**
|
**1.6: artefatos**
|
||||||
Registro do Windows, navegadores, EVTX e os apps de mensagem mais comuns, apoiados no leitor de SQLite.
|
Registro do Windows, navegadores, EVTX e os apps de mensagem mais comuns, apoiados no leitor de SQLite.
|
||||||
|
|
||||||
**1.4: mais estrutura**
|
**1.7: mais estrutura**
|
||||||
ReFS e Storage Spaces (quando houver imagem de referência), outros contêineres de imagem (VMDK, VHD, QCOW2), carving fragmentado ampliado.
|
ReFS e Storage Spaces (quando houver imagem de referência), DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2, carving fragmentado ampliado.
|
||||||
|
|
||||||
**2.0: dispositivos móveis e validação**
|
**2.0: dispositivos móveis e validação**
|
||||||
Backups do iTunes, ADB e backups do Android; submissão aos testes do NIST CFTT.
|
Backups do iTunes, ADB e backups do Android; submissão aos testes do NIST CFTT.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/lucasrguerra/guara"
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
@@ -17,6 +17,8 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
guara-core = { path = "../../../crates/guara-core" }
|
guara-core = { path = "../../../crates/guara-core" }
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,73 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json:
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Versão nova encontrada na release do GitHub.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct UpdateInfo {
|
||||||
|
current: String,
|
||||||
|
version: String,
|
||||||
|
notes: String,
|
||||||
|
/// O app consegue se atualizar sozinho (AppImage, Windows e macOS). Instalação por pacote
|
||||||
|
/// (.deb/.rpm) só avisa e leva à página da release.
|
||||||
|
auto: bool,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn self_update_possible() -> bool {
|
||||||
|
!cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Procura uma versão mais nova. `None` = já está na última.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> {
|
||||||
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
let found = app.updater().map_err(|e| e.to_string())?.check().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(found.map(|u| UpdateInfo {
|
||||||
|
current: u.current_version.clone(),
|
||||||
|
notes: u.body.clone().unwrap_or_default(),
|
||||||
|
url: format!("https://github.com/lucasrguerra/guara/releases/tag/v{}", u.version),
|
||||||
|
version: u.version,
|
||||||
|
auto: self_update_possible(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app.
|
||||||
|
/// O progresso sai no evento `update-progress`.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
if !self_update_possible() {
|
||||||
|
return Err("Esta instalação não se atualiza sozinha (pacote do sistema). Baixe a versão nova na página da release.".into());
|
||||||
|
}
|
||||||
|
let update = app
|
||||||
|
.updater()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.check()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or("Já está na versão mais recente.")?;
|
||||||
|
let (a, b) = (app.clone(), app.clone());
|
||||||
|
let mut got = 0u64;
|
||||||
|
update
|
||||||
|
.download_and_install(
|
||||||
|
move |chunk, total| {
|
||||||
|
got += chunk as u64;
|
||||||
|
let _ = a.emit("update-progress", serde_json::json!({ "downloaded": got, "total": total }));
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let _ = b.emit("update-progress", serde_json::json!({ "installing": true }));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Não foi possível atualizar: {e}"))?;
|
||||||
|
// Rodando como administrador, o arquivo novo nasce do root: devolve ao usuário que abriu o app.
|
||||||
|
if let Some(p) = std::env::var_os("APPIMAGE") {
|
||||||
|
guara_core::owner::give_to_user(std::path::Path::new(&p));
|
||||||
|
}
|
||||||
|
app.restart()
|
||||||
|
}
|
||||||
|
|
||||||
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
|
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
|
||||||
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
|
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
|
||||||
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
|
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
|
||||||
@@ -155,6 +222,7 @@ fn main() {
|
|||||||
let for_protocol = engine.clone();
|
let for_protocol = engine.clone();
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.manage(State(engine))
|
.manage(State(engine))
|
||||||
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
|
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
|
||||||
let eng = for_protocol.clone();
|
let eng = for_protocol.clone();
|
||||||
@@ -176,7 +244,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
|
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated, update_check, update_install])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("erro ao iniciar o Guará");
|
.expect("erro ao iniciar o Guará");
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Guara",
|
"productName": "Guara",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"identifier": "dev.guara.app",
|
"identifier": "dev.guara.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -34,6 +34,18 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"createUpdaterArtifacts": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/lucasrguerra/guara/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,3 +518,12 @@ dialog.lightbox[open] { display: grid; }
|
|||||||
|
|
||||||
/* logo inline: segue o tema escolhido no app, não o do sistema */
|
/* logo inline: segue o tema escolhido no app, não o do sistema */
|
||||||
.logo .a { stroke: var(--accent); } .logo .b { stroke: var(--accent-2); } .logo .t { fill: var(--text); }
|
.logo .a { stroke: var(--accent); } .logo .b { stroke: var(--accent-2); } .logo .t { fill: var(--text); }
|
||||||
|
|
||||||
|
/* atualização do app */
|
||||||
|
.update-card { position: fixed; right: 18px; bottom: 18px; z-index: 60; width: min(400px, calc(100vw - 36px)); display: grid; gap: 10px; padding: 16px 18px; border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||||
|
.update-card .upd-head { display: grid; gap: 2px; }
|
||||||
|
.update-card .upd-ver { font-size: 12.5px; color: var(--text-2); }
|
||||||
|
.update-card .upd-url { word-break: break-all; font-size: 12.5px; }
|
||||||
|
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.update-card .sub { margin: 0; }
|
||||||
|
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
|
||||||
+64
-1
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ configurações
|
// ------------------------------------------------------------------ configurações
|
||||||
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
|
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
|
||||||
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false };
|
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
|
||||||
async function saveSettings(patch) {
|
async function saveSettings(patch) {
|
||||||
Object.assign(SETTINGS, patch);
|
Object.assign(SETTINGS, patch);
|
||||||
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
|
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
|
||||||
@@ -745,11 +745,14 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
|
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
|
||||||
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
|
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
|
||||||
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
|
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
|
||||||
|
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o GitHub para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
|
||||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
|
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
|
||||||
d.querySelector('[name=lang]').value = SETTINGS.lang;
|
d.querySelector('[name=lang]').value = SETTINGS.lang;
|
||||||
|
d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); });
|
||||||
d.onclose = () => {
|
d.onclose = () => {
|
||||||
if (d.returnValue !== 'ok') return;
|
if (d.returnValue !== 'ok') return;
|
||||||
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
|
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
|
||||||
|
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
|
||||||
setLang(patch.lang);
|
setLang(patch.lang);
|
||||||
patch.ui_lang = currentLang();
|
patch.ui_lang = currentLang();
|
||||||
saveSettings(patch);
|
saveSettings(patch);
|
||||||
@@ -759,6 +762,64 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
d.showModal();
|
d.showModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ atualização do app
|
||||||
|
// O núcleo do app (Tauri) consulta o `latest.json` da release no GitHub, confere a assinatura do
|
||||||
|
// pacote com a chave pública embutida, instala e reabre. No navegador (`guara ui`) não há nada a fazer.
|
||||||
|
let UPD = null;
|
||||||
|
|
||||||
|
async function checkUpdate(manual) {
|
||||||
|
if (!tauri?.core?.invoke) return;
|
||||||
|
try {
|
||||||
|
UPD = await tauri.core.invoke('update_check');
|
||||||
|
renderUpdateCard();
|
||||||
|
if (manual) toast(UPD ? `Nova versão ${UPD.version} disponível` : 'Você já está na versão mais recente');
|
||||||
|
} catch (e) {
|
||||||
|
if (manual) toast(`Não foi possível verificar atualizações: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUpdateCard(state) {
|
||||||
|
let el = $('#updateCard');
|
||||||
|
if (!UPD) { el?.remove(); return; }
|
||||||
|
if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); }
|
||||||
|
if (state === 'busy') return;
|
||||||
|
el.innerHTML = `<div class="upd-head"><b>Nova versão do Guará disponível</b><span class="mono upd-ver">v${esc(UPD.current)} → v${esc(UPD.version)}</span></div>
|
||||||
|
${UPD.auto ? '' : '<p class="sub">Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:</p><p class="mono upd-url">' + esc(UPD.url) + '</p>'}
|
||||||
|
<div class="upd-actions">
|
||||||
|
${UPD.auto ? '<button class="btn primary sm" id="updGo">Atualizar agora</button>' : ''}
|
||||||
|
${UPD.notes ? '<button class="btn sm" id="updNotes">Novidades</button>' : ''}
|
||||||
|
<button class="btn sm" id="updLater">Depois</button>
|
||||||
|
</div>`;
|
||||||
|
$('#updGo')?.addEventListener('click', installUpdate);
|
||||||
|
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
|
||||||
|
$('#updNotes')?.addEventListener('click', () => info({ title: `Guará ${UPD.version}`, html: `<pre class="upd-notes">${esc(UPD.notes)}</pre>` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installUpdate() {
|
||||||
|
const el = $('#updateCard');
|
||||||
|
el.innerHTML = `<div class="upd-head"><b>Atualizando o Guará…</b></div>
|
||||||
|
<div class="bar indet" id="updBar"><i style="width:0"></i></div><p class="sub" id="updMsg">Baixando a atualização…</p>`;
|
||||||
|
renderUpdateCard('busy');
|
||||||
|
try {
|
||||||
|
await tauri.core.invoke('update_install');
|
||||||
|
} catch (e) {
|
||||||
|
toast(String(e));
|
||||||
|
renderUpdateCard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(p) {
|
||||||
|
const bar = $('#updBar'); const msg = $('#updMsg');
|
||||||
|
if (!bar || !msg) return;
|
||||||
|
if (p.installing) { bar.classList.add('indet'); msg.textContent = 'Instalando… o Guará vai reabrir sozinho.'; return; }
|
||||||
|
if (p.total) {
|
||||||
|
const pct = Math.min(100, Math.round((p.downloaded / p.total) * 100));
|
||||||
|
bar.classList.remove('indet');
|
||||||
|
bar.firstElementChild.style.width = `${pct}%`;
|
||||||
|
msg.textContent = `Baixando… ${pct}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ fechar com varredura em andamento
|
// ------------------------------------------------------------------ fechar com varredura em andamento
|
||||||
function askClose() {
|
function askClose() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -807,6 +868,8 @@ function watchClose() {
|
|||||||
setLang(SETTINGS.lang);
|
setLang(SETTINGS.lang);
|
||||||
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
|
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
|
||||||
watchClose();
|
watchClose();
|
||||||
|
tauri?.event?.listen?.('update-progress', (e) => updateProgress(e.payload));
|
||||||
|
if (SETTINGS.updates !== false) checkUpdate(false);
|
||||||
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
|
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
||||||
|
|||||||
@@ -575,4 +575,19 @@ export const EN = {
|
|||||||
'{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}',
|
'{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}',
|
||||||
'e mais {n}…': 'and {n} more…',
|
'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.',
|
'abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'opens in your player: the embedded Linux browser does not include the codecs and would freeze the window.',
|
||||||
|
'Nova versão do Guará disponível': 'New Guará version available',
|
||||||
|
'Atualizar agora': 'Update now',
|
||||||
|
'Novidades': 'What\'s new',
|
||||||
|
'Depois': 'Later',
|
||||||
|
'Atualizando o Guará…': 'Updating Guará…',
|
||||||
|
'Baixando a atualização…': 'Downloading the update…',
|
||||||
|
'Baixando… {n}%': 'Downloading… {n}%',
|
||||||
|
'Instalando… o Guará vai reabrir sozinho.': 'Installing… Guará will reopen by itself.',
|
||||||
|
'Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:': 'This install comes from a system package and cannot update itself. Download the new version from the release page:',
|
||||||
|
'Atualizações automáticas': 'Automatic updates',
|
||||||
|
'Ao abrir, consulta o GitHub para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks GitHub for a new version (one request, nothing of yours is sent)',
|
||||||
|
'Verificar agora': 'Check now',
|
||||||
|
'Nova versão {n} disponível': 'New version {n} available',
|
||||||
|
'Você já está na versão mais recente': 'You are already on the latest version',
|
||||||
|
'Não foi possível verificar atualizações: {n}': 'Could not check for updates: {n}',
|
||||||
};
|
};
|
||||||
@@ -18,6 +18,45 @@ struct Cli {
|
|||||||
cmd: Cmd,
|
cmd: Cmd,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum SignCmd {
|
||||||
|
/// Cria o par de chaves de assinatura (recusa sobrescrever uma chave existente)
|
||||||
|
Keygen {
|
||||||
|
/// Onde gravar a chave privada (padrão: assinatura.key na pasta de configuração ou GUARA_SIGNING_KEY)
|
||||||
|
#[arg(long)]
|
||||||
|
key: Option<String>,
|
||||||
|
},
|
||||||
|
/// Assina um arquivo e grava <arquivo>.assinatura.json
|
||||||
|
File {
|
||||||
|
path: String,
|
||||||
|
#[arg(long)]
|
||||||
|
key: Option<String>,
|
||||||
|
/// Nome de quem assina (padrão: GUARA_EXAMINER)
|
||||||
|
#[arg(long)]
|
||||||
|
signer: Option<String>,
|
||||||
|
},
|
||||||
|
/// Confere a assinatura de um arquivo
|
||||||
|
Verify {
|
||||||
|
path: String,
|
||||||
|
/// Arquivo de assinatura (padrão: <arquivo>.assinatura.json)
|
||||||
|
#[arg(long)]
|
||||||
|
sig: Option<String>,
|
||||||
|
/// Impressão digital esperada da chave, publicada pelo perito
|
||||||
|
#[arg(long)]
|
||||||
|
trust: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Confere uma pasta de recuperação: assinatura do manifesto e hash de cada arquivo
|
||||||
|
Recovery {
|
||||||
|
dir: String,
|
||||||
|
#[arg(long)]
|
||||||
|
trust: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Lista discos e pendrives detectados
|
/// Lista discos e pendrives detectados
|
||||||
@@ -88,6 +127,9 @@ enum Cmd {
|
|||||||
},
|
},
|
||||||
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
|
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
|
||||||
Verify { path: String },
|
Verify { path: String },
|
||||||
|
/// Assina e confere arquivos (manifestos, relatórios) e pastas de recuperação com Ed25519
|
||||||
|
#[command(subcommand)]
|
||||||
|
Sign(SignCmd),
|
||||||
/// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash
|
/// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash
|
||||||
Audit {
|
Audit {
|
||||||
/// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG)
|
/// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG)
|
||||||
@@ -455,6 +497,7 @@ fn run(cli: Cli) -> Result<(), String> {
|
|||||||
tail,
|
tail,
|
||||||
json,
|
json,
|
||||||
} => audit(log, verify, tail, json),
|
} => audit(log, verify, tail, json),
|
||||||
|
Cmd::Sign(c) => sign_cmd(c),
|
||||||
Cmd::Image {
|
Cmd::Image {
|
||||||
path,
|
path,
|
||||||
dest,
|
dest,
|
||||||
@@ -881,6 +924,116 @@ fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `guara sign`: chaves, assinatura e conferência.
|
||||||
|
fn sign_cmd(c: SignCmd) -> Result<(), String> {
|
||||||
|
use guara_core::sign;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
let key_path = |k: Option<String>| k.map(PathBuf::from).unwrap_or_else(sign::default_key_path);
|
||||||
|
match c {
|
||||||
|
SignCmd::Keygen { key } => {
|
||||||
|
let p = key_path(key);
|
||||||
|
let k = sign::SigningKey::generate();
|
||||||
|
k.save(&p).map_err(s)?;
|
||||||
|
guara_core::audit::record("sign.keygen", json!({ "fingerprint": k.fingerprint() }));
|
||||||
|
println!(
|
||||||
|
"Chave privada: {} (guarde uma cópia segura; quem a tem assina em seu nome)",
|
||||||
|
p.display()
|
||||||
|
);
|
||||||
|
println!("Chave pública: {}", p.with_extension("pub").display());
|
||||||
|
println!("Impressão digital: {}", k.fingerprint());
|
||||||
|
println!("Publique a impressão digital fora desta máquina para que outros confiram as assinaturas.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
SignCmd::File { path, key, signer } => {
|
||||||
|
let p = key_path(key);
|
||||||
|
let k = sign::SigningKey::load(&p).map_err(s)?;
|
||||||
|
let who = signer.or_else(|| std::env::var("GUARA_EXAMINER").ok());
|
||||||
|
let out = sign::sign_file(&k, Path::new(&path), who.as_deref()).map_err(s)?;
|
||||||
|
guara_core::audit::record(
|
||||||
|
"sign",
|
||||||
|
json!({ "path": path, "signature": out.to_string_lossy() }),
|
||||||
|
);
|
||||||
|
println!("Assinatura gravada em {}", out.display());
|
||||||
|
println!("Impressão digital da chave: {}", k.fingerprint());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
SignCmd::Verify {
|
||||||
|
path,
|
||||||
|
sig,
|
||||||
|
trust,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
let c = sign::verify_file(
|
||||||
|
Path::new(&path),
|
||||||
|
sig.as_deref().map(Path::new),
|
||||||
|
trust.as_deref(),
|
||||||
|
)
|
||||||
|
.map_err(s)?;
|
||||||
|
guara_core::audit::record("sign.verify", json!({ "path": path, "valid": c.valid }));
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&c).unwrap());
|
||||||
|
} else if c.valid {
|
||||||
|
println!("Assinatura válida: o arquivo não mudou desde que foi assinado.");
|
||||||
|
println!(
|
||||||
|
"Chave: {} · assinado em {}{}",
|
||||||
|
c.fingerprint,
|
||||||
|
c.signed_at,
|
||||||
|
c.signer
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| format!(" por {s}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
if c.trusted.is_none() {
|
||||||
|
println!(
|
||||||
|
"Confira a impressão digital acima com a que o perito publicou (--trust)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(c.problems.join("; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SignCmd::Recovery { dir, trust, json } => {
|
||||||
|
let c = sign::verify_recovery(Path::new(&dir), trust.as_deref()).map_err(s)?;
|
||||||
|
guara_core::audit::record(
|
||||||
|
"sign.verify_recovery",
|
||||||
|
json!({ "dir": dir, "valid": c.valid, "files": c.files }),
|
||||||
|
);
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&c).unwrap());
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{} de {} arquivo(s) conferem com o manifesto.",
|
||||||
|
c.ok, c.files
|
||||||
|
);
|
||||||
|
for f in &c.missing {
|
||||||
|
println!(" ausente: {f}");
|
||||||
|
}
|
||||||
|
for f in &c.changed {
|
||||||
|
println!(" alterado: {f}");
|
||||||
|
}
|
||||||
|
match &c.manifest_signature {
|
||||||
|
Some(m) if m.valid => {
|
||||||
|
println!("Manifesto assinado e íntegro. Chave: {}", m.fingerprint)
|
||||||
|
}
|
||||||
|
Some(m) => println!(
|
||||||
|
"Assinatura do manifesto com problema: {}",
|
||||||
|
m.problems.join("; ")
|
||||||
|
),
|
||||||
|
None => println!("Manifesto sem assinatura (assine com guara sign file)."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("a recuperação não confere".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String> {
|
fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String> {
|
||||||
let interrupted = Arc::new(AtomicBool::new(false));
|
let interrupted = Arc::new(AtomicBool::new(false));
|
||||||
let flag = interrupted.clone();
|
let flag = interrupted.clone();
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ hmac = "0.13"
|
|||||||
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
|
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
|
||||||
ripemd = "0.2"
|
ripemd = "0.2"
|
||||||
argon2 = "0.6"
|
argon2 = "0.6"
|
||||||
|
ed25519-compact = { version = "2", default-features = false, features = ["random", "std"] }
|
||||||
|
lzfse_rust = "0.2.1"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub mod scan;
|
|||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod sign;
|
||||||
pub mod signatures;
|
pub mod signatures;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod sqlite;
|
pub mod sqlite;
|
||||||
|
|||||||
@@ -286,10 +286,9 @@ pub fn recover_resumable(
|
|||||||
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
||||||
"relatorio": &report,
|
"relatorio": &report,
|
||||||
});
|
});
|
||||||
crate::owner::write_owned(
|
let manifest_path = dest.join("guara-manifesto.json");
|
||||||
&dest.join("guara-manifesto.json"),
|
crate::owner::write_owned(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
|
||||||
serde_json::to_vec_pretty(&manifest)?,
|
crate::sign::sign_if_configured(&manifest_path);
|
||||||
)?;
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -706,6 +706,7 @@ impl Engine {
|
|||||||
crate::report::html(&input).into_bytes()
|
crate::report::html(&input).into_bytes()
|
||||||
};
|
};
|
||||||
crate::owner::write_owned(Path::new(path), bytes)?;
|
crate::owner::write_owned(Path::new(path), bytes)?;
|
||||||
|
crate::sign::sign_if_configured(Path::new(path));
|
||||||
crate::audit::record(
|
crate::audit::record(
|
||||||
"export.report",
|
"export.report",
|
||||||
json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }),
|
json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }),
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ pub struct Settings {
|
|||||||
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
|
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub maps: bool,
|
pub maps: bool,
|
||||||
|
/// Procurar versão nova no GitHub ao abrir o app (uma consulta ao `latest.json` da release,
|
||||||
|
/// sem enviar nada além do que qualquer navegador envia).
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub updates: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auto() -> String {
|
fn auto() -> String {
|
||||||
@@ -41,6 +45,7 @@ impl Default for Settings {
|
|||||||
ui_lang: None,
|
ui_lang: None,
|
||||||
notify: true,
|
notify: true,
|
||||||
maps: false,
|
maps: false,
|
||||||
|
updates: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
//! Assinatura digital (Ed25519) de manifestos, relatórios e qualquer outro arquivo, e conferência
|
||||||
|
//! de uma recuperação inteira.
|
||||||
|
//!
|
||||||
|
//! A assinatura fica ao lado do arquivo, em `<arquivo>.assinatura.json`, e cobre o SHA-256 do
|
||||||
|
//! conteúdo, o tamanho, o nome, a hora e o assinante. Quem confere recalcula o hash do arquivo,
|
||||||
|
//! refaz a mensagem a partir dos campos do JSON e valida com a chave pública que vem nele. Isso
|
||||||
|
//! prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de
|
||||||
|
//! quem é a chave, compare a impressão digital (`fingerprint`) com a que o perito publicou ou
|
||||||
|
//! registrou fora da máquina, passando-a em `trusted_fingerprint`.
|
||||||
|
//!
|
||||||
|
//! A hora é a do relógio da máquina de quem assinou: não é um carimbo de tempo de terceiros
|
||||||
|
//! (RFC 3161) e, sozinha, não prova *quando* a assinatura foi feita.
|
||||||
|
//!
|
||||||
|
//! A chave privada fica em `assinatura.key` na pasta de configuração (ou onde
|
||||||
|
//! `GUARA_SIGNING_KEY` apontar), em arquivo de texto com permissão 0600 nos sistemas Unix. Quem
|
||||||
|
//! precisa de mais proteção deve guardá-la num pendrive ou volume cifrado e apontar para lá.
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const FORMAT: &str = "guara-assinatura-v1";
|
||||||
|
const KEY_HEADER: &str = "guara-ed25519-seed-v1";
|
||||||
|
const PUB_HEADER: &str = "guara-ed25519-public-v1";
|
||||||
|
|
||||||
|
fn hex(b: &[u8]) -> String {
|
||||||
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.len() % 2 != 0 || !s.is_ascii() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Impressão digital da chave pública: os 10 primeiros bytes do SHA-256, em grupos de 4 hex.
|
||||||
|
pub fn fingerprint(public_key: &[u8]) -> String {
|
||||||
|
let h = Sha256::digest(public_key);
|
||||||
|
let h = hex(&h[..10]);
|
||||||
|
h.as_bytes()
|
||||||
|
.chunks(4)
|
||||||
|
.map(|c| std::str::from_utf8(c).unwrap())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compara impressões digitais ignorando maiúsculas, `:` e espaços.
|
||||||
|
fn same_fingerprint(a: &str, b: &str) -> bool {
|
||||||
|
let n = |s: &str| {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.map(|c| c.to_ascii_lowercase())
|
||||||
|
.collect::<String>()
|
||||||
|
};
|
||||||
|
let (a, b) = (n(a), n(b));
|
||||||
|
!a.is_empty() && a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Par de chaves de assinatura.
|
||||||
|
pub struct SigningKey {
|
||||||
|
kp: KeyPair,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SigningKey {
|
||||||
|
pub fn generate() -> Self {
|
||||||
|
Self {
|
||||||
|
kp: KeyPair::from_seed(Seed::generate()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_bytes(&self) -> [u8; 32] {
|
||||||
|
*self.kp.pk
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fingerprint(&self) -> String {
|
||||||
|
fingerprint(&self.public_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let text = std::fs::read_to_string(path).map_err(|e| {
|
||||||
|
Error::msg(format!(
|
||||||
|
"não foi possível ler a chave {}: {e}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||||
|
if lines.next() != Some(KEY_HEADER) {
|
||||||
|
return Err(Error::msg(format!(
|
||||||
|
"{} não é uma chave de assinatura do Guará",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let seed = lines
|
||||||
|
.next()
|
||||||
|
.and_then(unhex)
|
||||||
|
.and_then(|b| <[u8; 32]>::try_from(b).ok())
|
||||||
|
.ok_or_else(|| Error::msg("chave de assinatura corrompida"))?;
|
||||||
|
Ok(Self {
|
||||||
|
kp: KeyPair::from_seed(Seed::new(seed)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grava a chave privada (0600 no Unix) e, ao lado, a pública (`.pub`). Recusa sobrescrever.
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
crate::owner::create_dir_all_owned(dir)?;
|
||||||
|
}
|
||||||
|
let mut opts = std::fs::OpenOptions::new();
|
||||||
|
opts.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
||||||
|
let mut f = opts.open(path).map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::AlreadyExists {
|
||||||
|
Error::msg(format!(
|
||||||
|
"já existe uma chave em {}; apague-a de propósito se quiser trocar (as assinaturas antigas continuam válidas com a chave pública antiga)",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
e.into()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
f.write_all(format!("{KEY_HEADER}\n{}\n", hex(self.kp.sk.seed().as_ref())).as_bytes())?;
|
||||||
|
drop(f);
|
||||||
|
crate::owner::give_to_user(path);
|
||||||
|
let pubp = pub_path(path);
|
||||||
|
crate::owner::write_owned(
|
||||||
|
&pubp,
|
||||||
|
format!(
|
||||||
|
"{PUB_HEADER}\n{}\n# impressão digital: {}\n",
|
||||||
|
hex(&self.public_bytes()),
|
||||||
|
self.fingerprint()
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pub_path(key: &Path) -> PathBuf {
|
||||||
|
key.with_extension("pub")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GUARA_SIGNING_KEY` ou `assinatura.key` na pasta de configuração.
|
||||||
|
pub fn default_key_path() -> PathBuf {
|
||||||
|
match std::env::var("GUARA_SIGNING_KEY") {
|
||||||
|
Ok(p) if !p.is_empty() => PathBuf::from(p),
|
||||||
|
_ => crate::settings::config_dir().join("assinatura.key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arquivo que acompanha `path` com a assinatura.
|
||||||
|
pub fn signature_path(path: &Path) -> PathBuf {
|
||||||
|
let mut n = path.as_os_str().to_os_string();
|
||||||
|
n.push(".assinatura.json");
|
||||||
|
PathBuf::from(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SignatureFile {
|
||||||
|
pub formato: String,
|
||||||
|
pub arquivo: String,
|
||||||
|
pub tamanho: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
pub assinado_em: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub assinante: Option<String>,
|
||||||
|
pub chave_publica: String,
|
||||||
|
pub impressao_digital: String,
|
||||||
|
pub assinatura: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(f: &SignatureFile) -> Vec<u8> {
|
||||||
|
format!(
|
||||||
|
"{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante={}\n",
|
||||||
|
f.formato,
|
||||||
|
f.arquivo,
|
||||||
|
f.tamanho,
|
||||||
|
f.sha256,
|
||||||
|
f.assinado_em,
|
||||||
|
f.assinante.as_deref().unwrap_or("")
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 e tamanho de um arquivo, lido em blocos.
|
||||||
|
pub fn hash_file(path: &Path) -> Result<(String, u64)> {
|
||||||
|
let mut f = std::fs::File::open(path)?;
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; 1 << 20];
|
||||||
|
let mut total = 0u64;
|
||||||
|
loop {
|
||||||
|
let n = f.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h.update(&buf[..n]);
|
||||||
|
total += n as u64;
|
||||||
|
}
|
||||||
|
Ok((hex(&h.finalize()), total))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
let secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
crate::audit::utc_iso(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assina `path` e grava `<path>.assinatura.json`. Devolve o caminho da assinatura.
|
||||||
|
pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<PathBuf> {
|
||||||
|
let (sha256, size) = hash_file(path)?;
|
||||||
|
let mut sig = SignatureFile {
|
||||||
|
formato: FORMAT.into(),
|
||||||
|
arquivo: path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
tamanho: size,
|
||||||
|
sha256,
|
||||||
|
assinado_em: now_iso(),
|
||||||
|
assinante: signer.filter(|s| !s.is_empty()).map(String::from),
|
||||||
|
chave_publica: hex(&key.public_bytes()),
|
||||||
|
impressao_digital: key.fingerprint(),
|
||||||
|
assinatura: String::new(),
|
||||||
|
};
|
||||||
|
sig.assinatura = hex(key
|
||||||
|
.kp
|
||||||
|
.sk
|
||||||
|
.sign(message(&sig), Some(Noise::generate()))
|
||||||
|
.as_ref());
|
||||||
|
let out = signature_path(path);
|
||||||
|
crate::owner::write_owned(&out, serde_json::to_vec_pretty(&sig)?)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado de conferir uma assinatura.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Check {
|
||||||
|
/// O arquivo é o mesmo e a assinatura vale para a chave que vem nela.
|
||||||
|
pub valid: bool,
|
||||||
|
pub file_matches: bool,
|
||||||
|
pub signature_valid: bool,
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub signed_at: String,
|
||||||
|
pub signer: Option<String>,
|
||||||
|
/// `Some(true/false)` quando uma impressão digital de confiança foi informada.
|
||||||
|
pub trusted: Option<bool>,
|
||||||
|
pub problems: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_file(
|
||||||
|
path: &Path,
|
||||||
|
sig_path: Option<&Path>,
|
||||||
|
trusted_fingerprint: Option<&str>,
|
||||||
|
) -> Result<Check> {
|
||||||
|
let sp = sig_path
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| signature_path(path));
|
||||||
|
let raw = std::fs::read(&sp).map_err(|e| {
|
||||||
|
Error::msg(format!(
|
||||||
|
"não foi possível ler a assinatura {}: {e}",
|
||||||
|
sp.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let sig: SignatureFile = serde_json::from_slice(&raw)
|
||||||
|
.map_err(|e| Error::msg(format!("assinatura ilegível: {e}")))?;
|
||||||
|
let mut problems = vec![];
|
||||||
|
if sig.formato != FORMAT {
|
||||||
|
problems.push(format!(
|
||||||
|
"formato de assinatura desconhecido ({})",
|
||||||
|
sig.formato
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (sha, size) = hash_file(path)?;
|
||||||
|
let file_matches = sha == sig.sha256 && size == sig.tamanho;
|
||||||
|
if !file_matches {
|
||||||
|
problems.push("o arquivo mudou depois de assinado (hash ou tamanho diferem)".into());
|
||||||
|
}
|
||||||
|
let pk = unhex(&sig.chave_publica).and_then(|b| PublicKey::from_slice(&b).ok());
|
||||||
|
let sg = unhex(&sig.assinatura).and_then(|b| Signature::from_slice(&b).ok());
|
||||||
|
let signature_valid = match (&pk, &sg) {
|
||||||
|
(Some(pk), Some(sg)) => pk.verify(message(&sig), sg).is_ok(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !signature_valid {
|
||||||
|
problems.push("a assinatura não confere (campos alterados ou chave errada)".into());
|
||||||
|
}
|
||||||
|
let real_fp = pk
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| fingerprint(p.as_ref()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if real_fp != sig.impressao_digital {
|
||||||
|
problems.push("a impressão digital declarada não é a da chave pública".into());
|
||||||
|
}
|
||||||
|
let trusted = trusted_fingerprint.map(|t| same_fingerprint(t, &real_fp));
|
||||||
|
if trusted == Some(false) {
|
||||||
|
problems.push(format!(
|
||||||
|
"a chave que assinou ({real_fp}) não é a esperada ({})",
|
||||||
|
trusted_fingerprint.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Check {
|
||||||
|
valid: problems.is_empty(),
|
||||||
|
file_matches,
|
||||||
|
signature_valid,
|
||||||
|
fingerprint: real_fp,
|
||||||
|
signed_at: sig.assinado_em,
|
||||||
|
signer: sig.assinante,
|
||||||
|
trusted,
|
||||||
|
problems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confere uma pasta de recuperação: a assinatura do manifesto (se houver) e o hash de cada
|
||||||
|
/// arquivo contra o que o manifesto registrou.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct RecoveryCheck {
|
||||||
|
pub manifest_signature: Option<Check>,
|
||||||
|
pub files: usize,
|
||||||
|
pub ok: usize,
|
||||||
|
pub missing: Vec<String>,
|
||||||
|
pub changed: Vec<String>,
|
||||||
|
/// `true` quando tudo confere (e, havendo assinatura, ela vale).
|
||||||
|
pub valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<RecoveryCheck> {
|
||||||
|
let manifest = dir.join("guara-manifesto.json");
|
||||||
|
let raw = std::fs::read(&manifest)
|
||||||
|
.map_err(|e| Error::msg(format!("não foi possível ler {}: {e}", manifest.display())))?;
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&raw)?;
|
||||||
|
let sigp = signature_path(&manifest);
|
||||||
|
let manifest_signature = if sigp.is_file() {
|
||||||
|
Some(verify_file(&manifest, None, trusted_fingerprint)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let list = v["relatorio"]["recovered"]
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (mut ok, mut missing, mut changed) = (0, vec![], vec![]);
|
||||||
|
for f in &list {
|
||||||
|
let (Some(rel), Some(want)) = (f["path"].as_str(), f["sha256"].as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// o manifesto é dado de entrada: não deixa sair da pasta
|
||||||
|
if rel.split('/').any(|c| c == "..") || Path::new(rel).is_absolute() {
|
||||||
|
changed.push(format!("{rel} (caminho fora da pasta)"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p = dir.join(rel);
|
||||||
|
match hash_file(&p) {
|
||||||
|
Ok((h, _)) if h == want => ok += 1,
|
||||||
|
Ok(_) => changed.push(rel.to_string()),
|
||||||
|
Err(_) => missing.push(rel.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let signature_ok = match &manifest_signature {
|
||||||
|
Some(c) => c.valid,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
let valid = missing.is_empty() && changed.is_empty() && signature_ok;
|
||||||
|
Ok(RecoveryCheck {
|
||||||
|
manifest_signature,
|
||||||
|
files: list.len(),
|
||||||
|
ok,
|
||||||
|
missing,
|
||||||
|
changed,
|
||||||
|
valid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assina `path` se existir uma chave configurada; nunca interrompe quem chamou. Registra na
|
||||||
|
/// trilha de auditoria.
|
||||||
|
pub fn sign_if_configured(path: &Path) {
|
||||||
|
let kp = default_key_path();
|
||||||
|
if !kp.is_file() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result = SigningKey::load(&kp)
|
||||||
|
.and_then(|k| sign_file(&k, path, std::env::var("GUARA_EXAMINER").ok().as_deref()));
|
||||||
|
match result {
|
||||||
|
Ok(sig) => crate::audit::record(
|
||||||
|
"sign",
|
||||||
|
serde_json::json!({ "path": path.to_string_lossy(), "signature": sig.to_string_lossy() }),
|
||||||
|
),
|
||||||
|
Err(e) => eprintln!("aviso: não foi possível assinar {}: {e}", path.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
|
//! 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
|
//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem
|
||||||
//! mensagem clara.
|
//! clara.
|
||||||
|
|
||||||
use super::{bad, be32, be64, read_exact, read_vec, Backend};
|
use super::{bad, be32, be64, read_exact, read_vec, Backend};
|
||||||
use crate::source::{FileSource, Source};
|
use crate::source::{FileSource, Source};
|
||||||
@@ -187,7 +187,8 @@ impl Dmg {
|
|||||||
bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
||||||
}
|
}
|
||||||
0x8000_0007 => {
|
0x8000_0007 => {
|
||||||
return Err(bad("chunk LZFSE (formato ULFO) ainda não é suportado"));
|
lzfse_rust::decode_bytes(&raw, &mut out)
|
||||||
|
.map_err(|e| bad(format!("chunk LZFSE inválido: {e}")))?;
|
||||||
}
|
}
|
||||||
t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))),
|
t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
|
//! 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
|
//! 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
|
//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a
|
||||||
//! arquivo de apoio são recusados com mensagem clara, porque ler só a camada filha
|
//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi
|
||||||
//! devolveria dados errados.
|
//! alterado depois do filho, os dados lidos refletem o pai atual.
|
||||||
|
|
||||||
mod dmg;
|
mod dmg;
|
||||||
mod qcow2;
|
mod qcow2;
|
||||||
@@ -130,8 +130,8 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
|||||||
};
|
};
|
||||||
let name = format!("{format} {}", path.display());
|
let name = format!("{format} {}", path.display());
|
||||||
let inner: Box<dyn Backend> = match format {
|
let inner: Box<dyn Backend> = match format {
|
||||||
"QCOW2" => Box::new(qcow2::Qcow2::open(file).map_err(|e| io_err(e, &name))?),
|
"QCOW2" => Box::new(qcow2::Qcow2::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||||
"VMDK" => Box::new(vmdk::Vmdk::open(file, path).map_err(|e| io_err(e, &name))?),
|
"VMDK" => Box::new(vmdk::Vmdk::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||||
"VHD" => Box::new(vhd::Vhd::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
"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))?),
|
"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))?),
|
_ => Box::new(dmg::Dmg::open(file).map_err(|e| io_err(e, &name))?),
|
||||||
@@ -139,17 +139,23 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
|||||||
Ok(Some(VDisk { inner, format }))
|
Ok(Some(VDisk { inner, format }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abre o disco pai de um VHD/VHDX diferencial.
|
/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o
|
||||||
|
/// arquivo é tratado como disco bruto sem tentar reconhecer o formato.
|
||||||
pub(crate) fn open_parent(
|
pub(crate) fn open_parent(
|
||||||
candidates: &[std::path::PathBuf],
|
candidates: &[std::path::PathBuf],
|
||||||
depth: u32,
|
depth: u32,
|
||||||
|
raw: bool,
|
||||||
) -> io::Result<Box<dyn Source>> {
|
) -> io::Result<Box<dyn Source>> {
|
||||||
for p in candidates {
|
for p in candidates {
|
||||||
if !p.is_file() {
|
if !p.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
|
if !raw {
|
||||||
return Ok(Box::new(v));
|
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
|
||||||
|
return Ok(Box::new(v));
|
||||||
|
}
|
||||||
|
} else if depth > MAX_DEPTH {
|
||||||
|
return Err(bad("cadeia de discos pai longa demais ou circular"));
|
||||||
}
|
}
|
||||||
let f = FileSource::open(p)?;
|
let f = FileSource::open(p)?;
|
||||||
return Ok(Box::new(f));
|
return Ok(Box::new(f));
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd) e
|
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd),
|
||||||
//! clusters zerados. Imagens cifradas, com arquivo de apoio, com arquivo de dados externo ou
|
//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai.
|
||||||
//! com L2 estendido são recusadas.
|
//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um
|
||||||
|
//! arquivo local (`json:`, `nbd:`, URLs) são recusadas.
|
||||||
|
|
||||||
use super::{bad, be32, be64, check_table, read_exact, read_vec, Backend};
|
use super::{
|
||||||
|
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
|
||||||
|
Backend,
|
||||||
|
};
|
||||||
use crate::source::{FileSource, Source};
|
use crate::source::{FileSource, Source};
|
||||||
use std::io::{self, Read};
|
use std::io::{self, Read};
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00;
|
const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00;
|
||||||
@@ -16,12 +21,13 @@ pub(super) struct Qcow2 {
|
|||||||
cluster_bits: u32,
|
cluster_bits: u32,
|
||||||
l1: Vec<u64>,
|
l1: Vec<u64>,
|
||||||
zstd: bool,
|
zstd: bool,
|
||||||
|
parent: Option<Box<dyn Source>>,
|
||||||
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
|
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
|
||||||
cache: Mutex<Option<(u64, Vec<u8>)>>,
|
cache: Mutex<Option<(u64, Vec<u8>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Qcow2 {
|
impl Qcow2 {
|
||||||
pub(super) fn open(file: FileSource) -> io::Result<Self> {
|
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||||
let h = read_vec(&file, 0, 112.min(file.size() as usize))?;
|
let h = read_vec(&file, 0, 112.min(file.size() as usize))?;
|
||||||
if h.len() < 72 {
|
if h.len() < 72 {
|
||||||
return Err(bad("cabeçalho QCOW2 truncado"));
|
return Err(bad("cabeçalho QCOW2 truncado"));
|
||||||
@@ -30,11 +36,6 @@ impl Qcow2 {
|
|||||||
if version != 2 && version != 3 {
|
if version != 2 && version != 3 {
|
||||||
return Err(bad(format!("versão QCOW {version} não suportada")));
|
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);
|
let cluster_bits = be32(&h, 20);
|
||||||
if !(9..=21).contains(&cluster_bits) {
|
if !(9..=21).contains(&cluster_bits) {
|
||||||
return Err(bad(format!(
|
return Err(bad(format!(
|
||||||
@@ -63,6 +64,11 @@ impl Qcow2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let parent = if be64(&h, 8) != 0 {
|
||||||
|
Some(Self::open_backing(&file, &h, version, path, depth)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let l1_size = be32(&h, 36) as u64;
|
let l1_size = be32(&h, 36) as u64;
|
||||||
let l1_off = be64(&h, 40);
|
let l1_off = be64(&h, 40);
|
||||||
let n = check_table(l1_size, 8, "tabela L1")?;
|
let n = check_table(l1_size, 8, "tabela L1")?;
|
||||||
@@ -74,10 +80,65 @@ impl Qcow2 {
|
|||||||
cluster_bits,
|
cluster_bits,
|
||||||
l1,
|
l1,
|
||||||
zstd,
|
zstd,
|
||||||
|
parent,
|
||||||
cache: Mutex::new(None),
|
cache: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw`
|
||||||
|
/// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz.
|
||||||
|
fn open_backing(
|
||||||
|
file: &FileSource,
|
||||||
|
h: &[u8],
|
||||||
|
version: u32,
|
||||||
|
path: &Path,
|
||||||
|
depth: u32,
|
||||||
|
) -> io::Result<Box<dyn Source>> {
|
||||||
|
let (off, len) = (be64(h, 8), be32(h, 16) as usize);
|
||||||
|
if len == 0 || len > 1023 {
|
||||||
|
return Err(bad("nome do arquivo de apoio QCOW2 inválido"));
|
||||||
|
}
|
||||||
|
let name = String::from_utf8_lossy(&read_vec(file, off, len)?).into_owned();
|
||||||
|
if name.starts_with("json:")
|
||||||
|
|| name.contains("://")
|
||||||
|
|| name.split(':').next().is_some_and(|p| {
|
||||||
|
matches!(p, "nbd" | "ssh" | "rbd" | "sheepdog" | "gluster" | "iscsi")
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(bad(format!(
|
||||||
|
"o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes
|
||||||
|
let hlen = if version == 3 && h.len() >= 104 {
|
||||||
|
be32(h, 100) as u64
|
||||||
|
} else {
|
||||||
|
72
|
||||||
|
};
|
||||||
|
let mut raw = false;
|
||||||
|
let mut pos = hlen.div_ceil(8) * 8;
|
||||||
|
for _ in 0..64 {
|
||||||
|
let mut eh = [0u8; 8];
|
||||||
|
if read_exact(file, pos, &mut eh).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (kind, elen) = (be32(&eh, 0), be32(&eh, 4) as u64);
|
||||||
|
if kind == 0 || elen > 1 << 20 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if kind == 0xE279_2ACA {
|
||||||
|
let fmt = read_vec(file, pos + 8, elen as usize)?;
|
||||||
|
raw = fmt == b"raw";
|
||||||
|
}
|
||||||
|
pos += 8 + elen.div_ceil(8) * 8;
|
||||||
|
}
|
||||||
|
if version == 2 {
|
||||||
|
raw = false;
|
||||||
|
}
|
||||||
|
let dir = path.parent().unwrap_or(Path::new("."));
|
||||||
|
open_parent(&candidate_paths(dir, &[name]), depth, raw)
|
||||||
|
}
|
||||||
|
|
||||||
fn cluster_size(&self) -> u64 {
|
fn cluster_size(&self) -> u64 {
|
||||||
1 << self.cluster_bits
|
1 << self.cluster_bits
|
||||||
}
|
}
|
||||||
@@ -139,8 +200,10 @@ impl Backend for Qcow2 {
|
|||||||
return Ok(n);
|
return Ok(n);
|
||||||
}
|
}
|
||||||
let off = entry & OFFSET_MASK;
|
let off = entry & OFFSET_MASK;
|
||||||
if off == 0 || entry & 1 != 0 {
|
if entry & 1 != 0 {
|
||||||
buf[..n].fill(0);
|
buf[..n].fill(0); // zerado de propósito: não consulta o pai
|
||||||
|
} else if off == 0 {
|
||||||
|
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||||
} else {
|
} else {
|
||||||
read_exact(&self.file, off + within, &mut buf[..n])?;
|
read_exact(&self.file, off + within, &mut buf[..n])?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl Vhd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
names.push(utf16(&dh[64..64 + 512], true));
|
names.push(utf16(&dh[64..64 + 512], true));
|
||||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ impl Vhdx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let dir = path.parent().unwrap_or(Path::new("."));
|
let dir = path.parent().unwrap_or(Path::new("."));
|
||||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
|
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
|
||||||
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
|
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
|
||||||
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) e o formato SEsparse
|
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos
|
||||||
//! são recusados.
|
//! que o filho não alocou; o formato SEsparse é recusado.
|
||||||
|
|
||||||
use super::{bad, check_table, le16, le32, le64, read_exact, read_vec, Backend};
|
use super::{
|
||||||
|
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
|
||||||
|
read_vec, Backend,
|
||||||
|
};
|
||||||
use crate::source::{FileSource, Source};
|
use crate::source::{FileSource, Source};
|
||||||
use std::io::{self, Read};
|
use std::io::{self, Read};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -36,20 +39,73 @@ struct Extent {
|
|||||||
pub(super) struct Vmdk {
|
pub(super) struct Vmdk {
|
||||||
extents: Vec<Extent>,
|
extents: Vec<Extent>,
|
||||||
size: u64,
|
size: u64,
|
||||||
|
parent: Option<Box<dyn Source>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parent_in(descriptor: &str) -> bool {
|
/// Valor de uma chave `chave=valor` do descritor, sem aspas.
|
||||||
descriptor.lines().any(|l| {
|
fn desc_value(descriptor: &str, key: &str) -> Option<String> {
|
||||||
let l = l.trim();
|
descriptor.lines().find_map(|l| {
|
||||||
l.strip_prefix("parentCID")
|
l.trim()
|
||||||
|
.strip_prefix(key)
|
||||||
.and_then(|r| r.trim_start().strip_prefix('='))
|
.and_then(|r| r.trim_start().strip_prefix('='))
|
||||||
.is_some_and(|v| {
|
.map(|v| v.trim().trim_matches('"').to_string())
|
||||||
let v = v.trim().trim_matches('"');
|
|
||||||
!v.is_empty() && !v.eq_ignore_ascii_case("ffffffff")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido
|
||||||
|
/// logo depois do cabeçalho de uma extensão esparsa.
|
||||||
|
fn descriptor_of(path: &Path) -> Option<String> {
|
||||||
|
let f = FileSource::open(path).ok()?;
|
||||||
|
let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?;
|
||||||
|
if head.starts_with(b"KDMV") {
|
||||||
|
let off = (le64(&head, 28) * SECTOR) as usize;
|
||||||
|
let len = (le64(&head, 36) * SECTOR) as usize;
|
||||||
|
let d = head.get(off..(off + len).min(head.len()))?;
|
||||||
|
let end = d.iter().position(|&b| b == 0).unwrap_or(d.len());
|
||||||
|
return Some(String::from_utf8_lossy(&d[..end]).into_owned());
|
||||||
|
}
|
||||||
|
head.starts_with(b"# Disk Desc")
|
||||||
|
.then(|| String::from_utf8_lossy(&head).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só
|
||||||
|
/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter.
|
||||||
|
struct ParentRef {
|
||||||
|
name: Option<String>,
|
||||||
|
uuid: Option<String>,
|
||||||
|
cid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parent_ref(descriptor: &str) -> Option<ParentRef> {
|
||||||
|
let cid = desc_value(descriptor, "parentCID")
|
||||||
|
.filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("ffffffff"));
|
||||||
|
let uuid = desc_value(descriptor, "ddb.uuid.parent")
|
||||||
|
.filter(|u| !u.is_empty() && u.chars().any(|c| c != '0' && c != '-'));
|
||||||
|
let name = desc_value(descriptor, "parentFileNameHint").filter(|n| !n.is_empty());
|
||||||
|
(cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`.
|
||||||
|
fn find_by_uuid(dir: &Path, uuid: &str) -> Option<std::path::PathBuf> {
|
||||||
|
let mut d = Some(dir);
|
||||||
|
for _ in 0..3 {
|
||||||
|
let cur = d?;
|
||||||
|
for e in std::fs::read_dir(cur).ok()?.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.extension()
|
||||||
|
.is_some_and(|x| x.eq_ignore_ascii_case("vmdk"))
|
||||||
|
&& descriptor_of(&p)
|
||||||
|
.and_then(|ds| desc_value(&ds, "ddb.uuid.image"))
|
||||||
|
.is_some_and(|u| u.eq_ignore_ascii_case(uuid))
|
||||||
|
{
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = cur.parent();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
impl Sparse {
|
impl Sparse {
|
||||||
fn open(file: FileSource) -> io::Result<(Self, u64, Option<String>)> {
|
fn open(file: FileSource) -> io::Result<(Self, u64, Option<String>)> {
|
||||||
let mut h = read_vec(&file, 0, SECTOR as usize)?;
|
let mut h = read_vec(&file, 0, SECTOR as usize)?;
|
||||||
@@ -95,11 +151,6 @@ impl Sparse {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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 gde = capacity.div_ceil(gtes * grain);
|
||||||
let n = check_table(gde, 4, "diretório de grãos")?;
|
let n = check_table(gde, 4, "diretório de grãos")?;
|
||||||
let raw = read_vec(&file, gd_off * SECTOR, n * 4)?;
|
let raw = read_vec(&file, gd_off * SECTOR, n * 4)?;
|
||||||
@@ -118,7 +169,9 @@ impl Sparse {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<usize> {
|
/// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo
|
||||||
|
/// (o chamador completa com o pai); grão zerado de propósito devolve zeros.
|
||||||
|
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> {
|
||||||
let g = off / self.grain;
|
let g = off / self.grain;
|
||||||
let within = off % self.grain;
|
let within = off % self.grain;
|
||||||
let n = buf.len().min((self.grain - within) as usize);
|
let n = buf.len().min((self.grain - within) as usize);
|
||||||
@@ -129,13 +182,16 @@ impl Sparse {
|
|||||||
read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?;
|
read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?;
|
||||||
sector = u32::from_le_bytes(b) as u64;
|
sector = u32::from_le_bytes(b) as u64;
|
||||||
}
|
}
|
||||||
if sector <= 1 {
|
if sector == 0 {
|
||||||
|
return Ok((n, false));
|
||||||
|
}
|
||||||
|
if sector == 1 {
|
||||||
buf[..n].fill(0);
|
buf[..n].fill(0);
|
||||||
return Ok(n);
|
return Ok((n, true));
|
||||||
}
|
}
|
||||||
if !self.compressed {
|
if !self.compressed {
|
||||||
read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?;
|
read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?;
|
||||||
return Ok(n);
|
return Ok((n, true));
|
||||||
}
|
}
|
||||||
let mut cache = self.cache.lock().unwrap();
|
let mut cache = self.cache.lock().unwrap();
|
||||||
if cache.as_ref().map(|c| c.0) != Some(g) {
|
if cache.as_ref().map(|c| c.0) != Some(g) {
|
||||||
@@ -153,17 +209,12 @@ impl Sparse {
|
|||||||
}
|
}
|
||||||
let data = &cache.as_ref().unwrap().1;
|
let data = &cache.as_ref().unwrap().1;
|
||||||
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
|
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
|
||||||
Ok(n)
|
Ok((n, true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
|
/// 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>> {
|
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 out = vec![];
|
||||||
let mut start = 0u64;
|
let mut start = 0u64;
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
@@ -216,27 +267,79 @@ fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Vmdk {
|
impl Vmdk {
|
||||||
pub(super) fn open(file: FileSource, path: &Path) -> io::Result<Self> {
|
fn open_parent_of(r: &ParentRef, dir: &Path, depth: u32) -> io::Result<Box<dyn Source>> {
|
||||||
|
let mut found = r
|
||||||
|
.name
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| candidate_paths(dir, std::slice::from_ref(n)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let by_name = found.iter().any(|p| p.is_file());
|
||||||
|
if !by_name {
|
||||||
|
if let Some(p) = r.uuid.as_deref().and_then(|u| find_by_uuid(dir, u)) {
|
||||||
|
found = vec![p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.name.is_none() && r.uuid.is_none() {
|
||||||
|
return Err(bad(
|
||||||
|
"VMDK com pai, mas o descritor não diz qual (sem parentFileNameHint nem ddb.uuid.parent)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if r.name.is_none() && found.is_empty() {
|
||||||
|
return Err(bad(
|
||||||
|
"VMDK com pai identificado só por UUID (VirtualBox) e o pai não está na pasta do filho nem nas duas acima",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(cid) = &r.cid {
|
||||||
|
// o pai precisa ser o mesmo de quando o filho foi criado
|
||||||
|
if let Some(p) = found.iter().find(|p| p.is_file()) {
|
||||||
|
let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID"));
|
||||||
|
if have
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|h| !h.eq_ignore_ascii_case(cid))
|
||||||
|
{
|
||||||
|
return Err(bad(format!(
|
||||||
|
"o pai {} foi alterado depois do filho (CID {} em vez de {cid}); os dados do filho não valem mais",
|
||||||
|
p.display(),
|
||||||
|
have.unwrap_or_default()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open_parent(&found, depth, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||||
let mut head = [0u8; 4];
|
let mut head = [0u8; 4];
|
||||||
let _ = file.read_at(0, &mut head);
|
let _ = file.read_at(0, &mut head);
|
||||||
let extents = if &head == b"KDMV" {
|
let dir = path.parent().unwrap_or(Path::new("."));
|
||||||
let (sp, size, _) = Sparse::open(file)?;
|
let (extents, descriptor) = if &head == b"KDMV" {
|
||||||
vec![Extent {
|
let (sp, size, descriptor) = Sparse::open(file)?;
|
||||||
start: 0,
|
(
|
||||||
len: size,
|
vec![Extent {
|
||||||
kind: Kind::Sparse(sp),
|
start: 0,
|
||||||
}]
|
len: size,
|
||||||
|
kind: Kind::Sparse(sp),
|
||||||
|
}],
|
||||||
|
descriptor,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
if file.size() > 1 << 20 {
|
if file.size() > 1 << 20 {
|
||||||
return Err(bad("descritor VMDK grande demais"));
|
return Err(bad("descritor VMDK grande demais"));
|
||||||
}
|
}
|
||||||
let raw = read_vec(&file, 0, file.size() as usize)?;
|
let raw = read_vec(&file, 0, file.size() as usize)?;
|
||||||
let text = String::from_utf8_lossy(&raw);
|
let text = String::from_utf8_lossy(&raw).into_owned();
|
||||||
let dir = path.parent().unwrap_or(Path::new("."));
|
(parse_descriptor(&text, dir)?, Some(text))
|
||||||
parse_descriptor(&text, dir)?
|
};
|
||||||
|
let parent = match descriptor.as_deref().and_then(parent_ref) {
|
||||||
|
Some(r) => Some(Self::open_parent_of(&r, dir, depth)?),
|
||||||
|
None => None,
|
||||||
};
|
};
|
||||||
let size = extents.iter().map(|e| e.len).sum();
|
let size = extents.iter().map(|e| e.len).sum();
|
||||||
Ok(Self { extents, size })
|
Ok(Self {
|
||||||
|
extents,
|
||||||
|
size,
|
||||||
|
parent,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +364,13 @@ impl Backend for Vmdk {
|
|||||||
read_exact(file, offset + rel, &mut buf[..n])?;
|
read_exact(file, offset + rel, &mut buf[..n])?;
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
Kind::Sparse(s) => s.read(rel, &mut buf[..n]),
|
Kind::Sparse(s) => {
|
||||||
|
let (got, allocated) = s.read(rel, &mut buf[..n])?;
|
||||||
|
if !allocated {
|
||||||
|
read_parent(&self.parent, voff, &mut buf[..got])?;
|
||||||
|
}
|
||||||
|
Ok(got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,6 +298,18 @@ fn configuracoes_de_tema_persistem() {
|
|||||||
init();
|
init();
|
||||||
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
|
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
|
||||||
assert_eq!(settings::load().theme, "auto");
|
assert_eq!(settings::load().theme, "auto");
|
||||||
|
assert!(
|
||||||
|
settings::load().updates,
|
||||||
|
"a verificação de atualização vem ligada"
|
||||||
|
);
|
||||||
|
// arquivo de uma versão anterior, sem o campo novo
|
||||||
|
std::fs::write(
|
||||||
|
settings::config_dir().join("settings.json"),
|
||||||
|
r#"{"theme":"dark"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(settings::load().updates);
|
||||||
|
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
|
||||||
settings::save(&settings::Settings {
|
settings::save(&settings::Settings {
|
||||||
theme: "dark".into(),
|
theme: "dark".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -142,12 +142,19 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
|
|||||||
ui_lang: Some("en".into()),
|
ui_lang: Some("en".into()),
|
||||||
notify: false,
|
notify: false,
|
||||||
maps: true,
|
maps: true,
|
||||||
|
updates: false,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let s = settings::load();
|
let s = settings::load();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
|
(
|
||||||
("light", "en", false, true)
|
s.theme.as_str(),
|
||||||
|
s.lang.as_str(),
|
||||||
|
s.notify,
|
||||||
|
s.maps,
|
||||||
|
s.updates
|
||||||
|
),
|
||||||
|
("light", "en", false, true, false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,267 @@
|
|||||||
|
//! Assinatura digital: chaves, arquivos, adulteração, recuperação inteira e interoperabilidade.
|
||||||
|
|
||||||
|
use guara_core::recover;
|
||||||
|
use guara_core::sign::{self, SigningKey};
|
||||||
|
use guara_core::source::FileSource;
|
||||||
|
use guara_core::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn resident(id: u64, name: &str, bytes: Vec<u8>) -> Found {
|
||||||
|
let ext = name.rsplit('.').next().unwrap_or("").to_string();
|
||||||
|
Found {
|
||||||
|
id,
|
||||||
|
name: name.into(),
|
||||||
|
dir: None,
|
||||||
|
category: signatures::category_for_ext(&ext),
|
||||||
|
ext,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
origin: Origin::Carved,
|
||||||
|
confidence: Confidence::High,
|
||||||
|
data: DataLoc::Resident { bytes },
|
||||||
|
modified: None,
|
||||||
|
created: None,
|
||||||
|
accessed: None,
|
||||||
|
taken: None,
|
||||||
|
camera: None,
|
||||||
|
px: None,
|
||||||
|
gps: false,
|
||||||
|
sig: None,
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `recover` assina sozinho quando `GUARA_SIGNING_KEY` existe; testes que recuperam não se misturam.
|
||||||
|
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
fn keyed(dir: &Path) -> SigningKey {
|
||||||
|
let k = SigningKey::generate();
|
||||||
|
k.save(&dir.join("k.key")).unwrap();
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_roundtrip_and_no_overwrite() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let k = keyed(dir.path());
|
||||||
|
let again = SigningKey::load(&dir.path().join("k.key")).unwrap();
|
||||||
|
assert_eq!(k.public_bytes(), again.public_bytes());
|
||||||
|
assert_eq!(k.fingerprint(), again.fingerprint());
|
||||||
|
assert_eq!(k.fingerprint().len(), 24); // 5 grupos de 4 + 4 separadores
|
||||||
|
let pubfile = std::fs::read_to_string(dir.path().join("k.pub")).unwrap();
|
||||||
|
assert!(pubfile.contains(&k.fingerprint()));
|
||||||
|
let e = SigningKey::generate()
|
||||||
|
.save(&dir.path().join("k.key"))
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
assert!(e.contains("já existe uma chave"), "{e}");
|
||||||
|
// a chave antiga continua intacta
|
||||||
|
assert_eq!(
|
||||||
|
SigningKey::load(&dir.path().join("k.key"))
|
||||||
|
.unwrap()
|
||||||
|
.fingerprint(),
|
||||||
|
k.fingerprint()
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(dir.path().join("k.key"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(
|
||||||
|
mode & 0o777,
|
||||||
|
0o600,
|
||||||
|
"a chave privada precisa ser só do dono"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::write(dir.path().join("lixo.key"), "outra coisa").unwrap();
|
||||||
|
assert!(SigningKey::load(&dir.path().join("lixo.key")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_file_verifies_and_tampering_is_caught() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let k = keyed(dir.path());
|
||||||
|
let f = dir.path().join("relatorio.pdf");
|
||||||
|
std::fs::write(&f, b"conteudo do relatorio").unwrap();
|
||||||
|
let sig = sign::sign_file(&k, &f, Some("Perita Ana")).unwrap();
|
||||||
|
assert_eq!(sig, sign::signature_path(&f));
|
||||||
|
let c = sign::verify_file(&f, None, Some(&k.fingerprint())).unwrap();
|
||||||
|
assert!(
|
||||||
|
c.valid && c.trusted == Some(true) && c.signer.as_deref() == Some("Perita Ana"),
|
||||||
|
"{c:?}"
|
||||||
|
);
|
||||||
|
// fingerprint aceito em outro formato (maiúsculas, sem dois pontos)
|
||||||
|
let loose = k.fingerprint().replace(':', " ").to_uppercase();
|
||||||
|
assert_eq!(
|
||||||
|
sign::verify_file(&f, None, Some(&loose)).unwrap().trusted,
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
// outra chave não é a esperada
|
||||||
|
let other = SigningKey::generate().fingerprint();
|
||||||
|
let c = sign::verify_file(&f, None, Some(&other)).unwrap();
|
||||||
|
assert!(!c.valid && c.trusted == Some(false));
|
||||||
|
|
||||||
|
// arquivo alterado
|
||||||
|
std::fs::write(&f, b"conteudo ALTERADO do relatorio").unwrap();
|
||||||
|
let c = sign::verify_file(&f, None, None).unwrap();
|
||||||
|
assert!(!c.valid && !c.file_matches && c.signature_valid, "{c:?}");
|
||||||
|
std::fs::write(&f, b"conteudo do relatorio").unwrap();
|
||||||
|
assert!(sign::verify_file(&f, None, None).unwrap().valid);
|
||||||
|
|
||||||
|
// campos da assinatura alterados (hora, assinante, hash) invalidam
|
||||||
|
let raw = std::fs::read_to_string(&sig).unwrap();
|
||||||
|
for (from, to) in [
|
||||||
|
("Perita Ana", "Outra Pessoa"),
|
||||||
|
("\"assinado_em\": \"20", "\"assinado_em\": \"19"),
|
||||||
|
] {
|
||||||
|
assert!(raw.contains(from), "{from}");
|
||||||
|
std::fs::write(&sig, raw.replacen(from, to, 1)).unwrap();
|
||||||
|
let c = sign::verify_file(&f, None, None).unwrap();
|
||||||
|
assert!(!c.valid && !c.signature_valid, "{from}: {c:?}");
|
||||||
|
}
|
||||||
|
// trocar a chave pública pela de outra pessoa também não passa
|
||||||
|
let mut j: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||||
|
let evil = SigningKey::generate();
|
||||||
|
j["chave_publica"] = evil
|
||||||
|
.public_bytes()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>()
|
||||||
|
.into();
|
||||||
|
std::fs::write(&sig, serde_json::to_vec(&j).unwrap()).unwrap();
|
||||||
|
assert!(!sign::verify_file(&f, None, None).unwrap().valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_folder_is_checked_file_by_file() {
|
||||||
|
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let k = keyed(dir.path());
|
||||||
|
let src = dir.path().join("origem.img");
|
||||||
|
std::fs::write(&src, b"x").unwrap();
|
||||||
|
let fs = FileSource::open(&src).unwrap();
|
||||||
|
let a = resident(1, "a.txt", b"primeiro arquivo".to_vec());
|
||||||
|
let b = resident(2, "b.txt", b"segundo arquivo".to_vec());
|
||||||
|
let out = dir.path().join("saida");
|
||||||
|
let rep = recover::recover(&fs, &[&a, &b], &out, &|| false, &mut |_, _, _| {}).unwrap();
|
||||||
|
assert_eq!(rep.recovered.len(), 2);
|
||||||
|
let manifest = out.join("guara-manifesto.json");
|
||||||
|
sign::sign_file(&k, &manifest, None).unwrap();
|
||||||
|
|
||||||
|
let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap();
|
||||||
|
assert!(c.valid && c.ok == 2 && c.files == 2, "{c:?}");
|
||||||
|
|
||||||
|
let first = out.join(&rep.recovered[0].path);
|
||||||
|
let second = out.join(&rep.recovered[1].path);
|
||||||
|
std::fs::write(&first, b"adulterado").unwrap();
|
||||||
|
std::fs::remove_file(&second).unwrap();
|
||||||
|
let c = sign::verify_recovery(&out, None).unwrap();
|
||||||
|
assert!(
|
||||||
|
!c.valid && c.changed.len() == 1 && c.missing.len() == 1,
|
||||||
|
"{c:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// manifesto adulterado (hash trocado para acompanhar o arquivo alterado) quebra a assinatura
|
||||||
|
let m = std::fs::read_to_string(&manifest).unwrap();
|
||||||
|
let real = &rep.recovered[0].sha256;
|
||||||
|
let fake = sign::hash_file(&first).unwrap().0;
|
||||||
|
std::fs::write(&manifest, m.replace(real, &fake)).unwrap();
|
||||||
|
let c = sign::verify_recovery(&out, None).unwrap();
|
||||||
|
let sigcheck = c.manifest_signature.as_ref().unwrap();
|
||||||
|
assert!(!sigcheck.valid && !sigcheck.file_matches, "{c:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_paths_cannot_escape_the_folder() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("fora.txt"), b"segredo").unwrap();
|
||||||
|
let out = dir.path().join("saida");
|
||||||
|
std::fs::create_dir(&out).unwrap();
|
||||||
|
let sha = sign::hash_file(&dir.path().join("fora.txt")).unwrap().0;
|
||||||
|
std::fs::write(
|
||||||
|
out.join("guara-manifesto.json"),
|
||||||
|
serde_json::json!({ "relatorio": { "recovered": [ { "path": "../fora.txt", "sha256": sha } ] } }).to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let c = sign::verify_recovery(&out, None).unwrap();
|
||||||
|
assert!(!c.valid && c.changed[0].contains("fora da pasta"), "{c:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// O mesmo que o `openssl` faria: a assinatura é Ed25519 padrão sobre a mensagem documentada.
|
||||||
|
#[test]
|
||||||
|
fn signature_verifies_with_openssl() {
|
||||||
|
let Ok(v) = std::process::Command::new("openssl")
|
||||||
|
.arg("version")
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !v.status.success() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let k = keyed(dir.path());
|
||||||
|
let f = dir.path().join("doc.txt");
|
||||||
|
std::fs::write(&f, b"hello\n").unwrap();
|
||||||
|
let sig = sign::sign_file(&k, &f, Some("Ana")).unwrap();
|
||||||
|
let j: serde_json::Value = serde_json::from_slice(&std::fs::read(sig).unwrap()).unwrap();
|
||||||
|
let unhex = |s: &str| -> Vec<u8> {
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let msg = format!(
|
||||||
|
"{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante=Ana\n",
|
||||||
|
j["formato"].as_str().unwrap(),
|
||||||
|
j["arquivo"].as_str().unwrap(),
|
||||||
|
j["tamanho"],
|
||||||
|
j["sha256"].as_str().unwrap(),
|
||||||
|
j["assinado_em"].as_str().unwrap()
|
||||||
|
);
|
||||||
|
std::fs::write(dir.path().join("msg"), msg).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("sig"),
|
||||||
|
unhex(j["assinatura"].as_str().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut der = unhex("302a300506032b6570032100");
|
||||||
|
der.extend(unhex(j["chave_publica"].as_str().unwrap()));
|
||||||
|
std::fs::write(dir.path().join("pub.der"), der).unwrap();
|
||||||
|
let r = std::process::Command::new("openssl")
|
||||||
|
.current_dir(dir.path())
|
||||||
|
.args([
|
||||||
|
"pkeyutl", "-verify", "-pubin", "-inkey", "pub.der", "-keyform", "DER", "-rawin",
|
||||||
|
"-in", "msg", "-sigfile", "sig",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
// OpenSSL 1.1 não tem -rawin para Ed25519; só exige sucesso onde ele existe
|
||||||
|
let text = String::from_utf8_lossy(&r.stdout).to_string() + &String::from_utf8_lossy(&r.stderr);
|
||||||
|
if text.contains("Unknown option") || text.contains("unknown option") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(r.status.success(), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Com chave configurada, a recuperação e o relatório saem assinados sem ninguém pedir.
|
||||||
|
#[test]
|
||||||
|
fn configured_key_signs_manifest_automatically() {
|
||||||
|
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let k = keyed(dir.path());
|
||||||
|
std::env::set_var("GUARA_SIGNING_KEY", dir.path().join("k.key"));
|
||||||
|
std::env::set_var("GUARA_CONFIG_DIR", dir.path().join("cfg"));
|
||||||
|
let src = dir.path().join("origem.img");
|
||||||
|
std::fs::write(&src, b"x").unwrap();
|
||||||
|
let fs = FileSource::open(&src).unwrap();
|
||||||
|
let a = resident(1, "a.txt", b"dados".to_vec());
|
||||||
|
let out = dir.path().join("saida");
|
||||||
|
recover::recover(&fs, &[&a], &out, &|| false, &mut |_, _, _| {}).unwrap();
|
||||||
|
std::env::remove_var("GUARA_SIGNING_KEY");
|
||||||
|
let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap();
|
||||||
|
assert!(c.valid && c.manifest_signature.is_some(), "{c:?}");
|
||||||
|
}
|
||||||
@@ -281,22 +281,82 @@ fn qcow2_with_zero_raw_and_compressed_clusters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn qcow2_with_backing_file_or_encryption_is_refused() {
|
fn qcow2_with_encryption_or_a_remote_backing_file_is_refused() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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());
|
let mut img = build_qcow2(&raw());
|
||||||
be(&mut img, 32, 2, 4);
|
be(&mut img, 32, 2, 4);
|
||||||
let p = dir.path().join("e.qcow2");
|
let p = dir.path().join("e.qcow2");
|
||||||
std::fs::write(&p, &img).unwrap();
|
std::fs::write(&p, &img).unwrap();
|
||||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||||
assert!(e.contains("LUKS"), "{e}");
|
assert!(e.contains("LUKS"), "{e}");
|
||||||
|
|
||||||
|
let mut img = build_qcow2(&raw());
|
||||||
|
let name = b"nbd://servidor/disco";
|
||||||
|
img[512..512 + name.len()].copy_from_slice(name);
|
||||||
|
be(&mut img, 8, 512, 8);
|
||||||
|
be(&mut img, 16, name.len() as u64, 4);
|
||||||
|
let p = dir.path().join("r.qcow2");
|
||||||
|
std::fs::write(&p, &img).unwrap();
|
||||||
|
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||||
|
assert!(e.contains("não é um arquivo local"), "{e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filho QCOW2 que só aloca o cluster 1 (dados), marca o 2 como zerado e deixa o resto para o pai.
|
||||||
|
fn build_qcow2_child(backing: &str, fmt: Option<&str>, own: &[u8]) -> Vec<u8> {
|
||||||
|
const CS: usize = 1 << 16;
|
||||||
|
let mut img = vec![0u8; 3 * CS];
|
||||||
|
img[..4].copy_from_slice(b"QFI\xfb");
|
||||||
|
be(&mut img, 4, 3, 4);
|
||||||
|
be(&mut img, 16, backing.len() as u64, 4);
|
||||||
|
be(&mut img, 8, 1024, 8);
|
||||||
|
img[1024..1024 + backing.len()].copy_from_slice(backing.as_bytes());
|
||||||
|
be(&mut img, 20, 16, 4);
|
||||||
|
be(&mut img, 24, 16 * CS as u64, 8);
|
||||||
|
be(&mut img, 36, 1, 4);
|
||||||
|
be(&mut img, 40, CS as u64, 8);
|
||||||
|
be(&mut img, 100, 112, 4);
|
||||||
|
if let Some(f) = fmt {
|
||||||
|
// extensão 0xE2792ACA com o nome do formato do apoio, depois o fim das extensões
|
||||||
|
be(&mut img, 112, 0xE279_2ACA, 4);
|
||||||
|
be(&mut img, 116, f.len() as u64, 4);
|
||||||
|
img[120..120 + f.len()].copy_from_slice(f.as_bytes());
|
||||||
|
}
|
||||||
|
be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8);
|
||||||
|
let host = img.len() as u64;
|
||||||
|
img.extend_from_slice(own);
|
||||||
|
img.resize(img.len().div_ceil(CS) * CS, 0);
|
||||||
|
be(&mut img, 2 * CS + 8, host | (1 << 63), 8); // cluster 1
|
||||||
|
be(&mut img, 2 * CS + 16, 1, 8); // cluster 2: zerado de propósito
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qcow2_backing_file_supplies_what_the_child_did_not_allocate() {
|
||||||
|
const CS: usize = 1 << 16;
|
||||||
|
let base = raw();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("base.raw"), &base).unwrap();
|
||||||
|
let own = vec![0x42u8; CS];
|
||||||
|
let mut expect = base.clone();
|
||||||
|
expect[CS..2 * CS].copy_from_slice(&own);
|
||||||
|
expect[2 * CS..3 * CS].fill(0);
|
||||||
|
for (name, fmt) in [("a.qcow2", Some("raw")), ("b.qcow2", None)] {
|
||||||
|
let p = dir.path().join(name);
|
||||||
|
std::fs::write(&p, build_qcow2_child("base.raw", fmt, &own)).unwrap();
|
||||||
|
check_content(&p, &expect);
|
||||||
|
}
|
||||||
|
// com o pai em outro formato: o filho aponta para um QCOW2
|
||||||
|
std::fs::write(dir.path().join("base.qcow2"), build_qcow2(&base)).unwrap();
|
||||||
|
let p = dir.path().join("c.qcow2");
|
||||||
|
std::fs::write(&p, build_qcow2_child("base.qcow2", Some("qcow2"), &own)).unwrap();
|
||||||
|
check_content(&p, &expect);
|
||||||
|
// sem o pai, a abertura diz o que falta
|
||||||
|
std::fs::remove_file(dir.path().join("base.raw")).unwrap();
|
||||||
|
let e = open_path(&dir.path().join("a.qcow2"))
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
assert!(e.contains("disco pai não encontrado"), "{e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- VMDK ----------
|
// ---------- VMDK ----------
|
||||||
@@ -324,8 +384,114 @@ fn vmdk_descriptor_with_flat_and_zero_extents() {
|
|||||||
check_content(&p, &expect);
|
check_content(&p, &expect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// VMDK esparso feito à mão: 16 grãos de 64 KiB; `grains[i]` = `None` (não alocado, vem do pai),
|
||||||
|
/// `Some(None)` (zerado de propósito) ou `Some(Some(dados))`.
|
||||||
|
fn build_vmdk_sparse(descriptor: &str, grains: &[Option<Option<Vec<u8>>>]) -> Vec<u8> {
|
||||||
|
const GRAIN: usize = 128; // setores
|
||||||
|
let mut img = vec![0u8; 26 * 512];
|
||||||
|
img[..4].copy_from_slice(b"KDMV");
|
||||||
|
le(&mut img, 4, 1, 4);
|
||||||
|
le(&mut img, 8, 3, 4);
|
||||||
|
le(&mut img, 12, (grains.len() * GRAIN) as u64, 8);
|
||||||
|
le(&mut img, 20, GRAIN as u64, 8);
|
||||||
|
le(&mut img, 28, 1, 8);
|
||||||
|
le(&mut img, 36, 20, 8);
|
||||||
|
le(&mut img, 44, 512, 4);
|
||||||
|
le(&mut img, 56, 21, 8);
|
||||||
|
le(&mut img, 64, 26, 8);
|
||||||
|
img[512..512 + descriptor.len()].copy_from_slice(descriptor.as_bytes());
|
||||||
|
le(&mut img, 21 * 512, 22, 4); // diretório de grãos -> tabela no setor 22
|
||||||
|
for (i, g) in grains.iter().enumerate() {
|
||||||
|
let entry = match g {
|
||||||
|
None => 0,
|
||||||
|
Some(None) => 1,
|
||||||
|
Some(Some(d)) => {
|
||||||
|
let sector = (img.len() / 512) as u64;
|
||||||
|
let mut d = d.clone();
|
||||||
|
d.resize(GRAIN * 512, 0);
|
||||||
|
img.extend_from_slice(&d);
|
||||||
|
sector
|
||||||
|
}
|
||||||
|
};
|
||||||
|
le(&mut img, 22 * 512 + i * 4, entry, 4);
|
||||||
|
}
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vmdk_with_a_parent_is_refused() {
|
fn vmdk_child_reads_the_parent_for_unallocated_grains() {
|
||||||
|
let base = raw();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("base.vmdk"), build_vmdk_sparse(
|
||||||
|
"# Disk DescriptorFile\nversion=1\nCID=11111111\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
|
||||||
|
&(0..16)
|
||||||
|
.map(|i| Some(Some(base[i * 65536..(i + 1) * 65536].to_vec())))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let own = vec![0x37u8; 65536];
|
||||||
|
let mut grains: Vec<Option<Option<Vec<u8>>>> = vec![None; 16];
|
||||||
|
grains[3] = Some(Some(own.clone()));
|
||||||
|
grains[5] = Some(None); // zerado de propósito: não consulta o pai
|
||||||
|
let child = build_vmdk_sparse(
|
||||||
|
"# Disk DescriptorFile\nversion=1\nCID=22222222\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
|
||||||
|
&grains,
|
||||||
|
);
|
||||||
|
let p = dir.path().join("child.vmdk");
|
||||||
|
std::fs::write(&p, child).unwrap();
|
||||||
|
let mut expect = base.clone();
|
||||||
|
expect[3 * 65536..4 * 65536].copy_from_slice(&own);
|
||||||
|
expect[5 * 65536..6 * 65536].fill(0);
|
||||||
|
check_content(&p, &expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot real do VirtualBox: o filho só diz o UUID do pai (`ddb.uuid.parent`), sem nome.
|
||||||
|
#[test]
|
||||||
|
fn virtualbox_snapshot_vmdk_finds_its_parent_by_uuid() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sub = dir.path().join("Snapshots");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
// o pai fica na pasta de cima, como o VirtualBox organiza a máquina
|
||||||
|
std::fs::copy(fx("disk.vmdk"), dir.path().join("disco-base.vmdk")).unwrap();
|
||||||
|
std::fs::copy(fx("vbox-snapshot.vmdk"), sub.join("filho.vmdk")).unwrap();
|
||||||
|
check_content(&sub.join("filho.vmdk"), &raw());
|
||||||
|
// sem o pai por perto, a abertura explica
|
||||||
|
std::fs::remove_file(dir.path().join("disco-base.vmdk")).unwrap();
|
||||||
|
let e = open_path(&sub.join("filho.vmdk"))
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
assert!(e.contains("identificado só por UUID"), "{e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vmdk_child_is_refused_when_the_parent_changed() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("base.vmdk"),
|
||||||
|
build_vmdk_sparse(
|
||||||
|
"# Disk DescriptorFile\nversion=1\nCID=aaaaaaaa\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
|
||||||
|
&vec![None; 16],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("filho.vmdk"),
|
||||||
|
build_vmdk_sparse(
|
||||||
|
"# Disk DescriptorFile\nversion=1\nCID=bbbbbbbb\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
|
||||||
|
&vec![None; 16],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let e = open_path(&dir.path().join("filho.vmdk"))
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
assert!(e.contains("foi alterado depois do filho"), "{e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vmdk_with_a_parent_but_no_name_is_refused() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let p = dir.path().join("c.vmdk");
|
let p = dir.path().join("c.vmdk");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -334,7 +500,7 @@ fn vmdk_with_a_parent_is_refused() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||||
assert!(e.contains("disco pai"), "{e}");
|
assert!(e.contains("não diz qual"), "{e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- VHD ----------
|
// ---------- VHD ----------
|
||||||
@@ -619,8 +785,36 @@ fn dmg_with_every_chunk_type() {
|
|||||||
check_content(&p, &expect);
|
check_content(&p, &expect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chunks `ULFO` com dados compactados pelo compressor de referência da Apple (`lzfse`):
|
||||||
|
/// um bloco `bvx2` (FSE, 64 KiB) e um pequeno (`bvxn`/LZVN).
|
||||||
#[test]
|
#[test]
|
||||||
fn dmg_lzfse_and_encrypted_are_refused_with_a_reason() {
|
fn dmg_lzfse_chunks_from_the_reference_compressor() {
|
||||||
|
let big = std::fs::read(fx("lzfse-64k.bin")).unwrap();
|
||||||
|
let small = std::fs::read(fx("lzfse-small.bin")).unwrap();
|
||||||
|
let mut small_padded = small.clone();
|
||||||
|
small_padded.resize(small.len().div_ceil(512) * 512, 0);
|
||||||
|
let chunks = [
|
||||||
|
DmgChunk {
|
||||||
|
ty: 0x8000_0007,
|
||||||
|
sectors: (big.len() / 512) as u64,
|
||||||
|
payload: std::fs::read(fx("lzfse-64k.bin.lzfse")).unwrap(),
|
||||||
|
},
|
||||||
|
DmgChunk {
|
||||||
|
ty: 0x8000_0007,
|
||||||
|
sectors: (small_padded.len() / 512) as u64,
|
||||||
|
payload: std::fs::read(fx("lzfse-small.bin.lzfse")).unwrap(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut expect = big.clone();
|
||||||
|
expect.extend_from_slice(&small_padded);
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("l.dmg");
|
||||||
|
std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap();
|
||||||
|
check_content(&p, &expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dmg_with_a_corrupt_lzfse_chunk_and_encrypted_are_refused_with_a_reason() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let p = dir.path().join("l.dmg");
|
let p = dir.path().join("l.dmg");
|
||||||
let chunks = [DmgChunk {
|
let chunks = [DmgChunk {
|
||||||
|
|||||||
@@ -268,3 +268,166 @@ fn reference_image_is_recognized() {
|
|||||||
let l = c.layout.unwrap();
|
let l = c.layout.unwrap();
|
||||||
assert_eq!(l.partitions[0].fs, FsKind::Xfs);
|
assert_eq!(l.partitions[0].fs, FsKind::Xfs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Meta: exclusão feita pelo kernel (scripts/make-xfs-real-deletion.sh, precisa de root): cada
|
||||||
|
/// arquivo apagado volta pelo nome, na pasta certa, com o conteúdo original no começo, e os que
|
||||||
|
/// ficaram não aparecem como apagados.
|
||||||
|
///
|
||||||
|
/// HOJE FALHA, e é por isso que está `ignore`: num XFS v5 real o kernel zera o inode e a lista de
|
||||||
|
/// extensões ao apagar, então só a simulação (teste acima) deixa a metadados para ler. Recuperar
|
||||||
|
/// nome e conteúdo de um XFS real exige ler o diário (log) do sistema de arquivos. Rodar com
|
||||||
|
/// `cargo test -p guara-core --test xfs -- --ignored` para ver o estado.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "XFS real: o kernel apaga os metadados; falta ler o diário (ver ROADMAP)"]
|
||||||
|
fn real_kernel_deletion_names_are_recovered() {
|
||||||
|
let dir =
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete");
|
||||||
|
let (img, tsv) = (dir.join("disk.img"), dir.join("expected.tsv"));
|
||||||
|
if !(img.exists() && tsv.exists()) {
|
||||||
|
assert!(
|
||||||
|
std::env::var("GUARA_REQUIRE_FIXTURES").is_err(),
|
||||||
|
"faltou a imagem: sudo scripts/make-xfs-real-deletion.sh"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: rode sudo scripts/make-xfs-real-deletion.sh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
let rows: Vec<(String, u64, String, bool)> = std::fs::read_to_string(&tsv)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(|l| {
|
||||||
|
let c: Vec<&str> = l.split('\t').collect();
|
||||||
|
(
|
||||||
|
c[0].to_string(),
|
||||||
|
c[1].parse().unwrap(),
|
||||||
|
c[2].to_string(),
|
||||||
|
c[3] == "deleted",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let c = scan_path(
|
||||||
|
&img,
|
||||||
|
&ScanOptions {
|
||||||
|
mode: ScanMode::Deep,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let xfs: Vec<&Found> = c.found.iter().filter(|f| f.origin == Origin::Xfs).collect();
|
||||||
|
let listing = || {
|
||||||
|
xfs.iter()
|
||||||
|
.map(|f| {
|
||||||
|
format!(
|
||||||
|
"{}/{} ({} bytes, {:?})",
|
||||||
|
f.dir.as_deref().unwrap_or(""),
|
||||||
|
f.name,
|
||||||
|
f.size,
|
||||||
|
f.confidence
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
for (rel, size, sha, deleted) in &rows {
|
||||||
|
let (dirname, name) = match rel.rsplit_once('/') {
|
||||||
|
Some((d, n)) => (format!("/{d}"), n),
|
||||||
|
None => (String::new(), rel.as_str()),
|
||||||
|
};
|
||||||
|
let hit = xfs
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.name == name && f.dir.as_deref().unwrap_or("") == dirname);
|
||||||
|
if !*deleted {
|
||||||
|
assert!(
|
||||||
|
hit.is_none(),
|
||||||
|
"{rel} não foi apagado, mas apareceu como recuperável: {:?}",
|
||||||
|
listing()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let f = hit.unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"{rel} apagado pelo kernel não foi encontrado; achados: {:?}",
|
||||||
|
listing()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let rep = recover_all(&img, std::slice::from_ref(*f), &out.path().join(name));
|
||||||
|
assert!(rep.failed.is_empty(), "{rel}: {:?}", rep.failed);
|
||||||
|
let rp = PathBuf::from(&rep.recovered[0].path);
|
||||||
|
let got = std::fs::read(if rp.is_absolute() {
|
||||||
|
rp
|
||||||
|
} else {
|
||||||
|
out.path().join(name).join(rp)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
// o tamanho exato se perde na exclusão: o arquivo vem com os blocos inteiros
|
||||||
|
assert!(
|
||||||
|
got.len() as u64 >= *size,
|
||||||
|
"{rel}: veio menor ({} < {size})",
|
||||||
|
got.len()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256(&got[..*size as usize]),
|
||||||
|
*sha,
|
||||||
|
"{rel}: o conteúdo inicial difere do original"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// O que funciona hoje num XFS apagado de verdade: o conteúdo dos blocos livres continua lá e a
|
||||||
|
/// varredura profunda recupera, por carving, o arquivo com estrutura reconhecível (SQLite), com
|
||||||
|
/// o conteúdo idêntico ao original. O nome e a pasta se perdem.
|
||||||
|
#[test]
|
||||||
|
fn real_kernel_deletion_content_is_recovered_by_carving() {
|
||||||
|
let dir =
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete");
|
||||||
|
let (img, tsv) = (dir.join("disk.img"), dir.join("expected.tsv"));
|
||||||
|
if !(img.exists() && tsv.exists()) {
|
||||||
|
assert!(
|
||||||
|
std::env::var("GUARA_REQUIRE_FIXTURES").is_err(),
|
||||||
|
"faltou a imagem: sudo scripts/make-xfs-real-deletion.sh"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: rode sudo scripts/make-xfs-real-deletion.sh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
let want = std::fs::read_to_string(&tsv)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.split('\t').collect::<Vec<_>>())
|
||||||
|
.find(|c| c[0].ends_with(".sqlite"))
|
||||||
|
.map(|c| (c[1].parse::<usize>().unwrap(), c[2].to_string()))
|
||||||
|
.expect("o SQLite apagado consta do expected.tsv");
|
||||||
|
let c = scan_path(
|
||||||
|
&img,
|
||||||
|
&ScanOptions {
|
||||||
|
mode: ScanMode::Deep,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
let mut seen = vec![];
|
||||||
|
for (i, f) in c.found.iter().enumerate() {
|
||||||
|
if f.size as usize != want.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rep = recover_all(
|
||||||
|
&img,
|
||||||
|
std::slice::from_ref(f),
|
||||||
|
&out.path().join(i.to_string()),
|
||||||
|
);
|
||||||
|
let rp = PathBuf::from(&rep.recovered[0].path);
|
||||||
|
let p = if rp.is_absolute() {
|
||||||
|
rp
|
||||||
|
} else {
|
||||||
|
out.path().join(i.to_string()).join(rp)
|
||||||
|
};
|
||||||
|
seen.push(sha256_file(&p));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
seen.contains(&want.1),
|
||||||
|
"o SQLite apagado não voltou idêntico; achados do mesmo tamanho: {seen:?}; todos: {:?}",
|
||||||
|
c.found
|
||||||
|
.iter()
|
||||||
|
.map(|f| (&f.name, f.size, f.origin))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ recover copies with MD5, SHA-1 and SHA-256 and writes the
|
|||||||
| `source`, `device` | Open devices and images read-only; list disks |
|
| `source`, `device` | Open devices and images read-only; list disks |
|
||||||
| `image` | Raw image tolerant of bad sectors, with an error map |
|
| `image` | Raw image tolerant of bad sectors, with an error map |
|
||||||
| `ewf`, `aff4` | Read and write E01/Ex01 and AFF4 |
|
| `ewf`, `aff4` | Read and write E01/Ex01 and AFF4 |
|
||||||
|
| `sign` | Ed25519 signatures of files and recovery folders |
|
||||||
| `audit` | Hash-chained audit trail of every examiner action |
|
| `audit` | Hash-chained audit trail of every examiner action |
|
||||||
| `vdisk/` | Read QCOW2, VMDK, VHD, VHDX and DMG as the disk they contain |
|
| `vdisk/` | Read QCOW2, VMDK, VHD, VHDX and DMG as the disk they contain |
|
||||||
| `assemble` | Builds the virtual disk: image, RAID, LVM and encrypted volume |
|
| `assemble` | Builds the virtual disk: image, RAID, LVM and encrypted volume |
|
||||||
|
|||||||
+12
@@ -20,6 +20,7 @@ Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linu
|
|||||||
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
|
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
|
||||||
| `export <session> <csv>` | Spreadsheet of the findings |
|
| `export <session> <csv>` | Spreadsheet of the findings |
|
||||||
| `report <session> <file>` | Printable report, HTML or PDF (by the file extension) |
|
| `report <session> <file>` | Printable report, HTML or PDF (by the file extension) |
|
||||||
|
| `sign keygen\|file\|verify\|recovery` | Ed25519 signatures of manifests, reports and files; checks a whole recovery folder |
|
||||||
| `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain |
|
| `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain |
|
||||||
| `sessions` | Automatically saved sessions |
|
| `sessions` | Automatically saved sessions |
|
||||||
| `ui` | Interface in the browser (127.0.0.1, with token) |
|
| `ui` | Interface in the browser (127.0.0.1, with token) |
|
||||||
@@ -100,6 +101,17 @@ 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).
|
For E01: `--segment-mib` (size of each segment, default 1500, 0 for a single file) and `--compression` (0 to 9, default 1).
|
||||||
|
|
||||||
|
## Digital signatures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
guara sign keygen # creates the key pair (never overwrites one)
|
||||||
|
guara sign file report.pdf # writes report.pdf.assinatura.json
|
||||||
|
guara sign verify report.pdf --trust 1a2b:3c4d:5e6f:7a8b:9c0d
|
||||||
|
guara sign recovery /mnt/other-disk/Recovered # manifest signature and the hash of every file
|
||||||
|
```
|
||||||
|
|
||||||
|
With a key configured, `recover` and `report` sign the manifest and the report on their own. The signature covers the SHA-256, size, name, time and signer; anyone can check it with Guará or with `openssl` (it is plain Ed25519 over the message documented in `crates/guara-core/src/sign.rs`). A valid signature proves the file did not change since it was signed **by whoever holds the key**; to know whose key it is, compare the fingerprint with the one the examiner published outside the machine (`--trust`). The private key lives in `assinatura.key` in the configuration folder (or where `GUARA_SIGNING_KEY` points), mode 0600; keep a copy and consider keeping it on an encrypted volume. The time is the signing machine's clock, not a third-party timestamp.
|
||||||
|
|
||||||
## Audit trail
|
## 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.
|
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.
|
||||||
|
|||||||
+5
-1
@@ -25,7 +25,11 @@ The switch at the bottom of the sidebar offers **Auto** (follows the system), **
|
|||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
|
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
When the desktop app opens it asks GitHub for the release's `latest.json`. If a newer version exists, a card appears at the bottom right with the versions, **What's new** (the CHANGELOG notes) and **Update now**. The app downloads the package, verifies its signature against the public key built into the app, installs and reopens by itself. On Linux this works for the AppImage (the file is replaced in place); installs from `.deb` or `.rpm` only show the notice with the release address. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to GitHub. `guara ui` in the browser does not update itself.
|
||||||
|
|
||||||
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto
|
|||||||
| `source`, `device` | Abrir dispositivos e imagens somente para leitura; listar discos |
|
| `source`, `device` | Abrir dispositivos e imagens somente para leitura; listar discos |
|
||||||
| `image` | Imagem bruta tolerante a setores ruins, com mapa de erros |
|
| `image` | Imagem bruta tolerante a setores ruins, com mapa de erros |
|
||||||
| `ewf`, `aff4` | Ler e gravar E01/Ex01 e AFF4 |
|
| `ewf`, `aff4` | Ler e gravar E01/Ex01 e AFF4 |
|
||||||
|
| `sign` | Assinaturas Ed25519 de arquivos e pastas de recuperação |
|
||||||
| `audit` | Trilha de auditoria encadeada por hash de cada ação do perito |
|
| `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 |
|
| `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 |
|
| `assemble` | Monta o disco virtual: imagem, RAID, LVM e volume cifrado |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e
|
|||||||
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
|
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
|
||||||
| `export <sessão> <csv>` | Planilha dos achados |
|
| `export <sessão> <csv>` | Planilha dos achados |
|
||||||
| `report <sessão> <arquivo>` | Relatório imprimível, HTML ou PDF (pela extensão do arquivo) |
|
| `report <sessão> <arquivo>` | Relatório imprimível, HTML ou PDF (pela extensão do arquivo) |
|
||||||
|
| `sign keygen\|file\|verify\|recovery` | Assinaturas Ed25519 de manifestos, relatórios e arquivos; confere uma pasta de recuperação inteira |
|
||||||
| `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash |
|
| `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash |
|
||||||
| `sessions` | Sessões salvas automaticamente |
|
| `sessions` | Sessões salvas automaticamente |
|
||||||
| `ui` | Interface no navegador (127.0.0.1, com token) |
|
| `ui` | Interface no navegador (127.0.0.1, com token) |
|
||||||
@@ -100,6 +101,17 @@ 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).
|
Para E01: `--segment-mib` (tamanho de cada segmento, padrão 1500, 0 para um arquivo só) e `--compression` (0 a 9, padrão 1).
|
||||||
|
|
||||||
|
## Assinatura digital
|
||||||
|
|
||||||
|
```bash
|
||||||
|
guara sign keygen # cria o par de chaves (nunca sobrescreve uma existente)
|
||||||
|
guara sign file relatorio.pdf # grava relatorio.pdf.assinatura.json
|
||||||
|
guara sign verify relatorio.pdf --trust 1a2b:3c4d:5e6f:7a8b:9c0d
|
||||||
|
guara sign recovery /mnt/outro-disco/Recuperados # assinatura do manifesto e o hash de cada arquivo
|
||||||
|
```
|
||||||
|
|
||||||
|
Com uma chave configurada, `recover` e `report` assinam sozinhos o manifesto e o relatório. A assinatura cobre o SHA-256, o tamanho, o nome, a hora e o assinante; qualquer pessoa confere com o Guará ou com o `openssl` (é Ed25519 comum sobre a mensagem documentada em `crates/guara-core/src/sign.rs`). Uma assinatura válida prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de quem é a chave, compare a impressão digital com a que o perito publicou fora da máquina (`--trust`). A chave privada fica em `assinatura.key` na pasta de configuração (ou onde `GUARA_SIGNING_KEY` apontar), com permissão 0600; guarde uma cópia e considere mantê-la num volume cifrado. A hora é a do relógio da máquina que assinou, não um carimbo de tempo de terceiros.
|
||||||
|
|
||||||
## Trilha de auditoria
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Clar
|
|||||||
|
|
||||||
## Idioma
|
## Idioma
|
||||||
|
|
||||||
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
|
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
|
||||||
|
|
||||||
|
## Atualizações
|
||||||
|
|
||||||
|
Ao abrir, o app desktop pede ao GitHub o `latest.json` da release. Havendo versão mais nova, aparece um cartão no canto inferior direito com as versões, **Novidades** (as notas do CHANGELOG) e **Atualizar agora**. O app baixa o pacote, confere a assinatura com a chave pública embutida nele, instala e reabre sozinho. No Linux isso vale para o AppImage (o arquivo é substituído no lugar); instalações por `.deb` ou `.rpm` só mostram o aviso com o endereço da release. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao GitHub. O `guara ui` no navegador não se atualiza sozinho.
|
||||||
|
|
||||||
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ MBR e GPT. Quando a tabela falha, o Guará procura volumes por assinatura (NTFS,
|
|||||||
| ext2/3/4 | ✅ | Inodes apagados, cópias antigas no diário (ext4), mapa de blocos livres | |
|
| ext2/3/4 | ✅ | Inodes apagados, cópias antigas no diário (ext4), mapa de blocos livres | |
|
||||||
| HFS+ | ✅ | Sobras de nós da árvore de catálogo, diário | |
|
| HFS+ | ✅ | Sobras de nós da árvore de catálogo, diário | |
|
||||||
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo | |
|
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo | |
|
||||||
| XFS | ✅ | Inodes liberados, nomes de entradas liberadas, espaço livre por grupo | Exclusão testada só em simulação |
|
| XFS | 🟡 | Inodes liberados, nomes de entradas liberadas e espaço livre por grupo (funciona na exclusão simulada); conteúdo por carving | Testado no CI com exclusão real do kernel: o kernel zera o inode e as extensões, então nome e pasta se perdem e só o carving recupera o conteúdo. Falta ler o diário |
|
||||||
| APFS | 🟡 | Contêiner, mapa de objetos, árvore de arquivos, instantâneos, nós antigos | Sem mapa de espaço livre; exclusão testada só em simulação |
|
| APFS | 🟡 | Contêiner, mapa de objetos, árvore de arquivos, instantâneos, nós antigos | Sem mapa de espaço livre; exclusão testada só em simulação |
|
||||||
| ReFS | ❌ | Apenas detectado | Recuperação por carving |
|
| ReFS | ❌ | Apenas detectado | Recuperação por carving |
|
||||||
| Storage Spaces | ❌ | Não suportado | |
|
| Storage Spaces | ❌ | Não suportado | |
|
||||||
@@ -55,12 +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 |
|
| Bruta (dd, `.img`) | ✅ | ✅ com mapa de erros de setores ruins |
|
||||||
| E01 e Ex01 (Expert Witness) | ✅ | ✅ com metadados do caso e segmentação |
|
| E01 e Ex01 (Expert Witness) | ✅ | ✅ com metadados do caso e segmentação |
|
||||||
| AFF4 | ✅ | ✅ |
|
| AFF4 | ✅ | ✅ |
|
||||||
| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) | ❌ |
|
| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) e arquivo de apoio | ❌ |
|
||||||
| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized` e descritores com extensões flat/zero | ❌ |
|
| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized`, descritores com extensões flat/zero e snapshots (pai por nome ou, no VirtualBox, por UUID) | ❌ |
|
||||||
| VHD e VHDX (Hyper-V) | ✅ fixo, dinâmico e diferencial (segue a cadeia de pais) | ❌ |
|
| 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 | ❌ |
|
| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib, bzip2 e LZFSE | ❌ |
|
||||||
|
|
||||||
Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 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).
|
Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 cifrado, QCOW2 cujo apoio não é um arquivo local, VMDK em SEsparse e DMG protegido por senha. Discos diferenciais (VHD, VHDX, QCOW2 com apoio, snapshot de VMDK) leem o pai no que o filho não gravou; o pai é procurado ao lado do filho (VMDK: por nome ou UUID) e, no VMDK, o CID dele precisa bater, senão a imagem é recusada porque o pai mudou. A identidade dos pais de VHD, VHDX e QCOW2 não é conferida. O log de transações do VHDX não é reproduzido (nada é gravado na imagem).
|
||||||
|
|
||||||
`guara verify` recalcula o MD5 de uma imagem E01 ou Ex01 e compara com o gravado nela.
|
`guara verify` recalcula o MD5 de uma imagem E01 ou Ex01 e compara com o gravado nela.
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -20,7 +20,7 @@ MBR and GPT. When the table fails, Guará searches for volumes by signature (NTF
|
|||||||
| ext2/3/4 | ✅ | Deleted inodes, old copies in the journal (ext4), free block map | |
|
| ext2/3/4 | ✅ | Deleted inodes, old copies in the journal (ext4), free block map | |
|
||||||
| HFS+ | ✅ | Leftovers of catalog tree nodes, journal | |
|
| HFS+ | ✅ | Leftovers of catalog tree nodes, journal | |
|
||||||
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents | |
|
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents | |
|
||||||
| XFS | ✅ | Freed inodes, names of freed entries, free space per group | Deletion tested only in simulation |
|
| XFS | 🟡 | Freed inodes, names of freed entries and free space per group (works on simulated deletion); content by carving | Tested with real kernel deletion in CI: the kernel wipes the inode and extents, so names and folders are lost and only carving recovers content. Journal reading is missing |
|
||||||
| APFS | 🟡 | Container, object map, file tree, snapshots, old nodes | No free-space map; deletion tested only in simulation |
|
| APFS | 🟡 | Container, object map, file tree, snapshots, old nodes | No free-space map; deletion tested only in simulation |
|
||||||
| ReFS | ❌ | Only detected | Recovery through carving |
|
| ReFS | ❌ | Only detected | Recovery through carving |
|
||||||
| Storage Spaces | ❌ | Not supported | |
|
| Storage Spaces | ❌ | Not supported | |
|
||||||
@@ -55,12 +55,12 @@ Passwords are **never** written to the session file. With `--remember-keys` the
|
|||||||
| Raw (dd, `.img`) | ✅ | ✅ with a bad sector error map |
|
| Raw (dd, `.img`) | ✅ | ✅ with a bad sector error map |
|
||||||
| E01 and Ex01 (Expert Witness) | ✅ | ✅ with case metadata and segmentation |
|
| E01 and Ex01 (Expert Witness) | ✅ | ✅ with case metadata and segmentation |
|
||||||
| AFF4 | ✅ | ✅ |
|
| AFF4 | ✅ | ✅ |
|
||||||
| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) | ❌ |
|
| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) and backing files | ❌ |
|
||||||
| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized` and descriptors with flat/zero extents | ❌ |
|
| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized`, descriptors with flat/zero extents and snapshots (parent by name or, for VirtualBox, by UUID) | ❌ |
|
||||||
| VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ |
|
| VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ |
|
||||||
| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib and bzip2 chunks | ❌ |
|
| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib, bzip2 and LZFSE chunks | ❌ |
|
||||||
|
|
||||||
Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: 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).
|
Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: encrypted QCOW2, QCOW2 whose backing file is not a local file, VMDK in SEsparse, and password-protected DMG. Differencing disks (VHD, VHDX, QCOW2 backing file, VMDK snapshot) read the parent for what the child did not write; the parent is found next to the child (VMDK: by name or UUID) and, for VMDK, its CID must match, otherwise the image is refused because the parent changed. The identity of VHD, VHDX and QCOW2 parents is not checked. The VHDX transaction log is not replayed (nothing is ever written to the image).
|
||||||
|
|
||||||
`guara verify` recomputes the MD5 of an E01 or Ex01 image and compares it with the one stored in it.
|
`guara verify` recomputes the MD5 of an E01 or Ex01 image and compares it with the one stored in it.
|
||||||
|
|
||||||
|
|||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Instala pacotes do apt no CI sem depender do espelho estar rápido: usa os .deb em cache quando
|
||||||
|
# existem e, se o apt travar ou falhar, tenta de novo com tempo limite por tentativa.
|
||||||
|
#
|
||||||
|
# Os passos de apt do CI já levaram de 13 a 28 minutos num dia de espelho lento, contra uns 35 s
|
||||||
|
# no normal. O cache evita o download; o tempo limite evita ficar preso.
|
||||||
|
#
|
||||||
|
# Uso: scripts/apt-install.sh <pasta-do-cache> <pacote>...
|
||||||
|
set -euo pipefail
|
||||||
|
[ $# -ge 2 ] || { echo "uso: $0 <pasta-do-cache> <pacote>..." >&2; exit 2; }
|
||||||
|
cache="$1"; shift
|
||||||
|
pkgs=("$@")
|
||||||
|
mkdir -p "$cache/partial"
|
||||||
|
opts=(-o "Dir::Cache::archives=$cache" -y)
|
||||||
|
|
||||||
|
# 1) com .deb em cache: instala direto, sem nem atualizar o índice do apt
|
||||||
|
if ls "$cache"/*.deb >/dev/null 2>&1 && timeout 300 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
||||||
|
echo "instalado a partir do cache"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) sem cache (ou incompleto): atualiza e instala, até 3 tentativas com tempo limite
|
||||||
|
for tentativa in 1 2 3; do
|
||||||
|
if timeout 240 sudo apt-get update && timeout 600 sudo apt-get "${opts[@]}" install "${pkgs[@]}"; then
|
||||||
|
echo "instalado (tentativa $tentativa)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "apt falhou ou passou do tempo (tentativa $tentativa de 3)" >&2
|
||||||
|
sleep $((tentativa * 10))
|
||||||
|
done
|
||||||
|
echo "não foi possível instalar: ${pkgs[*]}" >&2
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Monta o latest.json que o atualizador do app lê (Tauri updater), a partir dos pacotes e das
|
||||||
|
assinaturas `.sig` já enviados à release.
|
||||||
|
|
||||||
|
Roda no job de publicação, depois que todos os jobs de empacotamento terminaram. Montar o arquivo
|
||||||
|
aqui, em um passo só, evita a corrida de quem tenta atualizá-lo em paralelo por plataforma.
|
||||||
|
|
||||||
|
Uso: make-latest-json.py <tag> <notas.md> <saida.json> (usa `gh` e GH_REPO/GITHUB_REPOSITORY)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# (chaves de plataforma, padrão do nome do pacote). {v} = versão sem o "v".
|
||||||
|
PLATFORMS = [
|
||||||
|
(["linux-x86_64", "linux-x86_64-appimage"], "Guara_{v}_amd64.AppImage"),
|
||||||
|
(["windows-x86_64", "windows-x86_64-nsis"], "Guara_{v}_x64-setup.exe"),
|
||||||
|
(["windows-x86_64-msi"], "Guara_{v}_x64_en-US.msi"),
|
||||||
|
(["darwin-aarch64", "darwin-aarch64-app"], "Guara_{v}_aarch64.app.tar.gz"),
|
||||||
|
(["darwin-x86_64", "darwin-x86_64-app"], "Guara_{v}_x64.app.tar.gz"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build(version, notes, assets, pub_date):
|
||||||
|
"""`assets` = {nome_do_pacote: (url, assinatura)}. Devolve o dicionário do latest.json.
|
||||||
|
|
||||||
|
Plataforma sem pacote ou sem assinatura sai da lista; o chamador decide se isso é erro."""
|
||||||
|
platforms = {}
|
||||||
|
for keys, pattern in PLATFORMS:
|
||||||
|
name = pattern.format(v=version)
|
||||||
|
if name not in assets:
|
||||||
|
continue
|
||||||
|
url, sig = assets[name]
|
||||||
|
for k in keys:
|
||||||
|
platforms.setdefault(k, {"signature": sig.strip(), "url": url})
|
||||||
|
return {"version": version, "notes": notes.strip(), "pub_date": pub_date, "platforms": platforms}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tag, notes_path, out = sys.argv[1:4]
|
||||||
|
version = tag.lstrip("v")
|
||||||
|
repo = os.environ.get("GH_REPO") or os.environ["GITHUB_REPOSITORY"]
|
||||||
|
rel = json.loads(subprocess.check_output(["gh", "release", "view", tag, "--repo", repo, "--json", "assets"]))
|
||||||
|
urls = {a["name"]: a["url"] for a in rel["assets"]}
|
||||||
|
assets = {}
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
for _, pattern in PLATFORMS:
|
||||||
|
name = pattern.format(v=version)
|
||||||
|
if name in urls and name + ".sig" in urls:
|
||||||
|
subprocess.check_call(["gh", "release", "download", tag, "--repo", repo, "-p", name + ".sig", "-D", d])
|
||||||
|
assets[name] = (urls[name], open(os.path.join(d, name + ".sig")).read())
|
||||||
|
data = build(version, open(notes_path, encoding="utf-8").read(), assets,
|
||||||
|
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||||
|
# Sem o Linux (AppImage) o atualizador do dono do projeto não funciona: não publica pela metade.
|
||||||
|
for must in ("linux-x86_64", "windows-x86_64", "darwin-aarch64", "darwin-x86_64"):
|
||||||
|
if must not in data["platforms"]:
|
||||||
|
sys.exit(f"faltam o pacote ou a assinatura de {must}; confira se TAURI_SIGNING_PRIVATE_KEY está nos segredos")
|
||||||
|
with open(out, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
print(f"{out}: {sorted(data['platforms'])}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cria uma imagem XFS com exclusão REAL (feita pelo kernel), para validar a recuperação de XFS
|
||||||
|
# sem simulação. Precisa de root, porque monta a imagem por loop: sudo scripts/make-xfs-real-deletion.sh
|
||||||
|
#
|
||||||
|
# Saída em target/testdata/xfs-real-delete/: disk.img e expected.tsv (nome, tamanho, sha256 e se
|
||||||
|
# o arquivo foi apagado). O teste em crates/guara-core/tests/xfs.rs usa esses dois arquivos e é
|
||||||
|
# pulado quando eles não existem.
|
||||||
|
set -euo pipefail
|
||||||
|
[ "$(id -u)" = 0 ] || { echo "rode com sudo: sudo $0" >&2; exit 1; }
|
||||||
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
out="${1:-$root/target/testdata/xfs-real-delete}"
|
||||||
|
mkdir -p "$out"
|
||||||
|
img="$out/disk.img"
|
||||||
|
rm -f "$img"
|
||||||
|
truncate -s 320M "$img" # o mkfs.xfs recente recusa menos de 300 MiB
|
||||||
|
mkfs.xfs -q -f "$img"
|
||||||
|
mnt="$(mktemp -d)"
|
||||||
|
cleanup() { mountpoint -q "$mnt" && umount "$mnt"; rmdir "$mnt" 2>/dev/null || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
mount -o loop "$img" "$mnt"
|
||||||
|
python3 - "$mnt" "$out/expected.tsv" "$root/crates/guara-core/tests/data/exemplo.sqlite" <<'PY'
|
||||||
|
import hashlib, os, sys
|
||||||
|
mnt, tsv, sqlite = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
def data(seed, n):
|
||||||
|
x, out = seed | 1, bytearray()
|
||||||
|
for _ in range(n):
|
||||||
|
x ^= (x << 13) & 0xFFFFFFFFFFFFFFFF; x ^= x >> 7; x ^= (x << 17) & 0xFFFFFFFFFFFFFFFF
|
||||||
|
out.append((x >> 24) & 0xFF)
|
||||||
|
return bytes(out)
|
||||||
|
files = [ # (caminho, tamanho, semente, apagar?)
|
||||||
|
("docs/relatorio.dat", 150000, 5, True),
|
||||||
|
("docs/mantido.dat", 90000, 6, False),
|
||||||
|
("fotos/foto1.bin", 300000, 7, True),
|
||||||
|
("fotos/foto2.bin", 40000, 8, False),
|
||||||
|
("raiz-apagada.txt", 20000, 9, True),
|
||||||
|
]
|
||||||
|
# um arquivo com estrutura reconhecível (SQLite), para a recuperação por carving
|
||||||
|
real = open(sqlite, "rb").read()
|
||||||
|
rows = []
|
||||||
|
files.append(("dados/exemplo.sqlite", len(real), 0, True))
|
||||||
|
for rel, n, seed, gone in files:
|
||||||
|
p = os.path.join(mnt, rel)
|
||||||
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||||
|
d = real if rel.endswith('.sqlite') else data(seed, n)
|
||||||
|
with open(p, "wb") as f:
|
||||||
|
f.write(d)
|
||||||
|
rows.append((rel, n, hashlib.sha256(d).hexdigest(), "deleted" if gone else "live"))
|
||||||
|
os.sync()
|
||||||
|
for rel, n, seed, gone in files:
|
||||||
|
if gone:
|
||||||
|
os.remove(os.path.join(mnt, rel))
|
||||||
|
os.sync()
|
||||||
|
with open(tsv, "w") as f:
|
||||||
|
for r in rows:
|
||||||
|
f.write("\t".join(map(str, r)) + "\n")
|
||||||
|
PY
|
||||||
|
sync
|
||||||
|
umount "$mnt"
|
||||||
|
[ -n "${SUDO_UID:-}" ] && chown -R "$SUDO_UID:${SUDO_GID:-$SUDO_UID}" "$out"
|
||||||
|
echo "ok: $img"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Teste de scripts/make-latest-json.py: python3 scripts/test_make_latest_json.py"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("m", os.path.join(os.path.dirname(__file__), "make-latest-json.py"))
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
|
||||||
|
BASE = "https://github.com/x/y/releases/download/v1.3.0/"
|
||||||
|
|
||||||
|
|
||||||
|
def assets(version="1.3.0"):
|
||||||
|
names = [p.format(v=version) for _, p in m.PLATFORMS]
|
||||||
|
return {n: (BASE + n, f"assinatura-de-{n}\n") for n in names}
|
||||||
|
|
||||||
|
|
||||||
|
class LatestJson(unittest.TestCase):
|
||||||
|
def test_all_platforms_and_variants(self):
|
||||||
|
d = m.build("1.3.0", "### Novidades\n", assets(), "2026-10-23T12:00:00Z")
|
||||||
|
self.assertEqual(d["version"], "1.3.0")
|
||||||
|
self.assertEqual(d["notes"], "### Novidades")
|
||||||
|
p = d["platforms"]
|
||||||
|
for k in ("linux-x86_64", "linux-x86_64-appimage", "windows-x86_64", "windows-x86_64-nsis",
|
||||||
|
"windows-x86_64-msi", "darwin-aarch64", "darwin-x86_64"):
|
||||||
|
self.assertIn(k, p)
|
||||||
|
# a chave genérica do Windows aponta para o instalador NSIS, não para o MSI
|
||||||
|
self.assertTrue(p["windows-x86_64"]["url"].endswith("x64-setup.exe"))
|
||||||
|
self.assertTrue(p["windows-x86_64-msi"]["url"].endswith(".msi"))
|
||||||
|
self.assertEqual(p["linux-x86_64"]["signature"], "assinatura-de-Guara_1.3.0_amd64.AppImage")
|
||||||
|
|
||||||
|
def test_missing_package_is_left_out(self):
|
||||||
|
a = assets()
|
||||||
|
del a["Guara_1.3.0_x64.app.tar.gz"]
|
||||||
|
d = m.build("1.3.0", "", a, "2026-10-23T12:00:00Z")
|
||||||
|
self.assertNotIn("darwin-x86_64", d["platforms"])
|
||||||
|
self.assertIn("darwin-aarch64", d["platforms"])
|
||||||
|
|
||||||
|
def test_other_versions_do_not_leak(self):
|
||||||
|
d = m.build("1.3.0", "", assets("1.2.0"), "2026-10-23T12:00:00Z")
|
||||||
|
self.assertEqual(d["platforms"], {})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user