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>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 15:02:19 -03:00
commit 765cb0d4a2
57 files changed
+8621

No files matched your search

+17
View File
@@ -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 }
+7
View File
@@ -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)
+11
View File
@@ -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 }
+80
View File
@@ -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: ""
+134
View File
@@ -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,
});
+15
View File
@@ -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
+18
View File
@@ -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.
+25
View File
@@ -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.
Generated
+636
View File
@@ -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"
+18
View File
@@ -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"
+202
View File
@@ -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.
+21
View File
@@ -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.
+91
View File
@@ -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.
+3
View File
@@ -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.
+26
View File
@@ -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
+12
View File
@@ -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"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+56
View File
@@ -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á");
}
+29
View File
@@ -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>
+257
View File
@@ -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; }
}
+549
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
})();
+17
View File
@@ -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

+39
View File
@@ -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>
+20
View File
@@ -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"
+519
View File
@@ -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()
}
+103
View File
@@ -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();
}
+23
View File
@@ -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"
+114
View File
@@ -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)
}
+249
View File
@@ -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
}
+19
View File
@@ -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())
}
}
+269
View File
@@ -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;
}
+90
View File
@@ -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
}
+442
View File
@@ -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)
}
+183
View File
@@ -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
}
}
+445
View File
@@ -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)
}
+87
View File
@@ -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)
}
+22
View File
@@ -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");
+472
View File
@@ -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, &sector0, 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)
}
+212
View File
@@ -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())
}
+343
View File
@@ -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
}
+699
View File
@@ -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
}
+833
View File
@@ -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,
})
}
+276
View File
@@ -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)?))
}
+272
View File
@@ -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(),
}
}
}
+114
View File
@@ -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
}
+233
View File
@@ -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);
}
+85
View File
@@ -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)));
}
+88
View File
@@ -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
);
}
+83
View File
@@ -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
+42
View File
@@ -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)