From 765cb0d4a239def0f5d1e7b6876b13532d328e28 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 18 Oct 2026 15:02:19 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Guar=C3=A1=200.1.0=20=E2=80=94=20recupe?= =?UTF-8?q?ra=C3=A7=C3=A3o=20de=20arquivos=20(n=C3=BAcleo,=20CLI=20e=20int?= =?UTF-8?q?erface=20gr=C3=A1fica)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/ISSUE_TEMPLATE/bug.yml | 17 + .github/PULL_REQUEST_TEMPLATE.md | 7 + .github/dependabot.yml | 11 + .github/workflows/ci.yml | 80 ++ .github/workflows/release.yml | 134 +++ .gitignore | 15 + CHANGELOG.md | 18 + CONTRIBUTING.md | 25 + Cargo.lock | 636 ++++++++++++++ Cargo.toml | 18 + LICENSE-APACHE | 202 +++++ LICENSE-MIT | 21 + README.md | 91 ++ SECURITY.md | 3 + apps/gui/src-tauri/Cargo.toml | 26 + apps/gui/src-tauri/build.rs | 12 + apps/gui/src-tauri/capabilities/default.json | 7 + apps/gui/src-tauri/icons/128x128.png | Bin 0 -> 4896 bytes apps/gui/src-tauri/icons/128x128@2x.png | Bin 0 -> 9724 bytes apps/gui/src-tauri/icons/32x32.png | Bin 0 -> 1276 bytes apps/gui/src-tauri/icons/icon.ico | Bin 0 -> 109244 bytes apps/gui/src-tauri/icons/icon.png | Bin 0 -> 69518 bytes apps/gui/src-tauri/icons/source.png | Bin 0 -> 69518 bytes apps/gui/src-tauri/src/main.rs | 56 ++ apps/gui/src-tauri/tauri.conf.json | 29 + apps/gui/src-tauri/windows-app-manifest.xml | 14 + apps/gui/ui/app.css | 257 ++++++ apps/gui/ui/app.js | 549 ++++++++++++ apps/gui/ui/guara.svg | 17 + apps/gui/ui/index.html | 39 + crates/guara-cli/Cargo.toml | 20 + crates/guara-cli/src/main.rs | 519 ++++++++++++ crates/guara-cli/src/serve.rs | 103 +++ crates/guara-core/Cargo.toml | 23 + crates/guara-core/src/carve.rs | 114 +++ crates/guara-core/src/device.rs | 249 ++++++ crates/guara-core/src/error.rs | 19 + crates/guara-core/src/fs/exfat.rs | 269 ++++++ crates/guara-core/src/fs/ext.rs | 90 ++ crates/guara-core/src/fs/fat.rs | 442 ++++++++++ crates/guara-core/src/fs/mod.rs | 183 ++++ crates/guara-core/src/fs/ntfs.rs | 445 ++++++++++ crates/guara-core/src/image.rs | 87 ++ crates/guara-core/src/lib.rs | 22 + crates/guara-core/src/partition.rs | 472 +++++++++++ crates/guara-core/src/recover.rs | 212 +++++ crates/guara-core/src/scan.rs | 343 ++++++++ crates/guara-core/src/service.rs | 699 ++++++++++++++++ crates/guara-core/src/signatures.rs | 833 +++++++++++++++++++ crates/guara-core/src/source.rs | 276 ++++++ crates/guara-core/src/types.rs | 272 ++++++ crates/guara-core/tests/common/mod.rs | 114 +++ crates/guara-core/tests/disk.rs | 233 ++++++ crates/guara-core/tests/exfat.rs | 85 ++ crates/guara-core/tests/recovery.rs | 88 ++ scripts/make-fixtures.sh | 83 ++ scripts/ntfs-mark-deleted.py | 42 + 57 files changed, 8621 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/gui/src-tauri/Cargo.toml create mode 100644 apps/gui/src-tauri/build.rs create mode 100644 apps/gui/src-tauri/capabilities/default.json create mode 100644 apps/gui/src-tauri/icons/128x128.png create mode 100644 apps/gui/src-tauri/icons/128x128@2x.png create mode 100644 apps/gui/src-tauri/icons/32x32.png create mode 100644 apps/gui/src-tauri/icons/icon.ico create mode 100644 apps/gui/src-tauri/icons/icon.png create mode 100644 apps/gui/src-tauri/icons/source.png create mode 100644 apps/gui/src-tauri/src/main.rs create mode 100644 apps/gui/src-tauri/tauri.conf.json create mode 100644 apps/gui/src-tauri/windows-app-manifest.xml create mode 100644 apps/gui/ui/app.css create mode 100644 apps/gui/ui/app.js create mode 100644 apps/gui/ui/guara.svg create mode 100644 apps/gui/ui/index.html create mode 100644 crates/guara-cli/Cargo.toml create mode 100644 crates/guara-cli/src/main.rs create mode 100644 crates/guara-cli/src/serve.rs create mode 100644 crates/guara-core/Cargo.toml create mode 100644 crates/guara-core/src/carve.rs create mode 100644 crates/guara-core/src/device.rs create mode 100644 crates/guara-core/src/error.rs create mode 100644 crates/guara-core/src/fs/exfat.rs create mode 100644 crates/guara-core/src/fs/ext.rs create mode 100644 crates/guara-core/src/fs/fat.rs create mode 100644 crates/guara-core/src/fs/mod.rs create mode 100644 crates/guara-core/src/fs/ntfs.rs create mode 100644 crates/guara-core/src/image.rs create mode 100644 crates/guara-core/src/lib.rs create mode 100644 crates/guara-core/src/partition.rs create mode 100644 crates/guara-core/src/recover.rs create mode 100644 crates/guara-core/src/scan.rs create mode 100644 crates/guara-core/src/service.rs create mode 100644 crates/guara-core/src/signatures.rs create mode 100644 crates/guara-core/src/source.rs create mode 100644 crates/guara-core/src/types.rs create mode 100644 crates/guara-core/tests/common/mod.rs create mode 100644 crates/guara-core/tests/disk.rs create mode 100644 crates/guara-core/tests/exfat.rs create mode 100644 crates/guara-core/tests/recovery.rs create mode 100755 scripts/make-fixtures.sh create mode 100755 scripts/ntfs-mark-deleted.py 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 0000000000000000000000000000000000000000..b5f30378712dbd3cd1a4322bf9581dd5ef6006c8 GIT binary patch literal 4896 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 literal 0 HcmV?d00001 diff --git a/apps/gui/src-tauri/icons/32x32.png b/apps/gui/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..0b3fe10dabb9693df9cb57ce2fa8725fe014f4b7 GIT binary patch literal 1276 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 literal 0 HcmV?d00001 diff --git a/apps/gui/src-tauri/icons/source.png b/apps/gui/src-tauri/icons/source.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2a8d43728977a076faadf4569bab08ec82b4b3 GIT binary patch literal 69518 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 literal 0 HcmV?d00001 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("