feat: Guará 0.1.0 — recuperação de arquivos (núcleo, CLI e interface gráfica)
Núcleo em Rust somente leitura: NTFS, FAT, exFAT, partições MBR/GPT e perdidas, carving por estrutura, fusão sem duplicatas, confiança por arquivo, imagem forense, pausar/retomar. CLI `guara`, UI web/Tauri, CI e releases por tag. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
name: Relatar um problema
|
||||||
|
description: Algo não funcionou como esperado
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: o-que
|
||||||
|
attributes: { label: O que aconteceu?, description: "Passos, o que esperava e o que viu." }
|
||||||
|
validations: { required: true }
|
||||||
|
- type: input
|
||||||
|
id: sistema
|
||||||
|
attributes: { label: "Sistema operacional e versão do Guará", placeholder: "Windows 11 / guara 0.1.0" }
|
||||||
|
- type: input
|
||||||
|
id: midia
|
||||||
|
attributes: { label: "Tipo de mídia e sistema de arquivos", placeholder: "pendrive 32 GB, FAT32" }
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes: { label: "Saída do terminal (guara inspect / scan)", render: shell }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
## O que muda e por quê
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] `cargo fmt --all` e `cargo clippy --workspace --all-targets` sem avisos
|
||||||
|
- [ ] `cargo test --workspace` passa
|
||||||
|
- [ ] Nada neste PR escreve no dispositivo de origem
|
||||||
|
- [ ] CHANGELOG atualizado (se houver mudança visível)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: cargo
|
||||||
|
directory: /
|
||||||
|
schedule: { interval: weekly }
|
||||||
|
- package-ecosystem: cargo
|
||||||
|
directory: /apps/gui/src-tauri
|
||||||
|
schedule: { interval: weekly }
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule: { interval: weekly }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: -D warnings
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Formatação e clippy
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo fmt --all -- --check
|
||||||
|
- run: cargo clippy --workspace --all-targets
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Testes (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Ferramentas para imagens de teste (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs imagemagick zip
|
||||||
|
- name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
audit:
|
||||||
|
name: Auditoria de dependências
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: rustsec/audit-check@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
gui:
|
||||||
|
name: Compila o app desktop (Linux)
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: apps/gui/src-tauri
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- name: Dependências do Tauri
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
- name: Gera os ícones
|
||||||
|
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||||
|
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: ""
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Publica uma release pública ao empurrar uma tag vX.Y.Z (ex.: git tag v0.1.0 && git push --tags)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-release:
|
||||||
|
name: Cria a release
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.rel.outputs.result }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Notas da versão (do CHANGELOG)
|
||||||
|
id: notes
|
||||||
|
run: |
|
||||||
|
v="${GITHUB_REF_NAME#v}"
|
||||||
|
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
|
||||||
|
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
id: rel
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const tag = context.ref.replace('refs/tags/', '');
|
||||||
|
const r = await github.rest.repos.createRelease({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo, tag_name: tag,
|
||||||
|
name: `Guará ${tag}`, body: fs.readFileSync('notes.md', 'utf8'),
|
||||||
|
draft: true, prerelease: tag.includes('-'),
|
||||||
|
});
|
||||||
|
return r.data.id;
|
||||||
|
|
||||||
|
cli:
|
||||||
|
name: CLI (${{ matrix.name }})
|
||||||
|
needs: create-release
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- { name: linux-x86_64, os: ubuntu-24.04, target: x86_64-unknown-linux-gnu }
|
||||||
|
- { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||||
|
- { name: macos-aarch64, os: macos-latest, target: aarch64-apple-darwin }
|
||||||
|
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
|
||||||
|
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo build --release --locked -p guara-cli --target ${{ matrix.target }}
|
||||||
|
- name: Empacota
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
name="guara-${GITHUB_REF_NAME}-${{ matrix.name }}"
|
||||||
|
mkdir "$name"
|
||||||
|
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||||
|
cp target/${{ matrix.target }}/release/guara.exe "$name/"
|
||||||
|
else
|
||||||
|
cp target/${{ matrix.target }}/release/guara "$name/"
|
||||||
|
fi
|
||||||
|
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
|
||||||
|
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||||
|
7z a "$name.zip" "$name" >/dev/null; asset="$name.zip"
|
||||||
|
else
|
||||||
|
tar czf "$name.tar.gz" "$name"; asset="$name.tar.gz"
|
||||||
|
fi
|
||||||
|
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
|
||||||
|
echo "ASSET=$asset" >> "$GITHUB_ENV"
|
||||||
|
- name: Envia para a release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: gh release upload "$GITHUB_REF_NAME" "$ASSET" "$ASSET.sha256" --clobber
|
||||||
|
|
||||||
|
gui:
|
||||||
|
name: App desktop (${{ matrix.name }})
|
||||||
|
needs: create-release
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- { name: linux-x86_64, os: ubuntu-24.04, args: "" }
|
||||||
|
- { name: macos-aarch64, os: macos-latest, args: "--target aarch64-apple-darwin" }
|
||||||
|
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
|
||||||
|
- { name: windows-x86_64, os: windows-latest, args: "" }
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: apps/gui/src-tauri
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- name: Dependências do Tauri (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
- name: Gera os ícones
|
||||||
|
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: apps/gui/src-tauri
|
||||||
|
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
args: ${{ matrix.args }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publica a release
|
||||||
|
needs: [create-release, cli, gui]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.repos.updateRelease({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
release_id: ${{ needs.create-release.outputs.release_id }}, draft: false,
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Material de trabalho local, não versionado
|
||||||
|
.superpowers/
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Estado de ferramentas locais
|
||||||
|
.wolf/
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Build
|
||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
*.img
|
||||||
|
*.guara-session.json
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-10-18
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- Núcleo em Rust, somente leitura na origem: acesso a dispositivos e imagens (Windows, Linux, macOS).
|
||||||
|
- Leitura de MBR/GPT, detecção de partições perdidas e de espaço não particionado.
|
||||||
|
- Metadados de NTFS, FAT12/16/32 e exFAT: arquivos apagados com nome, pasta e data.
|
||||||
|
- Carving por estrutura de JPEG, PNG, GIF, BMP, PDF, ZIP e derivados (DOCX, XLSX, PPTX, ODF, EPUB, APK, JAR), MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE, MP3 e PE.
|
||||||
|
- Mapa de blocos livres do ext2/3/4 para varrer apenas o espaço livre.
|
||||||
|
- Fusão sem duplicatas e confiança (alta/média/baixa) por arquivo.
|
||||||
|
- Detecção de volumes cifrados (BitLocker, LUKS) e aviso de TRIM em SSD.
|
||||||
|
- Imagem forense tolerante a setores ruins, pausar/retomar varredura e sessões salvas.
|
||||||
|
- Recuperação com SHA-256 e manifesto; recusa de destino no disco de origem.
|
||||||
|
- CLI `guara` e interface gráfica (Tauri) com tema claro/escuro e layout responsivo; modo `guara ui` no navegador.
|
||||||
|
- CI (Linux/Windows/macOS) e releases automáticas por tag.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Contribuindo com o Guará
|
||||||
|
|
||||||
|
Obrigado por ajudar! Regras de ouro:
|
||||||
|
|
||||||
|
1. **Nunca escreva na origem.** Qualquer código que abra um dispositivo deve usar `source::open_path` (somente leitura). PRs que escrevem no disco de origem são recusados.
|
||||||
|
2. **Teste com dados reais.** Novos detectores/leitores precisam de teste que recupera um arquivo conhecido bit a bit (veja `crates/guara-core/tests/` e `scripts/make-fixtures.sh`).
|
||||||
|
3. **Seja honesto sobre confiança.** Se o conteúdo não puder ser validado, a confiança é no máximo média.
|
||||||
|
|
||||||
|
## Fluxo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` e `python3`. Sem essas ferramentas os testes com imagem são ignorados (defina `GUARA_REQUIRE_FIXTURES=1` para falhar).
|
||||||
|
|
||||||
|
## Adicionando um formato de arquivo
|
||||||
|
|
||||||
|
Edite `crates/guara-core/src/signatures.rs`: acrescente o detector em `detect`, o primeiro byte em `might_start`, a extensão em `category_for_ext`/`matches_ext` e um arquivo real ao `scripts/make-fixtures.sh` com teste de bit-exatidão.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
Mensagens curtas no imperativo (`corrige leitura de runs esparsos no NTFS`). Uma mudança lógica por PR.
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chunked_transfer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctrlc"
|
||||||
|
version = "3.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"nix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dispatch2"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "guara-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"ctrlc",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"guara-core",
|
||||||
|
"serde_json",
|
||||||
|
"tiny_http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "guara-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.191"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||||
|
dependencies = [
|
||||||
|
"objc2-encode",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-encode"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny_http"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"chunked_transfer",
|
||||||
|
"httpdate",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/guara-core", "crates/guara-cli"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
|
authors = ["Guará contributors"]
|
||||||
|
rust-version = "1.80"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = 3
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Guará contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<p align="center"><img src="apps/gui/ui/guara.svg" width="96" alt="Guará"></p>
|
||||||
|
<h1 align="center">Guará</h1>
|
||||||
|
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
O Guará junta, numa única varredura, o que ferramentas como Recuva, PhotoRec, TestDisk e Disk Drill fazem separadamente:
|
||||||
|
|
||||||
|
- **Metadados do sistema de arquivos** (NTFS, FAT12/16/32, exFAT): acha arquivos apagados **com nome, pasta e data**.
|
||||||
|
- **Carving por assinatura/estrutura** no espaço livre, em partições danificadas e no **espaço não particionado**: acha arquivos mesmo quando o sistema de arquivos sumiu. Os detectores leem a estrutura real (JPEG, PNG, GIF, BMP, PDF, ZIP/DOCX/XLSX/PPTX/ODF/EPUB, MP4/MOV/HEIC, AVI/WAV/WebP, SQLite, 7z, OLE (DOC/XLS/PPT), MP3, EXE/DLL) para saber o **tamanho exato** e a **confiança**.
|
||||||
|
- **Partições perdidas:** lê MBR/GPT e procura volumes (NTFS, FAT, exFAT, ext, APFS, HFS+, BitLocker, LUKS) por assinatura onde a tabela falhou.
|
||||||
|
- **Fusão sem duplicatas:** um arquivo achado pelos dois métodos aparece uma vez, com a melhor evidência.
|
||||||
|
- **Confiança por arquivo** (alta / média / baixa): estrutura íntegra, clusters reutilizados, conteúdo zerado (TRIM) ou que não confere com a extensão.
|
||||||
|
|
||||||
|
## Segurança dos dados
|
||||||
|
|
||||||
|
- A origem é aberta **somente para leitura**. Nada no núcleo escreve no dispositivo recuperado.
|
||||||
|
- A recuperação **recusa destinos no mesmo disco** da origem (Linux/macOS).
|
||||||
|
- **Imagem forense** (`guara image`) tolerante a setores ruins, com mapa de erros e SHA-256: trabalhe sempre na cópia de um disco com defeito.
|
||||||
|
- Cada recuperação grava `guara-manifesto.json` com o **SHA-256** de todos os arquivos.
|
||||||
|
- **Volumes cifrados** (BitLocker, LUKS) são **detectados e avisados**; a v1 não desbloqueia nem varre dentro deles.
|
||||||
|
|
||||||
|
## Instalação
|
||||||
|
|
||||||
|
Baixe o pacote do seu sistema em **[Releases](../../releases)**:
|
||||||
|
|
||||||
|
| | CLI | App gráfico |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows | `guara-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
|
||||||
|
| macOS | `guara-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
|
||||||
|
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
|
||||||
|
|
||||||
|
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+).
|
||||||
|
|
||||||
|
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
||||||
|
|
||||||
|
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS).
|
||||||
|
|
||||||
|
## Uso (terminal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo guara devices # discos e pendrives (avisa TRIM e volumes montados)
|
||||||
|
sudo guara inspect /dev/sdb # partições, espaço não particionado, cifragem
|
||||||
|
sudo guara scan /dev/sdb --session s.json --list # varredura profunda
|
||||||
|
sudo guara scan /dev/sdb --quick # só metadados (segundos)
|
||||||
|
guara scan disco.img --category image,document --min-confidence medium
|
||||||
|
guara scan disco.img --recover /mnt/outro-disco/Recuperados
|
||||||
|
guara recover s.json /mnt/outro-disco/Recuperados --category image
|
||||||
|
guara resume s.json # continua após Ctrl+C
|
||||||
|
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # imagem com mapa de erros
|
||||||
|
guara ui # interface no navegador (127.0.0.1 + token)
|
||||||
|
```
|
||||||
|
|
||||||
|
`--json` está disponível em `devices`, `inspect` e `scan` para automação.
|
||||||
|
|
||||||
|
## Interface gráfica
|
||||||
|
|
||||||
|
App desktop (Tauri) com barra lateral de dispositivos, mapa do disco, resultados ao vivo, filtros por tipo, busca, pré-visualização de imagens, confiança por arquivo, pausar/continuar e salvar sessão. Tema claro/escuro automático e layout responsivo. O mesmo front-end roda em `guara ui`.
|
||||||
|
|
||||||
|
## Limites conhecidos (v0.1)
|
||||||
|
|
||||||
|
Seja realista sobre o que dá para esperar:
|
||||||
|
|
||||||
|
- **Dados sobrescritos não voltam.** Em **SSD com TRIM** o próprio disco pode zerar blocos apagados; o Guará avisa, mas não faz milagre.
|
||||||
|
- **Carving só acha arquivos contíguos.** Arquivos fragmentados sem metadados podem sair truncados (marcados com confiança baixa).
|
||||||
|
- **ext2/3/4, APFS e HFS+:** só varredura por assinatura (no ext, apenas no espaço livre). Não há leitura de inodes/journal ainda.
|
||||||
|
- **NTFS:** arquivos comprimidos/EFS e registros com lista de atributos (arquivos muito fragmentados) são ignorados ou marcados como incompletos.
|
||||||
|
- **exFAT:** coberto por teste com imagem sintética; falta validar em mais mídias reais.
|
||||||
|
- **Cifragem:** somente detecção.
|
||||||
|
- **Windows/macOS:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Relatos de mídias reais são muito bem-vindos.
|
||||||
|
- **GUI:** no Linux/macOS o app reinicia inteiro como administrador (via `pkexec`/`osascript`) em vez de usar um processo auxiliar separado — melhoria planejada.
|
||||||
|
|
||||||
|
## Desenvolvimento
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/guara-core núcleo: leitura, partições, sistemas de arquivos, carving, fusão, recuperação
|
||||||
|
crates/guara-cli binário `guara` (CLI + servidor local da UI)
|
||||||
|
apps/gui interface (ui/ = HTML/CSS/JS; src-tauri/ = app desktop)
|
||||||
|
scripts/ geração de imagens de teste (FAT32, NTFS, ext4, exFAT)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --workspace # no Linux gera imagens reais com mtools/ntfs-3g/e2fsprogs (veja scripts/)
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Veja [CONTRIBUTING.md](CONTRIBUTING.md). Versões seguem [SemVer](https://semver.org/lang/pt-BR/); mudanças em [CHANGELOG.md](CHANGELOG.md). Para publicar: `git tag vX.Y.Z && git push --tags` (o workflow `release` compila e publica tudo).
|
||||||
|
|
||||||
|
## Licença
|
||||||
|
|
||||||
|
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Segurança
|
||||||
|
|
||||||
|
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades — corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação — devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Aplicativo desktop (Tauri 2). Fica fora do workspace principal porque exige
|
||||||
|
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
|
||||||
|
[package]
|
||||||
|
name = "guara-gui"
|
||||||
|
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/lucasrguerra/guara"
|
||||||
|
rust-version = "1.80"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
serde_json = "1"
|
||||||
|
guara-core = { path = "../../../crates/guara-core" }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
fn main() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// Ler discos brutos no Windows exige administrador: o manifesto pede elevação (UAC).
|
||||||
|
let manifest = include_str!("windows-app-manifest.xml");
|
||||||
|
let attrs = tauri_build::Attributes::new()
|
||||||
|
.windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest));
|
||||||
|
tauri_build::try_build(attrs).expect("falha no tauri-build");
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schema/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Permissões da janela principal",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "dialog:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
// Evita a janela de console extra no Windows em release.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use guara_core::service::Engine;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
struct State(Arc<Engine>);
|
||||||
|
|
||||||
|
/// Ponte única entre a interface e o núcleo (mesmos métodos do modo `guara ui`).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||||
|
let eng = state.0.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || eng.rpc(&method, params).map_err(|e| e.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
|
||||||
|
/// manifesto já exige elevação.
|
||||||
|
#[tauri::command]
|
||||||
|
fn relaunch_elevated() -> Result<(), String> {
|
||||||
|
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let mut cmd = std::process::Command::new("pkexec");
|
||||||
|
cmd.arg("env");
|
||||||
|
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] {
|
||||||
|
if let Ok(v) = std::env::var(k) {
|
||||||
|
cmd.arg(format!("{k}={v}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.arg(exe);
|
||||||
|
cmd.spawn().map_err(|e| format!("pkexec indisponível: {e}"))?;
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let script = format!("do shell script \"'{}' &> /dev/null &\" with administrator privileges", exe.display());
|
||||||
|
std::process::Command::new("osascript").args(["-e", &script]).spawn().map_err(|e| e.to_string())?;
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = exe;
|
||||||
|
Err("o Guará já roda como administrador no Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(State(Arc::new(Engine::new())))
|
||||||
|
.invoke_handler(tauri::generate_handler![rpc, relaunch_elevated])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("erro ao iniciar o Guará");
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Guará",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "dev.guara.app",
|
||||||
|
"build": { "frontendDist": "../ui" },
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Guará",
|
||||||
|
"width": 1180,
|
||||||
|
"height": 780,
|
||||||
|
"minWidth": 420,
|
||||||
|
"minHeight": 560,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": { "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"category": "Utility",
|
||||||
|
"shortDescription": "Recuperação de arquivos",
|
||||||
|
"longDescription": "Guará recupera arquivos apagados e partições perdidas de HD, SSD e pendrive, sempre em modo somente leitura na origem.",
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #fafafc;
|
||||||
|
--side: rgba(246, 246, 248, 0.82);
|
||||||
|
--text: #1d1d1f;
|
||||||
|
--text-2: #6e6e73;
|
||||||
|
--text-3: #8e8e93;
|
||||||
|
--line: rgba(0, 0, 0, 0.09);
|
||||||
|
--accent: #d9541e;
|
||||||
|
--accent-2: #ff8a3d;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
--accent-soft: rgba(217, 84, 30, 0.1);
|
||||||
|
--ok: #1a8f3c;
|
||||||
|
--warn: #b26a00;
|
||||||
|
--bad: #d12b2b;
|
||||||
|
--ok-soft: rgba(26, 143, 60, 0.12);
|
||||||
|
--warn-soft: rgba(178, 106, 0, 0.13);
|
||||||
|
--bad-soft: rgba(209, 43, 43, 0.11);
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
--radius: 16px;
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI Variable", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||||
|
--side-w: 288px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #000000;
|
||||||
|
--surface: #1c1c1e;
|
||||||
|
--surface-2: #232326;
|
||||||
|
--side: rgba(28, 28, 30, 0.8);
|
||||||
|
--text: #f5f5f7;
|
||||||
|
--text-2: #a1a1a6;
|
||||||
|
--text-3: #7d7d82;
|
||||||
|
--line: rgba(255, 255, 255, 0.1);
|
||||||
|
--accent: #ff7a3d;
|
||||||
|
--accent-2: #ff9d5c;
|
||||||
|
--accent-ink: #1a0b03;
|
||||||
|
--accent-soft: rgba(255, 122, 61, 0.15);
|
||||||
|
--ok: #32d74b;
|
||||||
|
--warn: #ffb340;
|
||||||
|
--bad: #ff6961;
|
||||||
|
--ok-soft: rgba(50, 215, 75, 0.15);
|
||||||
|
--warn-soft: rgba(255, 179, 64, 0.15);
|
||||||
|
--bad-soft: rgba(255, 105, 97, 0.15);
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.45 var(--font);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
button { font: inherit; color: inherit; }
|
||||||
|
:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 2px; border-radius: 8px; }
|
||||||
|
h1, h2, h3 { margin: 0; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ layout */
|
||||||
|
.app { display: grid; grid-template-columns: var(--side-w) 1fr; height: 100vh; height: 100dvh; }
|
||||||
|
.topbar { display: none; }
|
||||||
|
.sidebar {
|
||||||
|
background: var(--side);
|
||||||
|
backdrop-filter: saturate(180%) blur(24px);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(24px);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
display: flex; flex-direction: column; min-height: 0; overflow-y: auto;
|
||||||
|
padding: 18px 12px 12px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; gap: 10px; align-items: center; padding: 4px 10px 18px; }
|
||||||
|
.brand strong { display: block; font-size: 17px; letter-spacing: -0.02em; }
|
||||||
|
.brand small { color: var(--text-2); font-size: 12px; }
|
||||||
|
.side-title { display: flex; align-items: center; justify-content: space-between; padding: 14px 10px 6px; font-size: 12px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
||||||
|
.list:empty::after { content: attr(data-empty); display: block; padding: 6px 10px; color: var(--text-3); font-size: 13px; }
|
||||||
|
.dev {
|
||||||
|
width: 100%; text-align: left; border: 0; background: transparent; cursor: pointer;
|
||||||
|
display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: center;
|
||||||
|
padding: 9px 10px; border-radius: 12px; transition: background .15s;
|
||||||
|
}
|
||||||
|
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||||
|
.dev[aria-current="true"] { background: var(--accent-soft); }
|
||||||
|
.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
|
||||||
|
.dev[aria-current="true"] .ico { color: var(--accent); }
|
||||||
|
.dev > span:last-child { min-width: 0; }
|
||||||
|
.dev .t, .dev .s { display: block; }
|
||||||
|
.dev .t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dev .s { font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.side-action { display: flex; gap: 8px; align-items: center; width: 100%; margin-top: 8px; padding: 9px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--accent); cursor: pointer; font-weight: 500; }
|
||||||
|
.side-action:hover { background: var(--accent-soft); }
|
||||||
|
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 12px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.icon-btn { border: 0; background: transparent; cursor: pointer; width: 36px; height: 36px; border-radius: 10px; display: inline-grid; place-items: center; color: var(--text-2); }
|
||||||
|
.icon-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
|
||||||
|
.icon-btn.small { width: 26px; height: 26px; }
|
||||||
|
|
||||||
|
.main { overflow-y: auto; min-width: 0; outline: none; }
|
||||||
|
.page { max-width: 1120px; margin: 0 auto; padding: 36px 40px 120px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ componentes */
|
||||||
|
.hero { padding: 56px 0 28px; max-width: 640px; }
|
||||||
|
.hero h1 { font-size: clamp(32px, 5vw, 48px); line-height: 1.05; font-weight: 700; }
|
||||||
|
.hero p { color: var(--text-2); font-size: 18px; margin: 14px 0 0; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 18px; }
|
||||||
|
.card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px; border: 0; text-align: left; }
|
||||||
|
button.card { cursor: pointer; transition: transform .2s, box-shadow .2s; }
|
||||||
|
button.card:hover { transform: translateY(-2px); }
|
||||||
|
.card h3 { font-size: 16px; margin-bottom: 4px; }
|
||||||
|
.card p { margin: 0; color: var(--text-2); font-size: 13.5px; }
|
||||||
|
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); }
|
||||||
|
.pill.ok { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.pill.warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.pill.bad { background: var(--bad-soft); color: var(--bad); }
|
||||||
|
.pill.accent { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
|
||||||
|
.btn { border: 0; border-radius: 999px; padding: 10px 20px; font-weight: 600; cursor: pointer; background: color-mix(in srgb, var(--text) 9%, transparent); transition: transform .12s, filter .15s, background .15s; display: inline-flex; gap: 8px; align-items: center; justify-content: center; }
|
||||||
|
.btn:hover { background: color-mix(in srgb, var(--text) 14%, transparent); }
|
||||||
|
.btn:active { transform: scale(.98); }
|
||||||
|
.btn.primary { background: linear-gradient(180deg, var(--accent-2), var(--accent)); color: var(--accent-ink); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||||
|
.btn.primary:hover { filter: brightness(1.06); }
|
||||||
|
.btn.danger { color: var(--bad); }
|
||||||
|
.btn[disabled] { opacity: .45; pointer-events: none; }
|
||||||
|
.btn.lg { padding: 13px 26px; font-size: 16px; }
|
||||||
|
.btn.sm { padding: 6px 14px; font-size: 13px; }
|
||||||
|
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.spread { justify-content: space-between; }
|
||||||
|
|
||||||
|
.dev-head { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.dev-head h1 { font-size: 30px; }
|
||||||
|
.sub { color: var(--text-2); }
|
||||||
|
.section { margin-top: 26px; }
|
||||||
|
.section > h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.diskmap { display: flex; height: 54px; border-radius: 14px; overflow: hidden; gap: 3px; background: transparent; }
|
||||||
|
.seg { min-width: 6px; border-radius: 6px; padding: 7px 10px; font-size: 12px; color: #fff; overflow: hidden; white-space: nowrap; display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
|
||||||
|
.seg b { font-weight: 600; }
|
||||||
|
.seg.gap { background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--text) 10%, transparent) 0 6px, transparent 6px 12px); color: var(--text-2); }
|
||||||
|
.seg.lost { outline: 2px dashed rgba(255, 255, 255, .85); outline-offset: -4px; }
|
||||||
|
.fs-ntfs { background: #0a84ff; } .fs-fat { background: #30b0c7; } .fs-exfat { background: #34aadc; }
|
||||||
|
.fs-ext { background: #bf5af2; } .fs-apfs, .fs-hfsplus { background: #8e8e93; }
|
||||||
|
.fs-enc { background: #5e5ce6; } .fs-unknown { background: #636366; }
|
||||||
|
.legend { margin-top: 12px; display: grid; gap: 6px; font-size: 13.5px; }
|
||||||
|
.legend div { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||||
|
|
||||||
|
.notice { display: flex; gap: 12px; padding: 12px 14px; border-radius: 14px; background: var(--warn-soft); color: var(--text); font-size: 14px; margin-top: 10px; }
|
||||||
|
.notice.bad { background: var(--bad-soft); }
|
||||||
|
.notice.info { background: var(--accent-soft); }
|
||||||
|
.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
|
||||||
|
.notice.bad svg { color: var(--bad); }
|
||||||
|
.notice.info svg { color: var(--accent); }
|
||||||
|
|
||||||
|
.modes { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
|
||||||
|
.mode { position: relative; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.mode.rec { box-shadow: 0 0 0 2px var(--accent), var(--shadow); }
|
||||||
|
.mode h3 { font-size: 18px; }
|
||||||
|
.mode p { color: var(--text-2); margin: 0 0 10px; font-size: 14px; flex: 1; }
|
||||||
|
details.adv { margin-top: 18px; }
|
||||||
|
details.adv summary { cursor: pointer; color: var(--accent); font-weight: 500; list-style: none; }
|
||||||
|
details.adv summary::-webkit-details-marker { display: none; }
|
||||||
|
details.adv[open] summary { margin-bottom: 10px; }
|
||||||
|
.check { display: flex; gap: 10px; align-items: flex-start; padding: 6px 0; font-size: 14px; }
|
||||||
|
.check input { margin-top: 3px; accent-color: var(--accent); width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
/* progresso */
|
||||||
|
.progress-card { background: var(--surface); border-radius: 22px; box-shadow: var(--shadow); padding: 26px; display: grid; gap: 14px; }
|
||||||
|
.bar { height: 10px; border-radius: 99px; background: color-mix(in srgb, var(--text) 9%, transparent); overflow: hidden; }
|
||||||
|
.bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .3s ease; }
|
||||||
|
.bar.indet > i { width: 35% !important; animation: slide 1.2s infinite ease-in-out; }
|
||||||
|
@keyframes slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
|
||||||
|
.stats { display: flex; gap: 28px; flex-wrap: wrap; }
|
||||||
|
.stat b { display: block; font-size: 28px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat span { color: var(--text-2); font-size: 13px; }
|
||||||
|
|
||||||
|
/* resultados */
|
||||||
|
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 22px 0 12px; }
|
||||||
|
.chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
|
||||||
|
.chips::-webkit-scrollbar { display: none; }
|
||||||
|
.chip { border: 0; cursor: pointer; padding: 7px 14px; border-radius: 999px; background: color-mix(in srgb, var(--text) 7%, transparent); font-weight: 500; font-size: 13.5px; white-space: nowrap; }
|
||||||
|
.chip[aria-pressed="true"] { background: var(--text); color: var(--bg); }
|
||||||
|
.chip small { opacity: .6; margin-left: 4px; font-variant-numeric: tabular-nums; }
|
||||||
|
.input, .select { border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; min-width: 0; }
|
||||||
|
.input { flex: 1 1 200px; }
|
||||||
|
.input::placeholder { color: var(--text-3); }
|
||||||
|
.split { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 18px; align-items: start; }
|
||||||
|
.table { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
|
.thead, .tr { display: grid; grid-template-columns: 34px minmax(0, 1fr) 90px 92px 84px; gap: 10px; align-items: center; padding: 0 16px; }
|
||||||
|
.thead { height: 38px; font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: .05em; border-bottom: 1px solid var(--line); }
|
||||||
|
.tr { min-height: 52px; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||||
|
.tr:last-child { border-bottom: 0; }
|
||||||
|
.tr:hover { background: var(--surface-2); }
|
||||||
|
.tr[aria-selected="true"] { background: var(--accent-soft); }
|
||||||
|
.tr .nm { min-width: 0; }
|
||||||
|
.tr .nm b { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tr .nm small { display: block; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tr .num { text-align: right; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 13.5px; }
|
||||||
|
input[type="checkbox"] { accent-color: var(--accent); width: 17px; height: 17px; }
|
||||||
|
.empty { padding: 56px 24px; text-align: center; color: var(--text-2); }
|
||||||
|
.empty h3 { color: var(--text); margin-bottom: 6px; }
|
||||||
|
.more { padding: 14px; text-align: center; }
|
||||||
|
|
||||||
|
.preview { position: sticky; top: 20px; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; display: grid; gap: 12px; }
|
||||||
|
.preview .thumb { aspect-ratio: 4 / 3; border-radius: 12px; background: var(--surface-2); display: grid; place-items: center; overflow: hidden; color: var(--text-3); font-size: 13px; text-align: center; padding: 8px; }
|
||||||
|
.preview .thumb img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13.5px; }
|
||||||
|
.kv dt { color: var(--text-3); } .kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.actionbar { position: fixed; left: var(--side-w); right: 0; bottom: 0; padding: 14px 24px calc(14px + env(safe-area-inset-bottom)); display: flex; gap: 12px; align-items: center; justify-content: center; background: var(--side); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); border-top: 1px solid var(--line); z-index: 5; animation: rise .3s; }
|
||||||
|
.actionbar .info { color: var(--text-2); font-size: 14px; }
|
||||||
|
|
||||||
|
dialog { border: 0; border-radius: 22px; padding: 26px; width: min(520px, calc(100vw - 32px)); background: var(--surface); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); }
|
||||||
|
dialog::backdrop { background: rgba(0, 0, 0, .35); backdrop-filter: blur(6px); }
|
||||||
|
dialog h2 { font-size: 22px; margin-bottom: 6px; }
|
||||||
|
dialog p { color: var(--text-2); margin: 6px 0 14px; }
|
||||||
|
dialog .input { width: 100%; }
|
||||||
|
dialog menu { display: flex; gap: 10px; justify-content: flex-end; padding: 0; margin: 20px 0 0; }
|
||||||
|
code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92em; }
|
||||||
|
.toast { position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 20px); background: var(--text); color: var(--bg); padding: 11px 18px; border-radius: 999px; font-size: 14px; opacity: 0; pointer-events: none; transition: .25s; z-index: 50; max-width: calc(100vw - 32px); }
|
||||||
|
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
.scrim { display: none; }
|
||||||
|
.back { color: var(--accent); background: none; border: 0; cursor: pointer; font-weight: 500; padding: 0; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ responsivo */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.split { grid-template-columns: 1fr; }
|
||||||
|
.preview { position: static; order: -1; grid-template-columns: 120px 1fr; align-items: start; }
|
||||||
|
.preview .thumb { aspect-ratio: 1; }
|
||||||
|
.preview .kv, .preview .row { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
||||||
|
.topbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--side); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 10; }
|
||||||
|
.sidebar { position: fixed; inset: 0 auto 0 0; width: min(86vw, 330px); z-index: 30; transform: translateX(-102%); transition: transform .28s cubic-bezier(.2, .8, .2, 1); background: var(--surface); }
|
||||||
|
.app.menu-open .sidebar { transform: none; box-shadow: 0 0 60px rgba(0, 0, 0, .35); }
|
||||||
|
.scrim { display: block; position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .3); opacity: 0; pointer-events: none; transition: opacity .25s; }
|
||||||
|
.app.menu-open .scrim { opacity: 1; pointer-events: auto; }
|
||||||
|
.page { padding: 20px 16px 120px; }
|
||||||
|
.hero { padding-top: 24px; }
|
||||||
|
.actionbar { left: 0; padding-inline: 16px; flex-wrap: wrap; }
|
||||||
|
.thead, .tr { grid-template-columns: 30px minmax(0, 1fr) 76px; }
|
||||||
|
.thead > :nth-child(3), .tr > :nth-child(3), .thead > :nth-child(4), .tr > :nth-child(4) { display: none; }
|
||||||
|
.tr .num.conf { display: block; }
|
||||||
|
.tr > :nth-child(5) { display: block; }
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.stats { gap: 18px; }
|
||||||
|
.btn.lg { width: 100%; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
// Guará — interface. Fala com o núcleo por `rpc(método, parâmetros)`:
|
||||||
|
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
|
||||||
|
|
||||||
|
const tauri = window.__TAURI__;
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
let token = params.get('token');
|
||||||
|
try {
|
||||||
|
if (token) sessionStorage.setItem('guara-token', token);
|
||||||
|
else token = sessionStorage.getItem('guara-token');
|
||||||
|
} catch { /* armazenamento indisponível */ }
|
||||||
|
|
||||||
|
async function rpc(method, p = {}) {
|
||||||
|
if (tauri?.core?.invoke) return tauri.core.invoke('rpc', { method, params: p });
|
||||||
|
const r = await fetch(`/rpc/${method}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Guara-Token': token || '' },
|
||||||
|
body: JSON.stringify(p),
|
||||||
|
});
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(body.error || `erro ${r.status}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ utilidades
|
||||||
|
const $ = (s, el = document) => el.querySelector(s);
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
const human = (n) => {
|
||||||
|
if (!n) return '0 B';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0, v = n;
|
||||||
|
while (v >= 1024 && i < 4) { v /= 1024; i++; }
|
||||||
|
return i === 0 ? `${n} B` : `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`;
|
||||||
|
};
|
||||||
|
const dur = (s) => {
|
||||||
|
s = Math.round(s);
|
||||||
|
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`;
|
||||||
|
};
|
||||||
|
const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : '—');
|
||||||
|
const CATS = [
|
||||||
|
['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'],
|
||||||
|
['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'],
|
||||||
|
];
|
||||||
|
const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] };
|
||||||
|
const KIND_ICON = {
|
||||||
|
USB: '<path d="M12 3v12m0 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM12 8l-4 3m4-1l4 2M8 11v3m8-2v3" />',
|
||||||
|
default: '<rect x="3" y="6" width="18" height="12" rx="3"/><circle cx="17" cy="12" r="1.2"/><path d="M7 12h6"/>',
|
||||||
|
};
|
||||||
|
const icon = (kind) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${KIND_ICON[kind] || KIND_ICON.default}</svg>`;
|
||||||
|
const warnSvg = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/></svg>';
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('#toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('show');
|
||||||
|
clearTimeout(toast.t);
|
||||||
|
toast.t = setTimeout(() => t.classList.remove('show'), 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ask({ title, text, value, placeholder, ok = 'OK', pick }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const d = $('#dlg');
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text}</p>
|
||||||
|
<div class="row" style="flex-wrap:nowrap"><input class="input" name="v" value="${esc(value || '')}" placeholder="${esc(placeholder || '')}" autocomplete="off" spellcheck="false">
|
||||||
|
${pick && tauri?.dialog ? '<button type="button" class="btn" id="pickBtn">Escolher…</button>' : ''}</div>
|
||||||
|
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
|
||||||
|
const input = $('input', d);
|
||||||
|
$('#pickBtn', d)?.addEventListener('click', async () => {
|
||||||
|
const r = await tauri.dialog.open(pick);
|
||||||
|
if (r) input.value = Array.isArray(r) ? r[0] : r;
|
||||||
|
});
|
||||||
|
d.onclose = () => resolve(d.returnValue === 'ok' ? input.value.trim() : null);
|
||||||
|
d.showModal();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function info({ title, html }) {
|
||||||
|
const d = $('#dlg');
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2>${html}<menu><button class="btn primary" value="ok">Entendi</button></menu></form>`;
|
||||||
|
d.onclose = null;
|
||||||
|
d.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ estado
|
||||||
|
const S = {
|
||||||
|
devices: [],
|
||||||
|
defaults: {},
|
||||||
|
sessions: [], // {job, target, label}
|
||||||
|
device: null, // {path,name,...}
|
||||||
|
layout: null,
|
||||||
|
job: null, // id da varredura atual
|
||||||
|
status: null,
|
||||||
|
filters: { category: null, minConfidence: null, query: '', sort: 'confidence' },
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
selected: new Set(),
|
||||||
|
allFiltered: false,
|
||||||
|
current: null, // arquivo em foco (pré-visualização)
|
||||||
|
poll: null,
|
||||||
|
recJob: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = $('#main');
|
||||||
|
const setMenu = (open) => {
|
||||||
|
$('#app').classList.toggle('menu-open', open);
|
||||||
|
$('#menuBtn').setAttribute('aria-expanded', String(open));
|
||||||
|
};
|
||||||
|
$('#menuBtn').onclick = () => setMenu(!$('#app').classList.contains('menu-open'));
|
||||||
|
$('#scrim').onclick = () => setMenu(false);
|
||||||
|
$('#refreshBtn').onclick = () => loadDevices(true);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ dispositivos
|
||||||
|
async function loadDevices(manual) {
|
||||||
|
try {
|
||||||
|
S.devices = await rpc('devices');
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
renderSide();
|
||||||
|
if (manual) toast('Lista atualizada');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSide() {
|
||||||
|
const dl = $('#deviceList');
|
||||||
|
dl.dataset.empty = 'Nenhum disco encontrado';
|
||||||
|
dl.innerHTML = S.devices.map((d, i) => `
|
||||||
|
<li><button class="dev" data-i="${i}" aria-current="${S.device?.path === d.path}">
|
||||||
|
<span class="ico">${icon(d.kind)}</span>
|
||||||
|
<span><span class="t">${esc(d.name)}</span><span class="s">${esc(d.kind)} · ${d.size ? human(d.size) : '—'}${d.readable ? '' : ' · requer admin'}</span></span>
|
||||||
|
</button></li>`).join('');
|
||||||
|
dl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openDevice(S.devices[+b.dataset.i]); setMenu(false); }));
|
||||||
|
const sl = $('#sessionList');
|
||||||
|
sl.dataset.empty = 'Nenhuma ainda';
|
||||||
|
sl.innerHTML = S.sessions.map((s, i) => `
|
||||||
|
<li><button class="dev" data-j="${i}" aria-current="${S.job === s.job}"><span class="ico">${icon('default')}</span>
|
||||||
|
<span><span class="t">${esc(s.label)}</span><span class="s">${esc(s.target)}</span></span></button></li>`).join('');
|
||||||
|
sl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openJob(S.sessions[+b.dataset.j]); setMenu(false); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#openImageBtn').onclick = async () => {
|
||||||
|
setMenu(false);
|
||||||
|
let path;
|
||||||
|
if (tauri?.dialog) {
|
||||||
|
path = await tauri.dialog.open({ multiple: false, directory: false });
|
||||||
|
if (Array.isArray(path)) path = path[0];
|
||||||
|
} else {
|
||||||
|
path = await ask({ title: 'Abrir arquivo de imagem', text: 'Caminho completo de uma imagem de disco (.img, .dd, .iso…).', placeholder: '/caminho/para/disco.img', ok: 'Abrir' });
|
||||||
|
}
|
||||||
|
if (path) openDevice({ path, name: path.split(/[\\/]/).pop(), kind: 'Imagem', size: 0, readable: true, mounted_at: [], removable: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#loadSessionBtn').onclick = async () => {
|
||||||
|
setMenu(false);
|
||||||
|
let path;
|
||||||
|
if (tauri?.dialog) {
|
||||||
|
path = await tauri.dialog.open({ multiple: false, filters: [{ name: 'Sessão do Guará', extensions: ['json'] }] });
|
||||||
|
if (Array.isArray(path)) path = path[0];
|
||||||
|
} else {
|
||||||
|
path = await ask({ title: 'Abrir sessão salva', text: 'Caminho do arquivo de sessão (.json) criado pelo Guará.', placeholder: '/caminho/sessao.json', ok: 'Abrir' });
|
||||||
|
}
|
||||||
|
if (!path) return;
|
||||||
|
try {
|
||||||
|
const { job } = await rpc('session_load', { path });
|
||||||
|
const st = await rpc('status', { job });
|
||||||
|
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||||
|
S.sessions.unshift(s);
|
||||||
|
openJob(s);
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ início
|
||||||
|
function renderHome() {
|
||||||
|
stopPoll();
|
||||||
|
S.device = null; S.job = null;
|
||||||
|
renderSide();
|
||||||
|
main.innerHTML = `<div class="page">
|
||||||
|
<section class="hero"><h1>Encontre o que você achou que tinha perdido.</h1>
|
||||||
|
<p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo — arquivos apagados, partições perdidas e espaço não particionado — sem escrever nada no disco de origem.</p></section>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
|
||||||
|
<div class="card"><h3>Confiança por arquivo</h3><p>Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.</p></div>
|
||||||
|
<div class="card"><h3>Origem protegida</h3><p>Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.</p></div>
|
||||||
|
</div>
|
||||||
|
${S.devices.length ? '' : `<div class="notice info" style="margin-top:22px">${warnSvg}<div><b>Nenhum disco apareceu.</b> Discos brutos exigem administrador. Rode <code>sudo guara ui</code> (Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.</div></div>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ dispositivo
|
||||||
|
async function openDevice(d) {
|
||||||
|
stopPoll();
|
||||||
|
S.device = d; S.job = null; S.layout = null;
|
||||||
|
renderSide();
|
||||||
|
main.innerHTML = `<div class="page"><p class="sub">Lendo ${esc(d.name)}…</p></div>`;
|
||||||
|
main.scrollTop = 0;
|
||||||
|
let layout = null, err = null;
|
||||||
|
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
|
||||||
|
S.layout = layout;
|
||||||
|
renderDevice(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fsClass(p) {
|
||||||
|
if (p.encrypted) return 'fs-enc';
|
||||||
|
const f = (p.fs || '').toLowerCase();
|
||||||
|
if (f.startsWith('fat')) return 'fs-fat';
|
||||||
|
return `fs-${f}` in { 'fs-ntfs': 1, 'fs-exfat': 1, 'fs-ext': 1, 'fs-apfs': 1, 'fs-hfsplus': 1 } ? `fs-${f}` : 'fs-unknown';
|
||||||
|
}
|
||||||
|
const FS_NAME = { ntfs: 'NTFS', fat12: 'FAT12', fat16: 'FAT16', fat32: 'FAT32', exfat: 'exFAT', ext: 'ext2/3/4', apfs: 'APFS', hfsplus: 'HFS+', bitlocker: 'BitLocker', luks: 'LUKS', unknown: 'Desconhecido' };
|
||||||
|
|
||||||
|
function diskMap(layout) {
|
||||||
|
const total = layout.size || 1;
|
||||||
|
const segs = [];
|
||||||
|
layout.partitions.forEach((p) => segs.push({ start: p.start, len: p.len, p }));
|
||||||
|
layout.gaps.forEach((g) => segs.push({ start: g.start, len: g.len, gap: true }));
|
||||||
|
segs.sort((a, b) => a.start - b.start);
|
||||||
|
return `<div class="diskmap" role="img" aria-label="Mapa do disco">${segs.map((s) => {
|
||||||
|
const w = Math.max(s.len / total * 100, 1.4);
|
||||||
|
if (s.gap) return `<div class="seg gap" style="flex:${w} 1 0" title="Espaço não particionado: ${human(s.len)}"><b>Não particionado</b>${human(s.len)}</div>`;
|
||||||
|
const p = s.p;
|
||||||
|
return `<div class="seg ${fsClass(p)} ${p.lost ? 'lost' : ''}" style="flex:${w} 1 0" title="${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}"><b>${p.encrypted ? '🔒 ' : ''}${esc(p.label || FS_NAME[p.fs] || p.fs)}</b>${human(p.len)}</div>`;
|
||||||
|
}).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevice(err) {
|
||||||
|
const d = S.device, l = S.layout;
|
||||||
|
const notes = [];
|
||||||
|
if (!d.readable) notes.push(['bad', 'Este disco exige permissão de administrador. ' + (tauri ? '<button class="btn sm" id="elevate" style="margin-left:6px">Reiniciar como administrador</button>' : 'Feche e rode de novo com <code>sudo guara ui</code> (Linux/macOS) ou como administrador (Windows).')]);
|
||||||
|
if (err) notes.push(['bad', esc(err)]);
|
||||||
|
if (d.mounted_at?.length) notes.push(['warn', `Este disco está em uso (montado em <code>${esc(d.mounted_at.join(', '))}</code>). O Guará só lê, mas evite gravar nele enquanto recupera — qualquer escrita pode sobrescrever o que você quer salvar.`]);
|
||||||
|
if (d.trim) notes.push(['warn', 'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.']);
|
||||||
|
(l?.warnings || []).forEach((w) => notes.push(['warn', esc(w)]));
|
||||||
|
|
||||||
|
main.innerHTML = `<div class="page">
|
||||||
|
<div class="dev-head"><div><h1>${esc(d.name)}</h1><div class="sub">${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · <span class="mono">${esc(d.path)}</span></div></div></div>
|
||||||
|
${notes.map(([k, t]) => `<div class="notice ${k === 'bad' ? 'bad' : ''}">${warnSvg}<div>${t}</div></div>`).join('')}
|
||||||
|
${l ? `<div class="section"><h2>Mapa do disco</h2>${diskMap(l)}
|
||||||
|
<div class="legend">${l.partitions.map((p) => `<div><span class="dot ${fsClass(p)}"></span><b>Partição ${p.index + 1}</b> ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''}
|
||||||
|
${p.lost ? '<span class="pill accent">encontrada por varredura</span>' : ''}${p.encrypted ? `<span class="pill warn">cifrada (${esc(p.encrypted)}) · só detecção</span>` : ''}</div>`).join('')}
|
||||||
|
${l.gaps.map((g) => `<div><span class="dot" style="background:var(--text-3)"></span><b>Espaço não particionado</b> ${human(g.len)}</div>`).join('')}</div></div>` : ''}
|
||||||
|
<div class="section"><h2>Como procurar</h2>
|
||||||
|
<div class="modes">
|
||||||
|
<div class="mode rec"><span class="pill accent" style="align-self:flex-start">Recomendado</span><h3>Varredura profunda</h3><p>Lê os metadados e examina todo o espaço livre, partições danificadas e áreas não particionadas por assinatura. Encontra mais, leva mais tempo.</p>
|
||||||
|
<button class="btn primary lg" id="goDeep" ${d.readable ? '' : 'disabled'}>Procurar tudo</button></div>
|
||||||
|
<div class="mode"><h3>Varredura rápida</h3><p>Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.</p>
|
||||||
|
<button class="btn lg" id="goQuick" ${d.readable ? '' : 'disabled'}>Varredura rápida</button></div>
|
||||||
|
<div class="mode"><h3>Criar imagem do disco</h3><p>Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.</p>
|
||||||
|
<button class="btn lg" id="goImage" ${d.readable ? '' : 'disabled'}>Criar imagem…</button></div>
|
||||||
|
</div>
|
||||||
|
<details class="adv"><summary>Opções avançadas</summary>
|
||||||
|
<label class="check"><input type="checkbox" id="optAlloc"><span><b>Incluir áreas ainda em uso</b><br><span class="sub">Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.</span></span></label>
|
||||||
|
<div class="row" id="optCats">${CATS.map(([k, n]) => `<label class="check"><input type="checkbox" value="${k}"> ${n}</label>`).join('')}</div>
|
||||||
|
<div class="sub">Sem nenhuma categoria marcada, todos os tipos são procurados.</div>
|
||||||
|
</details>
|
||||||
|
</div></div>`;
|
||||||
|
$('#elevate')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
|
||||||
|
const opts = (mode) => ({
|
||||||
|
mode,
|
||||||
|
include_allocated: $('#optAlloc')?.checked || false,
|
||||||
|
categories: [...document.querySelectorAll('#optCats input:checked')].map((i) => i.value),
|
||||||
|
});
|
||||||
|
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
|
||||||
|
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
|
||||||
|
$('#goImage')?.addEventListener('click', startImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScan(options) {
|
||||||
|
try {
|
||||||
|
const { job } = await rpc('scan_start', { path: S.device.path, options });
|
||||||
|
const s = { job, target: S.device.path, label: S.device.name };
|
||||||
|
S.sessions.unshift(s);
|
||||||
|
openJob(s);
|
||||||
|
} catch (e) { toast(e.message); info({ title: 'Não foi possível iniciar', html: `<p>${esc(e.message)}</p>` }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startImage() {
|
||||||
|
const base = (S.defaults.home || '') + (S.defaults.sep || '/');
|
||||||
|
const dest = await ask({ title: 'Criar imagem do disco', text: 'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.', value: base + 'disco-guara.img', ok: 'Criar imagem' });
|
||||||
|
if (!dest) return;
|
||||||
|
try {
|
||||||
|
const { job } = await rpc('image_start', { path: S.device.path, dest });
|
||||||
|
watchAux(job, 'Criando imagem do disco', (st) => {
|
||||||
|
const r = st.report || {};
|
||||||
|
const bad = (r.bad_ranges || []).length;
|
||||||
|
return `<p>Imagem salva em <code>${esc(dest)}</code> (${human(r.bytes)}).</p><p>SHA-256: <code>${esc(r.sha256)}</code></p>${bad ? `<div class="notice">${warnSvg}<div>${bad} trecho(s) ilegível(is) foram preenchidos com zeros. Veja o mapa de erros ao lado da imagem.</div></div>` : '<p>Nenhum setor ruim encontrado.</p>'}`;
|
||||||
|
});
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ varredura
|
||||||
|
function openJob(s) {
|
||||||
|
stopPoll();
|
||||||
|
S.job = s.job;
|
||||||
|
S.filters = { category: null, minConfidence: null, query: '', sort: 'confidence' };
|
||||||
|
S.items = []; S.total = 0; S.selected = new Set(); S.allFiltered = false; S.current = null; S.status = null;
|
||||||
|
renderSide();
|
||||||
|
renderScan();
|
||||||
|
tick();
|
||||||
|
S.poll = setInterval(tick, 500);
|
||||||
|
main.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPoll() { clearInterval(S.poll); S.poll = null; }
|
||||||
|
|
||||||
|
let lastFound = -1, lastFilterKey = '';
|
||||||
|
async function tick() {
|
||||||
|
if (!S.job) return;
|
||||||
|
let st;
|
||||||
|
try { st = await rpc('status', { job: S.job }); } catch (e) { stopPoll(); toast(e.message); return; }
|
||||||
|
S.status = st;
|
||||||
|
const key = JSON.stringify(S.filters);
|
||||||
|
if (st.found !== lastFound || key !== lastFilterKey) {
|
||||||
|
lastFound = st.found; lastFilterKey = key;
|
||||||
|
await loadResults(true);
|
||||||
|
}
|
||||||
|
paintStatus();
|
||||||
|
if (st.status !== 'running') {
|
||||||
|
stopPoll();
|
||||||
|
if (st.status === 'error') toast(st.error);
|
||||||
|
await loadResults(true);
|
||||||
|
paintStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadResults(reset) {
|
||||||
|
const f = S.filters;
|
||||||
|
const query = { sort: f.sort, offset: reset ? 0 : S.items.length, limit: 200 };
|
||||||
|
if (f.category) query.category = f.category;
|
||||||
|
if (f.minConfidence) query.min_confidence = f.minConfidence;
|
||||||
|
if (f.query) query.query = f.query;
|
||||||
|
const r = await rpc('results', { job: S.job, query });
|
||||||
|
S.items = reset ? r.items : S.items.concat(r.items);
|
||||||
|
S.total = r.total;
|
||||||
|
paintList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScan() {
|
||||||
|
main.innerHTML = `<div class="page">
|
||||||
|
<button class="back" id="backBtn">‹ Dispositivos</button>
|
||||||
|
<div class="dev-head"><div><h1 id="scanTitle">Procurando…</h1><div class="sub" id="scanSub"></div></div></div>
|
||||||
|
<div id="progressArea"></div>
|
||||||
|
<div id="warnArea"></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="chips" id="chips" role="group" aria-label="Filtrar por tipo"></div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" style="margin-top:0">
|
||||||
|
<input class="input" id="q" type="search" placeholder="Buscar por nome ou pasta" value="${esc(S.filters.query)}" aria-label="Buscar">
|
||||||
|
<select class="select" id="conf" aria-label="Confiança mínima"><option value="">Qualquer confiança</option><option value="medium">Média ou alta</option><option value="high">Só alta</option></select>
|
||||||
|
<select class="select" id="sort" aria-label="Ordenar"><option value="confidence">Mais confiáveis</option><option value="size">Maiores</option><option value="name">Nome</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="split"><div class="table" id="table"></div><aside class="preview" id="preview" aria-live="polite"></aside></div>
|
||||||
|
</div><div id="barHost"></div>`;
|
||||||
|
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||||
|
let t;
|
||||||
|
$('#q').oninput = (e) => { clearTimeout(t); t = setTimeout(() => { S.filters.query = e.target.value; resetSel(); tick(); }, 250); };
|
||||||
|
$('#conf').onchange = (e) => { S.filters.minConfidence = e.target.value || null; resetSel(); tick(); };
|
||||||
|
$('#sort').onchange = (e) => { S.filters.sort = e.target.value; tick(); };
|
||||||
|
paintPreview();
|
||||||
|
}
|
||||||
|
const resetSel = () => { S.selected = new Set(); S.allFiltered = false; };
|
||||||
|
|
||||||
|
function paintStatus() {
|
||||||
|
const st = S.status;
|
||||||
|
if (!st || !$('#progressArea')) return;
|
||||||
|
const running = st.status === 'running';
|
||||||
|
const p = st.progress || {};
|
||||||
|
const pct = p.total ? Math.min(100, Math.floor((p.done / p.total) * 100)) : null;
|
||||||
|
$('#scanTitle').textContent = running ? 'Procurando…' : st.status === 'paused' ? 'Varredura pausada' : st.status === 'error' ? 'Algo deu errado' : st.status === 'stopped' ? 'Varredura interrompida' : 'Varredura concluída';
|
||||||
|
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
|
||||||
|
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
||||||
|
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
||||||
|
$('#progressArea').innerHTML = `<div class="progress-card">
|
||||||
|
<div class="row spread"><div><b>${esc(p.phase || (running ? 'Iniciando' : ''))}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div>
|
||||||
|
<div class="row">${running ? '<button class="btn sm" id="pauseBtn">Pausar</button><button class="btn sm danger" id="stopBtn">Parar</button>' : st.status === 'paused' ? '<button class="btn sm primary" id="resumeBtn">Continuar</button>' : ''}
|
||||||
|
${!running && st.found ? '<button class="btn sm" id="saveBtn">Salvar sessão…</button>' : ''}</div></div>
|
||||||
|
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos encontrados</span></div>
|
||||||
|
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
||||||
|
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>confiança alta</span></div>
|
||||||
|
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
||||||
|
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||||
|
</div></div>`;
|
||||||
|
$('#pauseBtn')?.addEventListener('click', async () => { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); });
|
||||||
|
$('#stopBtn')?.addEventListener('click', async () => { await rpc('scan_stop', { job: S.job }); });
|
||||||
|
$('#resumeBtn')?.addEventListener('click', async () => { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); });
|
||||||
|
$('#saveBtn')?.addEventListener('click', async () => {
|
||||||
|
const path = await ask({ title: 'Salvar sessão', text: 'Guarda os resultados para recuperar depois, sem varrer de novo.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-sessao.json', ok: 'Salvar' });
|
||||||
|
if (path) { try { await rpc('session_save', { job: S.job, path }); toast('Sessão salva'); } catch (e) { toast(e.message); } }
|
||||||
|
});
|
||||||
|
$('#warnArea').innerHTML = (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
||||||
|
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
||||||
|
// chips
|
||||||
|
const total = st.found;
|
||||||
|
$('#chips').innerHTML = [`<button class="chip" data-c="" aria-pressed="${!S.filters.category}">Tudo<small>${total}</small></button>`]
|
||||||
|
.concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => `<button class="chip" data-c="${k}" aria-pressed="${S.filters.category === k}">${n}<small>${st.categories[k]}</small></button>`)).join('');
|
||||||
|
$('#chips').querySelectorAll('.chip').forEach((c) => (c.onclick = () => { S.filters.category = c.dataset.c || null; resetSel(); tick(); }));
|
||||||
|
const sel = $('#conf'); if (sel && sel.value !== (S.filters.minConfidence || '')) sel.value = S.filters.minConfidence || '';
|
||||||
|
paintBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintList() {
|
||||||
|
const el = $('#table');
|
||||||
|
if (!el) return;
|
||||||
|
const st = S.status;
|
||||||
|
if (!S.items.length) {
|
||||||
|
el.innerHTML = `<div class="empty"><h3>${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}</h3><p>${st && st.status === 'running' ? 'Os arquivos aparecem aqui assim que são encontrados.' : (S.filters.category || S.filters.query || S.filters.minConfidence) ? 'Nenhum arquivo combina com os filtros.' : 'Não encontramos arquivos recuperáveis neste disco.'}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered;
|
||||||
|
el.innerHTML = `<div class="thead"><input type="checkbox" id="selAll" aria-label="Selecionar todos" ${allSel ? 'checked' : ''}><span>Nome</span><span style="text-align:right">Tamanho</span><span>Origem</span><span style="text-align:right">Confiança</span></div>`
|
||||||
|
+ S.items.map((i) => {
|
||||||
|
const [cl, cc] = CONF[i.confidence];
|
||||||
|
const on = S.allFiltered || S.selected.has(i.id);
|
||||||
|
return `<div class="tr" role="row" data-id="${i.id}" aria-selected="${on || S.current?.id === i.id}" tabindex="0">
|
||||||
|
<input type="checkbox" ${on ? 'checked' : ''} aria-label="Selecionar ${esc(i.name)}">
|
||||||
|
<div class="nm"><b>${esc(i.name)}</b><small>${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}</small></div>
|
||||||
|
<div class="num">${human(i.size)}</div><div class="num" style="text-align:left">${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}</div>
|
||||||
|
<div class="num"><span class="pill ${cc}">${cl}</span></div></div>`;
|
||||||
|
}).join('')
|
||||||
|
+ (S.items.length < S.total ? `<div class="more"><button class="btn" id="moreBtn">Mostrar mais (${(S.total - S.items.length).toLocaleString('pt-BR')})</button></div>` : '');
|
||||||
|
$('#selAll').onchange = (e) => {
|
||||||
|
if (e.target.checked) S.items.forEach((i) => S.selected.add(i.id)); else resetSel();
|
||||||
|
paintList(); paintBar();
|
||||||
|
};
|
||||||
|
el.querySelectorAll('.tr').forEach((row) => {
|
||||||
|
const id = +row.dataset.id;
|
||||||
|
const cb = $('input', row);
|
||||||
|
cb.onclick = (e) => { e.stopPropagation(); if (S.allFiltered) { S.allFiltered = false; S.items.forEach((i) => S.selected.add(i.id)); } cb.checked ? S.selected.add(id) : S.selected.delete(id); paintBar(); row.setAttribute('aria-selected', cb.checked); };
|
||||||
|
row.onclick = () => focusItem(S.items.find((i) => i.id === id));
|
||||||
|
row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focusItem(S.items.find((i) => i.id === id)); } };
|
||||||
|
});
|
||||||
|
$('#moreBtn')?.addEventListener('click', () => loadResults(false));
|
||||||
|
paintBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusItem(item) {
|
||||||
|
S.current = item;
|
||||||
|
document.querySelectorAll('.tr').forEach((r) => r.setAttribute('aria-selected', (+r.dataset.id === item.id) || S.selected.has(+r.dataset.id) || S.allFiltered));
|
||||||
|
paintPreview();
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(item.ext) && item.size < 8 * 1024 * 1024) {
|
||||||
|
try {
|
||||||
|
const p = await rpc('preview', { job: S.job, id: item.id });
|
||||||
|
if (S.current?.id === item.id && p.mime) $('.thumb', $('#preview')).innerHTML = `<img alt="Pré-visualização de ${esc(item.name)}" src="data:${p.mime};base64,${p.base64}">`;
|
||||||
|
} catch { /* sem prévia */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintPreview() {
|
||||||
|
const el = $('#preview');
|
||||||
|
if (!el) return;
|
||||||
|
const i = S.current;
|
||||||
|
if (!i) { el.innerHTML = `<div class="thumb">Selecione um arquivo para ver os detalhes</div>`; return; }
|
||||||
|
const [cl, cc] = CONF[i.confidence];
|
||||||
|
el.innerHTML = `<div class="thumb">${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}</div>
|
||||||
|
<dl class="kv"><dt>Nome</dt><dd>${esc(i.name)}</dd><dt>Pasta</dt><dd>${esc(i.dir || '—')}</dd><dt>Tamanho</dt><dd>${human(i.size)}</dd>
|
||||||
|
<dt>Modificado</dt><dd>${date(i.modified)}</dd><dt>Origem</dt><dd>${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}</dd>
|
||||||
|
<dt>Confiança</dt><dd><span class="pill ${cc}">${cl}</span></dd>${i.note ? `<dt>Aviso</dt><dd>${esc(i.note)}</dd>` : ''}</dl>
|
||||||
|
<div class="row"><button class="btn primary sm" id="recOne">Recuperar este arquivo</button></div>`;
|
||||||
|
$('#recOne').onclick = () => recover({ ids: [i.id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selCount() { return S.allFiltered ? S.total : S.selected.size; }
|
||||||
|
|
||||||
|
function paintBar() {
|
||||||
|
const host = $('#barHost');
|
||||||
|
if (!host) return;
|
||||||
|
const st = S.status;
|
||||||
|
const n = selCount();
|
||||||
|
const canAll = st && st.found > 0 && st.status !== 'error';
|
||||||
|
if (!n && !canAll) { host.innerHTML = ''; return; }
|
||||||
|
const moreOffer = !S.allFiltered && S.selected.size > 0 && S.selected.size < S.total && S.items.length === S.selected.size
|
||||||
|
? `<button class="btn sm" id="selFiltered">Selecionar todos os ${S.total.toLocaleString('pt-BR')}</button>` : '';
|
||||||
|
host.innerHTML = `<div class="actionbar"><span class="info">${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}</span>${moreOffer}
|
||||||
|
${n ? '<button class="btn primary" id="recSel">Recuperar selecionados</button>' : '<button class="btn primary" id="recAll">Recuperar todos da lista</button>'}</div>`;
|
||||||
|
$('#selFiltered')?.addEventListener('click', () => { S.allFiltered = true; paintList(); });
|
||||||
|
$('#recSel')?.addEventListener('click', () => recover(S.allFiltered ? { filter: currentFilter() } : { ids: [...S.selected] }));
|
||||||
|
$('#recAll')?.addEventListener('click', () => recover({ filter: currentFilter() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFilter() {
|
||||||
|
const f = S.filters;
|
||||||
|
const q = { sort: f.sort, limit: 1 };
|
||||||
|
if (f.category) q.category = f.category;
|
||||||
|
if (f.minConfidence) q.min_confidence = f.minConfidence;
|
||||||
|
if (f.query) q.query = f.query;
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recover(sel) {
|
||||||
|
const dest = await ask({
|
||||||
|
title: 'Recuperar para…',
|
||||||
|
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.',
|
||||||
|
value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados',
|
||||||
|
ok: 'Recuperar',
|
||||||
|
pick: { directory: true, multiple: false },
|
||||||
|
});
|
||||||
|
if (!dest) return;
|
||||||
|
try {
|
||||||
|
const { job } = await rpc('recover_start', { job: S.job, dest, ...sel });
|
||||||
|
watchAux(job, 'Recuperando arquivos', (st) => {
|
||||||
|
const r = st.report || {};
|
||||||
|
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
||||||
|
return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
|
||||||
|
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''}
|
||||||
|
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista o SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||||
|
});
|
||||||
|
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal de progresso de tarefas auxiliares (recuperação / imagem).
|
||||||
|
function watchAux(job, title, doneHtml) {
|
||||||
|
const d = $('#dlg');
|
||||||
|
d.onclose = null;
|
||||||
|
const draw = (st) => {
|
||||||
|
const p = st.progress || {};
|
||||||
|
const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0;
|
||||||
|
if (st.status === 'running') {
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${esc(p.phase || '')}</p><div class="bar"><i style="width:${pct}%"></i></div><p class="sub">${pct}%</p><menu><button type="button" class="btn danger" id="auxStop">Cancelar</button></menu></form>`;
|
||||||
|
$('#auxStop').onclick = () => rpc('job_stop', { job });
|
||||||
|
} else if (st.status === 'error') {
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>Algo deu errado</h2><div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div><menu><button class="btn primary">Fechar</button></menu></form>`;
|
||||||
|
} else {
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>${st.status === 'stopped' ? 'Interrompido' : 'Pronto'}</h2>${doneHtml(st)}<menu><button class="btn primary">Fechar</button></menu></form>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!d.open) d.showModal();
|
||||||
|
draw({ status: 'running', progress: {} });
|
||||||
|
const iv = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const st = await rpc('status', { job });
|
||||||
|
draw(st);
|
||||||
|
if (st.status !== 'running') clearInterval(iv);
|
||||||
|
} catch { clearInterval(iv); }
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openJobFromList(s) { openJob(s); }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ início do app
|
||||||
|
(async function init() {
|
||||||
|
try {
|
||||||
|
const v = await rpc('version');
|
||||||
|
$('#versionTag').textContent = `v${v.version}`;
|
||||||
|
S.defaults = await rpc('defaults');
|
||||||
|
} catch (e) {
|
||||||
|
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadDevices();
|
||||||
|
renderHome();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff8a3d"/>
|
||||||
|
<stop offset="1" stop-color="#d9441c"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="128" height="128" rx="30" fill="url(#g)"/>
|
||||||
|
<!-- orelhas grandes e focinho longo do lobo-guará -->
|
||||||
|
<path d="M30 22 L52 50 L38 62 Z" fill="#fff"/>
|
||||||
|
<path d="M98 22 L76 50 L90 62 Z" fill="#fff"/>
|
||||||
|
<path d="M38 62 L52 50 Q64 44 76 50 L90 62 L74 96 Q64 108 54 96 Z" fill="#fff"/>
|
||||||
|
<path d="M54 80 Q64 90 74 80 L69 96 Q64 100 59 96 Z" fill="#3a1608"/>
|
||||||
|
<circle cx="53" cy="68" r="4" fill="#3a1608"/>
|
||||||
|
<circle cx="75" cy="68" r="4" fill="#3a1608"/>
|
||||||
|
<path d="M54 52 Q64 47 74 52 L70 60 Q64 56 58 60 Z" fill="#ffd2b0"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 828 B |
@@ -0,0 +1,39 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Guará</title>
|
||||||
|
<link rel="icon" href="guara.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app" id="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
|
||||||
|
</button>
|
||||||
|
<img src="guara.svg" alt="" width="24" height="24"><strong>Guará</strong>
|
||||||
|
</header>
|
||||||
|
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
|
||||||
|
<div class="brand"><img src="guara.svg" alt="" width="30" height="30"><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
|
||||||
|
<nav>
|
||||||
|
<div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
|
||||||
|
<ul id="deviceList" class="list"></ul>
|
||||||
|
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
|
||||||
|
<div class="side-title">Sessões</div>
|
||||||
|
<ul id="sessionList" class="list"></ul>
|
||||||
|
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
||||||
|
</nav>
|
||||||
|
<footer class="side-foot"><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||||
|
</aside>
|
||||||
|
<div class="scrim" id="scrim"></div>
|
||||||
|
<main class="main" id="main" tabindex="-1"></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="dlg"></dialog>
|
||||||
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
|
<script src="app.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "guara-cli"
|
||||||
|
description = "Guará: recuperação de arquivos pelo terminal (e servidor da interface gráfica)"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "guara"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
guara-core = { path = "../guara-core" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tiny_http = "0.12"
|
||||||
|
getrandom = "0.2"
|
||||||
|
ctrlc = "3"
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
//! Guará CLI: `guara devices | inspect | scan | resume | recover | image | ui`.
|
||||||
|
|
||||||
|
mod serve;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use guara_core::service::{Engine, Query};
|
||||||
|
use guara_core::{Category, Confidence, ScanMode, ScanOptions};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::io::{IsTerminal, Write};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "guara", version, about = "Guará — recuperação de arquivos (somente leitura na origem)", long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Lista discos e pendrives detectados
|
||||||
|
Devices {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Mostra partições, sistemas de arquivos, espaço não particionado e volumes cifrados
|
||||||
|
Inspect {
|
||||||
|
/// Dispositivo (ex.: /dev/sdb, \\.\PhysicalDrive1) ou arquivo de imagem
|
||||||
|
path: String,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Procura arquivos apagados/perdidos (metadados + assinatura)
|
||||||
|
Scan(ScanArgs),
|
||||||
|
/// Continua uma varredura pausada (Ctrl+C) a partir do arquivo de sessão
|
||||||
|
Resume {
|
||||||
|
session: String,
|
||||||
|
#[command(flatten)]
|
||||||
|
out: OutArgs,
|
||||||
|
},
|
||||||
|
/// Recupera arquivos de uma sessão salva
|
||||||
|
Recover {
|
||||||
|
session: String,
|
||||||
|
/// Pasta de destino (em OUTRO disco)
|
||||||
|
dest: String,
|
||||||
|
#[command(flatten)]
|
||||||
|
filter: FilterArgs,
|
||||||
|
},
|
||||||
|
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
|
||||||
|
Image { path: String, dest: String },
|
||||||
|
/// Abre a interface gráfica no navegador (modo local)
|
||||||
|
Ui {
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
port: u16,
|
||||||
|
#[arg(long)]
|
||||||
|
no_open: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Clone)]
|
||||||
|
struct FilterArgs {
|
||||||
|
/// Categorias: image, document, video, audio, archive, database, other
|
||||||
|
#[arg(long, value_delimiter = ',')]
|
||||||
|
category: Vec<String>,
|
||||||
|
/// Confiança mínima: low, medium, high
|
||||||
|
#[arg(long, default_value = "low")]
|
||||||
|
min_confidence: String,
|
||||||
|
/// Só nomes/caminhos que contenham este texto
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args, Clone)]
|
||||||
|
struct OutArgs {
|
||||||
|
/// Salva a sessão (necessária para recover/resume)
|
||||||
|
#[arg(long)]
|
||||||
|
session: Option<String>,
|
||||||
|
/// Lista os arquivos encontrados
|
||||||
|
#[arg(long)]
|
||||||
|
list: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args)]
|
||||||
|
struct ScanArgs {
|
||||||
|
/// Dispositivo ou imagem
|
||||||
|
path: String,
|
||||||
|
/// Varredura rápida: só metadados dos sistemas de arquivos
|
||||||
|
#[arg(long)]
|
||||||
|
quick: bool,
|
||||||
|
/// Também varre áreas ainda alocadas (mais lento)
|
||||||
|
#[arg(long)]
|
||||||
|
include_allocated: bool,
|
||||||
|
/// Recupera tudo que casar com os filtros para esta pasta ao terminar
|
||||||
|
#[arg(long)]
|
||||||
|
recover: Option<String>,
|
||||||
|
#[command(flatten)]
|
||||||
|
filter: FilterArgs,
|
||||||
|
#[command(flatten)]
|
||||||
|
out: OutArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
if let Err(e) = run(cli) {
|
||||||
|
eprintln!("{} {e}", paint("31", "erro:"));
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(cli: Cli) -> Result<(), String> {
|
||||||
|
let eng = Engine::new();
|
||||||
|
match cli.cmd {
|
||||||
|
Cmd::Devices { json } => {
|
||||||
|
let v = eng.rpc("devices", json!({})).map_err(s)?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&v).unwrap());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let list = v.as_array().cloned().unwrap_or_default();
|
||||||
|
if list.is_empty() {
|
||||||
|
println!("Nenhum dispositivo encontrado (ou sem permissão de leitura).");
|
||||||
|
}
|
||||||
|
for d in list {
|
||||||
|
let trim = match d["trim"].as_bool() {
|
||||||
|
Some(true) => " [TRIM ativo: apagados tendem a sumir]",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{:<22} {:<6} {:>10} {}{}{}",
|
||||||
|
d["path"].as_str().unwrap_or(""),
|
||||||
|
d["kind"].as_str().unwrap_or(""),
|
||||||
|
human(d["size"].as_u64().unwrap_or(0)),
|
||||||
|
d["name"].as_str().unwrap_or(""),
|
||||||
|
if d["readable"].as_bool() == Some(false) {
|
||||||
|
" (precisa de administrador)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
trim
|
||||||
|
);
|
||||||
|
for m in d["mounted_at"].as_array().into_iter().flatten() {
|
||||||
|
println!("{:<22} montado em {}", "", m.as_str().unwrap_or(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Inspect { path, json } => {
|
||||||
|
let v = eng.rpc("inspect", json!({ "path": path })).map_err(s)?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&v).unwrap());
|
||||||
|
} else {
|
||||||
|
print_layout(&v);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Scan(a) => {
|
||||||
|
let opts = ScanOptions {
|
||||||
|
mode: if a.quick {
|
||||||
|
ScanMode::Quick
|
||||||
|
} else {
|
||||||
|
ScanMode::Deep
|
||||||
|
},
|
||||||
|
include_allocated: a.include_allocated,
|
||||||
|
categories: parse_cats(&a.filter.category)?,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let job = eng.start_scan(&a.path, opts).map_err(s)?;
|
||||||
|
drive_scan(&eng, &job, &a.out, &a.filter, a.recover.as_deref())
|
||||||
|
}
|
||||||
|
Cmd::Resume { session, out } => {
|
||||||
|
let job = eng.load_session(&session).map_err(s)?;
|
||||||
|
eng.resume(&job).map_err(s)?;
|
||||||
|
let out = OutArgs {
|
||||||
|
session: out.session.or(Some(session)),
|
||||||
|
..out
|
||||||
|
};
|
||||||
|
drive_scan(
|
||||||
|
&eng,
|
||||||
|
&job,
|
||||||
|
&out,
|
||||||
|
&FilterArgs {
|
||||||
|
category: vec![],
|
||||||
|
min_confidence: "low".into(),
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Cmd::Recover {
|
||||||
|
session,
|
||||||
|
dest,
|
||||||
|
filter,
|
||||||
|
} => {
|
||||||
|
let job = eng.load_session(&session).map_err(s)?;
|
||||||
|
let st = eng.status(&job).map_err(s)?;
|
||||||
|
if st["status"] == "paused" {
|
||||||
|
return Err(
|
||||||
|
"a varredura dessa sessão não terminou; use `guara resume` primeiro".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recover_job(&eng, &job, &dest, &filter)
|
||||||
|
}
|
||||||
|
Cmd::Image { path, dest } => {
|
||||||
|
let job = eng.start_image(&path, &dest).map_err(s)?;
|
||||||
|
let st = wait(&eng, &job, None)?;
|
||||||
|
if st["status"] == "error" {
|
||||||
|
return Err(st["error"].as_str().unwrap_or("falha").to_string());
|
||||||
|
}
|
||||||
|
let r = &st["report"];
|
||||||
|
eprintln!();
|
||||||
|
println!("Imagem criada: {dest}");
|
||||||
|
println!("SHA-256: {}", r["sha256"].as_str().unwrap_or(""));
|
||||||
|
let bad = r["bad_ranges"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
|
if bad > 0 {
|
||||||
|
println!("Atenção: {bad} trecho(s) ilegível(is) preenchidos com zeros (veja {}.map.json).", dest.trim_end_matches(".img"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cats(v: &[String]) -> Result<Vec<Category>, String> {
|
||||||
|
v.iter()
|
||||||
|
.map(|c| Category::parse(c).ok_or(format!("categoria desconhecida: {c}")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_from(f: &FilterArgs) -> Result<Query, String> {
|
||||||
|
let cats = parse_cats(&f.category)?;
|
||||||
|
let min = match f.min_confidence.as_str() {
|
||||||
|
"low" | "baixa" => Confidence::Low,
|
||||||
|
"medium" | "media" | "média" => Confidence::Medium,
|
||||||
|
"high" | "alta" => Confidence::High,
|
||||||
|
o => return Err(format!("confiança inválida: {o}")),
|
||||||
|
};
|
||||||
|
// Query aceita uma categoria; várias categorias são tratadas filtrando depois.
|
||||||
|
Ok(Query {
|
||||||
|
category: if cats.len() == 1 { Some(cats[0]) } else { None },
|
||||||
|
min_confidence: Some(min),
|
||||||
|
query: f.name.clone(),
|
||||||
|
sort: Some("confidence".into()),
|
||||||
|
offset: 0,
|
||||||
|
limit: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_scan(
|
||||||
|
eng: &Engine,
|
||||||
|
job: &str,
|
||||||
|
out: &OutArgs,
|
||||||
|
filter: &FilterArgs,
|
||||||
|
recover: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let st = wait(eng, job, out.session.as_deref())?;
|
||||||
|
eprintln!();
|
||||||
|
match st["status"].as_str() {
|
||||||
|
Some("error") => return Err(st["error"].as_str().unwrap_or("falha").to_string()),
|
||||||
|
Some("paused") => {
|
||||||
|
if let Some(p) = &out.session {
|
||||||
|
eng.save_session(job, p).map_err(s)?;
|
||||||
|
eprintln!("Varredura pausada. Continue com: guara resume {p}");
|
||||||
|
} else {
|
||||||
|
eprintln!("Varredura pausada (use --session <arquivo> para poder retomar).");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(p) = &out.session {
|
||||||
|
eng.save_session(job, p).map_err(s)?;
|
||||||
|
}
|
||||||
|
if out.json {
|
||||||
|
let res = eng.results(job, &query_from(filter)?).map_err(s)?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&json!({ "status": st, "results": res })).unwrap()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
print_summary(&st);
|
||||||
|
if out.list {
|
||||||
|
let q = query_from(filter)?;
|
||||||
|
let res = eng.results(job, &Query { limit: 2000, ..q }).map_err(s)?;
|
||||||
|
print_list(&res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(dest) = recover {
|
||||||
|
recover_job(eng, job, dest, filter)?;
|
||||||
|
} else if st["found"].as_u64().unwrap_or(0) > 0 && out.session.is_some() {
|
||||||
|
eprintln!(
|
||||||
|
"Para recuperar: guara recover {} <pasta-de-destino>",
|
||||||
|
out.session.as_deref().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_job(
|
||||||
|
eng: &Engine,
|
||||||
|
scan_job: &str,
|
||||||
|
dest: &str,
|
||||||
|
filter: &FilterArgs,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut q = query_from(filter)?;
|
||||||
|
// Várias categorias: seleciona por ids.
|
||||||
|
let cats = parse_cats(&filter.category)?;
|
||||||
|
let mut ids: Option<Vec<u64>> = None;
|
||||||
|
if cats.len() > 1 {
|
||||||
|
let mut all = vec![];
|
||||||
|
for c in cats {
|
||||||
|
q.category = Some(c);
|
||||||
|
let r = eng
|
||||||
|
.results(
|
||||||
|
scan_job,
|
||||||
|
&Query {
|
||||||
|
limit: 2000,
|
||||||
|
..q.clone()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(s)?;
|
||||||
|
all.extend(
|
||||||
|
r["items"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|i| i["id"].as_u64()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ids = Some(all);
|
||||||
|
}
|
||||||
|
let rj = eng.start_recover(scan_job, ids, Some(q), dest).map_err(s)?;
|
||||||
|
let st = wait(eng, &rj, None)?;
|
||||||
|
eprintln!();
|
||||||
|
if st["status"] == "error" {
|
||||||
|
return Err(st["error"].as_str().unwrap_or("falha").to_string());
|
||||||
|
}
|
||||||
|
let r = &st["report"];
|
||||||
|
let n = r["recovered"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
|
let failed = r["failed"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
|
println!(
|
||||||
|
"{} {n} arquivo(s) recuperado(s) para {dest} ({}).",
|
||||||
|
paint("32", "✓"),
|
||||||
|
human(r["total_bytes"].as_u64().unwrap_or(0))
|
||||||
|
);
|
||||||
|
if failed > 0 {
|
||||||
|
println!(
|
||||||
|
"{} {failed} falharam (detalhes no manifesto).",
|
||||||
|
paint("33", "!")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"Manifesto com SHA-256: {}/guara-manifesto.json",
|
||||||
|
dest.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result<Value, String> {
|
||||||
|
let interrupted = Arc::new(AtomicBool::new(false));
|
||||||
|
let flag = interrupted.clone();
|
||||||
|
let _ = ctrlc::set_handler(move || flag.store(true, Ordering::SeqCst));
|
||||||
|
let tty = std::io::stderr().is_terminal();
|
||||||
|
let mut asked = false;
|
||||||
|
loop {
|
||||||
|
let st = eng.status(job).map_err(s)?;
|
||||||
|
let status = st["status"].as_str().unwrap_or("").to_string();
|
||||||
|
if interrupted.load(Ordering::SeqCst) && !asked && status == "running" {
|
||||||
|
asked = true;
|
||||||
|
if session.is_some() {
|
||||||
|
eprintln!("\nPausando…");
|
||||||
|
let _ = eng.pause(job);
|
||||||
|
} else {
|
||||||
|
let _ = eng.stop(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status != "running" {
|
||||||
|
return Ok(st);
|
||||||
|
}
|
||||||
|
if tty {
|
||||||
|
let p = &st["progress"];
|
||||||
|
let (d, t) = (
|
||||||
|
p["done"].as_u64().unwrap_or(0),
|
||||||
|
p["total"].as_u64().unwrap_or(0),
|
||||||
|
);
|
||||||
|
let pct = d
|
||||||
|
.checked_mul(100)
|
||||||
|
.and_then(|v| v.checked_div(t))
|
||||||
|
.map_or(" … ".to_string(), |v| format!("{v:>3}%"));
|
||||||
|
let found = st["found"].as_u64().unwrap_or(0);
|
||||||
|
let line = format!(
|
||||||
|
"{pct} {} · {found} arquivo(s) encontrado(s) · {:.0}s",
|
||||||
|
p["phase"].as_str().unwrap_or(""),
|
||||||
|
st["elapsed"].as_f64().unwrap_or(0.0)
|
||||||
|
);
|
||||||
|
eprint!("\r\x1b[2K{line}");
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_layout(v: &Value) {
|
||||||
|
println!(
|
||||||
|
"Tamanho: {} Tabela de partições: {}",
|
||||||
|
human(v["size"].as_u64().unwrap_or(0)),
|
||||||
|
v["table"].as_str().unwrap_or("-").to_uppercase()
|
||||||
|
);
|
||||||
|
for p in v["partitions"].as_array().into_iter().flatten() {
|
||||||
|
let enc = p["encrypted"]
|
||||||
|
.as_str()
|
||||||
|
.map(|e| format!(" 🔒 cifrada ({e})"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!(
|
||||||
|
" #{:<2} início {:>12} tamanho {:>10} {:<10} {}{}{}",
|
||||||
|
p["index"].as_u64().unwrap_or(0) + 1,
|
||||||
|
p["start"].as_u64().unwrap_or(0),
|
||||||
|
human(p["len"].as_u64().unwrap_or(0)),
|
||||||
|
p["fs"].as_str().unwrap_or("?"),
|
||||||
|
p["label"].as_str().unwrap_or(""),
|
||||||
|
if p["lost"].as_bool() == Some(true) {
|
||||||
|
" (perdida)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
enc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for g in v["gaps"].as_array().into_iter().flatten() {
|
||||||
|
println!(
|
||||||
|
" espaço não particionado: início {} tamanho {}",
|
||||||
|
g["start"],
|
||||||
|
human(g["len"].as_u64().unwrap_or(0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for w in v["warnings"].as_array().into_iter().flatten() {
|
||||||
|
println!("{} {}", paint("33", "!"), w.as_str().unwrap_or(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_summary(st: &Value) {
|
||||||
|
println!(
|
||||||
|
"Concluído em {:.1}s — {} arquivo(s) encontrado(s).",
|
||||||
|
st["elapsed"].as_f64().unwrap_or(0.0),
|
||||||
|
st["found"]
|
||||||
|
);
|
||||||
|
let c = &st["confidence"];
|
||||||
|
println!(
|
||||||
|
" confiança alta: {} média: {} baixa: {}",
|
||||||
|
c["high"], c["medium"], c["low"]
|
||||||
|
);
|
||||||
|
if let Some(m) = st["categories"].as_object() {
|
||||||
|
let mut v: Vec<_> = m.iter().collect();
|
||||||
|
v.sort_by_key(|(k, _)| k.to_string());
|
||||||
|
let line: Vec<String> = v.iter().map(|(k, n)| format!("{k}: {n}")).collect();
|
||||||
|
println!(" {}", line.join(" "));
|
||||||
|
}
|
||||||
|
for w in st["warnings"].as_array().into_iter().flatten() {
|
||||||
|
println!("{} {}", paint("33", "!"), w.as_str().unwrap_or(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_list(res: &Value) {
|
||||||
|
println!(
|
||||||
|
"\n{:>6} {:<7} {:>10} {:<9} arquivo",
|
||||||
|
"id", "conf.", "tamanho", "origem"
|
||||||
|
);
|
||||||
|
for i in res["items"].as_array().into_iter().flatten() {
|
||||||
|
let name = match i["dir"].as_str() {
|
||||||
|
Some(d) if !d.is_empty() => format!("{d}/{}", i["name"].as_str().unwrap_or("")),
|
||||||
|
_ => i["name"].as_str().unwrap_or("").to_string(),
|
||||||
|
};
|
||||||
|
let conf = i["confidence"].as_str().unwrap_or("");
|
||||||
|
let colored = match conf {
|
||||||
|
"high" => paint("32", "alta "),
|
||||||
|
"medium" => paint("33", "média "),
|
||||||
|
_ => paint("31", "baixa "),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{:>6} {} {:>10} {:<9} {}",
|
||||||
|
i["id"],
|
||||||
|
colored,
|
||||||
|
human(i["size"].as_u64().unwrap_or(0)),
|
||||||
|
i["origin"].as_str().unwrap_or(""),
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("({} no total)", res["total"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn human(n: u64) -> String {
|
||||||
|
const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let mut v = n as f64;
|
||||||
|
let mut i = 0;
|
||||||
|
while v >= 1024.0 && i < 4 {
|
||||||
|
v /= 1024.0;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
format!("{n} B")
|
||||||
|
} else {
|
||||||
|
format!("{v:.1} {}", U[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(code: &str, s: &str) -> String {
|
||||||
|
if std::io::stdout().is_terminal() {
|
||||||
|
format!("\x1b[{code}m{s}\x1b[0m")
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s<E: std::fmt::Display>(e: E) -> String {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Servidor local da interface (127.0.0.1 + token): mesma UI do app Tauri.
|
||||||
|
|
||||||
|
use guara_core::service::Engine;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::io::Read;
|
||||||
|
use tiny_http::{Header, Method, Response, Server};
|
||||||
|
|
||||||
|
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||||
|
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||||
|
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
|
||||||
|
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
|
||||||
|
|
||||||
|
pub fn run(port: u16, open: bool) -> Result<(), String> {
|
||||||
|
let server = Server::http(("127.0.0.1", port)).map_err(|e| e.to_string())?;
|
||||||
|
let real_port = server
|
||||||
|
.server_addr()
|
||||||
|
.to_ip()
|
||||||
|
.map(|a| a.port())
|
||||||
|
.unwrap_or(port);
|
||||||
|
let mut tok = [0u8; 16];
|
||||||
|
getrandom::getrandom(&mut tok).map_err(|e| e.to_string())?;
|
||||||
|
let token: String = tok.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
let url = format!("http://127.0.0.1:{real_port}/?token={token}");
|
||||||
|
eprintln!("Guará UI em {url}\n(Ctrl+C para encerrar. Para ler discos brutos, rode como administrador/root.)");
|
||||||
|
if open {
|
||||||
|
open_browser(&url);
|
||||||
|
}
|
||||||
|
let eng = Engine::new();
|
||||||
|
let hosts = [
|
||||||
|
format!("127.0.0.1:{real_port}"),
|
||||||
|
format!("localhost:{real_port}"),
|
||||||
|
];
|
||||||
|
for mut req in server.incoming_requests() {
|
||||||
|
let host_ok = req
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()));
|
||||||
|
if !host_ok {
|
||||||
|
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = req.url().split('?').next().unwrap_or("/").to_string();
|
||||||
|
let ct = |v: &str| Header::from_bytes("Content-Type", v).unwrap();
|
||||||
|
match (req.method().clone(), path.as_str()) {
|
||||||
|
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string(INDEX).with_header(ct("text/html; charset=utf-8")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Method::Get, "/app.css") => {
|
||||||
|
let _ = req
|
||||||
|
.respond(Response::from_string(CSS).with_header(ct("text/css; charset=utf-8")));
|
||||||
|
}
|
||||||
|
(Method::Get, "/app.js") => {
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string(JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(Method::Get, "/guara.svg") => {
|
||||||
|
let _ = req.respond(Response::from_string(ICON).with_header(ct("image/svg+xml")));
|
||||||
|
}
|
||||||
|
(Method::Post, p) if p.starts_with("/rpc/") => {
|
||||||
|
let ok = req
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.field.equiv("X-Guara-Token") && h.value.as_str() == token);
|
||||||
|
if !ok {
|
||||||
|
let _ =
|
||||||
|
req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let method = p.trim_start_matches("/rpc/").to_string();
|
||||||
|
let mut body = String::new();
|
||||||
|
let _ = req.as_reader().take(1 << 20).read_to_string(&mut body);
|
||||||
|
let params = serde_json::from_str(&body).unwrap_or(json!({}));
|
||||||
|
let (code, out) = match eng.rpc(&method, params) {
|
||||||
|
Ok(v) => (200, v),
|
||||||
|
Err(e) => (400, json!({ "error": e.to_string() })),
|
||||||
|
};
|
||||||
|
let _ = req.respond(
|
||||||
|
Response::from_string(out.to_string())
|
||||||
|
.with_status_code(code)
|
||||||
|
.with_header(ct("application/json")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = req.respond(Response::from_string("não encontrado").with_status_code(404));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_browser(url: &str) {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let _ = std::process::Command::new("open").arg(url).spawn();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _ = std::process::Command::new("cmd")
|
||||||
|
.args(["/C", "start", "", url])
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "guara-core"
|
||||||
|
description = "Núcleo de recuperação de arquivos do Guará (somente leitura)"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Ioctl", "Win32_Security"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! Carving por assinatura/estrutura sobre intervalos de bytes.
|
||||||
|
|
||||||
|
use crate::signatures::{category_for_ext, detect, might_start, Hit};
|
||||||
|
use crate::source::Source;
|
||||||
|
use crate::types::Category;
|
||||||
|
|
||||||
|
pub const SECTOR: u64 = 512;
|
||||||
|
const CHUNK: usize = 8 * 1024 * 1024;
|
||||||
|
const LOOKAHEAD: usize = 4096;
|
||||||
|
const MIN_LEN: u64 = 256;
|
||||||
|
|
||||||
|
pub struct CarveCfg<'a> {
|
||||||
|
pub max_size: u64,
|
||||||
|
pub categories: &'a [Category],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Varre [start, end) a partir de `*next`. Chama `on_hit(offset, hit)` e retorna
|
||||||
|
/// `true` se terminou o intervalo (`false` se cancelado).
|
||||||
|
/// `advance(done_bytes)` informa o progresso dentro do intervalo.
|
||||||
|
pub fn carve_range(
|
||||||
|
src: &dyn Source,
|
||||||
|
start: u64,
|
||||||
|
end: u64,
|
||||||
|
next: &mut u64,
|
||||||
|
cfg: &CarveCfg,
|
||||||
|
cancelled: &(dyn Fn() -> bool + Sync),
|
||||||
|
ev: &mut dyn CarveEvents,
|
||||||
|
) -> bool {
|
||||||
|
let end = end.min(src.size());
|
||||||
|
let mut pos = (*next).max(start);
|
||||||
|
// Alinha ao setor.
|
||||||
|
pos = pos.div_ceil(SECTOR) * SECTOR;
|
||||||
|
let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
|
||||||
|
let mut skip_until = 0u64;
|
||||||
|
while pos < end {
|
||||||
|
if cancelled() {
|
||||||
|
*next = pos;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD);
|
||||||
|
let got = match read_full(src, pos, &mut buf[..want]) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => {
|
||||||
|
// Setor ruim: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
|
||||||
|
pos += CHUNK as u64;
|
||||||
|
ev.advance(CHUNK as u64);
|
||||||
|
*next = pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if got == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let scan_len = got.min(CHUNK).min((end - pos) as usize);
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < scan_len {
|
||||||
|
let abs = pos + i as u64;
|
||||||
|
if abs < skip_until {
|
||||||
|
i += SECTOR as usize;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let b0 = buf[i];
|
||||||
|
if might_start(b0) {
|
||||||
|
let hend = (i + 128).min(got);
|
||||||
|
let head = &buf[i..hend];
|
||||||
|
if let Some(hit) = detect(src, abs, head, cfg.max_size) {
|
||||||
|
let allowed = cfg.categories.is_empty()
|
||||||
|
|| cfg.categories.contains(&category_for_ext(hit.ext));
|
||||||
|
if hit.len >= MIN_LEN && allowed {
|
||||||
|
let action = ev.hit(abs, &hit);
|
||||||
|
if matches!(action, HitAction::Skip)
|
||||||
|
&& hit.confidence != crate::types::Confidence::Low
|
||||||
|
{
|
||||||
|
skip_until = abs + hit.len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += SECTOR as usize;
|
||||||
|
}
|
||||||
|
let step = scan_len as u64;
|
||||||
|
pos += step.div_ceil(SECTOR) * SECTOR;
|
||||||
|
ev.advance(step);
|
||||||
|
*next = pos;
|
||||||
|
}
|
||||||
|
*next = end;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receptor de eventos do carving.
|
||||||
|
pub trait CarveEvents {
|
||||||
|
fn advance(&mut self, bytes: u64);
|
||||||
|
fn hit(&mut self, offset: u64, hit: &Hit) -> HitAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// O que fazer depois de um acerto.
|
||||||
|
pub enum HitAction {
|
||||||
|
/// Pula o conteúdo do arquivo reconhecido (comportamento padrão).
|
||||||
|
Skip,
|
||||||
|
/// Segue setor a setor (acerto descartado).
|
||||||
|
Continue,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_full(src: &dyn Source, off: u64, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
let mut got = 0;
|
||||||
|
while got < buf.len() {
|
||||||
|
let n = src.read_at(off + got as u64, &mut buf[got..])?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
got += n;
|
||||||
|
}
|
||||||
|
Ok(got)
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
//! Enumeração de dispositivos de armazenamento (melhor esforço por plataforma).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
/// "HD", "SSD", "NVMe", "USB", "Disco" ou "Imagem".
|
||||||
|
pub kind: String,
|
||||||
|
pub removable: bool,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub mounted_at: Vec<String>,
|
||||||
|
/// O processo atual consegue abrir o dispositivo para leitura bruta.
|
||||||
|
pub readable: bool,
|
||||||
|
/// SSD com descarte (TRIM) ativo: arquivos apagados tendem a sumir de verdade.
|
||||||
|
pub trim: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (dispositivo, ponto de montagem)
|
||||||
|
pub fn mount_table() -> Vec<(String, String)> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let Ok(s) = std::fs::read_to_string("/proc/mounts") else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
s.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let mut it = l.split_whitespace();
|
||||||
|
let d = it.next()?;
|
||||||
|
let m = it.next()?;
|
||||||
|
d.starts_with("/dev/")
|
||||||
|
.then(|| (d.to_string(), m.replace("\\040", " ")))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let Ok(o) = std::process::Command::new("mount").output() else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let mut v = vec![];
|
||||||
|
for l in String::from_utf8_lossy(&o.stdout).lines() {
|
||||||
|
if let Some((d, rest)) = l.split_once(" on ") {
|
||||||
|
if d.starts_with("/dev/") {
|
||||||
|
let m = rest.split(" (").next().unwrap_or("").to_string();
|
||||||
|
v.push((d.to_string(), m.clone()));
|
||||||
|
v.push((d.replace("/dev/disk", "/dev/rdisk"), m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_devices() -> Vec<DeviceInfo> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
return linux();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return macos();
|
||||||
|
#[cfg(windows)]
|
||||||
|
return windows();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux() -> Vec<DeviceInfo> {
|
||||||
|
use std::fs;
|
||||||
|
let mounts = mount_table();
|
||||||
|
let mut out = vec![];
|
||||||
|
let Ok(rd) = fs::read_dir("/sys/block") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
if ["loop", "ram", "zram", "dm-", "md", "sr", "fd", "nbd"]
|
||||||
|
.iter()
|
||||||
|
.any(|p| name.starts_with(p))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let base = e.path();
|
||||||
|
let rd_str = |p: &str| {
|
||||||
|
fs::read_to_string(base.join(p))
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
};
|
||||||
|
let sectors: u64 = rd_str("size").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
if sectors == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let removable = rd_str("removable").as_deref() == Some("1");
|
||||||
|
let rotational = rd_str("queue/rotational").as_deref() == Some("1");
|
||||||
|
let trim = rd_str("queue/discard_max_bytes")
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.map(|v| v > 0);
|
||||||
|
let path = format!("/dev/{name}");
|
||||||
|
let kind = if removable {
|
||||||
|
"USB"
|
||||||
|
} else if name.starts_with("nvme") {
|
||||||
|
"NVMe"
|
||||||
|
} else if rotational {
|
||||||
|
"HD"
|
||||||
|
} else {
|
||||||
|
"SSD"
|
||||||
|
};
|
||||||
|
let model = rd_str("device/model").or_else(|| rd_str("device/name"));
|
||||||
|
let mounted_at = mounts
|
||||||
|
.iter()
|
||||||
|
.filter(|(d, _)| {
|
||||||
|
d.starts_with(&path)
|
||||||
|
&& d[path.len()..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.is_ascii_digit() || c == 'p')
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.map(|(_, m)| m.clone())
|
||||||
|
.collect();
|
||||||
|
let readable = fs::File::open(&path).is_ok();
|
||||||
|
out.push(DeviceInfo {
|
||||||
|
path,
|
||||||
|
name: model.clone().unwrap_or_else(|| name.clone()),
|
||||||
|
size: sectors * 512,
|
||||||
|
kind: kind.into(),
|
||||||
|
removable,
|
||||||
|
model,
|
||||||
|
mounted_at,
|
||||||
|
readable,
|
||||||
|
trim: if rotational { None } else { trim },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn macos() -> Vec<DeviceInfo> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let Ok(rd) = std::fs::read_dir("/dev") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let mounts = mount_table();
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let n = e.file_name().to_string_lossy().to_string();
|
||||||
|
let Some(num) = n.strip_prefix("disk") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if num.is_empty() || !num.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let info = std::process::Command::new("diskutil")
|
||||||
|
.args(["info", &n])
|
||||||
|
.output()
|
||||||
|
.ok();
|
||||||
|
let text = info
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let field = |k: &str| {
|
||||||
|
text.lines().find_map(|l| {
|
||||||
|
l.trim()
|
||||||
|
.strip_prefix(k)
|
||||||
|
.map(|v| v.trim().trim_start_matches(':').trim().to_string())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let size = field("Disk Size:")
|
||||||
|
.and_then(|v| {
|
||||||
|
v.split('(')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split_whitespace().next().map(String::from))
|
||||||
|
})
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let ssd = field("Solid State:").map(|v| v == "Yes").unwrap_or(false);
|
||||||
|
let removable = field("Removable Media:")
|
||||||
|
.map(|v| v != "Fixed" && v != "No")
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| field("Protocol:").map(|v| v == "USB").unwrap_or(false);
|
||||||
|
let model = field("Device / Media Name:");
|
||||||
|
let path = format!("/dev/r{n}");
|
||||||
|
let readable = std::fs::File::open(&path).is_ok();
|
||||||
|
let mounted_at = mounts
|
||||||
|
.iter()
|
||||||
|
.filter(|(d, _)| d.starts_with(&format!("/dev/{n}")))
|
||||||
|
.map(|(_, m)| m.clone())
|
||||||
|
.collect();
|
||||||
|
out.push(DeviceInfo {
|
||||||
|
path,
|
||||||
|
name: model.clone().unwrap_or(n.clone()),
|
||||||
|
size,
|
||||||
|
kind: if removable {
|
||||||
|
"USB"
|
||||||
|
} else if ssd {
|
||||||
|
"SSD"
|
||||||
|
} else {
|
||||||
|
"HD"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
removable,
|
||||||
|
model,
|
||||||
|
mounted_at,
|
||||||
|
readable,
|
||||||
|
trim: ssd.then_some(true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows() -> Vec<DeviceInfo> {
|
||||||
|
let mut out = vec![];
|
||||||
|
for i in 0..32 {
|
||||||
|
let path = format!(r"\\.\PhysicalDrive{i}");
|
||||||
|
match crate::source::open_windows_device(std::path::Path::new(&path)) {
|
||||||
|
Ok(s) => {
|
||||||
|
use crate::source::Source;
|
||||||
|
out.push(DeviceInfo {
|
||||||
|
path,
|
||||||
|
name: format!("Disco {i}"),
|
||||||
|
size: s.size(),
|
||||||
|
kind: "Disco".into(),
|
||||||
|
removable: false,
|
||||||
|
model: None,
|
||||||
|
mounted_at: vec![],
|
||||||
|
readable: true,
|
||||||
|
trim: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => out.push(DeviceInfo {
|
||||||
|
path,
|
||||||
|
name: format!("Disco {i}"),
|
||||||
|
size: 0,
|
||||||
|
kind: "Disco".into(),
|
||||||
|
removable: false,
|
||||||
|
model: None,
|
||||||
|
mounted_at: vec![],
|
||||||
|
readable: false,
|
||||||
|
trim: None,
|
||||||
|
}),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("erro de E/S: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
Msg(String),
|
||||||
|
#[error("JSON inválido: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn msg(s: impl Into<String>) -> Self {
|
||||||
|
Error::Msg(s.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//! exFAT: conjuntos de entradas apagados (0x05/0x40/0x41) e bitmap de alocação.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
|
||||||
|
};
|
||||||
|
use crate::source::{read_vec, Source};
|
||||||
|
use crate::types::{Confidence, DataLoc, Extent, Origin};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
struct Vol<'a> {
|
||||||
|
src: &'a dyn Source,
|
||||||
|
base: u64,
|
||||||
|
fat_off: u64,
|
||||||
|
heap_off: u64,
|
||||||
|
cbytes: u64,
|
||||||
|
clusters: u32,
|
||||||
|
root: u32,
|
||||||
|
bitmap: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vol<'_> {
|
||||||
|
fn coff(&self, c: u32) -> u64 {
|
||||||
|
self.heap_off + (c as u64 - 2) * self.cbytes
|
||||||
|
}
|
||||||
|
fn valid(&self, c: u32) -> bool {
|
||||||
|
c >= 2 && (c as u64) < self.clusters as u64 + 2
|
||||||
|
}
|
||||||
|
fn allocated(&self, c: u32) -> bool {
|
||||||
|
let i = (c - 2) as usize;
|
||||||
|
self.bitmap
|
||||||
|
.get(i / 8)
|
||||||
|
.map(|b| b >> (i % 8) & 1 == 1)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
fn fat(&self, c: u32) -> Option<u32> {
|
||||||
|
let v = read_vec(self.src, self.fat_off + c as u64 * 4, 4).ok()?;
|
||||||
|
(v.len() == 4).then(|| u32::from_le_bytes(v.try_into().unwrap()))
|
||||||
|
}
|
||||||
|
fn chain(&self, start: u32) -> Vec<u32> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut c = start;
|
||||||
|
while self.valid(c) && (out.len() as u64) <= self.clusters as u64 {
|
||||||
|
out.push(c);
|
||||||
|
match self.fat(c) {
|
||||||
|
Some(n) if self.valid(n) => c = n,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
fn read(&self, cl: &[u32], limit: usize) -> Vec<u8> {
|
||||||
|
let mut out = vec![];
|
||||||
|
for &c in cl {
|
||||||
|
if let Ok(v) = read_vec(self.src, self.coff(c), self.cbytes as usize) {
|
||||||
|
out.extend_from_slice(&v);
|
||||||
|
}
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
fn extents(&self, cl: &[u32]) -> Vec<Extent> {
|
||||||
|
merge_extents(
|
||||||
|
cl.iter()
|
||||||
|
.map(|&c| Extent::new(self.coff(c), self.cbytes))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||||
|
let mut res = VolumeScan::default();
|
||||||
|
let Some(b) = read_vec(ctx.src, base, 512).ok().filter(|b| b.len() == 512) else {
|
||||||
|
res.warnings.push("exFAT: setor de boot ilegível.".into());
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
let ss = 1u64 << b[108];
|
||||||
|
let cbytes = ss << b[109];
|
||||||
|
let fat_off = base + u32::from_le_bytes(b[80..84].try_into().unwrap()) as u64 * ss;
|
||||||
|
let heap_off = base + u32::from_le_bytes(b[88..92].try_into().unwrap()) as u64 * ss;
|
||||||
|
let clusters = u32::from_le_bytes(b[92..96].try_into().unwrap());
|
||||||
|
let root = u32::from_le_bytes(b[96..100].try_into().unwrap());
|
||||||
|
let mut vol = Vol {
|
||||||
|
src: ctx.src,
|
||||||
|
base,
|
||||||
|
fat_off,
|
||||||
|
heap_off,
|
||||||
|
cbytes,
|
||||||
|
clusters,
|
||||||
|
root,
|
||||||
|
bitmap: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Diretório raiz: procura a entrada do bitmap (0x81).
|
||||||
|
let root_chain = vol.chain(vol.root);
|
||||||
|
let root_data = vol.read(&root_chain, 64 << 20);
|
||||||
|
let mut bm: Option<(u32, u64)> = None;
|
||||||
|
for e in root_data.chunks_exact(32) {
|
||||||
|
if e[0] == 0x81 {
|
||||||
|
bm = Some((
|
||||||
|
u32::from_le_bytes(e[20..24].try_into().unwrap()),
|
||||||
|
u64::from_le_bytes(e[24..32].try_into().unwrap()),
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((c, len)) = bm {
|
||||||
|
if vol.valid(c) {
|
||||||
|
vol.bitmap =
|
||||||
|
read_vec(ctx.src, vol.coff(c), len.min(256 << 20) as usize).unwrap_or_default();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.warnings
|
||||||
|
.push("exFAT: bitmap de alocação não encontrado; confiança será menor.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extents vivos: metadados (antes do heap) + clusters alocados no bitmap.
|
||||||
|
let mut live = vec![Extent::new(base, heap_off - base)];
|
||||||
|
let mut run_start: Option<u32> = None;
|
||||||
|
for c in 2..clusters + 2 {
|
||||||
|
let a = vol.allocated(c);
|
||||||
|
match (a, run_start) {
|
||||||
|
(true, None) => run_start = Some(c),
|
||||||
|
(false, Some(s)) => {
|
||||||
|
live.push(Extent::new(vol.coff(s), (c - s) as u64 * cbytes));
|
||||||
|
run_start = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = run_start {
|
||||||
|
live.push(Extent::new(vol.coff(s), (clusters + 2 - s) as u64 * cbytes));
|
||||||
|
}
|
||||||
|
let live = merge_extents(live);
|
||||||
|
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
visited.insert(vol.root);
|
||||||
|
walk(ctx, &vol, &root_data, "", false, 0, &live, &mut visited);
|
||||||
|
res.live = live;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn walk(
|
||||||
|
ctx: &mut Ctx,
|
||||||
|
vol: &Vol,
|
||||||
|
data: &[u8],
|
||||||
|
path: &str,
|
||||||
|
in_deleted: bool,
|
||||||
|
depth: u32,
|
||||||
|
live: &[Extent],
|
||||||
|
visited: &mut HashSet<u32>,
|
||||||
|
) {
|
||||||
|
if depth > 48 || (ctx.cancelled)() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ents: Vec<&[u8]> = data.chunks_exact(32).collect();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < ents.len() {
|
||||||
|
let e = ents[i];
|
||||||
|
if e[0] == 0x00 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let ty = e[0];
|
||||||
|
// 0x85 = arquivo/pasta vivo; 0x05 = apagado.
|
||||||
|
if ty != 0x85 && ty != 0x05 {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let deleted = ty == 0x05;
|
||||||
|
let secondary = e[1] as usize;
|
||||||
|
let attr = u16::from_le_bytes([e[4], e[5]]);
|
||||||
|
let mdate = u16::from_le_bytes([e[14], e[15]]);
|
||||||
|
let mtime = u16::from_le_bytes([e[12], e[13]]);
|
||||||
|
let mod_unix = super::dos_to_unix(mdate, mtime);
|
||||||
|
let mut stream: Option<&[u8]> = None;
|
||||||
|
let mut name_u16: Vec<u8> = vec![];
|
||||||
|
for s in 1..=secondary.min(19) {
|
||||||
|
let Some(se) = ents.get(i + s) else { break };
|
||||||
|
match se[0] & 0x7F {
|
||||||
|
0x40 => stream = Some(se),
|
||||||
|
0x41 => name_u16.extend_from_slice(&se[2..32]),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1 + secondary;
|
||||||
|
let Some(st) = stream else { continue };
|
||||||
|
let nlen = st[3] as usize;
|
||||||
|
let name = {
|
||||||
|
let n = utf16le(&name_u16);
|
||||||
|
n.chars().take(nlen).collect::<String>()
|
||||||
|
};
|
||||||
|
let flags = st[1];
|
||||||
|
let first = u32::from_le_bytes(st[20..24].try_into().unwrap());
|
||||||
|
let size = u64::from_le_bytes(st[24..32].try_into().unwrap());
|
||||||
|
let no_chain = flags & 2 != 0;
|
||||||
|
let is_dir = attr & 0x10 != 0;
|
||||||
|
let child_path = if path.is_empty() {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{path}/{name}")
|
||||||
|
};
|
||||||
|
if !vol.valid(first) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let need = size.div_ceil(vol.cbytes) as u32;
|
||||||
|
let cl: Vec<u32> = if no_chain || deleted {
|
||||||
|
// Apagados: a cadeia na FAT pode ter sido preservada; senão presume contíguo.
|
||||||
|
let chain = if !no_chain { vol.chain(first) } else { vec![] };
|
||||||
|
if chain.len() as u32 >= need.max(1) {
|
||||||
|
chain
|
||||||
|
} else {
|
||||||
|
(first..first.saturating_add(need.max(1)))
|
||||||
|
.filter(|c| vol.valid(*c))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vol.chain(first)
|
||||||
|
};
|
||||||
|
if is_dir {
|
||||||
|
if !visited.insert(first) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let d = vol.read(&cl, 64 << 20);
|
||||||
|
walk(
|
||||||
|
ctx,
|
||||||
|
vol,
|
||||||
|
&d,
|
||||||
|
&child_path,
|
||||||
|
deleted || in_deleted,
|
||||||
|
depth + 1,
|
||||||
|
live,
|
||||||
|
visited,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !(deleted || in_deleted) || size == 0 || (cl.len() as u32) < need {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ex = super::fat::trim_extents(vol.extents(&cl[..need as usize]), size);
|
||||||
|
let reused =
|
||||||
|
!vol.bitmap.is_empty() && cl[..need as usize].iter().any(|&c| vol.allocated(c));
|
||||||
|
let (conf, note) = if reused {
|
||||||
|
(
|
||||||
|
Confidence::Low,
|
||||||
|
Some("clusters já reutilizados por outros arquivos"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Confidence::High, None)
|
||||||
|
};
|
||||||
|
let _ = overlaps(live, 0, 0);
|
||||||
|
let f = make_found(
|
||||||
|
ctx,
|
||||||
|
NewFound {
|
||||||
|
name: sanitize_name(&name),
|
||||||
|
dir: (!path.is_empty()).then(|| path.to_string()),
|
||||||
|
size,
|
||||||
|
origin: Origin::Exfat,
|
||||||
|
data: DataLoc::Extents { extents: ex },
|
||||||
|
modified: mod_unix,
|
||||||
|
base: conf,
|
||||||
|
note,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(ctx.emit)(f);
|
||||||
|
}
|
||||||
|
let _ = vol.base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! ext2/3/4: lê apenas o mapa de blocos livres, para o carving varrer só o
|
||||||
|
//! que está livre. (Inodes apagados de ext4 têm a árvore de extents zerada,
|
||||||
|
//! então a recuperação é por assinatura.)
|
||||||
|
|
||||||
|
use super::{merge_extents, Ctx, VolumeScan};
|
||||||
|
use crate::source::read_vec;
|
||||||
|
use crate::types::Extent;
|
||||||
|
|
||||||
|
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
|
||||||
|
let mut res = VolumeScan::default();
|
||||||
|
let Some(sb) = read_vec(ctx.src, base + 1024, 1024)
|
||||||
|
.ok()
|
||||||
|
.filter(|s| s.len() == 1024)
|
||||||
|
else {
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
let le32 = |o: usize| u32::from_le_bytes(sb[o..o + 4].try_into().unwrap()) as u64;
|
||||||
|
let le16 = |o: usize| u16::from_le_bytes(sb[o..o + 2].try_into().unwrap()) as usize;
|
||||||
|
if le16(56) != 0xEF53 {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
let bs = 1024u64 << le32(24).min(6);
|
||||||
|
let incompat = le32(96);
|
||||||
|
let is64 = incompat & 0x80 != 0;
|
||||||
|
let mut blocks = le32(4);
|
||||||
|
if is64 {
|
||||||
|
blocks |= le32(336) << 32;
|
||||||
|
}
|
||||||
|
let first_data = le32(20);
|
||||||
|
let bpg = le32(32).max(1);
|
||||||
|
let desc_size = if is64 { le16(254).max(64) } else { 32 };
|
||||||
|
let groups = blocks.saturating_sub(first_data).div_ceil(bpg);
|
||||||
|
let gdt_block = first_data + 1;
|
||||||
|
let gdt_len = groups * desc_size as u64;
|
||||||
|
let Some(gdt) = read_vec(ctx.src, base + gdt_block * bs, gdt_len as usize)
|
||||||
|
.ok()
|
||||||
|
.filter(|g| g.len() as u64 == gdt_len)
|
||||||
|
else {
|
||||||
|
res.warnings
|
||||||
|
.push("ext: tabela de grupos ilegível; varrendo a partição inteira.".into());
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
let mut free: Vec<Extent> = vec![];
|
||||||
|
for g in 0..groups {
|
||||||
|
if (ctx.cancelled)() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let d = &gdt[(g * desc_size as u64) as usize..];
|
||||||
|
let mut bb = u32::from_le_bytes(d[0..4].try_into().unwrap()) as u64;
|
||||||
|
if is64 && desc_size >= 64 {
|
||||||
|
bb |= (u32::from_le_bytes(d[0x20..0x24].try_into().unwrap()) as u64) << 32;
|
||||||
|
}
|
||||||
|
let flags = u16::from_le_bytes(d[0x12..0x14].try_into().unwrap());
|
||||||
|
let gstart = first_data + g * bpg;
|
||||||
|
let gblocks = bpg.min(blocks.saturating_sub(gstart));
|
||||||
|
if flags & 0x2 != 0 {
|
||||||
|
// BLOCK_UNINIT: grupo sem bitmap válido; trata como livre.
|
||||||
|
free.push(Extent::new(base + gstart * bs, gblocks * bs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(bm) = read_vec(ctx.src, base + bb * bs, (gblocks.div_ceil(8)) as usize).ok()
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut run: Option<u64> = None;
|
||||||
|
for i in 0..gblocks {
|
||||||
|
let used = bm
|
||||||
|
.get((i / 8) as usize)
|
||||||
|
.map(|b| b >> (i % 8) & 1 == 1)
|
||||||
|
.unwrap_or(true);
|
||||||
|
match (used, run) {
|
||||||
|
(false, None) => run = Some(i),
|
||||||
|
(true, Some(s)) => {
|
||||||
|
free.push(Extent::new(base + (gstart + s) * bs, (i - s) * bs));
|
||||||
|
run = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = run {
|
||||||
|
free.push(Extent::new(base + (gstart + s) * bs, (gblocks - s) * bs));
|
||||||
|
}
|
||||||
|
if g % 64 == 0 {
|
||||||
|
(ctx.progress)(g, groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = part_len;
|
||||||
|
res.free = Some(merge_extents(free));
|
||||||
|
res
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
//! FAT12/16/32: entradas de diretório apagadas (inclusive nomes longos) e
|
||||||
|
//! reconstrução dos clusters pela regra "próximos clusters livres".
|
||||||
|
|
||||||
|
use super::{make_found, merge_extents, sanitize_name, utf16le, Ctx, NewFound, VolumeScan};
|
||||||
|
use crate::source::{read_vec, Source};
|
||||||
|
use crate::types::{Confidence, DataLoc, Extent, FsKind, Origin};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
struct Fat<'a> {
|
||||||
|
src: &'a dyn Source,
|
||||||
|
kind: FsKind,
|
||||||
|
bps: u64,
|
||||||
|
spc: u64,
|
||||||
|
fat_off: u64,
|
||||||
|
root_off: u64,
|
||||||
|
root_len: u64,
|
||||||
|
root_cluster: u32,
|
||||||
|
data_off: u64,
|
||||||
|
clusters: u32,
|
||||||
|
cache: RefCell<(u64, Vec<u8>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE: u64 = 256 * 1024;
|
||||||
|
|
||||||
|
impl<'a> Fat<'a> {
|
||||||
|
fn open(src: &'a dyn Source, base: u64, kind: FsKind) -> Option<Self> {
|
||||||
|
let b = read_vec(src, base, 512).ok()?;
|
||||||
|
if b.len() < 512 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bps = u16::from_le_bytes([b[11], b[12]]) as u64;
|
||||||
|
let spc = b[13] as u64;
|
||||||
|
let reserved = u16::from_le_bytes([b[14], b[15]]) as u64;
|
||||||
|
let nfats = b[16] as u64;
|
||||||
|
let root_entries = u16::from_le_bytes([b[17], b[18]]) as u64;
|
||||||
|
let ts16 = u16::from_le_bytes([b[19], b[20]]) as u64;
|
||||||
|
let fat16 = u16::from_le_bytes([b[22], b[23]]) as u64;
|
||||||
|
let ts32 = u32::from_le_bytes(b[32..36].try_into().unwrap()) as u64;
|
||||||
|
let fat32 = u32::from_le_bytes(b[36..40].try_into().unwrap()) as u64;
|
||||||
|
let root_cluster = u32::from_le_bytes(b[44..48].try_into().unwrap());
|
||||||
|
let total = if ts16 != 0 { ts16 } else { ts32 };
|
||||||
|
let fatsz = if fat16 != 0 { fat16 } else { fat32 };
|
||||||
|
if bps == 0 || spc == 0 || fatsz == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let root_len = root_entries * 32;
|
||||||
|
let root_secs = root_len.div_ceil(bps);
|
||||||
|
let data_sec = reserved + nfats * fatsz + root_secs;
|
||||||
|
let clusters = (total.saturating_sub(data_sec) / spc) as u32;
|
||||||
|
Some(Self {
|
||||||
|
src,
|
||||||
|
kind,
|
||||||
|
bps,
|
||||||
|
spc,
|
||||||
|
fat_off: base + reserved * bps,
|
||||||
|
root_off: base + (reserved + nfats * fatsz) * bps,
|
||||||
|
root_len,
|
||||||
|
root_cluster,
|
||||||
|
data_off: base + data_sec * bps,
|
||||||
|
clusters,
|
||||||
|
cache: RefCell::new((u64::MAX, Vec::new())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster_bytes(&self) -> u64 {
|
||||||
|
self.bps * self.spc
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster_off(&self, c: u32) -> u64 {
|
||||||
|
self.data_off + (c as u64 - 2) * self.cluster_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid(&self, c: u32) -> bool {
|
||||||
|
c >= 2 && (c as u64) < self.clusters as u64 + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fat_bytes(&self, off: u64, n: usize) -> Option<Vec<u8>> {
|
||||||
|
let page = off / PAGE;
|
||||||
|
let mut cache = self.cache.borrow_mut();
|
||||||
|
if cache.0 != page {
|
||||||
|
let v = read_vec(self.src, self.fat_off + page * PAGE, PAGE as usize).ok()?;
|
||||||
|
*cache = (page, v);
|
||||||
|
}
|
||||||
|
let s = (off - page * PAGE) as usize;
|
||||||
|
if s + n <= cache.1.len() {
|
||||||
|
return Some(cache.1[s..s + n].to_vec());
|
||||||
|
}
|
||||||
|
drop(cache);
|
||||||
|
read_vec(self.src, self.fat_off + off, n)
|
||||||
|
.ok()
|
||||||
|
.filter(|v| v.len() == n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(&self, c: u32) -> Option<u32> {
|
||||||
|
match self.kind {
|
||||||
|
FsKind::Fat32 => {
|
||||||
|
let v = self.fat_bytes(c as u64 * 4, 4)?;
|
||||||
|
Some(u32::from_le_bytes(v.try_into().unwrap()) & 0x0FFF_FFFF)
|
||||||
|
}
|
||||||
|
FsKind::Fat16 => {
|
||||||
|
let v = self.fat_bytes(c as u64 * 2, 2)?;
|
||||||
|
Some(u16::from_le_bytes(v.try_into().unwrap()) as u32)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let v = self.fat_bytes(c as u64 * 3 / 2, 2)?;
|
||||||
|
let raw = u16::from_le_bytes(v.try_into().unwrap());
|
||||||
|
Some(if c % 2 == 0 { raw & 0x0FFF } else { raw >> 4 } as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_eoc(&self, v: u32) -> bool {
|
||||||
|
match self.kind {
|
||||||
|
FsKind::Fat32 => v >= 0x0FFF_FFF8,
|
||||||
|
FsKind::Fat16 => v >= 0xFFF8,
|
||||||
|
_ => v >= 0xFF8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cadeia de clusters viva a partir de `start` (com limite contra ciclos).
|
||||||
|
fn chain(&self, start: u32) -> Vec<u32> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut c = start;
|
||||||
|
let mut seen = 0u64;
|
||||||
|
while self.valid(c) && seen <= self.clusters as u64 {
|
||||||
|
out.push(c);
|
||||||
|
seen += 1;
|
||||||
|
match self.entry(c) {
|
||||||
|
Some(n) if !self.is_eoc(n) && n != 0 => c = n,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clusters_to_extents(&self, cl: &[u32]) -> Vec<Extent> {
|
||||||
|
let cb = self.cluster_bytes();
|
||||||
|
merge_extents(
|
||||||
|
cl.iter()
|
||||||
|
.map(|&c| Extent::new(self.cluster_off(c), cb))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_clusters(&self, cl: &[u32], limit: usize) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for &c in cl {
|
||||||
|
if let Ok(v) = read_vec(self.src, self.cluster_off(c), self.cluster_bytes() as usize) {
|
||||||
|
out.extend_from_slice(&v);
|
||||||
|
}
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Walk<'f, 'a, 'c, 'x> {
|
||||||
|
fat: &'f Fat<'a>,
|
||||||
|
ctx: &'c mut Ctx<'x>,
|
||||||
|
live: Vec<Extent>,
|
||||||
|
visited: HashSet<u32>,
|
||||||
|
count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan(ctx: &mut Ctx, base: u64, kind: FsKind) -> VolumeScan {
|
||||||
|
let mut res = VolumeScan::default();
|
||||||
|
let Some(fat) = Fat::open(ctx.src, base, kind) else {
|
||||||
|
res.warnings
|
||||||
|
.push("FAT: setor de boot ilegível; usando apenas assinatura.".into());
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
// Estruturas do sistema (reservado + FATs + raiz fixa) são "vivas".
|
||||||
|
let mut live = vec![Extent::new(base, fat.data_off - base)];
|
||||||
|
let mut w = Walk {
|
||||||
|
fat: &fat,
|
||||||
|
ctx,
|
||||||
|
live: vec![],
|
||||||
|
visited: HashSet::new(),
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
if kind == FsKind::Fat32 {
|
||||||
|
let root = fat.chain(fat.root_cluster);
|
||||||
|
w.live.extend(fat.clusters_to_extents(&root));
|
||||||
|
let data = fat.read_clusters(&root, 64 << 20);
|
||||||
|
w.dir(&data, "", false, 0);
|
||||||
|
} else {
|
||||||
|
let data = read_vec(fat.src, fat.root_off, fat.root_len as usize).unwrap_or_default();
|
||||||
|
w.dir(&data, "", false, 0);
|
||||||
|
}
|
||||||
|
live.extend(w.live);
|
||||||
|
res.live = merge_extents(live);
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Walk<'_, '_, '_, '_> {
|
||||||
|
fn dir(&mut self, data: &[u8], path: &str, in_deleted: bool, depth: u32) {
|
||||||
|
if depth > 48 || (self.ctx.cancelled)() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut lfn: Vec<(u8, [u16; 13])> = vec![];
|
||||||
|
for e in data.chunks_exact(32) {
|
||||||
|
if e[0] == 0x00 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let attr = e[11];
|
||||||
|
if attr & 0x3F == 0x0F {
|
||||||
|
let mut ch = [0u16; 13];
|
||||||
|
let idx = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
|
||||||
|
for (k, &o) in idx.iter().enumerate() {
|
||||||
|
ch[k] = u16::from_le_bytes([e[o], e[o + 1]]);
|
||||||
|
}
|
||||||
|
lfn.push((e[13], ch));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts = std::mem::take(&mut lfn);
|
||||||
|
if attr & 0x08 != 0 || e[0] == b'.' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let deleted = e[0] == 0xE5;
|
||||||
|
let is_dir = attr & 0x10 != 0;
|
||||||
|
let mut short = [0u8; 11];
|
||||||
|
short.copy_from_slice(&e[0..11]);
|
||||||
|
let name =
|
||||||
|
long_name(&parts, &short).unwrap_or_else(|| short_name(&short, e[12], deleted));
|
||||||
|
let hi = u16::from_le_bytes([e[20], e[21]]) as u32;
|
||||||
|
let lo = u16::from_le_bytes([e[26], e[27]]) as u32;
|
||||||
|
let start = if self.fat.kind == FsKind::Fat32 {
|
||||||
|
(hi << 16) | lo
|
||||||
|
} else {
|
||||||
|
lo
|
||||||
|
};
|
||||||
|
let size = u32::from_le_bytes(e[28..32].try_into().unwrap()) as u64;
|
||||||
|
let mtime = super::dos_to_unix(
|
||||||
|
u16::from_le_bytes([e[24], e[25]]),
|
||||||
|
u16::from_le_bytes([e[22], e[23]]),
|
||||||
|
);
|
||||||
|
self.count += 1;
|
||||||
|
if self.count % 512 == 0 {
|
||||||
|
(self.ctx.progress)(self.count, 0);
|
||||||
|
}
|
||||||
|
let child_path = if path.is_empty() {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{path}/{name}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_dir {
|
||||||
|
if !self.fat.valid(start) || !self.visited.insert(start) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if deleted || in_deleted {
|
||||||
|
// Diretório apagado: lê clusters contíguos livres enquanto parecerem entradas.
|
||||||
|
let cl = self.contiguous_free(start, 16);
|
||||||
|
let data = self
|
||||||
|
.fat
|
||||||
|
.read_clusters(&cl, 16 * self.fat.cluster_bytes() as usize);
|
||||||
|
self.dir(&data, &child_path, true, depth + 1);
|
||||||
|
} else {
|
||||||
|
let cl = self.fat.chain(start);
|
||||||
|
self.live.extend(self.fat.clusters_to_extents(&cl));
|
||||||
|
let data = self.fat.read_clusters(&cl, 64 << 20);
|
||||||
|
self.dir(&data, &child_path, false, depth + 1);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !deleted && !in_deleted {
|
||||||
|
if self.fat.valid(start) && size > 0 {
|
||||||
|
let cl = self.fat.chain(start);
|
||||||
|
self.live.extend(self.fat.clusters_to_extents(&cl));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if size == 0 || !self.fat.valid(start) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.deleted_file(&name, path, start, size, mtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contiguous_free(&self, start: u32, max: usize) -> Vec<u32> {
|
||||||
|
let mut v = vec![];
|
||||||
|
let mut c = start;
|
||||||
|
while v.len() < max && self.fat.valid(c) && (v.is_empty() || self.fat.entry(c) == Some(0)) {
|
||||||
|
v.push(c);
|
||||||
|
c += 1;
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deleted_file(&mut self, name: &str, dir: &str, start: u32, size: u64, mtime: Option<i64>) {
|
||||||
|
let fat = self.fat;
|
||||||
|
let cb = fat.cluster_bytes();
|
||||||
|
let need = size.div_ceil(cb);
|
||||||
|
if need > fat.clusters as u64 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let start_reused = fat.entry(start).map(|v| v != 0).unwrap_or(true);
|
||||||
|
let mut cl = Vec::with_capacity(need as usize);
|
||||||
|
let mut c = start;
|
||||||
|
let mut skipped = false;
|
||||||
|
while (cl.len() as u64) < need && fat.valid(c) {
|
||||||
|
let free = fat.entry(c) == Some(0);
|
||||||
|
if free || (cl.is_empty() && start_reused) {
|
||||||
|
cl.push(c);
|
||||||
|
} else {
|
||||||
|
skipped = true;
|
||||||
|
}
|
||||||
|
c += 1;
|
||||||
|
}
|
||||||
|
if (cl.len() as u64) < need {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (conf, note) = if start_reused {
|
||||||
|
(
|
||||||
|
Confidence::Low,
|
||||||
|
Some("cluster inicial já reutilizado por outro arquivo"),
|
||||||
|
)
|
||||||
|
} else if skipped {
|
||||||
|
(
|
||||||
|
Confidence::Medium,
|
||||||
|
Some("arquivo possivelmente fragmentado: ordem dos clusters presumida"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Confidence::High, None)
|
||||||
|
};
|
||||||
|
let extents = fat.clusters_to_extents(&cl);
|
||||||
|
let nf = NewFound {
|
||||||
|
name: sanitize_name(name),
|
||||||
|
dir: (!dir.is_empty()).then(|| dir.to_string()),
|
||||||
|
size,
|
||||||
|
origin: Origin::Fat,
|
||||||
|
data: DataLoc::Extents {
|
||||||
|
extents: trim_extents(extents, size),
|
||||||
|
},
|
||||||
|
modified: mtime,
|
||||||
|
base: conf,
|
||||||
|
note,
|
||||||
|
};
|
||||||
|
let f = make_found(self.ctx, nf);
|
||||||
|
(self.ctx.emit)(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corta a lista de extents para exatamente `size` bytes.
|
||||||
|
pub fn trim_extents(mut ex: Vec<Extent>, size: u64) -> Vec<Extent> {
|
||||||
|
let mut left = size;
|
||||||
|
let mut out = vec![];
|
||||||
|
for e in ex.drain(..) {
|
||||||
|
if left == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let take = e.len.min(left);
|
||||||
|
out.push(Extent {
|
||||||
|
offset: e.offset,
|
||||||
|
len: take,
|
||||||
|
sparse: e.sparse,
|
||||||
|
});
|
||||||
|
left -= take;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short_name(raw: &[u8; 11], case: u8, deleted: bool) -> String {
|
||||||
|
let mut base: Vec<u8> = raw[0..8].to_vec();
|
||||||
|
if deleted {
|
||||||
|
base[0] = b'_';
|
||||||
|
} else if base[0] == 0x05 {
|
||||||
|
base[0] = 0xE5;
|
||||||
|
}
|
||||||
|
let ext = &raw[8..11];
|
||||||
|
let f = |b: &[u8], lower: bool| -> String {
|
||||||
|
let s: String = b
|
||||||
|
.iter()
|
||||||
|
.map(|&c| c as char)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end()
|
||||||
|
.to_string();
|
||||||
|
if lower {
|
||||||
|
s.to_lowercase()
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let b = f(&base, case & 0x08 != 0);
|
||||||
|
let e = f(ext, case & 0x10 != 0);
|
||||||
|
if e.is_empty() {
|
||||||
|
b
|
||||||
|
} else {
|
||||||
|
format!("{b}.{e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn long_name(parts: &[(u8, [u16; 13])], short: &[u8; 11]) -> Option<String> {
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut sum = 0u8;
|
||||||
|
for &b in short {
|
||||||
|
sum = (sum >> 1).wrapping_add((sum & 1) << 7).wrapping_add(b);
|
||||||
|
}
|
||||||
|
// Entradas LFN de um arquivo apagado preservam o checksum do nome curto original,
|
||||||
|
// porém o primeiro byte do nome curto virou 0xE5; tentamos as duas variantes.
|
||||||
|
let ok = parts.iter().all(|p| p.0 == parts[0].0);
|
||||||
|
if !ok {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = parts[0].0;
|
||||||
|
if sum != target {
|
||||||
|
// Nome curto apagado perdeu o 1º caractere: aceita se o resto bater por tentativa.
|
||||||
|
let mut hit = false;
|
||||||
|
for c in 0u8..=255 {
|
||||||
|
let mut s2 = *short;
|
||||||
|
s2[0] = c;
|
||||||
|
let mut s = 0u8;
|
||||||
|
for &b in &s2 {
|
||||||
|
s = (s >> 1).wrapping_add((s & 1) << 7).wrapping_add(b);
|
||||||
|
}
|
||||||
|
if s == target {
|
||||||
|
hit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hit {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut u: Vec<u16> = vec![];
|
||||||
|
for (_, ch) in parts.iter().rev() {
|
||||||
|
u.extend_from_slice(ch);
|
||||||
|
}
|
||||||
|
let end = u
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0 || c == 0xFFFF)
|
||||||
|
.unwrap_or(u.len());
|
||||||
|
let raw: Vec<u8> = u[..end].iter().flat_map(|c| c.to_le_bytes()).collect();
|
||||||
|
let s = utf16le(&raw);
|
||||||
|
(!s.is_empty()).then_some(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! Leitores de metadados de sistemas de arquivos (somente leitura).
|
||||||
|
//!
|
||||||
|
//! Cada leitor percorre estruturas do volume atrás de arquivos apagados,
|
||||||
|
//! devolve os extents ainda alocados ("live") para o carving pular e, quando
|
||||||
|
//! possível, os trechos livres.
|
||||||
|
|
||||||
|
pub mod exfat;
|
||||||
|
pub mod ext;
|
||||||
|
pub mod fat;
|
||||||
|
pub mod ntfs;
|
||||||
|
|
||||||
|
use crate::signatures::{category_for_ext, matches_ext};
|
||||||
|
use crate::source::{read_vec, Source};
|
||||||
|
use crate::types::{Confidence, DataLoc, Extent, Found, Origin};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
/// Contexto compartilhado por todos os leitores.
|
||||||
|
pub struct Ctx<'a> {
|
||||||
|
pub src: &'a dyn Source,
|
||||||
|
pub next_id: &'a AtomicU64,
|
||||||
|
pub cancelled: &'a (dyn Fn() -> bool + Sync),
|
||||||
|
/// (feito, total) dentro da fase atual do leitor.
|
||||||
|
pub progress: &'a (dyn Fn(u64, u64) + Sync),
|
||||||
|
pub emit: &'a mut dyn FnMut(Found),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ctx<'_> {
|
||||||
|
pub fn id(&self) -> u64 {
|
||||||
|
self.next_id.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado de varrer um volume.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct VolumeScan {
|
||||||
|
/// Extents (absolutos) ocupados por arquivos existentes e metadados.
|
||||||
|
pub live: Vec<Extent>,
|
||||||
|
/// Se o leitor conhece o mapa de espaço livre (ext), os trechos livres (absolutos).
|
||||||
|
pub free: Option<Vec<Extent>>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NewFound<'a> {
|
||||||
|
pub name: String,
|
||||||
|
pub dir: Option<String>,
|
||||||
|
pub size: u64,
|
||||||
|
pub origin: Origin,
|
||||||
|
pub data: DataLoc,
|
||||||
|
pub modified: Option<i64>,
|
||||||
|
pub base: Confidence,
|
||||||
|
pub note: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monta um `Found`, ajustando a confiança pelo conteúdo real no disco.
|
||||||
|
pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
|
||||||
|
let ext = n
|
||||||
|
.name
|
||||||
|
.rsplit_once('.')
|
||||||
|
.map(|(_, e)| e.to_ascii_lowercase())
|
||||||
|
.filter(|e| e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut conf = n.base;
|
||||||
|
let mut note = n.note.map(String::from);
|
||||||
|
let head: Vec<u8> = match &n.data {
|
||||||
|
DataLoc::Resident { bytes } => bytes.iter().take(32).copied().collect(),
|
||||||
|
DataLoc::Extents { extents } => extents
|
||||||
|
.iter()
|
||||||
|
.find(|e| !e.sparse)
|
||||||
|
.and_then(|e| read_vec(ctx.src, e.offset, 32.min(e.len as usize)).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
if n.size > 0
|
||||||
|
&& !head.is_empty()
|
||||||
|
&& head.iter().all(|&b| b == 0)
|
||||||
|
&& !matches!(n.data, DataLoc::Resident { .. })
|
||||||
|
{
|
||||||
|
conf = Confidence::Low;
|
||||||
|
note = Some("início do arquivo zerado (apagado/TRIM ou sobrescrito)".into());
|
||||||
|
} else {
|
||||||
|
match matches_ext(&ext, &head) {
|
||||||
|
Some(false) => {
|
||||||
|
conf = Confidence::Low;
|
||||||
|
note =
|
||||||
|
Some("conteúdo não confere com a extensão: provavelmente sobrescrito".into());
|
||||||
|
}
|
||||||
|
Some(true) => {}
|
||||||
|
None => conf = conf.min(Confidence::Medium),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Found {
|
||||||
|
id: ctx.id(),
|
||||||
|
category: category_for_ext(&ext),
|
||||||
|
ext,
|
||||||
|
name: n.name,
|
||||||
|
dir: n.dir,
|
||||||
|
size: n.size,
|
||||||
|
origin: n.origin,
|
||||||
|
confidence: conf,
|
||||||
|
data: n.data,
|
||||||
|
modified: n.modified,
|
||||||
|
note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordena e funde extents adjacentes/sobrepostos.
|
||||||
|
pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
|
||||||
|
v.retain(|e| e.len > 0);
|
||||||
|
v.sort_by_key(|e| e.offset);
|
||||||
|
let mut out: Vec<Extent> = Vec::with_capacity(v.len());
|
||||||
|
for e in v {
|
||||||
|
if let Some(last) = out.last_mut() {
|
||||||
|
if e.offset <= last.end() {
|
||||||
|
if e.end() > last.end() {
|
||||||
|
last.len = e.end() - last.offset;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(e);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Há sobreposição entre [off, off+len) e algum extent de `sorted` (já mesclado)?
|
||||||
|
pub fn overlaps(sorted: &[Extent], off: u64, len: u64) -> bool {
|
||||||
|
let i = sorted.partition_point(|e| e.end() <= off);
|
||||||
|
sorted.get(i).map(|e| e.offset < off + len).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dos_to_unix(date: u16, time: u16) -> Option<i64> {
|
||||||
|
if date == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let year = 1980 + (date >> 9) as i64;
|
||||||
|
let month = ((date >> 5) & 0xF) as i64;
|
||||||
|
let day = (date & 0x1F) as i64;
|
||||||
|
if !(1..=12).contains(&month) || day == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (h, m, s) = (
|
||||||
|
(time >> 11) as i64,
|
||||||
|
((time >> 5) & 0x3F) as i64,
|
||||||
|
((time & 0x1F) * 2) as i64,
|
||||||
|
);
|
||||||
|
Some(days_from_civil(year, month, day) * 86400 + h * 3600 + m * 60 + s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dias desde 1970-01-01 (algoritmo de Howard Hinnant).
|
||||||
|
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||||
|
let y = if m <= 2 { y - 1 } else { y };
|
||||||
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||||
|
let yoe = y - era * 400;
|
||||||
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
era * 146097 + doe - 719468
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn utf16le(bytes: &[u8]) -> String {
|
||||||
|
let u: Vec<u16> = bytes
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
|
String::from_utf16_lossy(&u)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_name(s: &str) -> String {
|
||||||
|
let t: String = s
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
|
||||||
|
'_'
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let t = t.trim().trim_matches('.').to_string();
|
||||||
|
if t.is_empty() {
|
||||||
|
"arquivo".into()
|
||||||
|
} else {
|
||||||
|
t
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
//! NTFS: percorre a MFT atrás de registros apagados, resolve caminhos e
|
||||||
|
//! reconstrói os data runs. Coleta também os extents vivos.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
|
||||||
|
};
|
||||||
|
use crate::source::{read_vec, Source};
|
||||||
|
use crate::types::{Confidence, DataLoc, Extent, Origin};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
struct Run {
|
||||||
|
/// Cluster lógico inicial no arquivo (VCN).
|
||||||
|
vcn: u64,
|
||||||
|
len: u64,
|
||||||
|
/// None = esparso.
|
||||||
|
lcn: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Rec {
|
||||||
|
in_use: bool,
|
||||||
|
is_dir: bool,
|
||||||
|
parent: u64,
|
||||||
|
name: Option<(String, u8)>,
|
||||||
|
mtime: Option<i64>,
|
||||||
|
data: Option<Data>,
|
||||||
|
has_attr_list: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Data {
|
||||||
|
Resident(Vec<u8>),
|
||||||
|
NonResident {
|
||||||
|
runs: Vec<Run>,
|
||||||
|
real: u64,
|
||||||
|
unsupported: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
|
||||||
|
let mut res = VolumeScan::default();
|
||||||
|
let Some(boot) = read_vec(ctx.src, base, 512).ok().filter(|b| b.len() == 512) else {
|
||||||
|
res.warnings.push("NTFS: setor de boot ilegível.".into());
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
let bps = u16::from_le_bytes([boot[11], boot[12]]) as u64;
|
||||||
|
let spc = boot[13] as u64;
|
||||||
|
let cl = bps * spc;
|
||||||
|
let total_secs = u64::from_le_bytes(boot[40..48].try_into().unwrap());
|
||||||
|
let mft_lcn = u64::from_le_bytes(boot[48..56].try_into().unwrap());
|
||||||
|
let mirr_lcn = u64::from_le_bytes(boot[56..64].try_into().unwrap());
|
||||||
|
let rs = boot[64] as i8;
|
||||||
|
let rec_size = if rs > 0 {
|
||||||
|
rs as u64 * cl
|
||||||
|
} else {
|
||||||
|
1u64 << (-(rs as i32))
|
||||||
|
};
|
||||||
|
if cl == 0 || !(256..=65536).contains(&rec_size) {
|
||||||
|
res.warnings
|
||||||
|
.push("NTFS: parâmetros de boot inválidos.".into());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registro 0 ($MFT) define onde está toda a MFT.
|
||||||
|
let mut mft_runs: Option<(Vec<Run>, u64)> = None;
|
||||||
|
for lcn in [mft_lcn, mirr_lcn] {
|
||||||
|
if let Some(mut r) = read_vec(ctx.src, base + lcn * cl, rec_size as usize)
|
||||||
|
.ok()
|
||||||
|
.filter(|r| r.len() as u64 == rec_size)
|
||||||
|
{
|
||||||
|
if let Some(rec) = parse_record(&mut r, bps, base, cl) {
|
||||||
|
if let Some(Data::NonResident { runs, real, .. }) = rec.data {
|
||||||
|
mft_runs = Some((runs, real));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.warnings.push(
|
||||||
|
"NTFS: $MFT principal corrompida, tentando o espelho (só cobre registros iniciais)."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some((runs, mft_len)) = mft_runs else {
|
||||||
|
res.warnings
|
||||||
|
.push("NTFS: não foi possível ler a MFT; só varredura por assinatura.".into());
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut live: Vec<Extent> = vec![Extent::new(base, 0)];
|
||||||
|
for r in &runs {
|
||||||
|
if let Some(l) = r.lcn {
|
||||||
|
live.push(Extent::new(base + l * cl, r.len * cl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n_records = mft_len / rec_size;
|
||||||
|
let mut dirs: HashMap<u64, (u64, String)> = HashMap::new();
|
||||||
|
let mut deleted: Vec<(u64, Rec)> = vec![];
|
||||||
|
|
||||||
|
// Lê a MFT em blocos grandes seguindo os runs.
|
||||||
|
let chunk_recs = (1 << 20) / rec_size.max(1);
|
||||||
|
let mut idx = 0u64;
|
||||||
|
let mut buf: Vec<u8>;
|
||||||
|
while idx < n_records {
|
||||||
|
if (ctx.cancelled)() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nrec = chunk_recs.min(n_records - idx);
|
||||||
|
buf = read_mft_range(ctx.src, base, cl, &runs, idx * rec_size, nrec * rec_size);
|
||||||
|
for k in 0..nrec {
|
||||||
|
let s = (k * rec_size) as usize;
|
||||||
|
let e = s + rec_size as usize;
|
||||||
|
if e > buf.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let rn = idx + k;
|
||||||
|
let rec_buf = &mut buf[s..e];
|
||||||
|
let Some(rec) = parse_record(rec_buf, bps, base, cl) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if rec.in_use {
|
||||||
|
if rec.is_dir {
|
||||||
|
if let Some((n, _)) = &rec.name {
|
||||||
|
dirs.insert(rn, (rec.parent, n.clone()));
|
||||||
|
}
|
||||||
|
} else if let Some(Data::NonResident { runs, .. }) = &rec.data {
|
||||||
|
for r in runs {
|
||||||
|
if let Some(l) = r.lcn {
|
||||||
|
live.push(Extent::new(base + l * cl, r.len * cl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if rec.is_dir {
|
||||||
|
if let Some((n, _)) = &rec.name {
|
||||||
|
dirs.entry(rn).or_insert((rec.parent, n.clone()));
|
||||||
|
}
|
||||||
|
} else if rec.name.is_some() && rec.data.is_some() {
|
||||||
|
deleted.push((rn, rec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx += nrec;
|
||||||
|
(ctx.progress)(idx, n_records);
|
||||||
|
}
|
||||||
|
let live = merge_extents(live);
|
||||||
|
let _ = total_secs;
|
||||||
|
|
||||||
|
for (rn, rec) in deleted {
|
||||||
|
if (ctx.cancelled)() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let name = rec.name.as_ref().map(|n| n.0.clone()).unwrap_or_default();
|
||||||
|
let dir = resolve_path(&dirs, rec.parent, rn);
|
||||||
|
let (data, size, conf, note): (DataLoc, u64, Confidence, Option<&str>) =
|
||||||
|
match rec.data.unwrap() {
|
||||||
|
Data::Resident(b) => {
|
||||||
|
let n = b.len() as u64;
|
||||||
|
(DataLoc::Resident { bytes: b }, n, Confidence::High, None)
|
||||||
|
}
|
||||||
|
Data::NonResident {
|
||||||
|
runs,
|
||||||
|
real,
|
||||||
|
unsupported,
|
||||||
|
} => {
|
||||||
|
if unsupported {
|
||||||
|
continue; // comprimido/cifrado (EFS): fora do escopo da v1
|
||||||
|
}
|
||||||
|
let mut ex = vec![];
|
||||||
|
for r in &runs {
|
||||||
|
match r.lcn {
|
||||||
|
Some(l) => ex.push(Extent::new(base + l * cl, r.len * cl)),
|
||||||
|
None => ex.push(Extent {
|
||||||
|
offset: 0,
|
||||||
|
len: r.len * cl,
|
||||||
|
sparse: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ex = super::fat::trim_extents(ex, real);
|
||||||
|
let reused = ex
|
||||||
|
.iter()
|
||||||
|
.any(|e| !e.sparse && overlaps(&live, e.offset, e.len));
|
||||||
|
let (c, n) = if reused {
|
||||||
|
(
|
||||||
|
Confidence::Low,
|
||||||
|
Some("clusters já reutilizados por outros arquivos"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Confidence::High, None)
|
||||||
|
};
|
||||||
|
(DataLoc::Extents { extents: ex }, real, c, n)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if size == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let f = make_found(
|
||||||
|
ctx,
|
||||||
|
NewFound {
|
||||||
|
name: sanitize_name(&name),
|
||||||
|
dir,
|
||||||
|
size,
|
||||||
|
origin: Origin::Ntfs,
|
||||||
|
data,
|
||||||
|
modified: rec.mtime,
|
||||||
|
base: conf,
|
||||||
|
note: if rec.has_attr_list {
|
||||||
|
Some("registro com lista de atributos: pode estar incompleto")
|
||||||
|
} else {
|
||||||
|
note
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut f = f;
|
||||||
|
if rec.has_attr_list {
|
||||||
|
f.confidence = f.confidence.min(Confidence::Medium);
|
||||||
|
}
|
||||||
|
(ctx.emit)(f);
|
||||||
|
}
|
||||||
|
res.live = live;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(dirs: &HashMap<u64, (u64, String)>, mut parent: u64, own: u64) -> Option<String> {
|
||||||
|
let mut parts = vec![];
|
||||||
|
let mut guard = 0;
|
||||||
|
while parent != 5 && guard < 64 {
|
||||||
|
match dirs.get(&parent) {
|
||||||
|
Some((p, n)) => {
|
||||||
|
parts.push(n.clone());
|
||||||
|
if *p == parent {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parent = *p;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
parts.push(format!("[pasta perdida {parent}]"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
let _ = own;
|
||||||
|
parts.reverse();
|
||||||
|
let s = parts.join("/");
|
||||||
|
(!s.is_empty()).then_some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_mft_range(
|
||||||
|
src: &dyn Source,
|
||||||
|
base: u64,
|
||||||
|
cl: u64,
|
||||||
|
runs: &[Run],
|
||||||
|
mut off: u64,
|
||||||
|
mut len: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(len as usize);
|
||||||
|
for r in runs {
|
||||||
|
if len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let rs = r.vcn * cl;
|
||||||
|
let re = rs + r.len * cl;
|
||||||
|
if off >= re {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let within = off - rs.min(off);
|
||||||
|
let take = (re - off).min(len);
|
||||||
|
match r.lcn {
|
||||||
|
Some(l) => {
|
||||||
|
let v = read_vec(src, base + l * cl + within, take as usize).unwrap_or_default();
|
||||||
|
let got = v.len();
|
||||||
|
out.extend_from_slice(&v);
|
||||||
|
out.resize(out.len() + (take as usize - got), 0);
|
||||||
|
}
|
||||||
|
None => out.resize(out.len() + take as usize, 0),
|
||||||
|
}
|
||||||
|
off += take;
|
||||||
|
len -= take;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn le16(b: &[u8], o: usize) -> u16 {
|
||||||
|
u16::from_le_bytes([b[o], b[o + 1]])
|
||||||
|
}
|
||||||
|
fn le32(b: &[u8], o: usize) -> u32 {
|
||||||
|
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||||
|
}
|
||||||
|
fn le64(b: &[u8], o: usize) -> u64 {
|
||||||
|
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interpreta um registro de arquivo da MFT (aplica fixups no próprio buffer).
|
||||||
|
fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
|
||||||
|
if r.len() < 48 || &r[0..4] != b"FILE" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let usa_off = le16(r, 4) as usize;
|
||||||
|
let usa_n = le16(r, 6) as usize;
|
||||||
|
if usa_n < 2 || usa_off + usa_n * 2 > r.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let usn = [r[usa_off], r[usa_off + 1]];
|
||||||
|
for i in 1..usa_n {
|
||||||
|
let end = i * bps as usize;
|
||||||
|
if end > r.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if r[end - 2..end] != usn {
|
||||||
|
return None; // registro rasgado
|
||||||
|
}
|
||||||
|
r[end - 2] = r[usa_off + i * 2];
|
||||||
|
r[end - 1] = r[usa_off + i * 2 + 1];
|
||||||
|
}
|
||||||
|
let flags = le16(r, 22);
|
||||||
|
let base_ref = le64(r, 32) & 0xFFFF_FFFF_FFFF;
|
||||||
|
if base_ref != 0 {
|
||||||
|
return None; // registro de extensão: tratado só via lista de atributos (não suportado)
|
||||||
|
}
|
||||||
|
let mut rec = Rec {
|
||||||
|
in_use: flags & 1 != 0,
|
||||||
|
is_dir: flags & 2 != 0,
|
||||||
|
parent: 0,
|
||||||
|
name: None,
|
||||||
|
mtime: None,
|
||||||
|
data: None,
|
||||||
|
has_attr_list: false,
|
||||||
|
};
|
||||||
|
let mut p = le16(r, 20) as usize;
|
||||||
|
let used = (le32(r, 24) as usize).min(r.len());
|
||||||
|
while p + 16 <= used {
|
||||||
|
let ty = le32(r, p);
|
||||||
|
if ty == 0xFFFF_FFFF {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let alen = le32(r, p + 4) as usize;
|
||||||
|
if alen < 16 || p + alen > r.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nonres = r[p + 8] != 0;
|
||||||
|
let name_len = r[p + 9];
|
||||||
|
let aflags = le16(r, p + 12);
|
||||||
|
match ty {
|
||||||
|
0x10 if !nonres => {
|
||||||
|
let vo = le16(r, p + 20) as usize;
|
||||||
|
if p + vo + 16 <= r.len() {
|
||||||
|
rec.mtime = filetime_to_unix(le64(r, p + vo + 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x20 => rec.has_attr_list = true,
|
||||||
|
0x30 if !nonres => {
|
||||||
|
let vo = p + le16(r, p + 20) as usize;
|
||||||
|
if vo + 0x42 <= r.len() {
|
||||||
|
let ns = r[vo + 0x41];
|
||||||
|
let nl = r[vo + 0x40] as usize;
|
||||||
|
if vo + 0x42 + nl * 2 <= r.len() && ns != 2 {
|
||||||
|
let name = utf16le(&r[vo + 0x42..vo + 0x42 + nl * 2]);
|
||||||
|
let better = rec
|
||||||
|
.name
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, old)| ns == 1 || ns == 3 || *old == 2)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if better {
|
||||||
|
rec.parent = le64(r, vo) & 0xFFFF_FFFF_FFFF;
|
||||||
|
rec.name = Some((name, ns));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0x80 if name_len == 0 => {
|
||||||
|
if !nonres {
|
||||||
|
let vl = le32(r, p + 16) as usize;
|
||||||
|
let vo = p + le16(r, p + 20) as usize;
|
||||||
|
if vo + vl <= r.len() {
|
||||||
|
rec.data = Some(Data::Resident(r[vo..vo + vl].to_vec()));
|
||||||
|
}
|
||||||
|
} else if p + 64 <= r.len() {
|
||||||
|
let start_vcn = le64(r, p + 16);
|
||||||
|
let ro = p + le16(r, p + 32) as usize;
|
||||||
|
let real = le64(r, p + 48);
|
||||||
|
if start_vcn == 0 && ro < p + alen {
|
||||||
|
let runs = parse_runs(&r[ro..p + alen]);
|
||||||
|
rec.data = Some(Data::NonResident {
|
||||||
|
runs,
|
||||||
|
real,
|
||||||
|
unsupported: aflags & 0x4001 != 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
p += alen;
|
||||||
|
}
|
||||||
|
Some(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_runs(mut d: &[u8]) -> Vec<Run> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut vcn = 0u64;
|
||||||
|
let mut lcn = 0i64;
|
||||||
|
while let Some(&h) = d.first() {
|
||||||
|
if h == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let ll = (h & 0xF) as usize;
|
||||||
|
let lo = (h >> 4) as usize;
|
||||||
|
if ll == 0 || ll > 8 || lo > 8 || d.len() < 1 + ll + lo {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut len = 0u64;
|
||||||
|
for i in 0..ll {
|
||||||
|
len |= (d[1 + i] as u64) << (8 * i);
|
||||||
|
}
|
||||||
|
let lcn_opt = if lo == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut v = 0i64;
|
||||||
|
for i in 0..lo {
|
||||||
|
v |= (d[1 + ll + i] as i64) << (8 * i);
|
||||||
|
}
|
||||||
|
if d[ll + lo] & 0x80 != 0 && lo < 8 {
|
||||||
|
v |= -1i64 << (8 * lo);
|
||||||
|
}
|
||||||
|
lcn += v;
|
||||||
|
if lcn < 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(lcn as u64)
|
||||||
|
};
|
||||||
|
out.push(Run {
|
||||||
|
vcn,
|
||||||
|
len,
|
||||||
|
lcn: lcn_opt,
|
||||||
|
});
|
||||||
|
vcn += len;
|
||||||
|
d = &d[1 + ll + lo..];
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filetime_to_unix(ft: u64) -> Option<i64> {
|
||||||
|
if ft == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let secs = (ft / 10_000_000) as i64 - 11_644_473_600;
|
||||||
|
(secs > 0).then_some(secs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//! Imagem forense de um disco: leitura tolerante a setores ruins, com mapa de
|
||||||
|
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::source::Source;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ImageReport {
|
||||||
|
pub bytes: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
|
||||||
|
pub bad_ranges: Vec<(u64, u64)>,
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK: usize = 1 << 20;
|
||||||
|
const SECTOR: usize = 512;
|
||||||
|
|
||||||
|
pub fn create_image(
|
||||||
|
src: &dyn Source,
|
||||||
|
dest: &Path,
|
||||||
|
cancelled: &(dyn Fn() -> bool + Sync),
|
||||||
|
progress: &mut dyn FnMut(u64, u64),
|
||||||
|
) -> Result<ImageReport> {
|
||||||
|
let mut out = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(dest)
|
||||||
|
.map_err(|e| {
|
||||||
|
Error::msg(format!(
|
||||||
|
"não foi possível criar {}: {e} (o arquivo não pode existir)",
|
||||||
|
dest.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let size = src.size();
|
||||||
|
let mut rep = ImageReport::default();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; BLOCK];
|
||||||
|
let mut pos = 0u64;
|
||||||
|
while pos < size {
|
||||||
|
if cancelled() {
|
||||||
|
rep.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let want = (size - pos).min(BLOCK as u64) as usize;
|
||||||
|
match read_full(src, pos, &mut buf[..want]) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(_) => {
|
||||||
|
// Refaz setor a setor para isolar os trechos ruins.
|
||||||
|
let mut o = 0;
|
||||||
|
while o < want {
|
||||||
|
let n = SECTOR.min(want - o);
|
||||||
|
if read_full(src, pos + o as u64, &mut buf[o..o + n]).is_err() {
|
||||||
|
buf[o..o + n].fill(0);
|
||||||
|
match rep.bad_ranges.last_mut() {
|
||||||
|
Some(l) if l.0 + l.1 == pos + o as u64 => l.1 += n as u64,
|
||||||
|
_ => rep.bad_ranges.push((pos + o as u64, n as u64)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.write_all(&buf[..want])?;
|
||||||
|
hasher.update(&buf[..want]);
|
||||||
|
pos += want as u64;
|
||||||
|
progress(pos, size);
|
||||||
|
}
|
||||||
|
out.flush()?;
|
||||||
|
rep.bytes = pos;
|
||||||
|
rep.sha256 = hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect();
|
||||||
|
let map = dest.with_extension("map.json");
|
||||||
|
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
|
||||||
|
Ok(rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_full(src: &dyn Source, off: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||||
|
crate::source::read_exact_at(src, off, buf)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//! Guará — núcleo de recuperação de arquivos.
|
||||||
|
//!
|
||||||
|
//! Princípio central: a origem é aberta **somente para leitura**; nada neste
|
||||||
|
//! crate escreve no dispositivo que está sendo recuperado.
|
||||||
|
|
||||||
|
pub mod carve;
|
||||||
|
pub mod device;
|
||||||
|
pub mod error;
|
||||||
|
pub mod fs;
|
||||||
|
pub mod image;
|
||||||
|
pub mod partition;
|
||||||
|
pub mod recover;
|
||||||
|
pub mod scan;
|
||||||
|
pub mod service;
|
||||||
|
pub mod signatures;
|
||||||
|
pub mod source;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use error::{Error, Result};
|
||||||
|
pub use types::*;
|
||||||
|
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
//! Tabelas de partição (MBR/GPT), detecção de sistemas de arquivos,
|
||||||
|
//! volumes cifrados e busca de partições perdidas.
|
||||||
|
|
||||||
|
use crate::source::{read_vec, Source};
|
||||||
|
use crate::types::{DiskLayout, FsKind, Gap, PartInfo};
|
||||||
|
|
||||||
|
const SECTOR: u64 = 512;
|
||||||
|
const MIN_GAP: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Identifica o sistema de arquivos que começa em `start` e (se possível) seu tamanho.
|
||||||
|
pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)> {
|
||||||
|
let b = read_vec(src, start, 4096).ok()?;
|
||||||
|
if b.len() < 1100 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
detect_fs_in(&b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesma detecção sobre um buffer que começa no setor candidato (>= 1100 bytes).
|
||||||
|
pub fn detect_fs_in(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
|
||||||
|
if b.len() < 1100 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if &b[3..11] == b"NTFS " {
|
||||||
|
let bps = u16::from_le_bytes([b[11], b[12]]) as u64;
|
||||||
|
let total = u64::from_le_bytes(b[40..48].try_into().unwrap());
|
||||||
|
if bps.is_power_of_two() && (512..=4096).contains(&bps) && b[13] != 0 {
|
||||||
|
return Some((FsKind::Ntfs, Some((total + 1) * bps)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if &b[3..11] == b"EXFAT " {
|
||||||
|
let shift = b[108] as u32;
|
||||||
|
if (9..=12).contains(&shift) {
|
||||||
|
let vol = u64::from_le_bytes(b[72..80].try_into().unwrap());
|
||||||
|
return Some((FsKind::Exfat, Some(vol << shift)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if &b[3..11] == b"-FVE-FS-" {
|
||||||
|
return Some((FsKind::Bitlocker, None));
|
||||||
|
}
|
||||||
|
if b.starts_with(b"LUKS\xBA\xBE") {
|
||||||
|
return Some((FsKind::Luks, None));
|
||||||
|
}
|
||||||
|
if &b[32..36] == b"NXSB" {
|
||||||
|
let bs = u32::from_le_bytes(b[36..40].try_into().unwrap()) as u64;
|
||||||
|
let cnt = u64::from_le_bytes(b[40..48].try_into().unwrap());
|
||||||
|
return Some((FsKind::Apfs, Some(bs * cnt)));
|
||||||
|
}
|
||||||
|
if b[1024..1026] == *b"H+" || b[1024..1026] == *b"HX" {
|
||||||
|
return Some((FsKind::HfsPlus, None));
|
||||||
|
}
|
||||||
|
if b[1080] == 0x53 && b[1081] == 0xEF {
|
||||||
|
let blocks = u32::from_le_bytes(b[1028..1032].try_into().unwrap()) as u64;
|
||||||
|
let log = u32::from_le_bytes(b[1048..1052].try_into().unwrap());
|
||||||
|
if log <= 6 {
|
||||||
|
return Some((FsKind::Ext, Some(blocks << (10 + log))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(r) = detect_fat(b) {
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
|
||||||
|
if b[510] != 0x55 || b[511] != 0xAA {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bps = u16::from_le_bytes([b[11], b[12]]) as u64;
|
||||||
|
let spc = b[13] as u64;
|
||||||
|
let reserved = u16::from_le_bytes([b[14], b[15]]) as u64;
|
||||||
|
let nfats = b[16] as u64;
|
||||||
|
let root_entries = u16::from_le_bytes([b[17], b[18]]) as u64;
|
||||||
|
let ts16 = u16::from_le_bytes([b[19], b[20]]) as u64;
|
||||||
|
let fat16 = u16::from_le_bytes([b[22], b[23]]) as u64;
|
||||||
|
let ts32 = u32::from_le_bytes(b[32..36].try_into().unwrap()) as u64;
|
||||||
|
let fat32 = u32::from_le_bytes(b[36..40].try_into().unwrap()) as u64;
|
||||||
|
if !bps.is_power_of_two()
|
||||||
|
|| !(512..=4096).contains(&bps)
|
||||||
|
|| spc == 0
|
||||||
|
|| !spc.is_power_of_two()
|
||||||
|
|| reserved == 0
|
||||||
|
|| !(1..=2).contains(&nfats)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let total = if ts16 != 0 { ts16 } else { ts32 };
|
||||||
|
let fatsz = if fat16 != 0 { fat16 } else { fat32 };
|
||||||
|
if total == 0 || fatsz == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let root_secs = (root_entries * 32).div_ceil(bps);
|
||||||
|
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
|
||||||
|
let clusters = data / spc;
|
||||||
|
let kind = if clusters < 4085 {
|
||||||
|
FsKind::Fat12
|
||||||
|
} else if clusters < 65525 {
|
||||||
|
FsKind::Fat16
|
||||||
|
} else {
|
||||||
|
FsKind::Fat32
|
||||||
|
};
|
||||||
|
// Coerência: FAT32 tem root_entries == 0 e fat16 == 0.
|
||||||
|
if (kind == FsKind::Fat32) != (root_entries == 0 && fat16 == 0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((kind, Some(total * bps)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê o rótulo (label) do volume quando disponível de forma simples.
|
||||||
|
fn fat_label(b: &[u8], kind: FsKind) -> Option<String> {
|
||||||
|
let off = if kind == FsKind::Fat32 { 71 } else { 43 };
|
||||||
|
let s = String::from_utf8_lossy(b.get(off..off + 11)?)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
(!s.is_empty() && s != "NO NAME").then_some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- tabelas
|
||||||
|
|
||||||
|
/// Analisa o disco. `search_lost` varre lacunas atrás de volumes perdidos (lento em discos grandes).
|
||||||
|
pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
|
||||||
|
let size = src.size();
|
||||||
|
let mut layout = DiskLayout {
|
||||||
|
size,
|
||||||
|
table: "none".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let sector0 = read_vec(src, 0, 512).unwrap_or_default();
|
||||||
|
let has_mbr = sector0.len() == 512 && sector0[510] == 0x55 && sector0[511] == 0xAA;
|
||||||
|
let gpt = read_gpt(src, size);
|
||||||
|
let mut parts: Vec<PartInfo> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(g) = gpt {
|
||||||
|
layout.table = "gpt".into();
|
||||||
|
parts = g;
|
||||||
|
} else if has_mbr {
|
||||||
|
// Pode ser MBR de verdade ou um setor de boot de volume sem tabela (superfloppy).
|
||||||
|
let looks_boot = detect_fs_in(&read_vec(src, 0, 4096).unwrap_or_default()).is_some();
|
||||||
|
if !looks_boot {
|
||||||
|
let mp = read_mbr(src, §or0, size);
|
||||||
|
if !mp.is_empty() {
|
||||||
|
layout.table = "mbr".into();
|
||||||
|
parts = mp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for p in &mut parts {
|
||||||
|
if let Some((fs, _)) = detect_fs(src, p.start) {
|
||||||
|
p.fs = fs;
|
||||||
|
p.encrypted = fs.encrypted().map(String::from);
|
||||||
|
if matches!(fs, FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32) {
|
||||||
|
if let Ok(b) = read_vec(src, p.start, 512) {
|
||||||
|
p.label = fat_label(&b, fs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume sem tabela de partições (pendrive "superfloppy" ou partição crua).
|
||||||
|
if parts.is_empty() {
|
||||||
|
if let Some((fs, len)) = detect_fs(src, 0) {
|
||||||
|
parts.push(PartInfo {
|
||||||
|
index: 0,
|
||||||
|
start: 0,
|
||||||
|
len: len.unwrap_or(size).min(size),
|
||||||
|
fs,
|
||||||
|
label: None,
|
||||||
|
lost: false,
|
||||||
|
encrypted: fs.encrypted().map(String::from),
|
||||||
|
table_type: Some("volume sem tabela de partições".into()),
|
||||||
|
});
|
||||||
|
layout.table = "none".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.sort_by_key(|p| p.start);
|
||||||
|
layout.gaps = compute_gaps(&parts, size);
|
||||||
|
|
||||||
|
// Partições perdidas: procurar assinaturas de volumes nas lacunas.
|
||||||
|
let mut lost = Vec::new();
|
||||||
|
if search_lost {
|
||||||
|
lost = find_lost(src, &layout.gaps, &parts);
|
||||||
|
if parts.is_empty() && layout.table == "none" {
|
||||||
|
// Disco sem tabela nem volume no início: procurar no disco todo.
|
||||||
|
lost = find_lost(
|
||||||
|
src,
|
||||||
|
&[Gap {
|
||||||
|
start: 0,
|
||||||
|
len: size,
|
||||||
|
}],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for mut l in lost {
|
||||||
|
l.index = parts.len();
|
||||||
|
parts.push(l);
|
||||||
|
}
|
||||||
|
parts.sort_by_key(|p| p.start);
|
||||||
|
for (i, p) in parts.iter_mut().enumerate() {
|
||||||
|
p.index = i;
|
||||||
|
}
|
||||||
|
layout.gaps = compute_gaps(
|
||||||
|
&parts
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !p.lost)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
layout.partitions = parts;
|
||||||
|
|
||||||
|
for p in &layout.partitions {
|
||||||
|
if let Some(e) = &p.encrypted {
|
||||||
|
layout.warnings.push(format!(
|
||||||
|
"Partição {} está cifrada com {}: o Guará só detecta cifragem na v1 e não varre esse volume sem a chave.",
|
||||||
|
p.index + 1,
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if p.fs == FsKind::Apfs {
|
||||||
|
layout.warnings.push(format!(
|
||||||
|
"Partição {} é APFS: leitura de metadados ainda não suportada; usando apenas varredura por assinatura. Se estiver cifrada (FileVault), nada será encontrado.",
|
||||||
|
p.index + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_gaps(parts: &[PartInfo], size: u64) -> Vec<Gap> {
|
||||||
|
let mut gaps = vec![];
|
||||||
|
let mut cur = 0u64;
|
||||||
|
let mut sorted: Vec<&PartInfo> = parts.iter().collect();
|
||||||
|
sorted.sort_by_key(|p| p.start);
|
||||||
|
for p in sorted {
|
||||||
|
if p.start > cur && p.start - cur > MIN_GAP {
|
||||||
|
gaps.push(Gap {
|
||||||
|
start: cur,
|
||||||
|
len: p.start - cur,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cur = cur.max(p.start + p.len);
|
||||||
|
}
|
||||||
|
if size > cur && size - cur > MIN_GAP {
|
||||||
|
gaps.push(Gap {
|
||||||
|
start: cur,
|
||||||
|
len: size - cur,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_mbr(src: &dyn Source, s0: &[u8], size: u64) -> Vec<PartInfo> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut ext_base = None;
|
||||||
|
for i in 0..4 {
|
||||||
|
let e = &s0[446 + i * 16..462 + i * 16];
|
||||||
|
let ty = e[4];
|
||||||
|
let lba = u32::from_le_bytes(e[8..12].try_into().unwrap()) as u64;
|
||||||
|
let cnt = u32::from_le_bytes(e[12..16].try_into().unwrap()) as u64;
|
||||||
|
if ty == 0 || cnt == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ty == 0xEE {
|
||||||
|
continue; // MBR protetor de GPT
|
||||||
|
}
|
||||||
|
if matches!(ty, 0x05 | 0x0F | 0x85) {
|
||||||
|
ext_base = Some(lba * SECTOR);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if lba * SECTOR >= size {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(part(
|
||||||
|
out.len(),
|
||||||
|
lba * SECTOR,
|
||||||
|
cnt * SECTOR,
|
||||||
|
Some(format!("MBR 0x{ty:02X}")),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(base) = ext_base {
|
||||||
|
let mut cur = base;
|
||||||
|
for _ in 0..128 {
|
||||||
|
let Ok(ebr) = read_vec(src, cur, 512) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if ebr.len() < 512 || ebr[510] != 0x55 || ebr[511] != 0xAA {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let e1 = &ebr[446..462];
|
||||||
|
let lba = u32::from_le_bytes(e1[8..12].try_into().unwrap()) as u64;
|
||||||
|
let cnt = u32::from_le_bytes(e1[12..16].try_into().unwrap()) as u64;
|
||||||
|
if e1[4] != 0 && cnt != 0 {
|
||||||
|
out.push(part(
|
||||||
|
out.len(),
|
||||||
|
cur + lba * SECTOR,
|
||||||
|
cnt * SECTOR,
|
||||||
|
Some(format!("MBR lógica 0x{:02X}", e1[4])),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let e2 = &ebr[462..478];
|
||||||
|
let next = u32::from_le_bytes(e2[8..12].try_into().unwrap()) as u64;
|
||||||
|
if next == 0 || !matches!(e2[4], 0x05 | 0x0F | 0x85) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cur = base + next * SECTOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn part(index: usize, start: u64, len: u64, table_type: Option<String>) -> PartInfo {
|
||||||
|
PartInfo {
|
||||||
|
index,
|
||||||
|
start,
|
||||||
|
len,
|
||||||
|
fs: FsKind::Unknown,
|
||||||
|
label: None,
|
||||||
|
lost: false,
|
||||||
|
encrypted: None,
|
||||||
|
table_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
|
||||||
|
// Tenta o cabeçalho primário (LBA 1) e depois o de backup (último setor).
|
||||||
|
let hdr = read_vec(src, SECTOR, 512)
|
||||||
|
.ok()
|
||||||
|
.filter(|h| h.len() == 512 && &h[0..8] == b"EFI PART")
|
||||||
|
.or_else(|| {
|
||||||
|
let last = size.checked_sub(SECTOR)?;
|
||||||
|
read_vec(src, last, 512)
|
||||||
|
.ok()
|
||||||
|
.filter(|h| h.len() == 512 && &h[0..8] == b"EFI PART")
|
||||||
|
})?;
|
||||||
|
let table_lba = u64::from_le_bytes(hdr[72..80].try_into().unwrap());
|
||||||
|
let n = u32::from_le_bytes(hdr[80..84].try_into().unwrap()) as usize;
|
||||||
|
let esz = u32::from_le_bytes(hdr[84..88].try_into().unwrap()) as usize;
|
||||||
|
if n == 0 || n > 1024 || !(128..=4096).contains(&esz) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut tbl = read_vec(src, table_lba * SECTOR, n * esz).ok()?;
|
||||||
|
if tbl.len() < n * esz {
|
||||||
|
// Cabeçalho de backup aponta para a tabela de backup; se falhar, desiste.
|
||||||
|
tbl.resize(n * esz, 0);
|
||||||
|
}
|
||||||
|
let mut out = vec![];
|
||||||
|
for i in 0..n {
|
||||||
|
let e = &tbl[i * esz..(i + 1) * esz];
|
||||||
|
if e[0..16].iter().all(|&b| b == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let first = u64::from_le_bytes(e[32..40].try_into().unwrap());
|
||||||
|
let last = u64::from_le_bytes(e[40..48].try_into().unwrap());
|
||||||
|
if last < first || last * SECTOR >= size + SECTOR {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name: Vec<u16> = e[56..128]
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||||
|
.take_while(|&c| c != 0)
|
||||||
|
.collect();
|
||||||
|
let mut p = part(
|
||||||
|
out.len(),
|
||||||
|
first * SECTOR,
|
||||||
|
(last - first + 1) * SECTOR,
|
||||||
|
Some(gpt_type_name(&e[0..16]).into()),
|
||||||
|
);
|
||||||
|
let label = String::from_utf16_lossy(&name);
|
||||||
|
if !label.is_empty() {
|
||||||
|
p.label = Some(label);
|
||||||
|
}
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gpt_type_name(guid: &[u8]) -> &'static str {
|
||||||
|
// GUIDs em formato de disco (mixed-endian).
|
||||||
|
match guid {
|
||||||
|
[0x28, 0x73, 0x2A, 0xC1, 0x1F, 0xF8, 0xD2, 0x11, 0xBA, 0x4B, 0x00, 0xA0, 0xC9, 0x3E, 0xC9, 0x3B] => {
|
||||||
|
"EFI System"
|
||||||
|
}
|
||||||
|
[0xA2, 0xA0, 0xD0, 0xEB, 0xE5, 0xB9, 0x33, 0x44, 0x87, 0xC0, 0x68, 0xB6, 0xB7, 0x26, 0x99, 0xC7] => {
|
||||||
|
"Microsoft basic data"
|
||||||
|
}
|
||||||
|
[0x16, 0xE3, 0xC9, 0xE3, 0x5C, 0x0B, 0xB8, 0x4D, 0x81, 0x7D, 0xF9, 0x2D, 0xF0, 0x02, 0x15, 0xAE] => {
|
||||||
|
"Microsoft reserved"
|
||||||
|
}
|
||||||
|
[0xAF, 0x3D, 0xC6, 0x0F, 0x83, 0x84, 0x72, 0x47, 0x8E, 0x79, 0x3D, 0x69, 0xD8, 0x47, 0x7D, 0xE4] => {
|
||||||
|
"Linux filesystem"
|
||||||
|
}
|
||||||
|
[0x6D, 0xFD, 0x57, 0x06, 0xAB, 0xA4, 0xC4, 0x43, 0x84, 0xE5, 0x09, 0x33, 0xC8, 0x4B, 0x4F, 0x4F] => {
|
||||||
|
"Linux swap"
|
||||||
|
}
|
||||||
|
[0x00, 0x53, 0x46, 0x48, 0x00, 0x00, 0xAA, 0xAA, 0x11, 0xAA, 0x00, 0x30, 0x65, 0x43, 0xEC, 0xAC] => {
|
||||||
|
"Apple HFS+"
|
||||||
|
}
|
||||||
|
[0xEF, 0x57, 0x34, 0x7C, 0x00, 0x00, 0xAA, 0xAA, 0x11, 0xAA, 0x00, 0x30, 0x65, 0x43, 0xEC, 0xAC] => {
|
||||||
|
"Apple APFS"
|
||||||
|
}
|
||||||
|
_ => "GPT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Procura volumes (por assinatura) dentro de lacunas, em limites de setor.
|
||||||
|
fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo> {
|
||||||
|
const CHUNK: u64 = 4 * 1024 * 1024;
|
||||||
|
let mut found: Vec<PartInfo> = vec![];
|
||||||
|
for g in gaps {
|
||||||
|
let mut pos = g.start;
|
||||||
|
let end = g.start + g.len;
|
||||||
|
let mut skip_until = 0u64;
|
||||||
|
while pos < end {
|
||||||
|
let want = CHUNK.min(end - pos) + 4096;
|
||||||
|
let Ok(buf) = read_vec(src, pos, want as usize) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if buf.len() < 1100 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let limit = CHUNK.min(end - pos).min(buf.len() as u64 - 1100);
|
||||||
|
let mut off = 0u64;
|
||||||
|
while off <= limit {
|
||||||
|
let abs = pos + off;
|
||||||
|
if abs >= skip_until {
|
||||||
|
let s = &buf[off as usize..];
|
||||||
|
if s.len() >= 1100 {
|
||||||
|
// Filtro rápido: só chama a detecção completa se algum magic casa.
|
||||||
|
let quick = &s[3..11] == b"NTFS "
|
||||||
|
|| &s[3..11] == b"EXFAT "
|
||||||
|
|| &s[3..11] == b"-FVE-FS-"
|
||||||
|
|| s.starts_with(b"LUKS\xBA\xBE")
|
||||||
|
|| (s[510] == 0x55 && s[511] == 0xAA && s[0] != 0)
|
||||||
|
|| (s[1080] == 0x53 && s[1081] == 0xEF);
|
||||||
|
if quick {
|
||||||
|
if let Some((fs, len)) = detect_fs_in(s) {
|
||||||
|
// ext: o superbloco está a 1024 bytes do início do volume,
|
||||||
|
// por isso o candidato ext é o setor atual (início do volume).
|
||||||
|
let len = len.unwrap_or(0);
|
||||||
|
let ok = len >= 64 * 1024
|
||||||
|
&& abs + len <= src.size() + 1024 * 1024
|
||||||
|
&& !overlaps(known, abs, len);
|
||||||
|
// Evita falso positivo de FAT em setor de boot MBR genérico.
|
||||||
|
if ok && (fs != FsKind::Fat12 || len >= 512 * 1024) {
|
||||||
|
let mut p =
|
||||||
|
part(0, abs, len, Some("encontrada por varredura".into()));
|
||||||
|
p.fs = fs;
|
||||||
|
p.lost = true;
|
||||||
|
p.encrypted = fs.encrypted().map(String::from);
|
||||||
|
skip_until = abs + len;
|
||||||
|
found.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off += SECTOR;
|
||||||
|
}
|
||||||
|
pos += CHUNK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlaps(known: &[PartInfo], start: u64, len: u64) -> bool {
|
||||||
|
known
|
||||||
|
.iter()
|
||||||
|
.any(|p| start < p.start + p.len && p.start < start + len)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//! Gravação dos arquivos recuperados (sempre em outro destino) com SHA-256
|
||||||
|
//! e manifesto da sessão.
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::fs::sanitize_name;
|
||||||
|
use crate::source::{read_vec, Source};
|
||||||
|
use crate::types::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RecoveredFile {
|
||||||
|
pub id: u64,
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
pub confidence: Confidence,
|
||||||
|
pub origin: Origin,
|
||||||
|
pub partial: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RecoverReport {
|
||||||
|
pub destination: String,
|
||||||
|
pub recovered: Vec<RecoveredFile>,
|
||||||
|
pub failed: Vec<(u64, String)>,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
||||||
|
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(dest)?;
|
||||||
|
let dest = dest.canonicalize()?;
|
||||||
|
let Some(sp) = source_path else { return Ok(()) };
|
||||||
|
let sp_str = sp.to_string_lossy().to_string();
|
||||||
|
for (dev, mnt) in crate::device::mount_table() {
|
||||||
|
let same = dev == sp_str
|
||||||
|
|| (dev.starts_with(&sp_str)
|
||||||
|
&& dev[sp_str.len()..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.is_ascii_digit() || c == 'p' || c == 's')
|
||||||
|
.unwrap_or(false));
|
||||||
|
if same {
|
||||||
|
let m = Path::new(&mnt);
|
||||||
|
if dest.starts_with(m) && m != Path::new("/") {
|
||||||
|
return Err(Error::msg(format!(
|
||||||
|
"O destino {} fica em {} (montado a partir da origem {}). Escolha outro disco para não sobrescrever dados recuperáveis.",
|
||||||
|
dest.display(),
|
||||||
|
mnt,
|
||||||
|
sp_str
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if m == Path::new("/") {
|
||||||
|
return Err(Error::msg(format!("A origem {sp_str} contém o sistema (/). Recupere para um disco externo ou use uma imagem.")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(meta) = std::fs::metadata(sp) {
|
||||||
|
if meta.is_file() {
|
||||||
|
// Imagem: evita gravar ao lado só se for o mesmo arquivo (não faz sentido), mas aceita.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recover(
|
||||||
|
src: &dyn Source,
|
||||||
|
files: &[&Found],
|
||||||
|
dest: &Path,
|
||||||
|
cancelled: &(dyn Fn() -> bool + Sync),
|
||||||
|
progress: &mut dyn FnMut(usize, usize, u64),
|
||||||
|
) -> Result<RecoverReport> {
|
||||||
|
std::fs::create_dir_all(dest)?;
|
||||||
|
let mut report = RecoverReport {
|
||||||
|
destination: dest.display().to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut used: HashSet<PathBuf> = HashSet::new();
|
||||||
|
for (i, f) in files.iter().enumerate() {
|
||||||
|
if cancelled() {
|
||||||
|
report.cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
progress(i, files.len(), report.total_bytes);
|
||||||
|
let rel = target_rel(f);
|
||||||
|
let mut path = dest.join(&rel);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||||
|
report.failed.push((f.id, e.to_string()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut n = 1;
|
||||||
|
while path.exists() || used.contains(&path) {
|
||||||
|
let stem = Path::new(&f.name)
|
||||||
|
.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let name = if f.ext.is_empty() {
|
||||||
|
format!("{stem} ({n})")
|
||||||
|
} else {
|
||||||
|
format!("{stem} ({n}).{}", f.ext)
|
||||||
|
};
|
||||||
|
path = path.with_file_name(sanitize_name(&name));
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
used.insert(path.clone());
|
||||||
|
match write_file(src, f, &path) {
|
||||||
|
Ok(hash) => {
|
||||||
|
report.total_bytes += f.size;
|
||||||
|
report.recovered.push(RecoveredFile {
|
||||||
|
id: f.id,
|
||||||
|
path: path
|
||||||
|
.strip_prefix(dest)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/"),
|
||||||
|
size: f.size,
|
||||||
|
sha256: hash,
|
||||||
|
confidence: f.confidence,
|
||||||
|
origin: f.origin,
|
||||||
|
partial: f.confidence == Confidence::Low,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => report.failed.push((f.id, e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress(files.len(), files.len(), report.total_bytes);
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"ferramenta": "Guará",
|
||||||
|
"versao": env!("CARGO_PKG_VERSION"),
|
||||||
|
"gerado_em": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
||||||
|
"algoritmo_hash": "SHA-256",
|
||||||
|
"relatorio": &report,
|
||||||
|
});
|
||||||
|
std::fs::write(
|
||||||
|
dest.join("guara-manifesto.json"),
|
||||||
|
serde_json::to_vec_pretty(&manifest)?,
|
||||||
|
)?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_rel(f: &Found) -> PathBuf {
|
||||||
|
let mut p = PathBuf::new();
|
||||||
|
match &f.dir {
|
||||||
|
Some(d) if !d.is_empty() => {
|
||||||
|
p.push("Recuperados");
|
||||||
|
for c in d.split('/') {
|
||||||
|
if !c.is_empty() {
|
||||||
|
p.push(sanitize_name(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
p.push(f.category.label_pt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.push(sanitize_name(&f.name));
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escreve o conteúdo e devolve o SHA-256.
|
||||||
|
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
|
||||||
|
let mut out = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
match &f.data {
|
||||||
|
DataLoc::Resident { bytes } => {
|
||||||
|
out.write_all(bytes)?;
|
||||||
|
hasher.update(bytes);
|
||||||
|
}
|
||||||
|
DataLoc::Extents { extents } => {
|
||||||
|
let mut left = f.size;
|
||||||
|
let mut buf = vec![0u8; 1 << 20];
|
||||||
|
for e in extents {
|
||||||
|
let mut done = 0u64;
|
||||||
|
while done < e.len && left > 0 {
|
||||||
|
let n = (e.len - done).min(left).min(buf.len() as u64) as usize;
|
||||||
|
if e.sparse {
|
||||||
|
buf[..n].fill(0);
|
||||||
|
} else {
|
||||||
|
let got = read_vec(src, e.offset + done, n)?;
|
||||||
|
if got.len() < n {
|
||||||
|
return Err(Error::msg("leitura além do fim da origem"));
|
||||||
|
}
|
||||||
|
buf[..n].copy_from_slice(&got);
|
||||||
|
}
|
||||||
|
out.write_all(&buf[..n])?;
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
done += n as u64;
|
||||||
|
left -= n as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(m) = f.modified.filter(|m| *m > 0) {
|
||||||
|
let _ = out.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(m as u64));
|
||||||
|
}
|
||||||
|
out.flush()?;
|
||||||
|
Ok(hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos →
|
||||||
|
//! carving no espaço livre/desconhecido → fusão sem duplicatas.
|
||||||
|
|
||||||
|
use crate::carve::{carve_range, CarveCfg, CarveEvents, HitAction};
|
||||||
|
use crate::fs::{self, merge_extents, overlaps, Ctx};
|
||||||
|
use crate::partition;
|
||||||
|
use crate::signatures::category_for_ext;
|
||||||
|
use crate::source::Source;
|
||||||
|
use crate::types::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Progress {
|
||||||
|
pub phase: String,
|
||||||
|
pub done: u64,
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receptor dos resultados (a interface/CLI implementa).
|
||||||
|
pub trait Sink {
|
||||||
|
fn found(&mut self, f: Found);
|
||||||
|
/// Um achado de carving confirmou um arquivo já listado por metadados.
|
||||||
|
fn confirm(&mut self, id: u64, confidence: Confidence);
|
||||||
|
fn progress(&mut self, p: Progress);
|
||||||
|
fn warning(&mut self, w: String);
|
||||||
|
fn layout(&mut self, l: &DiskLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RangeState {
|
||||||
|
pub start: u64,
|
||||||
|
pub end: u64,
|
||||||
|
pub next: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estado serializável para pausar/retomar.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Checkpoint {
|
||||||
|
pub fs_done: bool,
|
||||||
|
pub ranges: Vec<RangeState>,
|
||||||
|
pub next_id: u64,
|
||||||
|
pub layout: Option<DiskLayout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Outcome {
|
||||||
|
Completed,
|
||||||
|
Interrupted(Checkpoint),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
src: &dyn Source,
|
||||||
|
opts: &ScanOptions,
|
||||||
|
resume: Option<Checkpoint>,
|
||||||
|
existing: &[Found],
|
||||||
|
cancelled: &(dyn Fn() -> bool + Sync),
|
||||||
|
sink: &mut dyn Sink,
|
||||||
|
) -> Outcome {
|
||||||
|
let mut cp = resume.unwrap_or_default();
|
||||||
|
let next_id = AtomicU64::new(
|
||||||
|
cp.next_id
|
||||||
|
.max(existing.iter().map(|f| f.id + 1).max().unwrap_or(1))
|
||||||
|
.max(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
sink.progress(Progress {
|
||||||
|
phase: "Lendo tabela de partições".into(),
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
let layout = match cp.layout.clone() {
|
||||||
|
Some(l) => l,
|
||||||
|
None => {
|
||||||
|
let l = partition::analyze(src, opts.mode == ScanMode::Deep);
|
||||||
|
sink.layout(&l);
|
||||||
|
for w in &l.warnings {
|
||||||
|
sink.warning(w.clone());
|
||||||
|
}
|
||||||
|
l
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cp.layout = Some(layout.clone());
|
||||||
|
|
||||||
|
// Índices para fusão: primeiro offset -> (id, confiança), e extents de achados por metadados.
|
||||||
|
let mut starts: HashMap<u64, (u64, Confidence)> = HashMap::new();
|
||||||
|
let mut fs_extents: Vec<Extent> = vec![];
|
||||||
|
for f in existing {
|
||||||
|
index_found(f, &mut starts, &mut fs_extents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cp.fs_done {
|
||||||
|
let mut live_all: Vec<Extent> = vec![];
|
||||||
|
let mut free_all: Vec<Extent> = vec![];
|
||||||
|
let mut have_free_for: Vec<(u64, u64)> = vec![];
|
||||||
|
for p in &layout.partitions {
|
||||||
|
if p.encrypted.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let phase = format!("Lendo metadados {} (partição {})", p.fs.name(), p.index + 1);
|
||||||
|
sink.progress(Progress {
|
||||||
|
phase: phase.clone(),
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
let mut emit_buf: Vec<Found> = vec![];
|
||||||
|
let res = {
|
||||||
|
let progress_phase = phase.clone();
|
||||||
|
let prog_cell = std::sync::Mutex::new((0u64, 0u64));
|
||||||
|
let progress = |d: u64, t: u64| {
|
||||||
|
*prog_cell.lock().unwrap() = (d, t);
|
||||||
|
};
|
||||||
|
let mut emit = |f: Found| emit_buf.push(f);
|
||||||
|
let mut ctx = Ctx {
|
||||||
|
src,
|
||||||
|
next_id: &next_id,
|
||||||
|
cancelled,
|
||||||
|
progress: &progress,
|
||||||
|
emit: &mut emit,
|
||||||
|
};
|
||||||
|
let r = match p.fs {
|
||||||
|
FsKind::Ntfs => Some(fs::ntfs::scan(&mut ctx, p.start)),
|
||||||
|
FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 => {
|
||||||
|
Some(fs::fat::scan(&mut ctx, p.start, p.fs))
|
||||||
|
}
|
||||||
|
FsKind::Exfat => Some(fs::exfat::scan(&mut ctx, p.start)),
|
||||||
|
FsKind::Ext => Some(fs::ext::scan(&mut ctx, p.start, p.len)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let _ = progress_phase;
|
||||||
|
r
|
||||||
|
};
|
||||||
|
for f in emit_buf {
|
||||||
|
index_found(&f, &mut starts, &mut fs_extents);
|
||||||
|
sink.found(f);
|
||||||
|
}
|
||||||
|
if let Some(r) = res {
|
||||||
|
for w in r.warnings {
|
||||||
|
sink.warning(w);
|
||||||
|
}
|
||||||
|
live_all.extend(r.live);
|
||||||
|
if let Some(fr) = r.free {
|
||||||
|
free_all.extend(fr);
|
||||||
|
have_free_for.push((p.start, p.start + p.len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cancelled() {
|
||||||
|
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
return Outcome::Interrupted(cp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cp.fs_done = true;
|
||||||
|
cp.ranges = if opts.mode == ScanMode::Deep {
|
||||||
|
build_ranges(
|
||||||
|
&layout,
|
||||||
|
&live_all,
|
||||||
|
&free_all,
|
||||||
|
&have_free_for,
|
||||||
|
opts,
|
||||||
|
src.size(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
fs_extents = merge_extents(fs_extents);
|
||||||
|
|
||||||
|
let total: u64 = cp.ranges.iter().map(|r| r.end - r.start).sum();
|
||||||
|
let done: u64 = cp
|
||||||
|
.ranges
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.next.saturating_sub(r.start).min(r.end - r.start))
|
||||||
|
.sum();
|
||||||
|
let cfg = CarveCfg {
|
||||||
|
max_size: opts.max_carve_size,
|
||||||
|
categories: &opts.categories,
|
||||||
|
};
|
||||||
|
let mut handler = Handler {
|
||||||
|
sink,
|
||||||
|
starts,
|
||||||
|
fs_extents,
|
||||||
|
next_id: &next_id,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
for ri in 0..cp.ranges.len() {
|
||||||
|
let (start, end) = (cp.ranges[ri].start, cp.ranges[ri].end);
|
||||||
|
let mut next = cp.ranges[ri].next;
|
||||||
|
if next >= end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let finished = carve_range(src, start, end, &mut next, &cfg, cancelled, &mut handler);
|
||||||
|
cp.ranges[ri].next = next;
|
||||||
|
if !finished {
|
||||||
|
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
return Outcome::Interrupted(cp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Outcome::Completed
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Handler<'a> {
|
||||||
|
sink: &'a mut dyn Sink,
|
||||||
|
starts: HashMap<u64, (u64, Confidence)>,
|
||||||
|
fs_extents: Vec<Extent>,
|
||||||
|
next_id: &'a AtomicU64,
|
||||||
|
done: u64,
|
||||||
|
total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CarveEvents for Handler<'_> {
|
||||||
|
fn advance(&mut self, n: u64) {
|
||||||
|
self.done += n;
|
||||||
|
self.sink.progress(Progress {
|
||||||
|
phase: "Varredura profunda por assinatura".into(),
|
||||||
|
done: self.done.min(self.total),
|
||||||
|
total: self.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hit(&mut self, off: u64, hit: &crate::signatures::Hit) -> HitAction {
|
||||||
|
if let Some(&(id, conf)) = self.starts.get(&off) {
|
||||||
|
if hit.confidence > conf {
|
||||||
|
self.sink.confirm(id, hit.confidence);
|
||||||
|
self.starts.insert(off, (id, hit.confidence));
|
||||||
|
}
|
||||||
|
return HitAction::Skip;
|
||||||
|
}
|
||||||
|
if overlaps(&self.fs_extents, off, 1) {
|
||||||
|
return HitAction::Continue; // dentro de arquivo já listado (ex.: miniatura embutida)
|
||||||
|
}
|
||||||
|
let id = self
|
||||||
|
.next_id
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let f = Found {
|
||||||
|
id,
|
||||||
|
name: format!("arquivo_{id:06}.{}", hit.ext),
|
||||||
|
dir: None,
|
||||||
|
ext: hit.ext.to_string(),
|
||||||
|
category: category_for_ext(hit.ext),
|
||||||
|
size: hit.len,
|
||||||
|
origin: Origin::Carved,
|
||||||
|
confidence: hit.confidence,
|
||||||
|
data: DataLoc::Extents {
|
||||||
|
extents: vec![Extent::new(off, hit.len)],
|
||||||
|
},
|
||||||
|
modified: None,
|
||||||
|
note: if hit.confidence == Confidence::Low {
|
||||||
|
Some("sem rodapé/estrutura completa: pode estar truncado".into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.starts.insert(off, (id, hit.confidence));
|
||||||
|
self.sink.found(f);
|
||||||
|
HitAction::Skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_found(f: &Found, starts: &mut HashMap<u64, (u64, Confidence)>, ex: &mut Vec<Extent>) {
|
||||||
|
if let DataLoc::Extents { extents } = &f.data {
|
||||||
|
if let Some(o) = f.first_offset() {
|
||||||
|
starts.insert(o, (f.id, f.confidence));
|
||||||
|
}
|
||||||
|
ex.extend(extents.iter().filter(|e| !e.sparse).copied());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ranges(
|
||||||
|
layout: &DiskLayout,
|
||||||
|
live: &[Extent],
|
||||||
|
free: &[Extent],
|
||||||
|
have_free_for: &[(u64, u64)],
|
||||||
|
opts: &ScanOptions,
|
||||||
|
disk_size: u64,
|
||||||
|
) -> Vec<RangeState> {
|
||||||
|
let live = merge_extents(live.to_vec());
|
||||||
|
let free = merge_extents(free.to_vec());
|
||||||
|
let mut out: Vec<Extent> = vec![];
|
||||||
|
for p in &layout.partitions {
|
||||||
|
if p.encrypted.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let whole = Extent::new(p.start, p.len.min(disk_size.saturating_sub(p.start)));
|
||||||
|
if opts.include_allocated {
|
||||||
|
out.push(whole);
|
||||||
|
} else if have_free_for
|
||||||
|
.iter()
|
||||||
|
.any(|&(s, e)| s == p.start && e == p.start + p.len)
|
||||||
|
{
|
||||||
|
out.extend(clip(&free, whole));
|
||||||
|
} else if p.fs.has_metadata_scanner() {
|
||||||
|
out.extend(subtract(whole, &live));
|
||||||
|
} else {
|
||||||
|
out.push(whole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for g in &layout.gaps {
|
||||||
|
out.push(Extent::new(g.start, g.len));
|
||||||
|
}
|
||||||
|
if layout.partitions.is_empty() && layout.gaps.is_empty() {
|
||||||
|
out.push(Extent::new(0, disk_size));
|
||||||
|
}
|
||||||
|
merge_extents(out)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| e.len >= 4096)
|
||||||
|
.map(|e| RangeState {
|
||||||
|
start: e.offset,
|
||||||
|
end: e.end(),
|
||||||
|
next: e.offset,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip(sorted: &[Extent], window: Extent) -> Vec<Extent> {
|
||||||
|
sorted
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let s = e.offset.max(window.offset);
|
||||||
|
let t = e.end().min(window.end());
|
||||||
|
(t > s).then(|| Extent::new(s, t - s))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subtract(window: Extent, sorted_live: &[Extent]) -> Vec<Extent> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut cur = window.offset;
|
||||||
|
let i = sorted_live.partition_point(|e| e.end() <= window.offset);
|
||||||
|
for e in &sorted_live[i..] {
|
||||||
|
if e.offset >= window.end() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if e.offset > cur {
|
||||||
|
out.push(Extent::new(cur, e.offset - cur));
|
||||||
|
}
|
||||||
|
cur = cur.max(e.end());
|
||||||
|
}
|
||||||
|
if cur < window.end() {
|
||||||
|
out.push(Extent::new(cur, window.end() - cur));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
//! Camada de serviço usada pela interface gráfica (Tauri e modo `guara ui`):
|
||||||
|
//! jobs em segundo plano (varredura, recuperação, imagem) e despachante RPC.
|
||||||
|
|
||||||
|
use crate::device;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::image;
|
||||||
|
use crate::partition;
|
||||||
|
use crate::recover;
|
||||||
|
use crate::scan::{self, Checkpoint, Outcome, Progress, Sink};
|
||||||
|
use crate::source::{open_path, Source};
|
||||||
|
use crate::types::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Status {
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Done,
|
||||||
|
Stopped,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum JobKind {
|
||||||
|
Scan,
|
||||||
|
Recover,
|
||||||
|
Image,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
status: Status,
|
||||||
|
progress: Progress,
|
||||||
|
found: Vec<Found>,
|
||||||
|
index: HashMap<u64, usize>,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
layout: Option<DiskLayout>,
|
||||||
|
checkpoint: Option<Checkpoint>,
|
||||||
|
error: Option<String>,
|
||||||
|
report: Option<Value>,
|
||||||
|
started: Instant,
|
||||||
|
finished_in: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Job {
|
||||||
|
id: String,
|
||||||
|
kind: JobKind,
|
||||||
|
target: String,
|
||||||
|
opts: ScanOptions,
|
||||||
|
cancel: AtomicBool,
|
||||||
|
stop_requested: AtomicBool,
|
||||||
|
src: Option<Arc<dyn Source>>,
|
||||||
|
st: Mutex<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job {
|
||||||
|
fn new(
|
||||||
|
id: String,
|
||||||
|
kind: JobKind,
|
||||||
|
target: String,
|
||||||
|
opts: ScanOptions,
|
||||||
|
src: Option<Arc<dyn Source>>,
|
||||||
|
) -> Arc<Job> {
|
||||||
|
Arc::new(Job {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
target,
|
||||||
|
opts,
|
||||||
|
cancel: AtomicBool::new(false),
|
||||||
|
stop_requested: AtomicBool::new(false),
|
||||||
|
src,
|
||||||
|
st: Mutex::new(State {
|
||||||
|
status: Status::Running,
|
||||||
|
progress: Progress::default(),
|
||||||
|
found: vec![],
|
||||||
|
index: HashMap::new(),
|
||||||
|
warnings: vec![],
|
||||||
|
layout: None,
|
||||||
|
checkpoint: None,
|
||||||
|
error: None,
|
||||||
|
report: None,
|
||||||
|
started: Instant::now(),
|
||||||
|
finished_in: None,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn cancelled(&self) -> bool {
|
||||||
|
self.cancel.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JobSink(Arc<Job>);
|
||||||
|
|
||||||
|
impl Sink for JobSink {
|
||||||
|
fn found(&mut self, f: Found) {
|
||||||
|
let mut s = self.0.st.lock().unwrap();
|
||||||
|
let i = s.found.len();
|
||||||
|
s.index.insert(f.id, i);
|
||||||
|
s.found.push(f);
|
||||||
|
}
|
||||||
|
fn confirm(&mut self, id: u64, c: Confidence) {
|
||||||
|
let mut s = self.0.st.lock().unwrap();
|
||||||
|
if let Some(&i) = s.index.get(&id) {
|
||||||
|
s.found[i].confidence = s.found[i].confidence.max(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn progress(&mut self, p: Progress) {
|
||||||
|
self.0.st.lock().unwrap().progress = p;
|
||||||
|
}
|
||||||
|
fn warning(&mut self, w: String) {
|
||||||
|
let mut s = self.0.st.lock().unwrap();
|
||||||
|
if !s.warnings.contains(&w) {
|
||||||
|
s.warnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn layout(&mut self, l: &DiskLayout) {
|
||||||
|
self.0.st.lock().unwrap().layout = Some(l.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Engine {
|
||||||
|
jobs: Mutex<HashMap<String, Arc<Job>>>,
|
||||||
|
counter: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Engine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
jobs: Mutex::new(HashMap::new()),
|
||||||
|
counter: AtomicU64::new(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job(&self, id: &str) -> Result<Arc<Job>> {
|
||||||
|
self.jobs
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| Error::msg(format!("job {id} não existe")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(
|
||||||
|
&self,
|
||||||
|
kind: JobKind,
|
||||||
|
target: String,
|
||||||
|
opts: ScanOptions,
|
||||||
|
src: Option<Arc<dyn Source>>,
|
||||||
|
) -> Arc<Job> {
|
||||||
|
let n = self.counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let id = format!("j{n}");
|
||||||
|
let job = Job::new(id.clone(), kind, target, opts, src);
|
||||||
|
self.jobs.lock().unwrap().insert(id, job.clone());
|
||||||
|
job
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- varredura
|
||||||
|
|
||||||
|
pub fn start_scan(&self, path: &str, opts: ScanOptions) -> Result<String> {
|
||||||
|
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||||
|
let job = self.register(JobKind::Scan, path.to_string(), opts, Some(src));
|
||||||
|
spawn_scan(job.clone(), None);
|
||||||
|
Ok(job.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self, id: &str) -> Result<()> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
j.cancel.store(true, Ordering::Relaxed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self, id: &str) -> Result<()> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
j.stop_requested.store(true, Ordering::Relaxed);
|
||||||
|
j.cancel.store(true, Ordering::Relaxed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&self, id: &str) -> Result<()> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let cp = {
|
||||||
|
let mut s = j.st.lock().unwrap();
|
||||||
|
if s.status != Status::Paused {
|
||||||
|
return Err(Error::msg("a varredura não está pausada"));
|
||||||
|
}
|
||||||
|
s.status = Status::Running;
|
||||||
|
s.checkpoint.take()
|
||||||
|
};
|
||||||
|
j.cancel.store(false, Ordering::Relaxed);
|
||||||
|
j.stop_requested.store(false, Ordering::Relaxed);
|
||||||
|
spawn_scan(j, cp);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self, id: &str) -> Result<Value> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
let mut counts: HashMap<&'static str, usize> = HashMap::new();
|
||||||
|
let mut by_conf = [0usize; 3];
|
||||||
|
for f in &s.found {
|
||||||
|
*counts.entry(cat_key(f.category)).or_default() += 1;
|
||||||
|
by_conf[f.confidence as usize] += 1;
|
||||||
|
}
|
||||||
|
let elapsed = s
|
||||||
|
.finished_in
|
||||||
|
.unwrap_or_else(|| s.started.elapsed().as_secs_f64());
|
||||||
|
Ok(json!({
|
||||||
|
"id": j.id,
|
||||||
|
"kind": j.kind,
|
||||||
|
"target": j.target,
|
||||||
|
"status": s.status,
|
||||||
|
"progress": s.progress,
|
||||||
|
"found": s.found.len(),
|
||||||
|
"categories": counts,
|
||||||
|
"confidence": { "low": by_conf[0], "medium": by_conf[1], "high": by_conf[2] },
|
||||||
|
"warnings": s.warnings,
|
||||||
|
"layout": s.layout,
|
||||||
|
"error": s.error,
|
||||||
|
"report": s.report,
|
||||||
|
"elapsed": elapsed,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn results(&self, id: &str, q: &Query) -> Result<Value> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
let needle = q
|
||||||
|
.query
|
||||||
|
.as_deref()
|
||||||
|
.map(|x| x.to_lowercase())
|
||||||
|
.filter(|x| !x.is_empty());
|
||||||
|
let mut items: Vec<&Found> = s
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.filter(|f| q.category.map(|c| f.category == c).unwrap_or(true))
|
||||||
|
.filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true))
|
||||||
|
.filter(|f| {
|
||||||
|
needle
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| f.full_path().to_lowercase().contains(n.as_str()))
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match q.sort.as_deref() {
|
||||||
|
Some("name") => items.sort_by_key(|f| f.name.to_lowercase()),
|
||||||
|
Some("size") => items.sort_by_key(|f| std::cmp::Reverse(f.size)),
|
||||||
|
Some("confidence") => {
|
||||||
|
items.sort_by(|a, b| b.confidence.cmp(&a.confidence).then(b.size.cmp(&a.size)))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let total = items.len();
|
||||||
|
let page: Vec<Value> = items
|
||||||
|
.into_iter()
|
||||||
|
.skip(q.offset)
|
||||||
|
.take(q.limit.clamp(1, 2000))
|
||||||
|
.map(|f| {
|
||||||
|
json!({
|
||||||
|
"id": f.id, "name": f.name, "dir": f.dir, "ext": f.ext, "category": f.category,
|
||||||
|
"size": f.size, "origin": f.origin, "confidence": f.confidence,
|
||||||
|
"modified": f.modified, "note": f.note,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(json!({ "total": total, "items": page }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preview(&self, id: &str, file: u64) -> Result<Value> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let (f, src) = {
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
let i = *s
|
||||||
|
.index
|
||||||
|
.get(&file)
|
||||||
|
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
||||||
|
(
|
||||||
|
s.found[i].clone(),
|
||||||
|
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mime = match f.ext.as_str() {
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"png" => "image/png",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"bmp" => "image/bmp",
|
||||||
|
_ => return Ok(json!({ "mime": null })),
|
||||||
|
};
|
||||||
|
if f.size > 8 * 1024 * 1024 {
|
||||||
|
return Ok(json!({ "mime": null, "reason": "grande demais para pré-visualizar" }));
|
||||||
|
}
|
||||||
|
let tmp =
|
||||||
|
std::env::temp_dir().join(format!("guara-preview-{}-{}", std::process::id(), f.id));
|
||||||
|
let r = recover::write_file(src.as_ref(), &f, &tmp);
|
||||||
|
let bytes = std::fs::read(&tmp);
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
r?;
|
||||||
|
Ok(json!({ "mime": mime, "base64": base64(&bytes?) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- recuperação
|
||||||
|
|
||||||
|
pub fn start_recover(
|
||||||
|
&self,
|
||||||
|
scan_id: &str,
|
||||||
|
ids: Option<Vec<u64>>,
|
||||||
|
filter: Option<Query>,
|
||||||
|
dest: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let sj = self.job(scan_id)?;
|
||||||
|
let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?;
|
||||||
|
let files: Vec<Found> = {
|
||||||
|
let s = sj.st.lock().unwrap();
|
||||||
|
match (&ids, &filter) {
|
||||||
|
(Some(ids), _) => ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone()))
|
||||||
|
.collect(),
|
||||||
|
(None, Some(q)) => {
|
||||||
|
let needle = q
|
||||||
|
.query
|
||||||
|
.as_deref()
|
||||||
|
.map(|x| x.to_lowercase())
|
||||||
|
.filter(|x| !x.is_empty());
|
||||||
|
s.found
|
||||||
|
.iter()
|
||||||
|
.filter(|f| q.category.map(|c| f.category == c).unwrap_or(true))
|
||||||
|
.filter(|f| q.min_confidence.map(|c| f.confidence >= c).unwrap_or(true))
|
||||||
|
.filter(|f| {
|
||||||
|
needle
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| f.full_path().to_lowercase().contains(n.as_str()))
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
(None, None) => s.found.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err(Error::msg("nenhum arquivo selecionado"));
|
||||||
|
}
|
||||||
|
let dest_path = PathBuf::from(dest);
|
||||||
|
recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?;
|
||||||
|
let job = self.register(
|
||||||
|
JobKind::Recover,
|
||||||
|
dest.to_string(),
|
||||||
|
ScanOptions::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let j2 = job.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let cancelled = || j2.cancelled();
|
||||||
|
let mut prog = |done: usize, total: usize, bytes: u64| {
|
||||||
|
let mut s = j2.st.lock().unwrap();
|
||||||
|
s.progress = Progress {
|
||||||
|
phase: format!("Recuperando ({bytes} bytes)"),
|
||||||
|
done: done as u64,
|
||||||
|
total: total as u64,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let refs: Vec<&Found> = files.iter().collect();
|
||||||
|
let res = recover::recover(src.as_ref(), &refs, &dest_path, &cancelled, &mut prog);
|
||||||
|
let mut s = j2.st.lock().unwrap();
|
||||||
|
match res {
|
||||||
|
Ok(rep) => {
|
||||||
|
s.report = serde_json::to_value(&rep).ok();
|
||||||
|
s.status = if rep.cancelled {
|
||||||
|
Status::Stopped
|
||||||
|
} else {
|
||||||
|
Status::Done
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
s.error = Some(e.to_string());
|
||||||
|
s.status = Status::Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||||
|
});
|
||||||
|
Ok(job.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- imagem
|
||||||
|
|
||||||
|
pub fn start_image(&self, path: &str, dest: &str) -> Result<String> {
|
||||||
|
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||||
|
recover::check_destination(
|
||||||
|
Some(Path::new(path)),
|
||||||
|
Path::new(dest).parent().unwrap_or(Path::new(".")),
|
||||||
|
)?;
|
||||||
|
let job = self.register(
|
||||||
|
JobKind::Image,
|
||||||
|
dest.to_string(),
|
||||||
|
ScanOptions::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let j2 = job.clone();
|
||||||
|
let dest = PathBuf::from(dest);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let cancelled = || j2.cancelled();
|
||||||
|
let mut prog = |d: u64, t: u64| {
|
||||||
|
j2.st.lock().unwrap().progress = Progress {
|
||||||
|
phase: "Criando imagem do disco".into(),
|
||||||
|
done: d,
|
||||||
|
total: t,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let res = image::create_image(src.as_ref(), &dest, &cancelled, &mut prog);
|
||||||
|
let mut s = j2.st.lock().unwrap();
|
||||||
|
match res {
|
||||||
|
Ok(rep) => {
|
||||||
|
s.status = if rep.cancelled {
|
||||||
|
Status::Stopped
|
||||||
|
} else {
|
||||||
|
Status::Done
|
||||||
|
};
|
||||||
|
s.report = serde_json::to_value(&rep).ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
s.error = Some(e.to_string());
|
||||||
|
s.status = Status::Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||||
|
});
|
||||||
|
Ok(job.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- sessões
|
||||||
|
|
||||||
|
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
|
||||||
|
let j = self.job(id)?;
|
||||||
|
let s = j.st.lock().unwrap();
|
||||||
|
let data = SessionFile {
|
||||||
|
version: 1,
|
||||||
|
target: j.target.clone(),
|
||||||
|
options: j.opts.clone(),
|
||||||
|
found: s.found.clone(),
|
||||||
|
checkpoint: s.checkpoint.clone(),
|
||||||
|
layout: s.layout.clone(),
|
||||||
|
warnings: s.warnings.clone(),
|
||||||
|
done: s.status == Status::Done,
|
||||||
|
};
|
||||||
|
std::fs::write(path, serde_json::to_vec(&data)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_session(&self, path: &str) -> Result<String> {
|
||||||
|
let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||||
|
let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?;
|
||||||
|
let job = self.register(
|
||||||
|
JobKind::Scan,
|
||||||
|
data.target.clone(),
|
||||||
|
data.options.clone(),
|
||||||
|
Some(src),
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut s = job.st.lock().unwrap();
|
||||||
|
for (i, f) in data.found.iter().enumerate() {
|
||||||
|
s.index.insert(f.id, i);
|
||||||
|
}
|
||||||
|
s.found = data.found;
|
||||||
|
s.layout = data.layout;
|
||||||
|
s.warnings = data.warnings;
|
||||||
|
s.checkpoint = data.checkpoint;
|
||||||
|
s.status = if data.done {
|
||||||
|
Status::Done
|
||||||
|
} else {
|
||||||
|
Status::Paused
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(job.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- RPC
|
||||||
|
|
||||||
|
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||||
|
let s = |k: &str| -> Result<String> {
|
||||||
|
p.get(k)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
.ok_or_else(|| Error::msg(format!("parâmetro '{k}' ausente")))
|
||||||
|
};
|
||||||
|
Ok(match method {
|
||||||
|
"devices" => json!(device::list_devices()),
|
||||||
|
"inspect" => {
|
||||||
|
let path = s("path")?;
|
||||||
|
let src = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?;
|
||||||
|
json!(partition::analyze(src.as_ref(), false))
|
||||||
|
}
|
||||||
|
"scan_start" => {
|
||||||
|
let opts: ScanOptions = p
|
||||||
|
.get("options")
|
||||||
|
.cloned()
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
json!({ "job": self.start_scan(&s("path")?, opts)? })
|
||||||
|
}
|
||||||
|
"scan_pause" => {
|
||||||
|
self.pause(&s("job")?)?;
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
"scan_resume" => {
|
||||||
|
self.resume(&s("job")?)?;
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
"scan_stop" | "job_stop" => {
|
||||||
|
self.stop(&s("job")?)?;
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
"status" => self.status(&s("job")?)?,
|
||||||
|
"results" => {
|
||||||
|
let q: Query = p
|
||||||
|
.get("query")
|
||||||
|
.cloned()
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.results(&s("job")?, &q)?
|
||||||
|
}
|
||||||
|
"preview" => self.preview(
|
||||||
|
&s("job")?,
|
||||||
|
p.get("id")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| Error::msg("id ausente"))?,
|
||||||
|
)?,
|
||||||
|
"recover_start" => {
|
||||||
|
let ids: Option<Vec<u64>> = p
|
||||||
|
.get("ids")
|
||||||
|
.cloned()
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?;
|
||||||
|
let filter: Option<Query> = p
|
||||||
|
.get("filter")
|
||||||
|
.cloned()
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?;
|
||||||
|
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?)? })
|
||||||
|
}
|
||||||
|
"image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }),
|
||||||
|
"session_save" => {
|
||||||
|
self.save_session(&s("job")?, &s("path")?)?;
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||||
|
"defaults" => {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() })
|
||||||
|
}
|
||||||
|
"version" => {
|
||||||
|
json!({ "version": env!("CARGO_PKG_VERSION"), "os": std::env::consts::OS })
|
||||||
|
}
|
||||||
|
other => return Err(Error::msg(format!("método desconhecido: {other}"))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Query {
|
||||||
|
#[serde(default)]
|
||||||
|
pub category: Option<Category>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub min_confidence: Option<Confidence>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub query: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: usize,
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
pub limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_limit() -> usize {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Query {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
category: None,
|
||||||
|
min_confidence: None,
|
||||||
|
query: None,
|
||||||
|
sort: None,
|
||||||
|
offset: 0,
|
||||||
|
limit: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct SessionFile {
|
||||||
|
version: u32,
|
||||||
|
target: String,
|
||||||
|
options: ScanOptions,
|
||||||
|
found: Vec<Found>,
|
||||||
|
checkpoint: Option<Checkpoint>,
|
||||||
|
layout: Option<DiskLayout>,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cat_key(c: Category) -> &'static str {
|
||||||
|
match c {
|
||||||
|
Category::Image => "image",
|
||||||
|
Category::Document => "document",
|
||||||
|
Category::Video => "video",
|
||||||
|
Category::Audio => "audio",
|
||||||
|
Category::Archive => "archive",
|
||||||
|
Category::Database => "database",
|
||||||
|
Category::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||||
|
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||||
|
Error::msg(format!(
|
||||||
|
"Sem permissão para ler {path}. Discos exigem privilégios de administrador: no Linux/macOS rode com sudo; no Windows, execute como administrador."
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Error::msg(format!("Não foi possível abrir {path}: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let src = job.src.clone().expect("origem");
|
||||||
|
let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
|
||||||
|
let cancelled = || job.cancelled();
|
||||||
|
let mut sink = JobSink(job.clone());
|
||||||
|
let outcome = scan::run(
|
||||||
|
src.as_ref(),
|
||||||
|
&job.opts,
|
||||||
|
cp,
|
||||||
|
&existing,
|
||||||
|
&cancelled,
|
||||||
|
&mut sink,
|
||||||
|
);
|
||||||
|
let mut s = job.st.lock().unwrap();
|
||||||
|
match outcome {
|
||||||
|
Outcome::Completed => {
|
||||||
|
s.status = Status::Done;
|
||||||
|
s.checkpoint = None;
|
||||||
|
}
|
||||||
|
Outcome::Interrupted(cp) => {
|
||||||
|
if job.stop_requested.load(Ordering::Relaxed) {
|
||||||
|
s.status = Status::Stopped;
|
||||||
|
} else {
|
||||||
|
s.status = Status::Paused;
|
||||||
|
s.checkpoint = Some(cp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.finished_in = Some(s.started.elapsed().as_secs_f64());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base64(data: &[u8]) -> String {
|
||||||
|
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||||
|
for c in data.chunks(3) {
|
||||||
|
let n = (c[0] as u32) << 16
|
||||||
|
| (*c.get(1).unwrap_or(&0) as u32) << 8
|
||||||
|
| *c.get(2).unwrap_or(&0) as u32;
|
||||||
|
out.push(T[(n >> 18) as usize & 63] as char);
|
||||||
|
out.push(T[(n >> 12) as usize & 63] as char);
|
||||||
|
out.push(if c.len() > 1 {
|
||||||
|
T[(n >> 6) as usize & 63] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
out.push(if c.len() > 2 {
|
||||||
|
T[n as usize & 63] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
//! Assinaturas de formatos e validação estrutural para o carving.
|
||||||
|
//!
|
||||||
|
//! Cada detector lê a estrutura real do arquivo (cabeçalhos, blocos, rodapés)
|
||||||
|
//! para determinar o tamanho exato e o nível de confiança, em vez de apenas
|
||||||
|
//! procurar bytes mágicos.
|
||||||
|
|
||||||
|
use crate::source::{read_vec, Source};
|
||||||
|
use crate::types::{Category, Confidence};
|
||||||
|
|
||||||
|
/// Resultado de um detector: arquivo reconhecido a partir de `offset`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Hit {
|
||||||
|
pub len: u64,
|
||||||
|
pub ext: &'static str,
|
||||||
|
pub confidence: Confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category_for_ext(ext: &str) -> Category {
|
||||||
|
match ext.to_ascii_lowercase().as_str() {
|
||||||
|
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "heic" | "avif" | "tif" | "tiff"
|
||||||
|
| "psd" | "svg" | "ico" | "cr2" | "nef" | "dng" => Category::Image,
|
||||||
|
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "odt" | "ods" | "odp"
|
||||||
|
| "txt" | "rtf" | "epub" | "md" | "csv" | "html" | "xml" | "json" => Category::Document,
|
||||||
|
"mp4" | "mov" | "m4v" | "avi" | "mkv" | "webm" | "wmv" | "flv" | "mpg" | "mpeg" | "3gp" => {
|
||||||
|
Category::Video
|
||||||
|
}
|
||||||
|
"mp3" | "wav" | "flac" | "ogg" | "m4a" | "aac" | "wma" | "opus" => Category::Audio,
|
||||||
|
"zip" | "rar" | "7z" | "gz" | "tar" | "bz2" | "xz" | "jar" | "apk" => Category::Archive,
|
||||||
|
"sqlite" | "db" | "sqlite3" | "mdb" | "accdb" => Category::Database,
|
||||||
|
_ => Category::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica se o início do conteúdo é coerente com a extensão.
|
||||||
|
/// `None` = extensão sem assinatura conhecida (não dá para julgar).
|
||||||
|
pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
|
||||||
|
let ext = ext.to_ascii_lowercase();
|
||||||
|
let starts = |m: &[u8]| head.starts_with(m);
|
||||||
|
let r = match ext.as_str() {
|
||||||
|
"jpg" | "jpeg" => starts(&[0xFF, 0xD8, 0xFF]),
|
||||||
|
"png" => starts(&[0x89, b'P', b'N', b'G']),
|
||||||
|
"gif" => starts(b"GIF8"),
|
||||||
|
"bmp" => starts(b"BM"),
|
||||||
|
"pdf" => starts(b"%PDF"),
|
||||||
|
"zip" | "docx" | "xlsx" | "pptx" | "odt" | "ods" | "odp" | "jar" | "epub" | "apk" => {
|
||||||
|
starts(b"PK\x03\x04") || starts(b"PK\x05\x06")
|
||||||
|
}
|
||||||
|
"doc" | "xls" | "ppt" => starts(&[0xD0, 0xCF, 0x11, 0xE0]),
|
||||||
|
"7z" => starts(&[b'7', b'z', 0xBC, 0xAF, 0x27, 0x1C]),
|
||||||
|
"rar" => starts(b"Rar!"),
|
||||||
|
"gz" => starts(&[0x1F, 0x8B]),
|
||||||
|
"mp4" | "mov" | "m4v" | "m4a" | "3gp" | "heic" | "avif" => {
|
||||||
|
head.len() >= 8 && &head[4..8] == b"ftyp"
|
||||||
|
|| (head.len() >= 8 && matches!(&head[4..8], b"moov" | b"mdat" | b"free" | b"wide"))
|
||||||
|
}
|
||||||
|
"avi" | "wav" | "webp" => starts(b"RIFF"),
|
||||||
|
"mp3" => starts(b"ID3") || (head.len() >= 2 && head[0] == 0xFF && head[1] & 0xE0 == 0xE0),
|
||||||
|
"flac" => starts(b"fLaC"),
|
||||||
|
"ogg" | "opus" => starts(b"OggS"),
|
||||||
|
"mkv" | "webm" => starts(&[0x1A, 0x45, 0xDF, 0xA3]),
|
||||||
|
"sqlite" | "sqlite3" | "db" => starts(b"SQLite format 3\0"),
|
||||||
|
"exe" | "dll" => starts(b"MZ"),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tabela de primeiros bytes -> detectores candidatos.
|
||||||
|
/// O carving consulta `candidates(first_byte)` e chama `detect`.
|
||||||
|
pub fn might_start(b0: u8) -> bool {
|
||||||
|
matches!(
|
||||||
|
b0,
|
||||||
|
0xFF | 0x89
|
||||||
|
| b'G'
|
||||||
|
| b'B'
|
||||||
|
| b'%'
|
||||||
|
| b'P'
|
||||||
|
| 0xD0
|
||||||
|
| b'7'
|
||||||
|
| b'S'
|
||||||
|
| b'R'
|
||||||
|
| b'I'
|
||||||
|
| b'M'
|
||||||
|
| 0x00
|
||||||
|
| b'f'
|
||||||
|
| 0x1F
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_HEADER: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Tenta reconhecer um arquivo que começa em `off`.
|
||||||
|
/// `head` já contém os primeiros bytes lidos em `off` (pelo menos 16, se existirem).
|
||||||
|
pub fn detect(src: &dyn Source, off: u64, head: &[u8], max_size: u64) -> Option<Hit> {
|
||||||
|
if head.len() < 12 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let h = head;
|
||||||
|
if h.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||||
|
return jpeg(src, off, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||||
|
return png(src, off, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"GIF87a") || h.starts_with(b"GIF89a") {
|
||||||
|
return gif(src, off, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"%PDF-") {
|
||||||
|
return pdf(src, off, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"PK\x03\x04") {
|
||||||
|
return zip(src, off, max_size);
|
||||||
|
}
|
||||||
|
if &h[4..8] == b"ftyp" {
|
||||||
|
return isobmff(src, off, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"RIFF") {
|
||||||
|
return riff(h, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"BM") {
|
||||||
|
return bmp(h, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"SQLite format 3\0") {
|
||||||
|
return sqlite(h, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(&[b'7', b'z', 0xBC, 0xAF, 0x27, 0x1C]) {
|
||||||
|
return sevenz(h, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) {
|
||||||
|
return ole(src, off, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"ID3") {
|
||||||
|
return mp3(src, off, max_size);
|
||||||
|
}
|
||||||
|
if h.starts_with(b"MZ") {
|
||||||
|
return pe(src, off, max_size);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- JPEG
|
||||||
|
|
||||||
|
fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let head = read_vec(src, off, MAX_HEADER).ok()?;
|
||||||
|
let mut p = 2usize;
|
||||||
|
let mut sos_end = None;
|
||||||
|
let mut saw_sof = false;
|
||||||
|
while p + 4 <= head.len() {
|
||||||
|
if head[p] != 0xFF {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let m = head[p + 1];
|
||||||
|
if m == 0xFF {
|
||||||
|
p += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if m == 0xD9 {
|
||||||
|
return Some(Hit {
|
||||||
|
len: p as u64 + 2,
|
||||||
|
ext: "jpg",
|
||||||
|
confidence: Confidence::Medium,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (0xD0..=0xD8).contains(&m) || m == 0x01 {
|
||||||
|
p += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let seg = u16::from_be_bytes([head[p + 2], head[p + 3]]) as usize;
|
||||||
|
if seg < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if matches!(m, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
|
||||||
|
saw_sof = true;
|
||||||
|
}
|
||||||
|
p += 2 + seg;
|
||||||
|
if m == 0xDA {
|
||||||
|
sos_end = Some(p);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let start = sos_end?;
|
||||||
|
if !saw_sof {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Dados entropy-coded: procurar EOI (FF D9) ignorando FF00 e RSTn.
|
||||||
|
let mut pos = off + start as u64;
|
||||||
|
let limit = off + max.min(512 * 1024 * 1024);
|
||||||
|
let mut buf = vec![0u8; 1 << 20];
|
||||||
|
let mut prev_ff = false;
|
||||||
|
while pos < limit {
|
||||||
|
let n = src.read_at(pos, &mut buf).ok()?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (i, &b) in buf[..n].iter().enumerate() {
|
||||||
|
if prev_ff {
|
||||||
|
if b == 0xD9 {
|
||||||
|
return Some(Hit {
|
||||||
|
len: pos + i as u64 + 1 - off,
|
||||||
|
ext: "jpg",
|
||||||
|
confidence: Confidence::High,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prev_ff = b == 0xFF;
|
||||||
|
} else {
|
||||||
|
prev_ff = b == 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += n as u64;
|
||||||
|
}
|
||||||
|
// Sem EOI: imagem truncada/sobrescrita; ainda útil, mas incerta.
|
||||||
|
Some(Hit {
|
||||||
|
len: (pos - off).min(4 * 1024 * 1024),
|
||||||
|
ext: "jpg",
|
||||||
|
confidence: Confidence::Low,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- PNG
|
||||||
|
|
||||||
|
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let mut p = 8u64;
|
||||||
|
let mut first = true;
|
||||||
|
for _ in 0..200_000 {
|
||||||
|
let c = read_vec(src, off + p, 8).ok()?;
|
||||||
|
if c.len() < 8 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
|
||||||
|
let ty = &c[4..8];
|
||||||
|
if !ty.iter().all(|b| b.is_ascii_alphabetic()) || len > 0x7FFF_FFFF {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if first && ty != b"IHDR" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
p += 12 + len;
|
||||||
|
if p > max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ty == b"IEND" {
|
||||||
|
return Some(Hit {
|
||||||
|
len: p,
|
||||||
|
ext: "png",
|
||||||
|
confidence: Confidence::High,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Hit {
|
||||||
|
len: p.min(max).min(src.size().saturating_sub(off)),
|
||||||
|
ext: "png",
|
||||||
|
confidence: Confidence::Low,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- GIF
|
||||||
|
|
||||||
|
fn gif(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let d = read_vec(src, off, (16usize * 1024 * 1024).min(max as usize)).ok()?;
|
||||||
|
if d.len() < 13 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let flags = d[10];
|
||||||
|
let mut p = 13usize;
|
||||||
|
if flags & 0x80 != 0 {
|
||||||
|
p += 3 * (1usize << ((flags & 7) + 1));
|
||||||
|
}
|
||||||
|
while p < d.len() {
|
||||||
|
match d[p] {
|
||||||
|
0x3B => {
|
||||||
|
return Some(Hit {
|
||||||
|
len: p as u64 + 1,
|
||||||
|
ext: "gif",
|
||||||
|
confidence: Confidence::High,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
0x21 => {
|
||||||
|
p += 2;
|
||||||
|
p = skip_subblocks(&d, p)?;
|
||||||
|
}
|
||||||
|
0x2C => {
|
||||||
|
if p + 10 > d.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let f = d[p + 9];
|
||||||
|
p += 10;
|
||||||
|
if f & 0x80 != 0 {
|
||||||
|
p += 3 * (1usize << ((f & 7) + 1));
|
||||||
|
}
|
||||||
|
p += 1;
|
||||||
|
p = skip_subblocks(&d, p)?;
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Hit {
|
||||||
|
len: d.len() as u64,
|
||||||
|
ext: "gif",
|
||||||
|
confidence: Confidence::Low,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_subblocks(d: &[u8], mut p: usize) -> Option<usize> {
|
||||||
|
loop {
|
||||||
|
let n = *d.get(p)? as usize;
|
||||||
|
p += 1 + n;
|
||||||
|
if n == 0 {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- BMP
|
||||||
|
|
||||||
|
fn bmp(h: &[u8], max: u64) -> Option<Hit> {
|
||||||
|
if h.len() < 18 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = u32::from_le_bytes([h[2], h[3], h[4], h[5]]) as u64;
|
||||||
|
let reserved = u32::from_le_bytes([h[6], h[7], h[8], h[9]]);
|
||||||
|
let dib = u32::from_le_bytes([h[14], h[15], h[16], h[17]]);
|
||||||
|
if reserved != 0
|
||||||
|
|| !matches!(dib, 12 | 40 | 52 | 56 | 64 | 108 | 124)
|
||||||
|
|| size < 26
|
||||||
|
|| size > max
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Hit {
|
||||||
|
len: size,
|
||||||
|
ext: "bmp",
|
||||||
|
confidence: Confidence::High,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- PDF
|
||||||
|
|
||||||
|
fn pdf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let limit = max.min(1 << 30);
|
||||||
|
let mut pos = off;
|
||||||
|
let mut buf = vec![0u8; 1 << 20 | 16];
|
||||||
|
let mut last_eof: Option<u64> = None;
|
||||||
|
let mut carry: Vec<u8> = Vec::new();
|
||||||
|
while pos - off < limit {
|
||||||
|
let n = src.read_at(pos, &mut buf[..(1 << 20)]).ok()?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut hay = std::mem::take(&mut carry);
|
||||||
|
let base = pos - hay.len() as u64;
|
||||||
|
hay.extend_from_slice(&buf[..n]);
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(f) = find(&hay[i..], b"%%EOF") {
|
||||||
|
let end = base + (i + f) as u64 + 5;
|
||||||
|
// Continua se logo após o %%EOF vier uma atualização incremental.
|
||||||
|
let tail = read_vec(src, end, 32).ok()?;
|
||||||
|
let rest: Vec<u8> = tail
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.skip_while(|b| b.is_ascii_whitespace())
|
||||||
|
.collect();
|
||||||
|
let skipped = tail.len() - rest.len();
|
||||||
|
last_eof = Some(end + skipped.min(2) as u64 - off);
|
||||||
|
let more = rest.first().map(|b| b.is_ascii_digit()).unwrap_or(false)
|
||||||
|
|| rest.starts_with(b"%PDF");
|
||||||
|
if !more {
|
||||||
|
let conf = if last_eof.is_some() {
|
||||||
|
Confidence::High
|
||||||
|
} else {
|
||||||
|
Confidence::Low
|
||||||
|
};
|
||||||
|
return Some(Hit {
|
||||||
|
len: last_eof.unwrap(),
|
||||||
|
ext: "pdf",
|
||||||
|
confidence: conf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
i += f + 5;
|
||||||
|
}
|
||||||
|
let keep = hay.len().min(8);
|
||||||
|
carry = hay[hay.len() - keep..].to_vec();
|
||||||
|
pos += n as u64;
|
||||||
|
}
|
||||||
|
last_eof.map(|l| Hit {
|
||||||
|
len: l,
|
||||||
|
ext: "pdf",
|
||||||
|
confidence: Confidence::Medium,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
hay.windows(needle.len()).position(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- ZIP & derivados
|
||||||
|
|
||||||
|
fn zip(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let limit = max.min(4 << 30);
|
||||||
|
let mut pos = off;
|
||||||
|
let mut buf = vec![0u8; 1 << 20];
|
||||||
|
let mut carry: Vec<u8> = Vec::new();
|
||||||
|
while pos - off < limit {
|
||||||
|
let n = src.read_at(pos, &mut buf).ok()?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut hay = std::mem::take(&mut carry);
|
||||||
|
let base = pos - hay.len() as u64;
|
||||||
|
hay.extend_from_slice(&buf[..n]);
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(f) = find(&hay[i..], b"PK\x05\x06") {
|
||||||
|
let at = base + (i + f) as u64;
|
||||||
|
if let Some(hit) = check_eocd(src, off, at) {
|
||||||
|
return Some(hit);
|
||||||
|
}
|
||||||
|
i += f + 4;
|
||||||
|
}
|
||||||
|
let keep = hay.len().min(3);
|
||||||
|
carry = hay[hay.len() - keep..].to_vec();
|
||||||
|
pos += n as u64;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_eocd(src: &dyn Source, start: u64, at: u64) -> Option<Hit> {
|
||||||
|
let e = read_vec(src, at, 22).ok()?;
|
||||||
|
if e.len() < 22 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let entries = u16::from_le_bytes([e[10], e[11]]) as u64;
|
||||||
|
let cd_size = u32::from_le_bytes([e[12], e[13], e[14], e[15]]) as u64;
|
||||||
|
let cd_off = u32::from_le_bytes([e[16], e[17], e[18], e[19]]) as u64;
|
||||||
|
let comment = u16::from_le_bytes([e[20], e[21]]) as u64;
|
||||||
|
if entries == 0 || cd_off + cd_size != at - start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cd = read_vec(src, start + cd_off, cd_size.min(4 << 20) as usize).ok()?;
|
||||||
|
let names = zip_names(&cd);
|
||||||
|
let ext = classify_zip(src, start, &names);
|
||||||
|
Some(Hit {
|
||||||
|
len: at - start + 22 + comment,
|
||||||
|
ext,
|
||||||
|
confidence: Confidence::High,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_names(cd: &[u8]) -> Vec<String> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut p = 0;
|
||||||
|
while p + 46 <= cd.len() && &cd[p..p + 4] == b"PK\x01\x02" {
|
||||||
|
let n = u16::from_le_bytes([cd[p + 28], cd[p + 29]]) as usize;
|
||||||
|
let m = u16::from_le_bytes([cd[p + 30], cd[p + 31]]) as usize;
|
||||||
|
let k = u16::from_le_bytes([cd[p + 32], cd[p + 33]]) as usize;
|
||||||
|
if p + 46 + n > cd.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(String::from_utf8_lossy(&cd[p + 46..p + 46 + n]).into_owned());
|
||||||
|
p += 46 + n + m + k;
|
||||||
|
if out.len() > 20_000 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_zip(src: &dyn Source, start: u64, names: &[String]) -> &'static str {
|
||||||
|
let has = |pre: &str| names.iter().any(|n| n.starts_with(pre));
|
||||||
|
if has("word/") {
|
||||||
|
"docx"
|
||||||
|
} else if has("xl/") {
|
||||||
|
"xlsx"
|
||||||
|
} else if has("ppt/") {
|
||||||
|
"pptx"
|
||||||
|
} else if names.first().map(|n| n == "mimetype").unwrap_or(false) {
|
||||||
|
// O primeiro item (sem compressão) traz o tipo MIME logo após o cabeçalho.
|
||||||
|
let head = read_vec(src, start, 256).unwrap_or_default();
|
||||||
|
let s = String::from_utf8_lossy(&head);
|
||||||
|
if s.contains("opendocument.text") {
|
||||||
|
"odt"
|
||||||
|
} else if s.contains("opendocument.spreadsheet") {
|
||||||
|
"ods"
|
||||||
|
} else if s.contains("opendocument.presentation") {
|
||||||
|
"odp"
|
||||||
|
} else if s.contains("epub+zip") {
|
||||||
|
"epub"
|
||||||
|
} else {
|
||||||
|
"zip"
|
||||||
|
}
|
||||||
|
} else if names.iter().any(|n| n == "AndroidManifest.xml") {
|
||||||
|
"apk"
|
||||||
|
} else if has("META-INF/") {
|
||||||
|
"jar"
|
||||||
|
} else {
|
||||||
|
"zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- MP4 / MOV / HEIC (ISO BMFF)
|
||||||
|
|
||||||
|
fn isobmff(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let head = read_vec(src, off, 32).ok()?;
|
||||||
|
let brand = head.get(8..12)?.to_vec();
|
||||||
|
let mut p = 0u64;
|
||||||
|
let mut saw_moov = false;
|
||||||
|
let mut saw_mdat = false;
|
||||||
|
let total = src.size().saturating_sub(off);
|
||||||
|
for _ in 0..100_000 {
|
||||||
|
let b = read_vec(src, off + p, 16).ok()?;
|
||||||
|
if b.len() < 8 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as u64;
|
||||||
|
let ty = [b[4], b[5], b[6], b[7]];
|
||||||
|
let known = matches!(
|
||||||
|
&ty,
|
||||||
|
b"ftyp"
|
||||||
|
| b"moov"
|
||||||
|
| b"mdat"
|
||||||
|
| b"free"
|
||||||
|
| b"skip"
|
||||||
|
| b"wide"
|
||||||
|
| b"uuid"
|
||||||
|
| b"meta"
|
||||||
|
| b"moof"
|
||||||
|
| b"mfra"
|
||||||
|
| b"sidx"
|
||||||
|
| b"pnot"
|
||||||
|
| b"styp"
|
||||||
|
| b"pdin"
|
||||||
|
| b"junk"
|
||||||
|
);
|
||||||
|
if !known {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if size == 1 {
|
||||||
|
if b.len() < 16 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size = u64::from_be_bytes(b[8..16].try_into().unwrap());
|
||||||
|
} else if size == 0 {
|
||||||
|
// Até o fim do arquivo: tamanho desconhecido.
|
||||||
|
size = total - p;
|
||||||
|
}
|
||||||
|
if size < 8 || p + size > max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match &ty {
|
||||||
|
b"moov" | b"moof" => saw_moov = true,
|
||||||
|
b"mdat" => saw_mdat = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
p += size;
|
||||||
|
if p >= total {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p < 16 || !(saw_moov || saw_mdat) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ext = match &brand[..] {
|
||||||
|
b"qt " => "mov",
|
||||||
|
b"heic" | b"heix" | b"mif1" | b"msf1" | b"heim" | b"hevc" => "heic",
|
||||||
|
b"avif" | b"avis" => "avif",
|
||||||
|
b"M4A " | b"M4B " => "m4a",
|
||||||
|
b"M4V " => "m4v",
|
||||||
|
b"3gp4" | b"3gp5" | b"3gp6" | b"3g2a" => "3gp",
|
||||||
|
_ => "mp4",
|
||||||
|
};
|
||||||
|
let conf = if saw_moov && p <= total {
|
||||||
|
Confidence::High
|
||||||
|
} else {
|
||||||
|
Confidence::Low
|
||||||
|
};
|
||||||
|
Some(Hit {
|
||||||
|
len: p.min(total),
|
||||||
|
ext,
|
||||||
|
confidence: conf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- RIFF (AVI / WAV / WEBP)
|
||||||
|
|
||||||
|
fn riff(h: &[u8], max: u64) -> Option<Hit> {
|
||||||
|
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as u64 + 8;
|
||||||
|
let ext = match &h[8..12] {
|
||||||
|
b"AVI " => "avi",
|
||||||
|
b"WAVE" => "wav",
|
||||||
|
b"WEBP" => "webp",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if size < 16 || size > max {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Hit {
|
||||||
|
len: size + (size & 1),
|
||||||
|
ext,
|
||||||
|
confidence: Confidence::High,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- SQLite
|
||||||
|
|
||||||
|
fn sqlite(h: &[u8], max: u64) -> Option<Hit> {
|
||||||
|
if h.len() < 100 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ps = u16::from_be_bytes([h[16], h[17]]) as u64;
|
||||||
|
let ps = if ps == 1 { 65536 } else { ps };
|
||||||
|
if !(512..=65536).contains(&ps) || !ps.is_power_of_two() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pages = u32::from_be_bytes([h[28], h[29], h[30], h[31]]) as u64;
|
||||||
|
let change = &h[24..28];
|
||||||
|
let valid_for = &h[92..96];
|
||||||
|
if pages == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let len = pages * ps;
|
||||||
|
if len > max {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let conf = if change == valid_for {
|
||||||
|
Confidence::High
|
||||||
|
} else {
|
||||||
|
Confidence::Medium
|
||||||
|
};
|
||||||
|
Some(Hit {
|
||||||
|
len,
|
||||||
|
ext: "sqlite",
|
||||||
|
confidence: conf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 7z
|
||||||
|
|
||||||
|
fn sevenz(h: &[u8], max: u64) -> Option<Hit> {
|
||||||
|
if h.len() < 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let crc = u32::from_le_bytes([h[8], h[9], h[10], h[11]]);
|
||||||
|
if crc32(&h[12..32]) != crc {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let next_off = u64::from_le_bytes(h[12..20].try_into().unwrap());
|
||||||
|
let next_len = u64::from_le_bytes(h[20..28].try_into().unwrap());
|
||||||
|
let len = 32u64.checked_add(next_off)?.checked_add(next_len)?;
|
||||||
|
if len > max {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Hit {
|
||||||
|
len,
|
||||||
|
ext: "7z",
|
||||||
|
confidence: Confidence::High,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn crc32(data: &[u8]) -> u32 {
|
||||||
|
let mut crc = 0xFFFF_FFFFu32;
|
||||||
|
for &b in data {
|
||||||
|
crc ^= b as u32;
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = if crc & 1 != 0 {
|
||||||
|
(crc >> 1) ^ 0xEDB8_8320
|
||||||
|
} else {
|
||||||
|
crc >> 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!crc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- OLE2 (doc/xls/ppt legado)
|
||||||
|
|
||||||
|
fn ole(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let hdr = read_vec(src, off, 512).ok()?;
|
||||||
|
if hdr.len() < 512 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let shift = u16::from_le_bytes([hdr[30], hdr[31]]) as u32;
|
||||||
|
if !(shift == 9 || shift == 12) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ssz = 1u64 << shift;
|
||||||
|
let n_fat = u32::from_le_bytes(hdr[44..48].try_into().unwrap()) as usize;
|
||||||
|
if n_fat == 0 || n_fat > 109 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut last_used = 0u64;
|
||||||
|
for i in 0..n_fat {
|
||||||
|
let sec = u32::from_le_bytes(hdr[76 + i * 4..80 + i * 4].try_into().unwrap()) as u64;
|
||||||
|
let fat = read_vec(src, off + (sec + 1) * ssz, ssz as usize).ok()?;
|
||||||
|
for (j, e) in fat.chunks_exact(4).enumerate() {
|
||||||
|
let v = u32::from_le_bytes(e.try_into().unwrap());
|
||||||
|
if v != 0xFFFF_FFFF {
|
||||||
|
last_used = last_used.max(i as u64 * (ssz / 4) + j as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let len = (last_used + 2) * ssz;
|
||||||
|
if len > max {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let data = read_vec(src, off, len.min(8 << 20) as usize).ok()?;
|
||||||
|
let u16s = |s: &str| -> Vec<u8> { s.encode_utf16().flat_map(|c| c.to_le_bytes()).collect() };
|
||||||
|
let ext = if find(&data, &u16s("WordDocument")).is_some() {
|
||||||
|
"doc"
|
||||||
|
} else if find(&data, &u16s("Workbook")).is_some() || find(&data, &u16s("Book")).is_some() {
|
||||||
|
"xls"
|
||||||
|
} else if find(&data, &u16s("PowerPoint Document")).is_some() {
|
||||||
|
"ppt"
|
||||||
|
} else {
|
||||||
|
"ole"
|
||||||
|
};
|
||||||
|
Some(Hit {
|
||||||
|
len,
|
||||||
|
ext,
|
||||||
|
confidence: Confidence::High,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- MP3
|
||||||
|
|
||||||
|
fn mp3(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let h = read_vec(src, off, 10).ok()?;
|
||||||
|
if h.len() < 10 || h[3] == 0xFF || h[4] == 0xFF {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let tag = ((h[6] as u64 & 0x7F) << 21)
|
||||||
|
| ((h[7] as u64 & 0x7F) << 14)
|
||||||
|
| ((h[8] as u64 & 0x7F) << 7)
|
||||||
|
| (h[9] as u64 & 0x7F);
|
||||||
|
let mut p = 10 + tag;
|
||||||
|
let mut frames = 0u32;
|
||||||
|
loop {
|
||||||
|
let f = read_vec(src, off + p, 4).ok()?;
|
||||||
|
if f.len() < 4 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(len) = mpeg_frame_len(&f) else { break };
|
||||||
|
p += len;
|
||||||
|
frames += 1;
|
||||||
|
if p > max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if frames < 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Possível tag ID3v1 de 128 bytes ao final.
|
||||||
|
if let Ok(t) = read_vec(src, off + p, 3) {
|
||||||
|
if t == b"TAG" {
|
||||||
|
p += 128;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Hit {
|
||||||
|
len: p,
|
||||||
|
ext: "mp3",
|
||||||
|
confidence: Confidence::Medium,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mpeg_frame_len(h: &[u8]) -> Option<u64> {
|
||||||
|
if h[0] != 0xFF || h[1] & 0xE0 != 0xE0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ver = (h[1] >> 3) & 3; // 3 = MPEG1, 2 = MPEG2, 0 = 2.5
|
||||||
|
let layer = (h[1] >> 1) & 3; // 1 = layer III
|
||||||
|
if ver == 1 || layer != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let br_idx = (h[2] >> 4) as usize;
|
||||||
|
let sr_idx = ((h[2] >> 2) & 3) as usize;
|
||||||
|
if br_idx == 0 || br_idx == 15 || sr_idx == 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
const BR1: [u32; 16] = [
|
||||||
|
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0,
|
||||||
|
];
|
||||||
|
const BR2: [u32; 16] = [
|
||||||
|
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0,
|
||||||
|
];
|
||||||
|
const SR1: [u32; 3] = [44100, 48000, 32000];
|
||||||
|
let (br, sr, coef) = if ver == 3 {
|
||||||
|
(BR1[br_idx], SR1[sr_idx], 144)
|
||||||
|
} else {
|
||||||
|
(BR2[br_idx], SR1[sr_idx] / if ver == 2 { 2 } else { 4 }, 72)
|
||||||
|
};
|
||||||
|
let pad = ((h[2] >> 1) & 1) as u32;
|
||||||
|
Some((coef * br * 1000 / sr + pad) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- PE (exe/dll)
|
||||||
|
|
||||||
|
fn pe(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||||
|
let h = read_vec(src, off, 4096).ok()?;
|
||||||
|
if h.len() < 0x40 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lfanew = u32::from_le_bytes(h[0x3C..0x40].try_into().unwrap()) as usize;
|
||||||
|
if lfanew + 24 > h.len() || &h[lfanew..lfanew + 4] != b"PE\0\0" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let nsec = u16::from_le_bytes([h[lfanew + 6], h[lfanew + 7]]) as usize;
|
||||||
|
let opt = u16::from_le_bytes([h[lfanew + 20], h[lfanew + 21]]) as usize;
|
||||||
|
let mut s = lfanew + 24 + opt;
|
||||||
|
let mut end = 0u64;
|
||||||
|
for _ in 0..nsec.min(96) {
|
||||||
|
if s + 40 > h.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let raw_size = u32::from_le_bytes(h[s + 16..s + 20].try_into().unwrap()) as u64;
|
||||||
|
let raw_ptr = u32::from_le_bytes(h[s + 20..s + 24].try_into().unwrap()) as u64;
|
||||||
|
end = end.max(raw_ptr + raw_size);
|
||||||
|
s += 40;
|
||||||
|
}
|
||||||
|
if end < 512 || end > max {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ext = if u16::from_le_bytes([h[lfanew + 22], h[lfanew + 23]]) & 0x2000 != 0 {
|
||||||
|
"dll"
|
||||||
|
} else {
|
||||||
|
"exe"
|
||||||
|
};
|
||||||
|
Some(Hit {
|
||||||
|
len: end,
|
||||||
|
ext,
|
||||||
|
confidence: Confidence::Medium,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! Acesso somente leitura a discos, partições e imagens.
|
||||||
|
//!
|
||||||
|
//! Nenhum tipo deste módulo expõe escrita: a origem nunca é modificada.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Origem de bytes legível por posição absoluta.
|
||||||
|
pub trait Source: Send + Sync {
|
||||||
|
fn size(&self) -> u64;
|
||||||
|
/// Lê até `buf.len()` bytes a partir de `offset`. Retorna 0 no fim.
|
||||||
|
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Source + ?Sized> Source for Arc<T> {
|
||||||
|
fn size(&self) -> u64 {
|
||||||
|
(**self).size()
|
||||||
|
}
|
||||||
|
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
(**self).read_at(offset, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê exatamente `buf.len()` bytes, ou falha com `UnexpectedEof`.
|
||||||
|
pub fn read_exact_at(src: &dyn Source, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
|
||||||
|
while !buf.is_empty() {
|
||||||
|
let n = src.read_at(offset, buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::UnexpectedEof,
|
||||||
|
"fim da origem",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
offset += n as u64;
|
||||||
|
buf = &mut buf[n..];
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê o máximo possível (pode devolver menos que `len` no fim da origem).
|
||||||
|
pub fn read_vec(src: &dyn Source, offset: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||||
|
let mut v = vec![0u8; len];
|
||||||
|
let mut got = 0;
|
||||||
|
while got < len {
|
||||||
|
let n = src.read_at(offset + got as u64, &mut v[got..])?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
got += n;
|
||||||
|
}
|
||||||
|
v.truncate(got);
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arquivo de imagem ou dispositivo de blocos aberto somente para leitura.
|
||||||
|
pub struct FileSource {
|
||||||
|
file: File,
|
||||||
|
size: u64,
|
||||||
|
/// Dispositivos brutos (Windows/macOS) exigem leituras alinhadas ao setor.
|
||||||
|
align: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSource {
|
||||||
|
pub fn open(path: &Path) -> io::Result<Self> {
|
||||||
|
let meta = std::fs::metadata(path)?;
|
||||||
|
let is_device = is_device(&meta);
|
||||||
|
let file = open_readonly(path, is_device)?;
|
||||||
|
let size = if is_device {
|
||||||
|
device_size(&file, path)?
|
||||||
|
} else {
|
||||||
|
meta.len()
|
||||||
|
};
|
||||||
|
let align = if is_device && cfg!(any(windows, target_os = "macos")) {
|
||||||
|
4096
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
Ok(Self { file, size, align })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_read(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
std::os::unix::fs::FileExt::read_at(&self.file, buf, offset)
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
std::os::windows::fs::FileExt::seek_read(&self.file, buf, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source for FileSource {
|
||||||
|
fn size(&self) -> u64 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
if offset >= self.size || buf.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let want = buf.len().min((self.size - offset) as usize);
|
||||||
|
if self.align <= 1 {
|
||||||
|
return self.raw_read(offset, &mut buf[..want]);
|
||||||
|
}
|
||||||
|
// Leitura alinhada para dispositivos brutos.
|
||||||
|
let a = self.align;
|
||||||
|
let start = offset / a * a;
|
||||||
|
let end = ((offset + want as u64).div_ceil(a) * a).min(self.size.div_ceil(a) * a);
|
||||||
|
let mut tmp = vec![0u8; (end - start) as usize];
|
||||||
|
let mut got = 0;
|
||||||
|
while got < tmp.len() {
|
||||||
|
let n = self.raw_read(start + got as u64, &mut tmp[got..])?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
got += n;
|
||||||
|
}
|
||||||
|
let skip = (offset - start) as usize;
|
||||||
|
if got <= skip {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let n = (got - skip).min(want);
|
||||||
|
buf[..n].copy_from_slice(&tmp[skip..skip + n]);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Janela [start, start+len) de outra origem (uma partição, por exemplo).
|
||||||
|
pub struct SubSource {
|
||||||
|
inner: Arc<dyn Source>,
|
||||||
|
start: u64,
|
||||||
|
len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubSource {
|
||||||
|
pub fn new(inner: Arc<dyn Source>, start: u64, len: u64) -> Self {
|
||||||
|
let len = len.min(inner.size().saturating_sub(start));
|
||||||
|
Self { inner, start, len }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source for SubSource {
|
||||||
|
fn size(&self) -> u64 {
|
||||||
|
self.len
|
||||||
|
}
|
||||||
|
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
if offset >= self.len {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let want = buf.len().min((self.len - offset) as usize);
|
||||||
|
self.inner.read_at(self.start + offset, &mut buf[..want])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Origem em memória (testes e dados pequenos).
|
||||||
|
pub struct MemSource(pub Vec<u8>);
|
||||||
|
|
||||||
|
impl Source for MemSource {
|
||||||
|
fn size(&self) -> u64 {
|
||||||
|
self.0.len() as u64
|
||||||
|
}
|
||||||
|
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
let off = offset as usize;
|
||||||
|
if off >= self.0.len() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let n = buf.len().min(self.0.len() - off);
|
||||||
|
buf[..n].copy_from_slice(&self.0[off..off + n]);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_device(meta: &std::fs::Metadata) -> bool {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::FileTypeExt;
|
||||||
|
let ft = meta.file_type();
|
||||||
|
ft.is_block_device() || ft.is_char_device()
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = meta;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
|
||||||
|
std::fs::OpenOptions::new().read(true).open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
// FILE_SHARE_READ | FILE_SHARE_WRITE: não bloqueia outros processos.
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(0x1 | 0x2)
|
||||||
|
.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
const DKIOCGETBLOCKSIZE: libc::c_ulong = 0x4004_6418;
|
||||||
|
const DKIOCGETBLOCKCOUNT: libc::c_ulong = 0x4008_6419;
|
||||||
|
let fd = file.as_raw_fd();
|
||||||
|
let (mut bs, mut bc) = (0u32, 0u64);
|
||||||
|
// SAFETY: ioctls de leitura com ponteiros para variáveis locais válidas.
|
||||||
|
let ok = unsafe {
|
||||||
|
libc::ioctl(fd, DKIOCGETBLOCKSIZE, &mut bs) == 0
|
||||||
|
&& libc::ioctl(fd, DKIOCGETBLOCKCOUNT, &mut bc) == 0
|
||||||
|
};
|
||||||
|
if ok {
|
||||||
|
return Ok(bs as u64 * bc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use std::io::{Seek, SeekFrom};
|
||||||
|
let mut f = file.try_clone()?;
|
||||||
|
f.seek(SeekFrom::End(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abre um dispositivo Windows (`\\.\PhysicalDriveN`) e obtém o tamanho.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn open_windows_device(path: &Path) -> io::Result<FileSource> {
|
||||||
|
let file = open_readonly(path, true)?;
|
||||||
|
let size = device_size(&file, path)?;
|
||||||
|
Ok(FileSource {
|
||||||
|
file,
|
||||||
|
size,
|
||||||
|
align: 4096,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
|
use windows_sys::Win32::System::Ioctl::{GET_LENGTH_INFORMATION, IOCTL_DISK_GET_LENGTH_INFO};
|
||||||
|
use windows_sys::Win32::System::IO::DeviceIoControl;
|
||||||
|
let mut info = GET_LENGTH_INFORMATION { Length: 0 };
|
||||||
|
let mut ret = 0u32;
|
||||||
|
// SAFETY: buffer de saída válido com o tamanho exato da estrutura.
|
||||||
|
let ok = unsafe {
|
||||||
|
DeviceIoControl(
|
||||||
|
file.as_raw_handle() as _,
|
||||||
|
IOCTL_DISK_GET_LENGTH_INFO,
|
||||||
|
std::ptr::null(),
|
||||||
|
0,
|
||||||
|
&mut info as *mut _ as *mut _,
|
||||||
|
std::mem::size_of::<GET_LENGTH_INFORMATION>() as u32,
|
||||||
|
&mut ret,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(info.Length as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abre um caminho (imagem ou dispositivo) como origem somente leitura.
|
||||||
|
pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if s.starts_with(r"\\.\") {
|
||||||
|
return Ok(Arc::new(open_windows_device(path)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Arc::new(FileSource::open(path)?))
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! Tipos compartilhados entre núcleo, CLI e interface gráfica.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Confidence {
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Confidence {
|
||||||
|
pub fn label_pt(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Confidence::High => "alta",
|
||||||
|
Confidence::Medium => "média",
|
||||||
|
Confidence::Low => "baixa",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Category {
|
||||||
|
Image,
|
||||||
|
Document,
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
Archive,
|
||||||
|
Database,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Category {
|
||||||
|
pub const ALL: [Category; 7] = [
|
||||||
|
Category::Image,
|
||||||
|
Category::Document,
|
||||||
|
Category::Video,
|
||||||
|
Category::Audio,
|
||||||
|
Category::Archive,
|
||||||
|
Category::Database,
|
||||||
|
Category::Other,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label_pt(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Category::Image => "Fotos",
|
||||||
|
Category::Document => "Documentos",
|
||||||
|
Category::Video => "Vídeos",
|
||||||
|
Category::Audio => "Áudio",
|
||||||
|
Category::Archive => "Compactados",
|
||||||
|
Category::Database => "Bancos de dados",
|
||||||
|
Category::Other => "Outros",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Category> {
|
||||||
|
Some(match s.to_ascii_lowercase().as_str() {
|
||||||
|
"image" | "imagem" | "fotos" => Category::Image,
|
||||||
|
"document" | "documento" | "documentos" => Category::Document,
|
||||||
|
"video" | "vídeo" | "videos" | "vídeos" => Category::Video,
|
||||||
|
"audio" | "áudio" => Category::Audio,
|
||||||
|
"archive" | "compactados" => Category::Archive,
|
||||||
|
"database" | "banco" => Category::Database,
|
||||||
|
"other" | "outros" => Category::Other,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trecho contíguo de bytes na origem (offset absoluto).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Extent {
|
||||||
|
pub offset: u64,
|
||||||
|
pub len: u64,
|
||||||
|
/// Trecho esparso: lido como zeros.
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
pub sparse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_false(b: &bool) -> bool {
|
||||||
|
!*b
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Extent {
|
||||||
|
pub fn new(offset: u64, len: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
offset,
|
||||||
|
len,
|
||||||
|
sparse: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn end(&self) -> u64 {
|
||||||
|
self.offset + self.len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
|
pub enum DataLoc {
|
||||||
|
/// Dados dentro do próprio registro (NTFS residente).
|
||||||
|
Resident {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
Extents {
|
||||||
|
extents: Vec<Extent>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Origin {
|
||||||
|
Ntfs,
|
||||||
|
Fat,
|
||||||
|
Exfat,
|
||||||
|
Carved,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Um arquivo encontrado (apagado, perdido ou reconstruído por assinatura).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Found {
|
||||||
|
pub id: u64,
|
||||||
|
pub name: String,
|
||||||
|
/// Pasta de origem, quando o sistema de arquivos a preserva.
|
||||||
|
pub dir: Option<String>,
|
||||||
|
pub ext: String,
|
||||||
|
pub category: Category,
|
||||||
|
pub size: u64,
|
||||||
|
pub origin: Origin,
|
||||||
|
pub confidence: Confidence,
|
||||||
|
pub data: DataLoc,
|
||||||
|
/// Segundos desde 1970-01-01 UTC.
|
||||||
|
pub modified: Option<i64>,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Found {
|
||||||
|
pub fn first_offset(&self) -> Option<u64> {
|
||||||
|
match &self.data {
|
||||||
|
DataLoc::Extents { extents } => extents.iter().find(|e| !e.sparse).map(|e| e.offset),
|
||||||
|
DataLoc::Resident { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn full_path(&self) -> String {
|
||||||
|
match &self.dir {
|
||||||
|
Some(d) if !d.is_empty() => format!("{}/{}", d.trim_end_matches('/'), self.name),
|
||||||
|
_ => self.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FsKind {
|
||||||
|
Ntfs,
|
||||||
|
Fat12,
|
||||||
|
Fat16,
|
||||||
|
Fat32,
|
||||||
|
Exfat,
|
||||||
|
Ext,
|
||||||
|
Apfs,
|
||||||
|
HfsPlus,
|
||||||
|
Bitlocker,
|
||||||
|
Luks,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsKind {
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FsKind::Ntfs => "NTFS",
|
||||||
|
FsKind::Fat12 => "FAT12",
|
||||||
|
FsKind::Fat16 => "FAT16",
|
||||||
|
FsKind::Fat32 => "FAT32",
|
||||||
|
FsKind::Exfat => "exFAT",
|
||||||
|
FsKind::Ext => "ext2/3/4",
|
||||||
|
FsKind::Apfs => "APFS",
|
||||||
|
FsKind::HfsPlus => "HFS+",
|
||||||
|
FsKind::Bitlocker => "BitLocker",
|
||||||
|
FsKind::Luks => "LUKS",
|
||||||
|
FsKind::Unknown => "desconhecido",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume cifrado: só detectamos (v1), sem desbloquear.
|
||||||
|
pub fn encrypted(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
FsKind::Bitlocker => Some("BitLocker"),
|
||||||
|
FsKind::Luks => Some("LUKS"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Há leitor de metadados (arquivos apagados com nome) para este sistema.
|
||||||
|
pub fn has_metadata_scanner(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PartInfo {
|
||||||
|
pub index: usize,
|
||||||
|
pub start: u64,
|
||||||
|
pub len: u64,
|
||||||
|
pub fs: FsKind,
|
||||||
|
pub label: Option<String>,
|
||||||
|
/// Encontrada por varredura, não consta da tabela de partições.
|
||||||
|
pub lost: bool,
|
||||||
|
pub encrypted: Option<String>,
|
||||||
|
/// Descrição do tipo na tabela (ex.: "Microsoft basic data").
|
||||||
|
pub table_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Gap {
|
||||||
|
pub start: u64,
|
||||||
|
pub len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct DiskLayout {
|
||||||
|
pub size: u64,
|
||||||
|
/// "gpt", "mbr" ou "none".
|
||||||
|
pub table: String,
|
||||||
|
pub partitions: Vec<PartInfo>,
|
||||||
|
/// Espaço não particionado relevante (> 1 MiB).
|
||||||
|
pub gaps: Vec<Gap>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ScanMode {
|
||||||
|
/// Só metadados dos sistemas de arquivos (segundos).
|
||||||
|
Quick,
|
||||||
|
/// Metadados + carving por assinatura em todo espaço livre/desconhecido.
|
||||||
|
Deep,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScanOptions {
|
||||||
|
pub mode: ScanMode,
|
||||||
|
/// Inclui áreas ainda alocadas no carving (mais lento, acha cópias antigas).
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_allocated: bool,
|
||||||
|
/// Limita o carving a estas categorias (vazio = todas).
|
||||||
|
#[serde(default)]
|
||||||
|
pub categories: Vec<Category>,
|
||||||
|
/// Tamanho máximo aceito para arquivos carved (bytes).
|
||||||
|
#[serde(default = "default_max_carve")]
|
||||||
|
pub max_carve_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_carve() -> u64 {
|
||||||
|
8 * 1024 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScanOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: ScanMode::Deep,
|
||||||
|
include_allocated: false,
|
||||||
|
categories: vec![],
|
||||||
|
max_carve_size: default_max_carve(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
|
use guara_core::scan::{self, Outcome, Progress, Sink};
|
||||||
|
use guara_core::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Collect {
|
||||||
|
pub found: Vec<Found>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
pub layout: Option<DiskLayout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sink for Collect {
|
||||||
|
fn found(&mut self, f: Found) {
|
||||||
|
self.found.push(f);
|
||||||
|
}
|
||||||
|
fn confirm(&mut self, id: u64, c: Confidence) {
|
||||||
|
if let Some(f) = self.found.iter_mut().find(|f| f.id == id) {
|
||||||
|
f.confidence = f.confidence.max(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn progress(&mut self, _: Progress) {}
|
||||||
|
fn warning(&mut self, w: String) {
|
||||||
|
self.warnings.push(w);
|
||||||
|
}
|
||||||
|
fn layout(&mut self, l: &DiskLayout) {
|
||||||
|
self.layout = Some(l.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_path(path: &Path, opts: &ScanOptions) -> Collect {
|
||||||
|
let src = source::open_path(path).unwrap();
|
||||||
|
let mut sink = Collect::default();
|
||||||
|
let out = scan::run(src.as_ref(), opts, None, &[], &|| false, &mut sink);
|
||||||
|
assert!(matches!(out, Outcome::Completed));
|
||||||
|
sink
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recover_all(path: &Path, found: &[Found], dest: &Path) -> recover::RecoverReport {
|
||||||
|
let src = source::open_path(path).unwrap();
|
||||||
|
let refs: Vec<&Found> = found.iter().collect();
|
||||||
|
recover::recover(src.as_ref(), &refs, dest, &|| false, &mut |_, _, _| {}).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_file(p: &Path) -> String {
|
||||||
|
sha256(&std::fs::read(p).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256(b: &[u8]) -> String {
|
||||||
|
Sha256::digest(b)
|
||||||
|
.iter()
|
||||||
|
.map(|x| format!("{x:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gera (uma vez) as imagens de teste; `None` se as ferramentas não existirem.
|
||||||
|
pub fn fixtures() -> Option<PathBuf> {
|
||||||
|
static ONCE: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||||
|
ONCE.get_or_init(fixtures_inner).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixtures_inner() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let dir = root.join("target/fixtures");
|
||||||
|
if dir.join("ext4.img").exists() && dir.join("expected-ntfs.txt").exists() {
|
||||||
|
return Some(dir);
|
||||||
|
}
|
||||||
|
let ok = Command::new("bash")
|
||||||
|
.arg(root.join("scripts/make-fixtures.sh"))
|
||||||
|
.arg(&dir)
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
Some(dir)
|
||||||
|
} else if std::env::var("GUARA_REQUIRE_FIXTURES").is_ok() {
|
||||||
|
panic!("não foi possível gerar os fixtures");
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"SKIP: ferramentas de fixture ausentes (mtools, ntfs-3g, e2fsprogs, imagemagick, zip)"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expected(dir: &Path, fs: &str) -> Vec<(String, String)> {
|
||||||
|
std::fs::read_to_string(dir.join(format!("expected-{fs}.txt")))
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(|l| {
|
||||||
|
let (h, p) = l.split_once(' ').unwrap();
|
||||||
|
(h.to_string(), p.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hashes_in(dir: &Path) -> HashMap<String, PathBuf> {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
fn walk(d: &Path, m: &mut HashMap<String, PathBuf>) {
|
||||||
|
for e in std::fs::read_dir(d).unwrap().flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.is_dir() {
|
||||||
|
walk(&p, m);
|
||||||
|
} else if p.file_name().unwrap() != "guara-manifesto.json" {
|
||||||
|
m.insert(sha256_file(&p), p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(dir, &mut m);
|
||||||
|
m
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! Discos com tabela de partições, espaço não particionado, partições perdidas e cifragem.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
use guara_core::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
fn mbr(parts: &[(u8, u64, u64)]) -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; 512];
|
||||||
|
for (i, (ty, start, len)) in parts.iter().enumerate() {
|
||||||
|
let e = &mut s[446 + i * 16..462 + i * 16];
|
||||||
|
e[4] = *ty;
|
||||||
|
e[8..12].copy_from_slice(&((start / 512) as u32).to_le_bytes());
|
||||||
|
e[12..16].copy_from_slice(&((len / 512) as u32).to_le_bytes());
|
||||||
|
}
|
||||||
|
s[510] = 0x55;
|
||||||
|
s[511] = 0xAA;
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png(extra: usize) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
let chunk = |ty: &[u8; 4], data: &[u8]| {
|
||||||
|
let mut c = (data.len() as u32).to_be_bytes().to_vec();
|
||||||
|
c.extend_from_slice(ty);
|
||||||
|
c.extend_from_slice(data);
|
||||||
|
c.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
v.extend(chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||||
|
v.extend(chunk(
|
||||||
|
b"IDAT",
|
||||||
|
&(0..extra).map(|i| (i * 31 % 251) as u8).collect::<Vec<_>>(),
|
||||||
|
));
|
||||||
|
v.extend(chunk(b"IEND", &[]));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disco: [1 MiB vazio][partição FAT32 do fixture][8 MiB não particionado com um PNG]
|
||||||
|
#[test]
|
||||||
|
fn particao_mbr_e_espaco_nao_particionado() {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let fat = std::fs::read(dir.join("fat32.img")).unwrap();
|
||||||
|
let plen = fat.len() as u64;
|
||||||
|
let gap_start = MIB + plen;
|
||||||
|
let mut disk = vec![0u8; (gap_start + 8 * MIB) as usize];
|
||||||
|
disk[..512].copy_from_slice(&mbr(&[(0x0C, MIB, plen)]));
|
||||||
|
disk[MIB as usize..(MIB + plen) as usize].copy_from_slice(&fat);
|
||||||
|
let lost_png = png(40_000);
|
||||||
|
let at = (gap_start + 3 * MIB) as usize;
|
||||||
|
disk[at..at + lost_png.len()].copy_from_slice(&lost_png);
|
||||||
|
let path = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
path.as_file().write_all(&disk).unwrap();
|
||||||
|
|
||||||
|
let res = scan_path(path.path(), &ScanOptions::default());
|
||||||
|
let layout = res.layout.unwrap();
|
||||||
|
assert_eq!(layout.table, "mbr");
|
||||||
|
assert_eq!(layout.partitions.len(), 1);
|
||||||
|
assert_eq!(layout.partitions[0].fs, FsKind::Fat32);
|
||||||
|
assert_eq!(layout.partitions[0].start, MIB);
|
||||||
|
assert!(
|
||||||
|
layout.gaps.iter().any(|g| g.start == gap_start),
|
||||||
|
"gap: {:?}",
|
||||||
|
layout.gaps
|
||||||
|
);
|
||||||
|
// Metadados da partição (offsets corrigidos pela posição da partição)...
|
||||||
|
let fat_found: Vec<_> = res
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.origin == Origin::Fat)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(fat_found.len(), 7);
|
||||||
|
// ... e o PNG no espaço não particionado.
|
||||||
|
let carved: Vec<_> = res
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.origin == Origin::Carved && f.ext == "png")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(carved.len(), 1);
|
||||||
|
assert_eq!(carved[0].first_offset(), Some(gap_start + 3 * MIB));
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
recover_all(path.path(), &res.found, out.path());
|
||||||
|
assert!(hashes_in(out.path()).contains_key(&sha256(&lost_png)));
|
||||||
|
for (h, p) in expected(&dir, "fat32") {
|
||||||
|
assert!(hashes_in(out.path()).contains_key(&h), "faltou {p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn particao_perdida_e_encontrada_sem_tabela() {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let ntfs = std::fs::read(dir.join("ntfs.img")).unwrap();
|
||||||
|
let off = 5 * MIB;
|
||||||
|
let mut disk = vec![0u8; (off as usize) + ntfs.len() + MIB as usize * 2];
|
||||||
|
disk[off as usize..off as usize + ntfs.len()].copy_from_slice(&ntfs);
|
||||||
|
let path = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
path.as_file().write_all(&disk).unwrap();
|
||||||
|
let res = scan_path(path.path(), &ScanOptions::default());
|
||||||
|
let layout = res.layout.unwrap();
|
||||||
|
let lost: Vec<_> = layout.partitions.iter().filter(|p| p.lost).collect();
|
||||||
|
assert_eq!(lost.len(), 1, "{layout:?}");
|
||||||
|
assert_eq!(lost[0].fs, FsKind::Ntfs);
|
||||||
|
assert_eq!(lost[0].start, off);
|
||||||
|
assert_eq!(
|
||||||
|
res.found
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.origin == Origin::Ntfs)
|
||||||
|
.count(),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_cifrado_e_detectado_e_nao_varrido() {
|
||||||
|
let mut disk = vec![0u8; 8 * MIB as usize];
|
||||||
|
disk[..512].copy_from_slice(&mbr(&[(0x07, MIB, 4 * MIB), (0x83, 5 * MIB, 2 * MIB)]));
|
||||||
|
// BitLocker na partição 1, LUKS na 2.
|
||||||
|
disk[MIB as usize + 3..MIB as usize + 11].copy_from_slice(b"-FVE-FS-");
|
||||||
|
let l = 5 * MIB as usize;
|
||||||
|
disk[l..l + 6].copy_from_slice(b"LUKS\xBA\xBE");
|
||||||
|
// Um PNG "dentro" do volume cifrado não deve ser tocado.
|
||||||
|
let p = png(10_000);
|
||||||
|
let at = (2 * MIB) as usize;
|
||||||
|
disk[at..at + p.len()].copy_from_slice(&p);
|
||||||
|
let path = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
path.as_file().write_all(&disk).unwrap();
|
||||||
|
let res = scan_path(path.path(), &ScanOptions::default());
|
||||||
|
let layout = res.layout.unwrap();
|
||||||
|
assert_eq!(layout.partitions[0].encrypted.as_deref(), Some("BitLocker"));
|
||||||
|
assert_eq!(layout.partitions[1].encrypted.as_deref(), Some("LUKS"));
|
||||||
|
assert!(res.warnings.iter().any(|w| w.contains("BitLocker")));
|
||||||
|
assert!(
|
||||||
|
res.found.is_empty(),
|
||||||
|
"não deve varrer volume cifrado: {:?}",
|
||||||
|
res.found.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gpt_e_lido() {
|
||||||
|
// GPT mínimo com 1 partição de dados.
|
||||||
|
let mut disk = vec![0u8; 8 * MIB as usize];
|
||||||
|
disk[..512].copy_from_slice(&mbr(&[(0xEE, 512, 8 * MIB - 512)]));
|
||||||
|
let mut h = vec![0u8; 512];
|
||||||
|
h[0..8].copy_from_slice(b"EFI PART");
|
||||||
|
h[72..80].copy_from_slice(&2u64.to_le_bytes());
|
||||||
|
h[80..84].copy_from_slice(&128u32.to_le_bytes());
|
||||||
|
h[84..88].copy_from_slice(&128u32.to_le_bytes());
|
||||||
|
disk[512..1024].copy_from_slice(&h);
|
||||||
|
let e = &mut disk[1024..1024 + 128];
|
||||||
|
e[0..16].copy_from_slice(&[
|
||||||
|
0xA2, 0xA0, 0xD0, 0xEB, 0xE5, 0xB9, 0x33, 0x44, 0x87, 0xC0, 0x68, 0xB6, 0xB7, 0x26, 0x99,
|
||||||
|
0xC7,
|
||||||
|
]);
|
||||||
|
e[32..40].copy_from_slice(&(MIB / 512).to_le_bytes());
|
||||||
|
e[40..48].copy_from_slice(&((5 * MIB) / 512 - 1).to_le_bytes());
|
||||||
|
let name: Vec<u8> = "Dados"
|
||||||
|
.encode_utf16()
|
||||||
|
.flat_map(|c| c.to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
e[56..56 + name.len()].copy_from_slice(&name);
|
||||||
|
let path = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
path.as_file().write_all(&disk).unwrap();
|
||||||
|
let src = source::open_path(path.path()).unwrap();
|
||||||
|
let l = partition::analyze(src.as_ref(), false);
|
||||||
|
assert_eq!(l.table, "gpt");
|
||||||
|
assert_eq!(l.partitions.len(), 1);
|
||||||
|
assert_eq!(l.partitions[0].start, MIB);
|
||||||
|
assert_eq!(l.partitions[0].label.as_deref(), Some("Dados"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imagem_forense_e_identica_e_origem_intacta() {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let src_path = dir.join("fat32.img");
|
||||||
|
let src = source::open_path(&src_path).unwrap();
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
let dest = out.path().join("copia.img");
|
||||||
|
let rep = image::create_image(src.as_ref(), &dest, &|| false, &mut |_, _| {}).unwrap();
|
||||||
|
assert!(rep.bad_ranges.is_empty());
|
||||||
|
assert_eq!(rep.sha256, sha256_file(&src_path));
|
||||||
|
assert_eq!(sha256_file(&dest), sha256_file(&src_path));
|
||||||
|
// Não sobrescreve arquivo existente.
|
||||||
|
assert!(image::create_image(src.as_ref(), &dest, &|| false, &mut |_, _| {}).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pausa_e_retoma_encontra_o_mesmo() {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let path = dir.join("ext4.img");
|
||||||
|
let full = scan_path(&path, &ScanOptions::default());
|
||||||
|
let src = source::open_path(&path).unwrap();
|
||||||
|
// Cancela logo na 3ª consulta, retoma do checkpoint.
|
||||||
|
let n = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 2;
|
||||||
|
let mut sink = Collect::default();
|
||||||
|
let out = scan::run(
|
||||||
|
src.as_ref(),
|
||||||
|
&ScanOptions::default(),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
&cancel,
|
||||||
|
&mut sink,
|
||||||
|
);
|
||||||
|
let scan::Outcome::Interrupted(cp) = out else {
|
||||||
|
panic!("deveria ter pausado")
|
||||||
|
};
|
||||||
|
let before = sink.found.clone();
|
||||||
|
let out = scan::run(
|
||||||
|
src.as_ref(),
|
||||||
|
&ScanOptions::default(),
|
||||||
|
Some(cp),
|
||||||
|
&before,
|
||||||
|
&|| false,
|
||||||
|
&mut sink,
|
||||||
|
);
|
||||||
|
assert!(matches!(out, scan::Outcome::Completed));
|
||||||
|
let mut a: Vec<_> = full
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.map(|f| (f.first_offset(), f.size))
|
||||||
|
.collect();
|
||||||
|
let mut b: Vec<_> = sink
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.map(|f| (f.first_offset(), f.size))
|
||||||
|
.collect();
|
||||||
|
a.sort();
|
||||||
|
b.sort();
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! exFAT: injeta um conjunto de entradas APAGADO num sistema criado por mkfs.exfat.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
use guara_core::*;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exfat_apagado_e_recuperado_com_nome() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let img = dir.path().join("e.img");
|
||||||
|
let ok = Command::new("truncate")
|
||||||
|
.args(["-s", "32M"])
|
||||||
|
.arg(&img)
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& Command::new("mkfs.exfat")
|
||||||
|
.arg(&img)
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !ok {
|
||||||
|
eprintln!("SKIP: mkfs.exfat ausente");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut d = std::fs::read(&img).unwrap();
|
||||||
|
let ss = 1usize << d[108];
|
||||||
|
let cs = ss << d[109];
|
||||||
|
let heap = u32::from_le_bytes(d[88..92].try_into().unwrap()) as usize * ss;
|
||||||
|
let root = u32::from_le_bytes(d[96..100].try_into().unwrap()) as usize;
|
||||||
|
let root_off = heap + (root - 2) * cs;
|
||||||
|
// Primeira entrada livre da raiz.
|
||||||
|
let mut slot = root_off;
|
||||||
|
while d[slot] != 0 {
|
||||||
|
slot += 32;
|
||||||
|
}
|
||||||
|
let content: Vec<u8> = (0..70_000u32)
|
||||||
|
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
|
||||||
|
.collect();
|
||||||
|
let first_cluster = 300u32;
|
||||||
|
let data_off = heap + (first_cluster as usize - 2) * cs;
|
||||||
|
d[data_off..data_off + content.len()].copy_from_slice(&content);
|
||||||
|
let name = "relatório final.txt";
|
||||||
|
let units: Vec<u16> = name.encode_utf16().collect();
|
||||||
|
// 0x05 = arquivo apagado (0x85 sem o bit "em uso"), secundárias 0x40 e 0x41.
|
||||||
|
let n_name = units.len().div_ceil(15);
|
||||||
|
d[slot] = 0x05;
|
||||||
|
d[slot + 1] = (1 + n_name) as u8;
|
||||||
|
d[slot + 12..slot + 14].copy_from_slice(&0x6000u16.to_le_bytes()); // hora
|
||||||
|
d[slot + 14..slot + 16].copy_from_slice(&((44u16 << 9) | (6 << 5) | 15).to_le_bytes()); // 2024-06-15
|
||||||
|
let st = slot + 32;
|
||||||
|
d[st] = 0x40;
|
||||||
|
d[st + 1] = 0x03; // alocação possível + sem cadeia na FAT
|
||||||
|
d[st + 3] = units.len() as u8;
|
||||||
|
d[st + 20..st + 24].copy_from_slice(&first_cluster.to_le_bytes());
|
||||||
|
d[st + 24..st + 32].copy_from_slice(&(content.len() as u64).to_le_bytes());
|
||||||
|
d[st + 8..st + 16].copy_from_slice(&(content.len() as u64).to_le_bytes());
|
||||||
|
for k in 0..n_name {
|
||||||
|
let e = slot + 64 + k * 32;
|
||||||
|
d[e] = 0x41;
|
||||||
|
for (j, u) in units.iter().skip(k * 15).take(15).enumerate() {
|
||||||
|
d[e + 2 + j * 2..e + 4 + j * 2].copy_from_slice(&u.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::write(&img, &d).unwrap();
|
||||||
|
|
||||||
|
let res = scan_path(
|
||||||
|
&img,
|
||||||
|
&ScanOptions {
|
||||||
|
mode: ScanMode::Quick,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(res.warnings.is_empty(), "{:?}", res.warnings);
|
||||||
|
assert_eq!(res.found.len(), 1);
|
||||||
|
let f = &res.found[0];
|
||||||
|
assert_eq!(f.name, "relatório final.txt");
|
||||||
|
assert_eq!(f.origin, Origin::Exfat);
|
||||||
|
assert_eq!(f.size, content.len() as u64);
|
||||||
|
assert!(f.modified.is_some());
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
recover_all(&img, &res.found, out.path());
|
||||||
|
assert!(hashes_in(out.path()).contains_key(&sha256(&content)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Recuperação de ponta a ponta sobre imagens reais (FAT32, NTFS, ext4).
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
use guara_core::*;
|
||||||
|
|
||||||
|
fn check(fs: &str, only_carvable: bool) {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let img = dir.join(format!("{fs}.img"));
|
||||||
|
let before = sha256_file(&img);
|
||||||
|
let res = scan_path(&img, &ScanOptions::default());
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
let rep = recover_all(&img, &res.found, out.path());
|
||||||
|
assert!(rep.failed.is_empty(), "falhas: {:?}", rep.failed);
|
||||||
|
let got = hashes_in(out.path());
|
||||||
|
for (hash, path) in expected(&dir, fs) {
|
||||||
|
let carvable = [".jpg", ".png", ".pdf", ".docx", ".db"]
|
||||||
|
.iter()
|
||||||
|
.any(|e| path.ends_with(e));
|
||||||
|
if only_carvable && !carvable {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
got.contains_key(&hash),
|
||||||
|
"[{fs}] não recuperado bit a bit: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Os dois métodos juntos não podem listar o mesmo conteúdo duas vezes.
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for f in &res.found {
|
||||||
|
if let Some(o) = f.first_offset() {
|
||||||
|
assert!(seen.insert(o), "[{fs}] achado duplicado no offset {o}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(before, sha256_file(&img), "a origem foi modificada!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fat32_recupera_apagados_com_nome_e_pasta() {
|
||||||
|
check("fat32", false);
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let res = scan_path(&dir.join("fat32.img"), &ScanOptions::default());
|
||||||
|
assert!(res
|
||||||
|
.found
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.dir.as_deref() == Some("Documentos/Trabalho") && f.name == "relatorio.pdf"));
|
||||||
|
assert!(
|
||||||
|
res.found.iter().all(|f| f.name != "mantida.jpg"),
|
||||||
|
"arquivo existente não deve ser listado"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ntfs_recupera_apagados() {
|
||||||
|
check("ntfs", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ext4_recupera_por_assinatura() {
|
||||||
|
check("ext4", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn varredura_rapida_nao_faz_carving() {
|
||||||
|
let Some(dir) = fixtures() else { return };
|
||||||
|
let res = scan_path(
|
||||||
|
&dir.join("ext4.img"),
|
||||||
|
&ScanOptions {
|
||||||
|
mode: ScanMode::Quick,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(res.found.is_empty());
|
||||||
|
let res = scan_path(
|
||||||
|
&dir.join("ntfs.img"),
|
||||||
|
&ScanOptions {
|
||||||
|
mode: ScanMode::Quick,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
res.found
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.origin == Origin::Ntfs)
|
||||||
|
.count(),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Gera imagens de disco de teste (FAT32, exFAT, NTFS, ext4) com arquivos
|
||||||
|
# conhecidos, apaga alguns e grava os SHA-256 esperados em <saida>/expected-<fs>.txt.
|
||||||
|
#
|
||||||
|
# Uso: scripts/make-fixtures.sh <diretório-de-saída> [fat32 exfat ntfs ext4]
|
||||||
|
# Requer (Linux): mtools, dosfstools, exfatprogs/exfat-fuse, ntfs-3g, e2fsprogs,
|
||||||
|
# imagemagick, zip, python3 e FUSE para exFAT/NTFS.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT="$(realpath -m "${1:?uso: $0 <saida> [fs...]}")"
|
||||||
|
shift || true
|
||||||
|
FSES=("${@:-fat32 exfat ntfs ext4}")
|
||||||
|
[ $# -eq 0 ] && FSES=(fat32 exfat ntfs ext4)
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
SIZE_MB=96
|
||||||
|
|
||||||
|
CONTENT="$OUT/content"
|
||||||
|
rm -rf "$CONTENT"; mkdir -p "$CONTENT/Documentos/Trabalho" "$CONTENT/Fotos"
|
||||||
|
|
||||||
|
# --- conteúdo de teste (mistura de formatos com e sem assinatura) ---
|
||||||
|
convert -size 1400x900 plasma:fractal -quality 92 "$CONTENT/Fotos/praia.jpg"
|
||||||
|
convert -size 900x700 plasma:red-blue "$CONTENT/Fotos/montanha.png"
|
||||||
|
convert -size 600x400 plasma:green-yellow "$CONTENT/Fotos/mantida.jpg"
|
||||||
|
head -c 700000 /dev/urandom | base64 > "$CONTENT/Documentos/Trabalho/notas.txt"
|
||||||
|
head -c 3000000 /dev/urandom > "$CONTENT/Documentos/dados.bin"
|
||||||
|
python3 - "$CONTENT" <<'PY'
|
||||||
|
import sys, sqlite3, os
|
||||||
|
d = sys.argv[1]
|
||||||
|
pdf = b"%PDF-1.4\n" + b"".join(b"%% linha %d preenchimento %s\n" % (i, os.urandom(40).hex().encode()) for i in range(6000)) + b"trailer\n<< /Root 1 0 R >>\n%%EOF\n"
|
||||||
|
open(d + "/Documentos/Trabalho/relatorio.pdf", "wb").write(pdf)
|
||||||
|
db = d + "/Documentos/contatos.db"
|
||||||
|
c = sqlite3.connect(db)
|
||||||
|
c.execute("create table t(id integer primary key, v text)")
|
||||||
|
c.executemany("insert into t(v) values (?)", [(os.urandom(64).hex(),) for _ in range(4000)])
|
||||||
|
c.commit(); c.close()
|
||||||
|
PY
|
||||||
|
mkdir -p "$OUT/zip" && ( cd "$OUT/zip" && rm -rf * && mkdir -p word _rels && echo '<?xml version="1.0"?><Types/>' > '[Content_Types].xml' \
|
||||||
|
&& head -c 400000 /dev/urandom | base64 > word/document.xml && zip -q -r "$CONTENT/Documentos/Trabalho/tese.docx" . )
|
||||||
|
rm -rf "$OUT/zip"
|
||||||
|
|
||||||
|
# Arquivos que serão APAGADOS (caminhos relativos a $CONTENT)
|
||||||
|
DELETE=(Fotos/praia.jpg Fotos/montanha.png Documentos/Trabalho/notas.txt Documentos/Trabalho/relatorio.pdf Documentos/Trabalho/tese.docx Documentos/contatos.db Documentos/dados.bin)
|
||||||
|
|
||||||
|
write_expected() { # $1=fs
|
||||||
|
: > "$OUT/expected-$1.txt"
|
||||||
|
for f in "${DELETE[@]}"; do
|
||||||
|
echo "$(sha256sum "$CONTENT/$f" | cut -d' ' -f1) $f" >> "$OUT/expected-$1.txt"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
mnt_cleanup() { fusermount -u "$1" 2>/dev/null || true; rmdir "$1" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
for fs in "${FSES[@]}"; do
|
||||||
|
img="$OUT/$fs.img"
|
||||||
|
rm -f "$img"; truncate -s ${SIZE_MB}M "$img"
|
||||||
|
case $fs in
|
||||||
|
fat32)
|
||||||
|
mkfs.vfat -F 32 -n GUARATEST "$img" >/dev/null
|
||||||
|
mcopy -i "$img" -s -m "$CONTENT"/* ::
|
||||||
|
for f in "${DELETE[@]}"; do mdel -i "$img" "::/$f"; done
|
||||||
|
;;
|
||||||
|
exfat)
|
||||||
|
# Sem FUSE sem privilégio, só o sistema vazio é criado aqui; o teste em Rust
|
||||||
|
# injeta as entradas (veja crates/guara-core/tests/exfat.rs).
|
||||||
|
mkfs.exfat -L GUARATEST "$img" >/dev/null
|
||||||
|
;;
|
||||||
|
ntfs)
|
||||||
|
# ntfscp só grava na raiz; "apagar" = limpar o bit "em uso" do registro da MFT,
|
||||||
|
# exatamente o que o NTFS faz (clusters e nome permanecem no disco).
|
||||||
|
mkntfs -F -Q -L GUARATEST "$img" >/dev/null 2>&1
|
||||||
|
for f in $(cd "$CONTENT" && find . -type f | sed 's|^\./||'); do
|
||||||
|
ntfscp -f "$img" "$CONTENT/$f" "/$(basename "$f")" >/dev/null 2>&1
|
||||||
|
done
|
||||||
|
python3 "$(dirname "$0")/ntfs-mark-deleted.py" "$img" "${DELETE[@]}"
|
||||||
|
;;
|
||||||
|
ext4)
|
||||||
|
mkfs.ext4 -q -F -L GUARATEST -d "$CONTENT" "$img"
|
||||||
|
for f in "${DELETE[@]}"; do debugfs -w -R "rm /$f" "$img" >/dev/null 2>&1; done
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
write_expected "$fs"
|
||||||
|
echo "ok: $img"
|
||||||
|
done
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Marca registros da MFT como apagados (limpa o bit 'em uso') para fixtures de teste.
|
||||||
|
|
||||||
|
Uso: ntfs-mark-deleted.py imagem.img caminho/arquivo1 caminho/arquivo2 ...
|
||||||
|
Só o nome base é usado (ntfscp grava na raiz).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
img, names = sys.argv[1], {os.path.basename(n) for n in sys.argv[2:]}
|
||||||
|
f = open(img, "r+b")
|
||||||
|
boot = f.read(512)
|
||||||
|
bps = struct.unpack_from("<H", boot, 11)[0]
|
||||||
|
spc = boot[13]
|
||||||
|
mft_lcn = struct.unpack_from("<Q", boot, 48)[0]
|
||||||
|
rs = struct.unpack_from("<b", boot, 64)[0]
|
||||||
|
rec = rs * bps * spc if rs > 0 else 1 << -rs
|
||||||
|
f.seek(mft_lcn * bps * spc)
|
||||||
|
data = f.read(rec * 4096) # MFT inicial é contígua em imagens pequenas
|
||||||
|
count = 0
|
||||||
|
for i in range(len(data) // rec):
|
||||||
|
r = bytearray(data[i * rec:(i + 1) * rec])
|
||||||
|
if r[:4] != b"FILE" or not (r[22] & 1):
|
||||||
|
continue
|
||||||
|
p = struct.unpack_from("<H", r, 20)[0]
|
||||||
|
while p + 8 <= len(r):
|
||||||
|
t, ln = struct.unpack_from("<II", r, p)
|
||||||
|
if t == 0xFFFFFFFF or ln == 0:
|
||||||
|
break
|
||||||
|
if t == 0x30 and r[p + 8] == 0:
|
||||||
|
vo = p + struct.unpack_from("<H", r, p + 20)[0]
|
||||||
|
nl = r[vo + 0x40]
|
||||||
|
name = bytes(r[vo + 0x42:vo + 0x42 + nl * 2]).decode("utf-16le")
|
||||||
|
if name in names and r[vo + 0x41] != 2:
|
||||||
|
f.seek(mft_lcn * bps * spc + i * rec + 22)
|
||||||
|
f.write(bytes([r[22] & ~1 & 0xFF]))
|
||||||
|
count += 1
|
||||||
|
break
|
||||||
|
p += ln
|
||||||
|
f.flush()
|
||||||
|
print(f"marcados como apagados: {count}", file=sys.stderr)
|
||||||