commit 765cb0d4a239def0f5d1e7b6876b13532d328e28 Author: Lucas Rayan Date: Sun Oct 18 15:02:19 2026 -0300 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 diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..7a09a14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -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 } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a4a0623 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..383668b --- /dev/null +++ b/.github/dependabot.yml @@ -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 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f67494d --- /dev/null +++ b/.github/workflows/ci.yml @@ -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: "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..72bb0e4 --- /dev/null +++ b/.github/workflows/release.yml @@ -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, + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5687c0 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..23e4fc8 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3745801 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..47262bf --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5ebb7ce --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE-APACHE @@ -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. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..d166e18 --- /dev/null +++ b/LICENSE-MIT @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7074cb --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +

Guará

+

Guará

+

Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.
Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.

+ +--- + +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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5816b71 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml new file mode 100644 index 0000000..b59a2f1 --- /dev/null +++ b/apps/gui/src-tauri/Cargo.toml @@ -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 diff --git a/apps/gui/src-tauri/build.rs b/apps/gui/src-tauri/build.rs new file mode 100644 index 0000000..7c2717d --- /dev/null +++ b/apps/gui/src-tauri/build.rs @@ -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(); +} diff --git a/apps/gui/src-tauri/capabilities/default.json b/apps/gui/src-tauri/capabilities/default.json new file mode 100644 index 0000000..b954939 --- /dev/null +++ b/apps/gui/src-tauri/capabilities/default.json @@ -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"] +} diff --git a/apps/gui/src-tauri/icons/128x128.png b/apps/gui/src-tauri/icons/128x128.png new file mode 100644 index 0000000..b5f3037 Binary files /dev/null and b/apps/gui/src-tauri/icons/128x128.png differ diff --git a/apps/gui/src-tauri/icons/128x128@2x.png b/apps/gui/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..21850a2 Binary files /dev/null and b/apps/gui/src-tauri/icons/128x128@2x.png differ diff --git a/apps/gui/src-tauri/icons/32x32.png b/apps/gui/src-tauri/icons/32x32.png new file mode 100644 index 0000000..0b3fe10 Binary files /dev/null and b/apps/gui/src-tauri/icons/32x32.png differ diff --git a/apps/gui/src-tauri/icons/icon.ico b/apps/gui/src-tauri/icons/icon.ico new file mode 100644 index 0000000..cf03709 Binary files /dev/null and b/apps/gui/src-tauri/icons/icon.ico differ diff --git a/apps/gui/src-tauri/icons/icon.png b/apps/gui/src-tauri/icons/icon.png new file mode 100644 index 0000000..ae2a8d4 Binary files /dev/null and b/apps/gui/src-tauri/icons/icon.png differ diff --git a/apps/gui/src-tauri/icons/source.png b/apps/gui/src-tauri/icons/source.png new file mode 100644 index 0000000..ae2a8d4 Binary files /dev/null and b/apps/gui/src-tauri/icons/source.png differ diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs new file mode 100644 index 0000000..1b39114 --- /dev/null +++ b/apps/gui/src-tauri/src/main.rs @@ -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); + +/// 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 { + 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á"); +} diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json new file mode 100644 index 0000000..df153c2 --- /dev/null +++ b/apps/gui/src-tauri/tauri.conf.json @@ -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"] + } +} diff --git a/apps/gui/src-tauri/windows-app-manifest.xml b/apps/gui/src-tauri/windows-app-manifest.xml new file mode 100644 index 0000000..9597f92 --- /dev/null +++ b/apps/gui/src-tauri/windows-app-manifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css new file mode 100644 index 0000000..70f6d37 --- /dev/null +++ b/apps/gui/ui/app.css @@ -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; } +} diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js new file mode 100644 index 0000000..00b26b7 --- /dev/null +++ b/apps/gui/ui/app.js @@ -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/ com token. + +const tauri = window.__TAURI__; +const params = new URLSearchParams(location.search); +let token = params.get('token'); +try { + if (token) sessionStorage.setItem('guara-token', token); + else token = sessionStorage.getItem('guara-token'); +} catch { /* armazenamento indisponível */ } + +async function rpc(method, p = {}) { + if (tauri?.core?.invoke) return tauri.core.invoke('rpc', { method, params: p }); + const r = await fetch(`/rpc/${method}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Guara-Token': token || '' }, + body: JSON.stringify(p), + }); + const body = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(body.error || `erro ${r.status}`); + return body; +} + +// ------------------------------------------------------------------ utilidades +const $ = (s, el = document) => el.querySelector(s); +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const human = (n) => { + if (!n) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0, v = n; + while (v >= 1024 && i < 4) { v /= 1024; i++; } + return i === 0 ? `${n} B` : `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`; +}; +const dur = (s) => { + s = Math.round(s); + return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`; +}; +const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : '—'); +const CATS = [ + ['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'], + ['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'], +]; +const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] }; +const KIND_ICON = { + USB: '', + default: '', +}; +const icon = (kind) => `${KIND_ICON[kind] || KIND_ICON.default}`; +const warnSvg = ''; + +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 = `

${esc(title)}

${text}

+
+ ${pick && tauri?.dialog ? '' : ''}
+
`; + 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 = `

${esc(title)}

${html}
`; + 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) => ` +
  • `).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) => ` +
  • `).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 = `
    +

    Encontre o que você achou que tinha perdido.

    +

    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.

    +
    +

    Uma varredura, tudo junto

    Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.

    +

    Confiança por arquivo

    Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.

    +

    Origem protegida

    Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.

    +
    + ${S.devices.length ? '' : `
    ${warnSvg}
    Nenhum disco apareceu. Discos brutos exigem administrador. Rode sudo guara ui (Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.
    `} +
    `; +} + +// ------------------------------------------------------------------ dispositivo +async function openDevice(d) { + stopPoll(); + S.device = d; S.job = null; S.layout = null; + renderSide(); + main.innerHTML = `

    Lendo ${esc(d.name)}…

    `; + 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 ``; +} + +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 ? '' : 'Feche e rode de novo com sudo guara ui (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 ${esc(d.mounted_at.join(', '))}). 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 = `
    +

    ${esc(d.name)}

    ${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · ${esc(d.path)}
    + ${notes.map(([k, t]) => `
    ${warnSvg}
    ${t}
    `).join('')} + ${l ? `

    Mapa do disco

    ${diskMap(l)} +
    ${l.partitions.map((p) => `
    Partição ${p.index + 1} ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''} + ${p.lost ? 'encontrada por varredura' : ''}${p.encrypted ? `cifrada (${esc(p.encrypted)}) · só detecção` : ''}
    `).join('')} + ${l.gaps.map((g) => `
    Espaço não particionado ${human(g.len)}
    `).join('')}
    ` : ''} +

    Como procurar

    +
    +
    Recomendado

    Varredura profunda

    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.

    +
    +

    Varredura rápida

    Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.

    +
    +

    Criar imagem do disco

    Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.

    +
    +
    +
    Opções avançadas + +
    ${CATS.map(([k, n]) => ``).join('')}
    +
    Sem nenhuma categoria marcada, todos os tipos são procurados.
    +
    +
    `; + $('#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: `

    ${esc(e.message)}

    ` }); } +} + +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 outro disco 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 `

    Imagem salva em ${esc(dest)} (${human(r.bytes)}).

    SHA-256: ${esc(r.sha256)}

    ${bad ? `
    ${warnSvg}
    ${bad} trecho(s) ilegível(is) foram preenchidos com zeros. Veja o mapa de erros ao lado da imagem.
    ` : '

    Nenhum setor ruim encontrado.

    '}`; + }); + } 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 = `
    + +

    Procurando…

    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    `; + $('#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 = `${esc(st.target)}`; + 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 = `
    +
    ${esc(p.phase || (running ? 'Iniciando' : ''))}${pct != null ? ` · ${pct}%` : ''}
    +
    ${running ? '' : st.status === 'paused' ? '' : ''} + ${!running && st.found ? '' : ''}
    +
    +
    +
    ${st.found.toLocaleString('pt-BR')}arquivos encontrados
    +
    ${dur(st.elapsed)}decorrido${eta ? ' · faltam ~' + eta : ''}
    +
    ${st.confidence.high}confiança alta
    +
    ${st.confidence.medium}média
    +
    ${st.confidence.low}baixa
    +
    `; + $('#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) => `
    ${warnSvg}
    ${esc(w)}
    `).join('') + + (st.error ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : ''); + // chips + const total = st.found; + $('#chips').innerHTML = [``] + .concat(CATS.filter(([k]) => st.categories[k]).map(([k, n]) => ``)).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 = `

    ${st && st.status === 'running' ? 'Procurando…' : 'Nada por aqui'}

    ${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.'}

    `; + return; + } + const allSel = S.items.every((i) => S.selected.has(i.id)) || S.allFiltered; + el.innerHTML = `
    NomeTamanhoOrigemConfiança
    ` + + S.items.map((i) => { + const [cl, cc] = CONF[i.confidence]; + const on = S.allFiltered || S.selected.has(i.id); + return `
    + +
    ${esc(i.name)}${esc(i.dir || (i.origin === 'carved' ? 'Encontrado por assinatura' : '—'))}
    +
    ${human(i.size)}
    ${i.origin === 'carved' ? 'assinatura' : esc(i.origin)}
    +
    ${cl}
    `; + }).join('') + + (S.items.length < S.total ? `
    ` : ''); + $('#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 = `Pré-visualização de ${esc(item.name)}`; + } catch { /* sem prévia */ } + } +} + +function paintPreview() { + const el = $('#preview'); + if (!el) return; + const i = S.current; + if (!i) { el.innerHTML = `
    Selecione um arquivo para ver os detalhes
    `; return; } + const [cl, cc] = CONF[i.confidence]; + el.innerHTML = `
    ${esc(i.ext ? i.ext.toUpperCase() : 'ARQUIVO')}
    +
    Nome
    ${esc(i.name)}
    Pasta
    ${esc(i.dir || '—')}
    Tamanho
    ${human(i.size)}
    +
    Modificado
    ${date(i.modified)}
    Origem
    ${i.origin === 'carved' ? 'Assinatura de formato' : esc(i.origin.toUpperCase())}
    +
    Confiança
    ${cl}
    ${i.note ? `
    Aviso
    ${esc(i.note)}
    ` : ''}
    +
    `; + $('#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 + ? `` : ''; + host.innerHTML = `
    ${n ? `${n.toLocaleString('pt-BR')} selecionado(s)` : `${S.total.toLocaleString('pt-BR')} arquivo(s) na lista`}${moreOffer} + ${n ? '' : ''}
    `; + $('#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 outro disco. 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 `

    ${ok} arquivo(s) salvos em ${esc(dest)} (${human(r.total_bytes)}).

    + ${bad ? `
    ${warnSvg}
    ${bad} arquivo(s) não puderam ser gravados.
    ` : ''} +

    O arquivo guara-manifesto.json lista o SHA-256 de cada arquivo para você conferir a integridade.

    `; + }); + } catch (e) { info({ title: 'Não foi possível recuperar', html: `

    ${esc(e.message)}

    ` }); } +} + +// 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 = `

    ${esc(title)}

    ${esc(p.phase || '')}

    ${pct}%

    `; + $('#auxStop').onclick = () => rpc('job_stop', { job }); + } else if (st.status === 'error') { + d.innerHTML = `

    Algo deu errado

    ${warnSvg}
    ${esc(st.error)}
    `; + } else { + d.innerHTML = `

    ${st.status === 'stopped' ? 'Interrompido' : 'Pronto'}

    ${doneHtml(st)}
    `; + } + }; + 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 = `
    ${warnSvg}
    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 guara ui (ele inclui o token).
    `; + return; + } + await loadDevices(); + renderHome(); +})(); diff --git a/apps/gui/ui/guara.svg b/apps/gui/ui/guara.svg new file mode 100644 index 0000000..a49fc40 --- /dev/null +++ b/apps/gui/ui/guara.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/gui/ui/index.html b/apps/gui/ui/index.html new file mode 100644 index 0000000..93a20ea --- /dev/null +++ b/apps/gui/ui/index.html @@ -0,0 +1,39 @@ + + + + + + + Guará + + + + +
    +
    + + Guará +
    + +
    +
    +
    + + +
    + + + diff --git a/crates/guara-cli/Cargo.toml b/crates/guara-cli/Cargo.toml new file mode 100644 index 0000000..0c70941 --- /dev/null +++ b/crates/guara-cli/Cargo.toml @@ -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" diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs new file mode 100644 index 0000000..4ebc96e --- /dev/null +++ b/crates/guara-cli/src/main.rs @@ -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, + /// 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, +} + +#[derive(clap::Args, Clone)] +struct OutArgs { + /// Salva a sessão (necessária para recover/resume) + #[arg(long)] + session: Option, + /// 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, + #[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, String> { + v.iter() + .map(|c| Category::parse(c).ok_or(format!("categoria desconhecida: {c}"))) + .collect() +} + +fn query_from(f: &FilterArgs) -> Result { + 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 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 {} ", + 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> = 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 { + 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 = 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: E) -> String { + e.to_string() +} diff --git a/crates/guara-cli/src/serve.rs b/crates/guara-cli/src/serve.rs new file mode 100644 index 0000000..ee3e7e1 --- /dev/null +++ b/crates/guara-cli/src/serve.rs @@ -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(); +} diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml new file mode 100644 index 0000000..6f4bb60 --- /dev/null +++ b/crates/guara-core/Cargo.toml @@ -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" diff --git a/crates/guara-core/src/carve.rs b/crates/guara-core/src/carve.rs new file mode 100644 index 0000000..b7bea4c --- /dev/null +++ b/crates/guara-core/src/carve.rs @@ -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 { + 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) +} diff --git a/crates/guara-core/src/device.rs b/crates/guara-core/src/device.rs new file mode 100644 index 0000000..2c1dfeb --- /dev/null +++ b/crates/guara-core/src/device.rs @@ -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, + pub mounted_at: Vec, + /// 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, +} + +/// (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 { + #[cfg(target_os = "linux")] + return linux(); + #[cfg(target_os = "macos")] + return macos(); + #[cfg(windows)] + return windows(); +} + +#[cfg(target_os = "linux")] +fn linux() -> Vec { + 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::().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 { + 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 { + 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 +} diff --git a/crates/guara-core/src/error.rs b/crates/guara-core/src/error.rs new file mode 100644 index 0000000..904479c --- /dev/null +++ b/crates/guara-core/src/error.rs @@ -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 = std::result::Result; + +impl Error { + pub fn msg(s: impl Into) -> Self { + Error::Msg(s.into()) + } +} diff --git a/crates/guara-core/src/fs/exfat.rs b/crates/guara-core/src/fs/exfat.rs new file mode 100644 index 0000000..3307c50 --- /dev/null +++ b/crates/guara-core/src/fs/exfat.rs @@ -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, +} + +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 { + 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 { + 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 { + 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 { + 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 = 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, +) { + 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 = 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::() + }; + 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 = 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; +} diff --git a/crates/guara-core/src/fs/ext.rs b/crates/guara-core/src/fs/ext.rs new file mode 100644 index 0000000..1df9f2a --- /dev/null +++ b/crates/guara-core/src/fs/ext.rs @@ -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 = 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 = 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 +} diff --git a/crates/guara-core/src/fs/fat.rs b/crates/guara-core/src/fs/fat.rs new file mode 100644 index 0000000..d1530b4 --- /dev/null +++ b/crates/guara-core/src/fs/fat.rs @@ -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)>, +} + +const PAGE: u64 = 256 * 1024; + +impl<'a> Fat<'a> { + fn open(src: &'a dyn Source, base: u64, kind: FsKind) -> Option { + 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> { + 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 { + 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 { + 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 { + 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 { + 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, + visited: HashSet, + 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 { + 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) { + 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, size: u64) -> Vec { + 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 = 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::() + .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 { + 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 = 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 = u[..end].iter().flat_map(|c| c.to_le_bytes()).collect(); + let s = utf16le(&raw); + (!s.is_empty()).then_some(s) +} diff --git a/crates/guara-core/src/fs/mod.rs b/crates/guara-core/src/fs/mod.rs new file mode 100644 index 0000000..b6671b1 --- /dev/null +++ b/crates/guara-core/src/fs/mod.rs @@ -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, + /// Se o leitor conhece o mapa de espaço livre (ext), os trechos livres (absolutos). + pub free: Option>, + pub warnings: Vec, +} + +pub struct NewFound<'a> { + pub name: String, + pub dir: Option, + pub size: u64, + pub origin: Origin, + pub data: DataLoc, + pub modified: Option, + 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 = 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) -> Vec { + v.retain(|e| e.len > 0); + v.sort_by_key(|e| e.offset); + let mut out: Vec = 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 { + 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 = 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 + } +} diff --git a/crates/guara-core/src/fs/ntfs.rs b/crates/guara-core/src/fs/ntfs.rs new file mode 100644 index 0000000..a0a4da6 --- /dev/null +++ b/crates/guara-core/src/fs/ntfs.rs @@ -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, +} + +struct Rec { + in_use: bool, + is_dir: bool, + parent: u64, + name: Option<(String, u8)>, + mtime: Option, + data: Option, + has_attr_list: bool, +} + +enum Data { + Resident(Vec), + NonResident { + runs: Vec, + 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, 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 = 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 = 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; + 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, mut parent: u64, own: u64) -> Option { + 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 { + 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 { + 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 { + 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 { + if ft == 0 { + return None; + } + let secs = (ft / 10_000_000) as i64 - 11_644_473_600; + (secs > 0).then_some(secs) +} diff --git a/crates/guara-core/src/image.rs b/crates/guara-core/src/image.rs new file mode 100644 index 0000000..f472ff1 --- /dev/null +++ b/crates/guara-core/src/image.rs @@ -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 { + 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) +} diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs new file mode 100644 index 0000000..010e5f6 --- /dev/null +++ b/crates/guara-core/src/lib.rs @@ -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"); diff --git a/crates/guara-core/src/partition.rs b/crates/guara-core/src/partition.rs new file mode 100644 index 0000000..92f6aa4 --- /dev/null +++ b/crates/guara-core/src/partition.rs @@ -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)> { + 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)> { + 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)> { + 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 { + 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 = Vec::new(); + + if let Some(g) = gpt { + layout.table = "gpt".into(); + parts = g; + } else if has_mbr { + // Pode ser MBR de verdade ou um setor de boot de volume sem tabela (superfloppy). + let looks_boot = detect_fs_in(&read_vec(src, 0, 4096).unwrap_or_default()).is_some(); + if !looks_boot { + let mp = read_mbr(src, §or0, size); + if !mp.is_empty() { + layout.table = "mbr".into(); + parts = mp; + } + } + } + + for p in &mut parts { + if let Some((fs, _)) = detect_fs(src, p.start) { + p.fs = fs; + p.encrypted = fs.encrypted().map(String::from); + if matches!(fs, FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32) { + if let Ok(b) = read_vec(src, p.start, 512) { + p.label = fat_label(&b, fs); + } + } + } + } + + // Volume sem tabela de partições (pendrive "superfloppy" ou partição crua). + if parts.is_empty() { + if let Some((fs, len)) = detect_fs(src, 0) { + parts.push(PartInfo { + index: 0, + start: 0, + len: len.unwrap_or(size).min(size), + fs, + label: None, + lost: false, + encrypted: fs.encrypted().map(String::from), + table_type: Some("volume sem tabela de partições".into()), + }); + layout.table = "none".into(); + } + } + + parts.sort_by_key(|p| p.start); + layout.gaps = compute_gaps(&parts, size); + + // Partições perdidas: procurar assinaturas de volumes nas lacunas. + let mut lost = Vec::new(); + if search_lost { + lost = find_lost(src, &layout.gaps, &parts); + if parts.is_empty() && layout.table == "none" { + // Disco sem tabela nem volume no início: procurar no disco todo. + lost = find_lost( + src, + &[Gap { + start: 0, + len: size, + }], + &[], + ); + } + } + for mut l in lost { + l.index = parts.len(); + parts.push(l); + } + parts.sort_by_key(|p| p.start); + for (i, p) in parts.iter_mut().enumerate() { + p.index = i; + } + layout.gaps = compute_gaps( + &parts + .iter() + .filter(|p| !p.lost) + .cloned() + .collect::>(), + 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 { + 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 { + 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) -> 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> { + // 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 = 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 { + const CHUNK: u64 = 4 * 1024 * 1024; + let mut found: Vec = 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) +} diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs new file mode 100644 index 0000000..0c6fa5c --- /dev/null +++ b/crates/guara-core/src/recover.rs @@ -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, + 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 { + std::fs::create_dir_all(dest)?; + let mut report = RecoverReport { + destination: dest.display().to_string(), + ..Default::default() + }; + let mut used: HashSet = 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 { + 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()) +} diff --git a/crates/guara-core/src/scan.rs b/crates/guara-core/src/scan.rs new file mode 100644 index 0000000..0708e22 --- /dev/null +++ b/crates/guara-core/src/scan.rs @@ -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, + pub next_id: u64, + pub layout: Option, +} + +pub enum Outcome { + Completed, + Interrupted(Checkpoint), +} + +pub fn run( + src: &dyn Source, + opts: &ScanOptions, + resume: Option, + 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 = HashMap::new(); + let mut fs_extents: Vec = vec![]; + for f in existing { + index_found(f, &mut starts, &mut fs_extents); + } + + if !cp.fs_done { + let mut live_all: Vec = vec![]; + let mut free_all: Vec = 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 = 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, + fs_extents: Vec, + 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, ex: &mut Vec) { + 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 { + let live = merge_extents(live.to_vec()); + let free = merge_extents(free.to_vec()); + let mut out: Vec = 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 { + 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 { + 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 +} diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs new file mode 100644 index 0000000..b924bda --- /dev/null +++ b/crates/guara-core/src/service.rs @@ -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, + index: HashMap, + warnings: Vec, + layout: Option, + checkpoint: Option, + error: Option, + report: Option, + started: Instant, + finished_in: Option, +} + +pub struct Job { + id: String, + kind: JobKind, + target: String, + opts: ScanOptions, + cancel: AtomicBool, + stop_requested: AtomicBool, + src: Option>, + st: Mutex, +} + +impl Job { + fn new( + id: String, + kind: JobKind, + target: String, + opts: ScanOptions, + src: Option>, + ) -> Arc { + 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); + +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>>, + 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> { + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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>, + filter: Option, + dest: &str, + ) -> Result { + let sj = self.job(scan_id)?; + let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?; + let files: Vec = { + 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 { + 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 { + 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 { + let s = |k: &str| -> Result { + 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> = p + .get("ids") + .cloned() + .filter(|v| !v.is_null()) + .map(serde_json::from_value) + .transpose()?; + let filter: Option = 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, + #[serde(default)] + pub min_confidence: Option, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub sort: Option, + #[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, + checkpoint: Option, + layout: Option, + warnings: Vec, + 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, cp: Option) { + std::thread::spawn(move || { + let src = job.src.clone().expect("origem"); + let existing: Vec = 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 +} diff --git a/crates/guara-core/src/signatures.rs b/crates/guara-core/src/signatures.rs new file mode 100644 index 0000000..2179fd6 --- /dev/null +++ b/crates/guara-core/src/signatures.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let limit = max.min(1 << 30); + let mut pos = off; + let mut buf = vec![0u8; 1 << 20 | 16]; + let mut last_eof: Option = None; + let mut carry: Vec = 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 = 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 { + hay.windows(needle.len()).position(|w| w == needle) +} + +// ---------------------------------------------------------------- ZIP & derivados + +fn zip(src: &dyn Source, off: u64, max: u64) -> Option { + let limit = max.min(4 << 30); + let mut pos = off; + let mut buf = vec![0u8; 1 << 20]; + let mut carry: Vec = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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 { + 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 { + 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, + }) +} diff --git a/crates/guara-core/src/source.rs b/crates/guara-core/src/source.rs new file mode 100644 index 0000000..326f688 --- /dev/null +++ b/crates/guara-core/src/source.rs @@ -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; +} + +impl Source for Arc { + fn size(&self) -> u64 { + (**self).size() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result { + (**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> { + 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 { + 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 { + #[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 { + 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, + start: u64, + len: u64, +} + +impl SubSource { + pub fn new(inner: Arc, 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 { + 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); + +impl Source for MemSource { + fn size(&self) -> u64 { + self.0.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result { + 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 { + std::fs::OpenOptions::new().read(true).open(path) +} + +#[cfg(windows)] +fn open_readonly(path: &Path, _device: bool) -> io::Result { + 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 { + #[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 { + 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 { + 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::() 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> { + #[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)?)) +} diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs new file mode 100644 index 0000000..6b9600c --- /dev/null +++ b/crates/guara-core/src/types.rs @@ -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 { + 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, + }, + Extents { + extents: Vec, + }, +} + +#[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, + 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, + pub note: Option, +} + +impl Found { + pub fn first_offset(&self) -> Option { + 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, + /// Encontrada por varredura, não consta da tabela de partições. + pub lost: bool, + pub encrypted: Option, + /// Descrição do tipo na tabela (ex.: "Microsoft basic data"). + pub table_type: Option, +} + +#[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, + /// Espaço não particionado relevante (> 1 MiB). + pub gaps: Vec, + pub warnings: Vec, +} + +#[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, + /// 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(), + } + } +} diff --git a/crates/guara-core/tests/common/mod.rs b/crates/guara-core/tests/common/mod.rs new file mode 100644 index 0000000..cdf9097 --- /dev/null +++ b/crates/guara-core/tests/common/mod.rs @@ -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, + pub warnings: Vec, + pub layout: Option, +} + +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 { + static ONCE: std::sync::OnceLock> = std::sync::OnceLock::new(); + ONCE.get_or_init(fixtures_inner).clone() +} + +fn fixtures_inner() -> Option { + 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 { + let mut m = HashMap::new(); + fn walk(d: &Path, m: &mut HashMap) { + 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 +} diff --git a/crates/guara-core/tests/disk.rs b/crates/guara-core/tests/disk.rs new file mode 100644 index 0000000..047c0b2 --- /dev/null +++ b/crates/guara-core/tests/disk.rs @@ -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 { + 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 { + 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::>(), + )); + 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 = "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); +} diff --git a/crates/guara-core/tests/exfat.rs b/crates/guara-core/tests/exfat.rs new file mode 100644 index 0000000..61cd5fe --- /dev/null +++ b/crates/guara-core/tests/exfat.rs @@ -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 = (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 = 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))); +} diff --git a/crates/guara-core/tests/recovery.rs b/crates/guara-core/tests/recovery.rs new file mode 100644 index 0000000..86e62dd --- /dev/null +++ b/crates/guara-core/tests/recovery.rs @@ -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 + ); +} diff --git a/scripts/make-fixtures.sh b/scripts/make-fixtures.sh new file mode 100755 index 0000000..2bae179 --- /dev/null +++ b/scripts/make-fixtures.sh @@ -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 /expected-.txt. +# +# Uso: scripts/make-fixtures.sh [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 [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 '' > '[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 diff --git a/scripts/ntfs-mark-deleted.py b/scripts/ntfs-mark-deleted.py new file mode 100755 index 0000000..9c61245 --- /dev/null +++ b/scripts/ntfs-mark-deleted.py @@ -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(" 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("