chore(i18n): finish translating code comments and scripts to English
CI / Format and clippy (push) Successful in 1m57s
CI / Dependency audit (push) Successful in 13s
CI / Check desktop app (Linux) (push) Successful in 3m54s
CI / Tests (Linux) (push) Failing after 7m25s
Release / Create release (push) Successful in 6s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / CLI (linux-x86_64) (push) Successful in 2m39s
Release / Desktop app (linux-aarch64) (push) Successful in 10m9s
Release / Desktop app (linux-x86_64) (push) Successful in 12m20s
Release / Desktop app (windows) (push) Successful in 7m32s
Release / Build latest.json (push) Successful in 5s

Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
Lucas RayanandClaude Sonnet 5 committed 2026-10-30 20:43:02 -03:00
1 parent da212e6926
commit 6328220c60
129 files changed
+1959 -1832

No files matched your search

+9 -9
View File
@@ -1,17 +1,17 @@
name: Relatar um problema
description: Algo não funcionou como esperado
name: Report a bug
description: Something did not work as expected
labels: [bug]
body:
- type: textarea
id: o-que
attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." }
id: what-happened
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
validations: { required: true }
- type: input
id: sistema
attributes: { label: "Sistema operacional e versão do Guará", placeholder: "Windows 11 / guara 0.1.0" }
id: system
attributes: { label: "Operating system and Guará version", placeholder: "Ubuntu 24.04 / guara 1.3.2" }
- type: input
id: midia
attributes: { label: "Tipo de mídia e sistema de arquivos", placeholder: "pendrive 32 GB, FAT32" }
id: media
attributes: { label: "Media type and filesystem", placeholder: "32 GB USB drive, FAT32" }
- type: textarea
id: log
attributes: { label: "Saída do terminal (guara inspect / scan)", render: shell }
attributes: { label: "Terminal output (guara inspect / scan)", render: shell }
+6 -5
View File
@@ -1,7 +1,8 @@
## O que muda e por quê
## What changes and why
## 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)
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings
- [ ] `cargo test --workspace` passes
- [ ] Nothing in this PR writes to the source device
- [ ] Commits follow Conventional Commits in English and code/comments are in English
- [ ] CHANGELOG updated (if there is a user-visible change)
+26 -27
View File
@@ -1,7 +1,7 @@
name: CI
on:
# Mudança só de documentação ou estado de ferramentas não precisa dos jobs de CI.
# Documentation-only or local agent state changes do not trigger CI jobs.
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
@@ -15,17 +15,17 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
# O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs:
# a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório
# target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job
# usa o seu próprio target (flags diferentes invalidariam o de outro job).
# The runner persists /opt/hostedtoolcache across jobs: rustup toolchains,
# cargo registry, npm/apt caches, and target directories live there so nothing
# is re-downloaded or rebuilt from scratch on every run. Each job uses its own
# target directory so differing RUSTFLAGS do not invalidate another job's cache.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
jobs:
lint:
name: Formatação e clippy
name: Format and clippy
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint
@@ -37,64 +37,63 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cobertura da tradução da interface
- name: UI translation coverage
run: node scripts/check-ui-i18n.mjs
- name: Script do latest.json do atualizador
- name: Updater latest.json unit tests
run: python3 scripts/test_make_latest_json.py
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
# Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64.
# Linux-only runner (ARM64).
test:
name: Testes (Linux)
name: Tests (Linux)
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Ferramentas para imagens de teste (Linux)
- name: Test image tools (Linux)
if: runner.os == 'Linux'
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/guara-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
- name: Reference disk images (BitLocker, FileVault 2, dfvfs, AFF4)
shell: bash
run: |
mkdir -p /opt/hostedtoolcache/guara-testdata target
ln -sfn /opt/hostedtoolcache/guara-testdata target/testdata
scripts/fetch-testdata.sh
# Monta a imagem por loop (mount -o loop), o que exige um container
# privilegiado. O runner do Gitea roda os jobs sem privilégio de propósito
# (o CI de repositório público executa código de terceiros), então este
# passo pode falhar aqui; nesse caso os testes que dependem dele são
# pulados em vez de reprovar a execução.
- name: Imagem XFS com exclusão feita pelo kernel (Linux)
id: xfs
# Mounts an image via loop device (mount -o loop), which requires a
# privileged container. Unprivileged runners skip this step cleanly.
- name: Real XFS deletion fixture (Linux)
if: runner.os == 'Linux'
continue-on-error: true
run: scripts/make-xfs-real-deletion.sh
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux)
run: |
if [ ! -f target/testdata/xfs-real-delete/disk.img ]; then
sudo scripts/make-xfs-real-deletion.sh
fi
- name: Run tests
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ] && [ "${{ steps.xfs.outcome }}" = "success" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
if [ "${RUNNER_OS}" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
cargo test --workspace
audit:
name: Auditoria de dependências
name: Dependency audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
# needs nothing but the advisory database, and installing it once into the
# persistent CARGO_HOME makes every later run skip the compile.
# persistent CARGO_HOME makes every later run skip compilation.
- name: cargo audit
run: |
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit
gui:
name: Compila o app desktop (Linux)
name: Check desktop app (Linux)
runs-on: ubuntu-latest
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui
@@ -104,10 +103,10 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri
- name: Tauri system dependencies
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
- name: Generate icons
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
env:
+24 -25
View File
@@ -1,17 +1,16 @@
name: Release
# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do
# CHANGELOG.md, compila o CLI (Linux x86_64 e aarch64) e o app desktop (Linux
# e Windows, este por compilação cruzada com cargo-xwin), e monta o
# latest.json que o atualizador do app lê.
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes
# from CHANGELOG.md, builds the CLI (Linux x86_64 and aarch64) and the desktop
# app (Linux aarch64, Linux x86_64, and Windows via cargo-xwin cross-compilation),
# and generates the latest.json manifest read by the desktop updater.
#
# Sem macOS: o Tauri precisa do SDK e da assinatura da Apple, presos a
# hardware Apple e a uma conta de desenvolvedor, que este runner não tem.
# No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which
# this Linux ARM64 runner does not have.
#
# Segredos de usuário necessários: REGISTRY_TOKEN (não usado aqui, fica para o
# registro de containers de outros projetos), GUARA_TAURI_SIGNING_PRIVATE_KEY e
# GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD (assinam os pacotes para o
# atualizador). Os nomes antigos, sem prefixo, continuam aceitos como fallback.
# Required repository/user secrets: GUARA_TAURI_SIGNING_PRIVATE_KEY and
# GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD (sign updater bundles). Unprefixed
# secret names remain supported as fallback.
on:
push:
tags: ['v*']
@@ -25,7 +24,7 @@ env:
jobs:
release:
name: Cria a release
name: Create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -37,9 +36,9 @@ jobs:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: |
# Uma execução repetida não recria a release.
# Re-running the workflow does not recreate an existing release.
if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
echo "a release $TAG já existe"; exit 0
echo "release $TAG already exists"; exit 0
fi
scripts/release-notes.sh "$TAG" > notes.md
pre=false; case "$TAG" in *-*) pre=true ;; esac
@@ -65,19 +64,19 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Linker desta plataforma
- name: Platform cross-linker
if: ${{ matrix.apt != '' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
- name: Compila e empacota
- name: Build and package
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
- name: Anexa à release
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
gui:
name: App desktop (${{ matrix.platform }})
name: Desktop app (${{ matrix.platform }})
needs: release
runs-on: ubuntu-latest
env:
@@ -94,11 +93,11 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Dependências do Tauri (Linux aarch64)
- name: Tauri dependencies (Linux aarch64)
if: matrix.platform == 'linux-aarch64'
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Dependências do Tauri (Linux x86_64)
- name: Tauri dependencies (Linux x86_64)
if: matrix.platform == 'linux-x86_64'
timeout-minutes: 25
run: |
@@ -118,21 +117,21 @@ jobs:
else
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
fi
- name: Ferramentas para compilação cruzada (Windows)
- name: Cross-compilation tools (Windows)
if: matrix.platform == 'windows'
run: |
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
- name: Compila e empacota
- name: Build and package
run: scripts/build-gui.sh "${{ matrix.platform }}"
- name: Anexa à release
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
atualizador:
name: Monta o latest.json
updater:
name: Build latest.json
needs: gui
runs-on: ubuntu-latest
steps:
@@ -145,7 +144,7 @@ jobs:
run: |
scripts/release-notes.sh "$TAG" > notes.md
python3 scripts/make-latest-json.py "$TAG" notes.md latest.json
- name: Anexa à release
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Enforces Conventional Commits 1.0.0 and English-only commit messages.
set -euo pipefail
msg_file="${1:?usage: commit-msg <file>}"
python3 - "$msg_file" <<'PY'
import re, sys
from pathlib import Path
raw = Path(sys.argv[1]).read_text(encoding="utf-8")
lines = [l for l in raw.splitlines() if not l.startswith("#")]
while lines and not lines[0].strip():
lines.pop(0)
if not lines:
sys.exit("commit-msg: empty commit message")
subject = lines[0].strip()
if subject.startswith(("Merge ", "Revert \"")):
sys.exit(0)
conv_re = re.compile(
r"^(feat|fix|chore|ci|docs|refactor|test|perf|build|style|revert)(\([a-z0-9_./-]+\))?!?: .+$"
)
if not conv_re.match(subject):
sys.exit(
f"commit-msg: invalid Conventional Commit header: {subject!r}\n"
"Expected: <type>[optional scope]: <description in English>\n"
"Allowed types: feat, fix, chore, ci, docs, refactor, test, perf, build, style, revert"
)
body = "\n".join(lines)
cleaned = re.sub(r"\b(Guará|Sabiá|Acauã|Ciberfauna)\b", "", body)
pt_accents = re.search(r"[áàâãéêíóôõúçÁÀÂÃÉÊÍÓÔÕÚÇ]", cleaned)
pt_words = re.search(
r"\b(não|para|como|quando|então|pelos|pelas|pelo|pela|estão|está|também|versão|"
r"corrige|adiciona|atualiza|remove|padroniza|implementa|ajusta|arquivo|arquivos)\b",
cleaned,
re.I,
)
if pt_accents or pt_words:
sys.exit("commit-msg: commit messages must be written 100% in English.")
PY
+2 -2
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia).
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh"
set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
+7 -7
View File
@@ -1,4 +1,4 @@
# Build
# Build artifacts
target/
apps/gui/src-tauri/target/
node_modules/
@@ -7,15 +7,15 @@ node_modules/
.DS_Store
**/*.rs.bk
# Capturas e estado local do Sabiá
# Sabiá local captures and audit log
*.pcap
*.cap
.sabia-audit.log
# Logo copiada para a interface pelo build.rs
# Logo copied to the UI directory by build.rs
/apps/gui/ui/logo.png
# Configuração e memória local de agentes de IA — não faz parte do produto
# Local AI agent configuration and state — not part of the product
.claude/
.wolf/
.opencode/
@@ -27,16 +27,16 @@ CLAUDE.md
AGENTS.md
GEMINI.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
# Local environment files
.env
.env.*
# Lixo local
# Local scratch files
*.eml
__pycache__/
*.pyc
# Pacotes gerados por scripts/package-cli.sh
# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
/guara-v*
/sabia-v*
/dist-gui
+8 -5
View File
@@ -7,13 +7,14 @@ Thanks for helping! Golden rules:
1. **Never write to the source.** Any code that opens a device must use `source::open_path` (read-only). PRs that write to the source disk are rejected.
2. **Test with real data.** New detectors/readers need a test that recovers a known file bit for bit (see `crates/guara-core/tests/` and `scripts/make-fixtures.sh`).
3. **Be honest about confidence.** If the content cannot be validated, confidence is at most medium.
4. **100% English codebase and comments.** All source code, comments, scripts, CI workflows, and commit messages must be written in English, while the GUI/CLI remains bilingual (PT-BR and EN via `i18n-en.js`).
## Workflow
```bash
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
scripts/install-hooks.sh # once: enables commit-msg and pre-push Git hooks
scripts/check.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows, semver
scripts/check.sh --full # includes cargo test --workspace and cargo audit
```
The CI takes 5 to 30 minutes on Linux, so run the check before pushing: almost everything that makes the CI fail shows up locally in seconds. Documentation-only changes (`*.md`, `docs/`) skip the CI.
@@ -24,7 +25,9 @@ For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `sc
The desktop app signs its update packages: `tauri.conf.json` has `createUpdaterArtifacts` on, so a local `tauri build` needs `TAURI_SIGNING_PRIVATE_KEY` (any key made with `tauri signer generate`; the release key lives only in the repository secrets). For a quick local bundle, pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
## Releases
## Releases and Versioning
Versions strictly follow **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH`), synchronized across `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml`, and `apps/gui/src-tauri/tauri.conf.json` (enforced by `scripts/check.sh`).
Pushing a `v*` tag makes the Gitea CI create the release (notes come from `CHANGELOG.md`, so update it first), attach the Linux CLI (x86_64 and aarch64), the desktop app for Linux (x86_64 and aarch64) and Windows, and build the updater's `latest.json`.
@@ -48,4 +51,4 @@ The main version is in English and every file has a Portuguese translation (`*.p
## Commits
Short messages in the imperative (`fix reading of sparse runs in NTFS`). One logical change per PR.
Commits must follow **Conventional Commits 1.0.0** in English (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), e.g. `fix(ntfs): handle sparse data runs correctly`. One logical change per PR. Enforced automatically by `.githooks/commit-msg`.
+8 -5
View File
@@ -7,13 +7,14 @@ 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.
4. **Código, comentários e commits 100% em inglês.** Todo código-fonte, comentários, scripts, workflows de CI e mensagens de commit devem ser escritos em inglês, enquanto a interface gráfica/CLI permanece bilíngue (PT-BR e EN via `i18n-en.js`).
## Fluxo
```bash
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
scripts/install-hooks.sh # uma vez: ativa os hooks commit-msg e pre-push
scripts/check.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows, semver
scripts/check.sh --full # inclui cargo test --workspace e cargo audit
```
O CI leva de 5 a 30 minutos em Linux, então rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI.
@@ -24,7 +25,9 @@ Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), r
O app desktop assina os pacotes de atualização: o `tauri.conf.json` tem `createUpdaterArtifacts` ligado, então um `tauri build` local precisa de `TAURI_SIGNING_PRIVATE_KEY` (qualquer chave feita com `tauri signer generate`; a chave da release fica só nos segredos do repositório). Para um pacote local rápido, passe `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
## Releases
## Releases e Versionamento
As versões seguem estritamente o **Versionamento Semântico 2.0.0** (`MAJOR.MINOR.PATCH`), sincronizadas entre `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml` e `apps/gui/src-tauri/tauri.conf.json` (validado por `scripts/check.sh`).
Enviar uma tag `v*` faz o CI do Gitea criar a release (as notas vêm do `CHANGELOG.md`, então atualize-o antes), anexar o CLI para Linux (x86_64 e aarch64), o app desktop para Linux (x86_64 e aarch64) e Windows, e montar o `latest.json` do atualizador.
@@ -48,4 +51,4 @@ A versão principal é em inglês e cada arquivo tem uma tradução em portuguê
## Commits
Mensagens curtas no imperativo (`corrige leitura de runs esparsos no NTFS`). Uma mudança lógica por PR.
Os commits devem seguir **Conventional Commits 1.0.0** em inglês (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), ex.: `fix(ntfs): handle sparse data runs correctly`. Uma mudança lógica por PR. Validado automaticamente por `.githooks/commit-msg`.
+1 -1
View File
@@ -17,6 +17,6 @@ opt-level = 3
strip = true
panic = "abort"
# Dependências otimizadas também em debug: os testes de cifra fazem milhões de SHA-256.
# Optimize dependencies in debug builds as well: cipher tests run millions of SHA-256 rounds.
[profile.dev.package."*"]
opt-level = 3
+3 -3
View File
@@ -1,8 +1,8 @@
# Aplicativo desktop (Tauri 2). Fica fora do workspace principal porque exige
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
# Desktop application (Tauri 2). Kept outside the main workspace because it
# requires system WebView libraries (WebKitGTK on Linux).
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
description = "Guará: graphical file recovery interface"
version = "1.3.2"
edition = "2021"
license = "MIT OR Apache-2.0"
+2 -2
View File
@@ -3,11 +3,11 @@ fn main() {
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
#[cfg(windows)]
{
// Ler discos brutos no Windows exige administrador: o manifesto pede elevação (UAC).
// Reading raw disks on Windows requires Administrator privileges: embed UAC elevation manifest.
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");
tauri_build::try_build(attrs).expect("tauri-build failed");
}
#[cfg(not(windows))]
tauri_build::build();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissões da janela principal",
"description": "Main window permissions",
"windows": ["main"],
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
}
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Permissões da janela principal","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close","dialog:default"]}}
{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close","dialog:default"]}}
+121 -46
View File
@@ -1,4 +1,4 @@
// Evita a janela de console extra no Windows em release.
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use guara_core::service::Engine;
@@ -6,23 +6,29 @@ use std::sync::Arc;
struct State(Arc<Engine>);
/// Ponte única entre a interface e o núcleo (mesmos métodos do modo `guara ui`).
/// Single bridge between the UI and the core (same methods as `guara ui` mode).
#[tauri::command]
async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json::Value) -> Result<serde_json::Value, String> {
async fn rpc(
state: tauri::State<'_, State>,
method: String,
params: serde_json::Value,
) -> Result<serde_json::Value, String> {
let eng = state.0.clone();
tauri::async_runtime::spawn_blocking(move || eng.rpc(&method, params).map_err(|e| e.to_string()))
tauri::async_runtime::spawn_blocking(move || {
eng.rpc(&method, params).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
/// Versão nova encontrada na release do Gitea.
/// New version found on Gitea releases.
#[derive(serde::Serialize)]
struct UpdateInfo {
current: String,
version: String,
notes: String,
/// O app consegue se atualizar sozinho (AppImage, Windows e macOS). Instalação por pacote
/// (.deb/.rpm) só avisa e leva à página da release.
/// Whether the app can self-update (AppImage, Windows, and macOS). Package installs
/// (.deb/.rpm) only notify the user and open the release page.
auto: bool,
url: String,
}
@@ -31,22 +37,30 @@ fn self_update_possible() -> bool {
!cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some()
}
/// Procura uma versão mais nova. `None` = já está na última.
/// Checks for a newer version. `None` = already up to date.
#[tauri::command]
async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> {
use tauri_plugin_updater::UpdaterExt;
let found = app.updater().map_err(|e| e.to_string())?.check().await.map_err(|e| e.to_string())?;
let found = app
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?;
Ok(found.map(|u| UpdateInfo {
current: u.current_version.clone(),
notes: u.body.clone().unwrap_or_default(),
url: format!("https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/tag/v{}", u.version),
url: format!(
"https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/tag/v{}",
u.version
),
version: u.version,
auto: self_update_possible(),
}))
}
/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app.
/// O progresso sai no evento `update-progress`.
/// Downloads, verifies the signature (embedded public key), installs, and restarts the app.
/// Progress is emitted via the `update-progress` event.
#[tauri::command]
async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Emitter;
@@ -67,7 +81,10 @@ async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
.download_and_install(
move |chunk, total| {
got += chunk as u64;
let _ = a.emit("update-progress", serde_json::json!({ "downloaded": got, "total": total }));
let _ = a.emit(
"update-progress",
serde_json::json!({ "downloaded": got, "total": total }),
);
},
move || {
let _ = b.emit("update-progress", serde_json::json!({ "installing": true }));
@@ -75,16 +92,16 @@ async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
)
.await
.map_err(|e| format!("Não foi possível atualizar: {e}"))?;
// Rodando como administrador, o arquivo novo nasce do root: devolve ao usuário que abriu o app.
// When running as administrator, the new file is owned by root: hand it back to the invoking user.
if let Some(p) = std::env::var_os("APPIMAGE") {
guara_core::owner::give_to_user(std::path::Path::new(&p));
}
app.restart()
}
/// `true` quando o processo já tem privilégio suficiente para ler discos brutos.
/// No Windows o manifesto já exige administrador antes mesmo do app abrir,
/// então aqui é sempre `true`.
/// `true` when the process already has sufficient privileges to read raw disks.
/// On Windows the manifest already requires administrator privileges before launch,
/// so this is always `true` here.
#[tauri::command]
fn is_elevated() -> bool {
#[cfg(unix)]
@@ -97,9 +114,9 @@ fn is_elevated() -> bool {
}
}
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
/// Relaunches the app with administrator privileges (Linux/macOS). On Windows the
/// manifest already enforces elevation. The new instance terminates this one on startup; if
/// authorization fails, this window stays open and the UI displays the reason.
#[tauri::command]
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -120,7 +137,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let cfg = guara_core::settings::config_dir();
let log = std::env::temp_dir().join(format!("guara-elevate-{}.log", std::process::id()));
let _ = std::fs::write(&log, b"");
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
let out = std::fs::OpenOptions::new()
.append(true)
.open(&log)
.map_err(|e| e.to_string())?;
let err = out.try_clone().map_err(|e| e.to_string())?;
let pid = std::process::id().to_string();
@@ -128,7 +148,14 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let mut cmd = {
let mut c = std::process::Command::new("pkexec");
c.arg("/usr/bin/env");
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
for k in [
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"GDK_BACKEND",
] {
if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}"));
}
@@ -146,16 +173,21 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
pid,
exe.display()
);
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
let script = format!(
"do shell script \"{}\" with administrator privileges",
shell.replace('\\', "\\\\").replace('"', "\\\"")
);
let mut c = std::process::Command::new("osascript");
c.args(["-e", &script]);
c
};
cmd.stdout(out).stderr(err);
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
let mut child = cmd
.spawn()
.map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
std::thread::spawn(move || {
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso.
// On Linux, pkexec only exits with success when the elevated app closes (and the elevated
// app will have already terminated this process). Any failure before that becomes a warning.
let status = child.wait();
if matches!(&status, Ok(s) if s.success()) {
let _ = std::fs::remove_file(&log);
@@ -165,7 +197,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
let msg = match status.ok().and_then(|s| s.code()) {
Some(126) => "Autorização cancelada.".to_string(),
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
_ => format!(
"Não foi possível reiniciar como administrador. {}",
detail.lines().last().unwrap_or("").trim()
),
};
let _ = app.emit("elevate-failed", msg);
let _ = std::fs::remove_file(&log);
@@ -173,31 +208,43 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o
/// próprio arquivo `.AppImage` (variável `APPIMAGE`).
/// Executable path accessible to root. Inside an AppImage, `current_exe` points
/// inside a user FUSE mount that root cannot access: use the
/// `.AppImage` file itself (`APPIMAGE` environment variable).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn elevated_exe() -> Result<std::path::PathBuf, String> {
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
if let Some(p) = std::env::var_os("APPIMAGE")
.map(std::path::PathBuf::from)
.filter(|p| p.is_file())
{
return Ok(p);
}
std::env::current_exe().map_err(|e| e.to_string())
}
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`),
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem.
/// Serves recovered files for preview (`guara://localhost/file/<job>/<id>`),
/// with Range support for video/audio/PDF, reading directly from the source.
fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
use tauri::http::Response;
if let Some(rest) = req.uri().path().strip_prefix("/thumb/") {
let mut it = rest.split('/');
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
let (job, id) = (
it.next().unwrap_or(""),
it.next().and_then(|s| s.parse::<u64>().ok()),
);
let size = req
.uri()
.query()
.and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s=")))
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(256);
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
let err = |code: u16| {
Response::builder()
.status(code)
.header("Access-Control-Allow-Origin", "*")
.body(Vec::new())
.unwrap()
};
let Some(id) = id else { return err(400) };
return match eng.thumb(job, id, size) {
Ok(b) => Response::builder()
@@ -212,8 +259,17 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http:
}
const CAP: u64 = 8 * 1024 * 1024;
let mut it = req.uri().path().trim_start_matches("/file/").split('/');
let (job, id) = (it.next().unwrap_or(""), it.next().and_then(|s| s.parse::<u64>().ok()));
let err = |code: u16| Response::builder().status(code).header("Access-Control-Allow-Origin", "*").body(Vec::new()).unwrap();
let (job, id) = (
it.next().unwrap_or(""),
it.next().and_then(|s| s.parse::<u64>().ok()),
);
let err = |code: u16| {
Response::builder()
.status(code)
.header("Access-Control-Allow-Origin", "*")
.body(Vec::new())
.unwrap()
};
let Some(id) = id else { return err(400) };
let range = req.headers().get("range").and_then(|v| v.to_str().ok());
let slice = match eng.file_slice(job, id, range) {
@@ -225,14 +281,24 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http:
let want = slice.len();
let mut headers = slice.headers();
let partial = slice.partial || want > CAP;
let Ok(data) = slice.read_all(CAP) else { return err(500) };
let Ok(data) = slice.read_all(CAP) else {
return err(500);
};
let mut b = Response::builder().status(if partial { 206 } else { 200 });
headers.retain(|(k, _)| *k != "Content-Range");
for (k, v) in &headers {
b = b.header(*k, v);
}
if partial && !data.is_empty() {
b = b.header("Content-Range", format!("bytes {}-{}/{}", start, start + data.len() as u64 - 1, total));
b = b.header(
"Content-Range",
format!(
"bytes {}-{}/{}",
start,
start + data.len() as u64 - 1,
total
),
);
}
b.body(data).unwrap_or_else(|_| err(500))
}
@@ -249,12 +315,15 @@ fn main() {
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
})
.setup(|app| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
// When launched elevated by the unprivileged instance, terminate the old process.
#[cfg(unix)]
if let Some(pid) = std::env::var("GUARA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
.ok()
.and_then(|p| p.parse::<i32>().ok())
{
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1200));
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
// SAFETY: only sends SIGTERM to the previous process owned by the invoking user.
unsafe {
if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM);
@@ -263,9 +332,9 @@ fn main() {
});
}
// Ler discos brutos exige root: pede elevação assim que o app abre,
// sem esperar o usuário procurar um botão. Se a autorização falhar,
// a janela continua aberta e a interface mostra o aviso.
// Reading raw disks requires root: request elevation as soon as the app launches,
// without waiting for the user to look for a button. If authorization fails,
// the window stays open and the UI displays a warning.
#[cfg(any(target_os = "linux", target_os = "macos"))]
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
let handle = app.handle().clone();
@@ -275,7 +344,13 @@ fn main() {
}
Ok(())
})
.invoke_handler(tauri::generate_handler![rpc, is_elevated, relaunch_elevated, update_check, update_install])
.invoke_handler(tauri::generate_handler![
rpc,
is_elevated,
relaunch_elevated,
update_check,
update_install
])
.run(tauri::generate_context!())
.expect("erro ao iniciar o Guará");
}
+12 -12
View File
@@ -129,7 +129,7 @@ h1, h2, h3 { margin: 0; letter-spacing: -0.02em; }
.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 */
/* ------------------------------------------------------------ components */
.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; }
@@ -194,7 +194,7 @@ 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 */
.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; }
@@ -204,7 +204,7 @@ details.adv[open] summary { margin-bottom: 10px; }
.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 */
/* results */
.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; }
@@ -212,7 +212,7 @@ details.adv[open] summary { margin-bottom: 10px; }
.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; }
/* O fundo do select é translúcido; a lista aberta não herda isso e vinha branca com texto claro no tema escuro. */
/* The select background is translucent; the open dropdown does not inherit it and rendered white with light text in dark mode. */
.select option, .select optgroup { background-color: var(--surface); color: var(--text); }
.input { flex: 1 1 200px; }
.input::placeholder { color: var(--text-3); }
@@ -255,7 +255,7 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
.scrim { display: none; }
.back { color: var(--accent); background: none; border: 0; cursor: pointer; font-weight: 500; padding: 0; margin-bottom: 8px; }
/* ------------------------------------------------------------ responsivo */
/* ------------------------------------------------------------ responsive */
@media (max-width: 1100px) {
.split { grid-template-columns: 1fr; }
.preview { position: static; order: -1; grid-template-columns: 120px 1fr; align-items: start; }
@@ -285,12 +285,12 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
* { animation: none !important; transition: none !important; }
}
/* ------------------------------------------------------------ tema */
/* ------------------------------------------------------------ theme */
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 12px; color: var(--text-2); }
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
/* ------------------------------------------------------------ pré-visualização */
/* ------------------------------------------------------------ preview */
.viewer { position: relative; min-height: 160px; max-height: 52vh; overflow: auto; border-radius: 12px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); display: flex; flex-direction: column; align-items: center; }
.viewer.large { max-height: none; height: 100%; min-height: 0; }
.viewer > * { max-width: 100%; }
@@ -335,7 +335,7 @@ dialog.lightbox[open] { display: grid; }
.hero-logo { width: min(340px, 70%); height: auto; margin: 0 0 14px -6px; }
.pill.accent { background: var(--accent-soft); color: var(--accent); }
/* ------------------------------------------------------------ explorador de arquivos */
/* ------------------------------------------------------------ file explorer */
.main.fixed { overflow: hidden; }
.page.wide { max-width: none; padding: 14px 18px 0; }
.page.fill { height: 100%; display: flex; flex-direction: column; }
@@ -467,7 +467,7 @@ dialog.lightbox[open] { display: grid; }
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
.cand .s { white-space: normal; }
/* ------------------------------------------------------------ visualizador de bancos de dados */
/* ------------------------------------------------------------ database viewer */
.db-sum { width: 100%; padding: 16px; display: grid; gap: 8px; text-align: left; }
.db-sum ul { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 2px; font-size: 13px; }
.db-sum li { display: flex; justify-content: space-between; gap: 10px; } .db-sum li small { color: var(--text-3); }
@@ -497,7 +497,7 @@ dialog.lightbox[open] { display: grid; }
.xp-tiles img { width: 100%; height: 100%; display: block; }
.xp-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #0f8f8f; border: 3px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
/* saúde do disco (SMART) */
/* disk health (SMART) */
.h-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }
.h-tile { background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; }
.h-tile small { color: var(--text-3); font-size: 12px; }
@@ -516,10 +516,10 @@ dialog.lightbox[open] { display: grid; }
.h-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); }
.h-dot.warning { background: var(--warn); } .h-dot.failing { background: var(--bad); }
/* logo: uma arte só (laranja sobre transparente) serve aos temas claro e escuro */
/* logo: a single artwork (orange on transparent) works for both light and dark themes */
.logo-img { display: block; flex: none; }
/* atualização do app */
/* app update */
.update-card { position: fixed; right: 18px; bottom: 18px; z-index: 60; width: min(400px, calc(100vw - 36px)); display: grid; gap: 10px; padding: 16px 18px; border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.update-card .upd-head { display: grid; gap: 2px; }
.update-card .upd-ver { font-size: 12.5px; color: var(--text-2); }
+30 -30
View File
@@ -1,5 +1,5 @@
// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`:
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
// Guará: UI. Communicates with the core via `rpc(method, params)`:
// • Tauri app: invoke('rpc') • `guara ui` mode: POST /rpc/<method> with token.
import { createExplorer } from './explorer.js';
import { setLang, LOC, currentLang } from './i18n.js';
@@ -10,7 +10,7 @@ let token = params.get('token');
try {
if (token) sessionStorage.setItem('guara-token', token);
else token = sessionStorage.getItem('guara-token');
} catch { /* armazenamento indisponível */ }
} catch { /* storage unavailable */ }
async function rpc(method, p = {}) {
if (tauri?.core?.invoke) return tauri.core.invoke('rpc', { method, params: p });
@@ -29,7 +29,7 @@ const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost'
const thumbUrl = (job, id, s = 256) => `${fileBase}/thumb/${job}/${id}?s=${s}` + (tauri ? '' : `&t=${encodeURIComponent(token || '')}`);
const fileUrl = (job, id) => `${fileBase}/file/${job}/${id}` + (tauri ? '' : `?t=${encodeURIComponent(token || '')}`);
// Atualiza o HTML só quando mudou (evita piscar e perder o foco/hover).
// Update HTML only when changed (prevents flickering and losing focus/hover).
function setHTML(el, html) {
if (!el || el._h === html) return false;
el._h = html;
@@ -37,7 +37,7 @@ function setHTML(el, html) {
return true;
}
// ------------------------------------------------------------------ tema
// ------------------------------------------------------------------ theme
function applyTheme(t) {
const root = document.documentElement;
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme;
@@ -47,19 +47,19 @@ try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { apply
document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
const t = b.dataset.themePick;
applyTheme(t);
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
try { localStorage.setItem('guara-theme', t); } catch { /* no storage */ }
saveSettings({ theme: t });
}));
// ------------------------------------------------------------------ configurações
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
// ------------------------------------------------------------------ settings
// (declared before use: `S` is defined further below, so the object lives here)
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
async function saveSettings(patch) {
Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
}
// ------------------------------------------------------------------ utilidades
// ------------------------------------------------------------------ utilities
const $ = (s, el = document) => el.querySelector(s);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = (n) => {
@@ -153,14 +153,14 @@ const S = {
sessions: [], // {job, target, label}
device: null, // {path,name,...}
layout: null,
job: null, // id da varredura atual
job: null, // active scan job id
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)
current: null, // focused file (preview)
poll: null,
recJob: null,
};
@@ -259,7 +259,7 @@ $('#loadSessionBtn').onclick = async () => {
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
};
// ------------------------------------------------------------------ início
// ------------------------------------------------------------------ home
function renderHome() {
main.classList.remove('fixed');
stopPoll();
@@ -278,7 +278,7 @@ function renderHome() {
$('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
}
// ------------------------------------------------------------------ dispositivo
// ------------------------------------------------------------------ device
async function openDevice(d) {
main.classList.remove('fixed');
stopPoll();
@@ -291,7 +291,7 @@ async function openDevice(d) {
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
S.layout = layout;
renderDevice(err);
// saúde (SMART) em segundo plano: não atrasa a tela
// Query SMART health in the background without blocking the screen.
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
}
@@ -325,7 +325,7 @@ function paintHealth() {
${attrs.map((a) => `<tr><td><span class="h-dot ${esc(a.status)}" title="${HEALTH_WORD[a.status] || ''}"></span></td><td class="mono">${esc(a.id)}</td><td class="mono">${esc(a.name)}</td><td>${dash(a.value)}</td><td>${dash(a.worst)}</td><td>${dash(a.thresh)}</td><td class="mono">${esc(a.raw)}</td></tr>`).join('')}
</tbody></table></div></details>` : '';
const scope = h.partition ? `<p class="sub">O SMART é do disco inteiro: os dados abaixo são de <span class="mono">${esc(h.disk)}</span>, que contém <span class="mono">${esc(h.partition)}</span>.</p>` : '';
// o que motivou o alerta (os campos da ficha já aparecem abaixo)
// Fields that triggered the alert (standard summary fields appear below).
const known = new Set(['Modelo', 'Nº de série', 'Horas ligado', 'Temperatura', 'Autoavaliação SMART', 'Vida útil usada']);
const why = (h.details || []).filter(([k]) => !known.has(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
el.innerHTML = `${bad ? `<div class="notice ${h.status === 'failing' ? 'bad' : ''}">${warnSvg}<div><b>${esc(h.summary)}</b>
@@ -338,7 +338,7 @@ function paintHealth() {
$('#healthImg')?.addEventListener('click', startImage);
}
/** Disco com sinais de defeito: oferece criar a imagem antes de varrer. */
/** Disk showing signs of failure: offer to create a disk image before scanning. */
function confirmRisky() {
return new Promise((resolve) => {
const h = S.health;
@@ -532,8 +532,8 @@ function openSearch(job, terms) {
(async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })();
}
// ------------------------------------------------------------------ varredura
let EX = null; // explorador de arquivos (grade, navegação, detalhes)
// ------------------------------------------------------------------ scan
let EX = null; // file explorer (grid, navigation, details)
function openJob(s) {
stopPoll();
@@ -556,7 +556,7 @@ async function tick() {
S.status = st;
paintStatus();
EX?.onStatus(st);
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
// Keep polling while the scan is running or dates/EXIF are still being enriched.
if (st.status !== 'running' && !st.enriching) {
stopPoll();
loadRecent();
@@ -615,7 +615,7 @@ function paintStatus() {
async function chooseSource() {
let cands = [];
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* fall back to manual path input */ }
const st = S.status || {};
const size = st.layout?.size;
const d = $('#dlg');
@@ -711,7 +711,7 @@ async function recover(sel) {
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
}
// Modal de progresso de tarefas auxiliares (recuperação / imagem).
// Progress modal for auxiliary jobs (recovery / disk imaging).
function watchAux(job, title, doneHtml) {
const d = $('#dlg');
d.onclose = null;
@@ -740,7 +740,7 @@ function watchAux(job, title, doneHtml) {
async function openJobFromList(s) { openJob(s); }
// ------------------------------------------------------------------ configurações (janela)
// ------------------------------------------------------------------ settings modal
$('#settingsBtn')?.addEventListener('click', () => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
@@ -764,9 +764,9 @@ $('#settingsBtn')?.addEventListener('click', () => {
d.showModal();
});
// ------------------------------------------------------------------ atualização do app
// O núcleo do app (Tauri) consulta o `latest.json` da release no Gitea, confere a assinatura do
// pacote com a chave pública embutida, instala e reabre. No navegador (`guara ui`) não há nada a fazer.
// ------------------------------------------------------------------ app updater
// The desktop shell (Tauri) queries `latest.json` from the Gitea release, verifies the
// package signature against the embedded public key, installs, and relaunches. No-op in browser mode (`guara ui`).
let UPD = null;
async function checkUpdate(manual) {
@@ -822,7 +822,7 @@ function updateProgress(p) {
}
}
// ------------------------------------------------------------------ fechar com varredura em andamento
// ------------------------------------------------------------------ close while scan is running
function askClose() {
return new Promise((resolve) => {
const d = $('#dlg');
@@ -848,14 +848,14 @@ function watchClose() {
try {
await rpc('scan_pause', { job: S.job });
for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); }
await new Promise((r) => setTimeout(r, 600)); // dá tempo do salvamento automático
} catch { /* sai mesmo assim */ }
await new Promise((r) => setTimeout(r, 600)); // allow auto-save to flush
} catch { /* exit anyway */ }
}
win.destroy();
});
}
// ------------------------------------------------------------------ início do app
// ------------------------------------------------------------------ app startup
(async function init() {
try {
const v = await rpc('version');
+24 -24
View File
@@ -1,7 +1,7 @@
// Explorador de arquivos recuperados: grade de miniaturas (virtualizada, aguenta
// dezenas de milhares de itens), navegação por tipo/data/pasta/câmera, seleção
// estilo gerenciador de arquivos (Ctrl/Shift/teclado), painel de detalhes e
// visualização rápida em tela cheia.
// Recovered file explorer: thumbnail grid (virtualized, handles
// tens of thousands of items), navigation by type/date/folder/camera, file-manager
// style selection (Ctrl/Shift/keyboard), inspector panel, and
// fullscreen quick look.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js';
@@ -66,7 +66,7 @@ export function createExplorer(deps) {
try {
E.view = localStorage.getItem('guara-view') || 'grid';
E.tile = +localStorage.getItem('guara-tile') || 150;
} catch { /* sem armazenamento */ }
} catch { /* no storage */ }
host.innerHTML = `<div class="xp" data-view="${E.view}">
<div class="xp-bar">
@@ -91,7 +91,7 @@ export function createExplorer(deps) {
$('#xpSort').value = E.sort;
$('#xpSize').value = E.tile;
// ------------------------------------------------------------ dados
// ------------------------------------------------------------ data
const toQuery = () => {
const f = E.filters;
const q = {};
@@ -139,8 +139,8 @@ export function createExplorer(deps) {
const reloadAll = () => { reload(); loadFacets(); };
// ------------------------------------------------------------ layout virtual
// Agrupa por dia; se houver poucos itens por dia, agrupa por mês.
// ------------------------------------------------------------ virtual layout
// Group by day; if there are too few items per day, group by month.
let groupBy = 'day';
const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7));
const dayLabel = (k) => {
@@ -150,7 +150,7 @@ export function createExplorer(deps) {
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
};
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
// Metadados do núcleo trazem datas em ISO 8601; o idioma decide a ordem.
// Core metadata provides ISO 8601 dates; the active locale determines the order.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
const fmtMeta = (v) => ISO_DATE.test(v) ? new Date(v.endsWith('Z') ? v : v + 'Z').toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'medium' }) : v;
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
@@ -195,7 +195,7 @@ export function createExplorer(deps) {
});
flush();
if (grouped) {
// contagem por grupo
// count per group
for (let r = 0; r < rows.length; r++) {
if (rows[r].t !== 'head') continue;
let n = 0;
@@ -245,7 +245,7 @@ export function createExplorer(deps) {
sizer.appendChild(frag);
}
// ------------------------------------------------------------ itens
// ------------------------------------------------------------ items
const isSel = (id) => E.allFiltered || E.sel.has(id);
function headHtml(row) {
@@ -288,7 +288,7 @@ export function createExplorer(deps) {
</div>`;
}
// imagens que falham viram um aviso (arquivo possivelmente danificado)
// failed images become a warning (file possibly damaged)
scroll.addEventListener('error', (e) => {
const img = e.target;
if (img.tagName !== 'IMG' || !img.dataset.th) return;
@@ -300,7 +300,7 @@ export function createExplorer(deps) {
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
}, true);
// ------------------------------------------------------------ seleção
// ------------------------------------------------------------ selection
const selCount = () => (E.allFiltered ? E.total : E.sel.size);
const idxOf = (id) => E.items.findIndex((x) => x.id === id);
@@ -387,7 +387,7 @@ export function createExplorer(deps) {
else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
});
// ------------------------------------------------------------ barra de ações
// ------------------------------------------------------------ action bar
function paintActions() {
const n = selCount();
const html = n
@@ -407,7 +407,7 @@ export function createExplorer(deps) {
else if (a === 'recall') onRecover({ filter: toQuery() });
});
// ------------------------------------------------------------ navegação lateral
// ------------------------------------------------------------ sidebar navigation
function paintNav() {
const f = E.facets;
if (!f) return;
@@ -424,7 +424,7 @@ export function createExplorer(deps) {
+ (open ? y.months.map((m) => node(MONTHS[m.month - 1], m.count, `data-f="month" data-y="${y.year}" data-v="${m.month}"`, F.year === y.year && F.month === m.month, 1)).join('') : '');
}).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : '');
const folders = f.folders.filter((d) => {
// só mostra se todos os ancestrais estiverem expandidos
// only show if all ancestors are expanded
const parts = d.path.split('/');
for (let k = 1; k < parts.length; k++) if (!E.expanded.has('d' + parts.slice(0, k).join('/')) && !(F.folder || '').startsWith(parts.slice(0, k).join('/'))) return false;
return true;
@@ -528,7 +528,7 @@ export function createExplorer(deps) {
changed();
});
// ------------------------------------------------------------ barra superior
// ------------------------------------------------------------ top bar
let qt;
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
$('#xpSort').addEventListener('change', (e) => { E.sort = e.target.value; reload(); });
@@ -550,7 +550,7 @@ export function createExplorer(deps) {
$('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
// ------------------------------------------------------------ painel de detalhes
// ------------------------------------------------------------ inspector panel
let inspT, stopPv = null, metaToken = 0;
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); };
@@ -584,7 +584,7 @@ export function createExplorer(deps) {
<div class="xp-meta" id="xpMeta"></div>
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
// clicar na miniatura abre o modal grande
// clicking the thumbnail opens the large modal
$('#xpPv').addEventListener('click', (ev) => {
if (ev.target.closest('button, a, .pv-pdfbar')) return;
openLightbox(idxOf(it.id));
@@ -599,7 +599,7 @@ export function createExplorer(deps) {
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
}).catch(() => {});
}
/** Coordenadas e, se o usuário permitiu, um mapa (blocos do OpenStreetMap, 3x3 ao redor do ponto). */
/** Coordinates and, if enabled by the user, a map (3x3 OpenStreetMap tiles around the point). */
function gpsHtml([lat, lon]) {
let map = '';
if (deps.getSettings?.().maps) {
@@ -625,7 +625,7 @@ export function createExplorer(deps) {
else if (a === 'close') root.classList.remove('show-insp');
});
// ------------------------------------------------------------ tela cheia
// ------------------------------------------------------------ fullscreen
let stopLb = null;
function openLightbox(i) {
const it = E.items[i];
@@ -649,14 +649,14 @@ export function createExplorer(deps) {
if (!d.open) d.showModal();
}
// ------------------------------------------------------------ paint geral
// ------------------------------------------------------------ main paint
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
paintView(); paintCrumb(); paintActions(); paintInspector();
reloadAll();
return {
/** Chamado a cada atualização de status da varredura. */
/** Called on every scan status update. */
onStatus(st) {
E.status = st;
const enriching = !!st.enriching;
@@ -672,7 +672,7 @@ export function createExplorer(deps) {
},
query: () => toQuery(),
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
/** Idioma trocado em Configurações: refaz o que foi montado em JS (datas, meses, rótulos). */
/** Language changed in Settings: rebuild JS-rendered elements (dates, months, labels). */
relang() { paintCrumb(); paintChips(); paintNav(); paintActions(); paintInspector(); relayout(); },
focus() { scroll.focus({ preventScroll: true }); },
};
+25 -25
View File
@@ -1,6 +1,6 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
// Portuguese -> English UI dictionary. {n} marks a variable placeholder.
export const EN = {
// saúde do disco (SMART)
// disk health (SMART)
'O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco.': 'SMART could not be read. Install the smartmontools package (smartctl command) and run as administrator to see the disk health.',
'Setores realocados': 'Reallocated sectors',
'Setores pendentes de realocação': 'Pending sectors',
@@ -38,7 +38,7 @@ export const EN = {
'O SMART é do disco inteiro: os dados abaixo são de': 'SMART belongs to the whole disk: the data below is for',
', que contém': ', which contains',
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.',
// barra lateral e estrutura
// sidebar and layout
'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices',
'Dispositivos e sessões': 'Devices and sessions',
@@ -64,7 +64,7 @@ export const EN = {
'· requer admin': '· needs admin',
'requer admin': 'needs admin',
'{n} · requer admin': '{n} · needs admin',
// início
// home
'Encontre o que você achou que tinha perdido.': 'Find what you thought was lost.',
'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.': 'Pick a disk, SSD or USB drive on the left. Guará reads everything: deleted files, lost partitions and unpartitioned space, without writing anything to the source disk.',
'Uma varredura, tudo junto': 'One scan, everything together',
@@ -74,7 +74,7 @@ export const EN = {
'Origem protegida': 'Protected source',
'Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.': 'Read-only. Recovery always goes to another folder, with SHA-256 of everything.',
'Nenhum disco apareceu.': 'No disks showed up.',
// dispositivo
// device
'Mapa do disco': 'Disk map',
'Como procurar': 'How to search',
'Recomendado': 'Recommended',
@@ -108,7 +108,7 @@ export const EN = {
'encontrada por varredura': 'found by scanning',
'Desconhecido': 'Unknown',
'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.': 'This SSD uses TRIM: the system tells the disk which blocks were deleted and the SSD itself may zero them. Files deleted a long time ago tend to be unrecoverable.',
// saúde do disco
// disk health
'Saúde do disco': 'Disk health',
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
@@ -119,7 +119,7 @@ export const EN = {
'Varrer mesmo assim': 'Scan anyway',
'Cancelar': 'Cancel',
'Este disco parece estar com defeito': 'This disk seems to be failing',
// varredura
// scan
'‹ Dispositivos': '‹ Devices',
'Procurando…': 'Searching…',
'Varredura concluída': 'Scan complete',
@@ -152,7 +152,7 @@ export const EN = {
'Lendo tabela de partições': 'Reading partition table',
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
'Varredura profunda por assinatura': 'Deep signature scan',
// explorador
// explorer
'Navegação': 'Navigation',
'Filtros': 'Filters',
'Todos os arquivos': 'All files',
@@ -246,7 +246,7 @@ export const EN = {
'Tem localização GPS': 'Has GPS location',
'Confiança {n}': 'Confidence {n}',
'Nada por aqui': 'Nothing here',
// pré-visualização
// preview
'Imagem': 'Image',
'Vídeo': 'Video',
'PDF': 'PDF',
@@ -277,7 +277,7 @@ export const EN = {
'Neste app o vídeo abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, video opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'Neste app o áudio abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, audio opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'pasta': 'folder',
// banco de dados
// database
'Abrir o visualizador': 'Open the viewer',
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
'{n} linhas · {n} colunas': '{n} rows · {n} columns',
@@ -297,7 +297,7 @@ export const EN = {
'{n} a {n} de {n}': '{n} to {n} of {n}',
'‹ Anterior': '‹ Previous',
'Próximas ›': 'Next ›',
// recuperar
// recover
'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover',
'Pasta de destino': 'Destination folder',
@@ -336,7 +336,7 @@ export const EN = {
'{n} arquivo(s) não puderam ser gravados.': '{n} file(s) could not be written.',
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
// imagem, hash, busca
// image, hash, search
'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes',
@@ -354,7 +354,7 @@ export const EN = {
'{n} ocorrência{n}': '{n} occurrence{n}',
'Termos: {n}': 'Terms: {n}',
'{n}% · {n} até agora': '{n}% · {n} so far',
// sessões e origem
// sessions and source
'Abrir arquivo de imagem': 'Open disk image file',
'Caminho completo de uma imagem de disco (.img, .dd, .iso…).': 'Full path of a disk image (.img, .dd, .iso…).',
'Caminho do arquivo de sessão (.json) criado pelo Guará.': 'Path of the session file (.json) created by Guará.',
@@ -374,7 +374,7 @@ export const EN = {
'mesmo disco': 'same disk',
'tamanho igual, conteúdo diferente': 'same size, different content',
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
// configurações e fechamento
// settings and shutdown
'Idioma': 'Language',
'Notificações': 'Notifications',
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
@@ -389,14 +389,14 @@ export const EN = {
'Sair sem pausar': 'Quit without pausing',
'Continuar varrendo': 'Keep scanning',
'A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.': 'The session is saved automatically. If you pause now, you resume exactly where you stopped.',
// erros comuns vindos do núcleo
// common errors from the core
'Não foi possível falar com o núcleo do Guará: {n}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando': 'Could not talk to the Guará core: {n}. If you opened this page manually, use the full address shown by the command',
'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
'Autorização cancelada.': 'Authorization canceled.',
'Origem desconectada': 'Source disconnected',
'Este disco está em uso (montado em': 'This disk is in use (mounted at',
'). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.': '). Guará only reads, but avoid writing to it while recovering, because any write can overwrite what you want to save.',
// formulário de opções de varredura
// scan options form
'Volumes cifrados, RAID e vídeos': 'Encrypted volumes, RAID and videos',
'Senha do volume (BitLocker, LUKS, FileVault 2)': 'Volume password (BitLocker, LUKS, FileVault 2)',
'Chave de recuperação do BitLocker': 'BitLocker recovery key',
@@ -407,7 +407,7 @@ export const EN = {
'Vídeo íntegro da mesma câmera/drone (refaz o índice de MP4 sem moov)': 'Intact video from the same camera/drone (rebuilds the index of an MP4 without moov)',
'Lembrar as chaves dos volumes abertos na sessão': 'Remember the keys of opened volumes in the session',
'A senha nunca é gravada; só a chave derivada.': 'The password is never saved; only the derived key.',
// criar imagem do disco
// disk image creation
'Arquivo de destino': 'Destination file',
'Número do caso (E01/AFF4)': 'Case number (E01/AFF4)',
'Formato da imagem': 'Image format',
@@ -416,18 +416,18 @@ export const EN = {
'Examinador (E01/AFF4)': 'Examiner (E01/AFF4)',
'Imagem salva em': 'Image saved to',
'Hashes do conteúdo inteiro de': 'Hashes of the entire content of',
// resultados e partições
// results and partitions
'arquivo(s) salvos em': 'file(s) saved to',
'arquivos em {n}': 'files in {n}',
'Espaço não particionado: {n}': 'Unpartitioned space: {n}',
'Partição {n}': 'Partition {n}',
'erro {n}': 'error {n}',
// saúde do disco (campos vindos do núcleo)
// disk health (fields from the core)
'Autoavaliação SMART': 'SMART self-assessment',
'Vida útil usada': 'Life used',
'aprovado': 'passed',
'REPROVADO: o próprio disco prevê falha': 'FAILED: the disk itself predicts failure',
// pré-visualização
// preview
'Pré-visualização': 'Preview',
'Pré-visualização de {n}': 'Preview of {n}',
'vídeo': 'video',
@@ -438,7 +438,7 @@ export const EN = {
'diretório do ZIP não encontrado (arquivo possivelmente incompleto)': 'ZIP directory not found (file possibly incomplete)',
'visualizador indisponível': 'viewer unavailable',
'© colaboradores do OpenStreetMap': '© OpenStreetMap contributors',
// explorador: textos que a interface divide em nós separados
// explorer: strings split across separate DOM nodes by the UI
'selecionado': 'selected',
'selecionados': 'selected',
'selecionado (todos desta vista)': 'selected (all in this view)',
@@ -459,7 +459,7 @@ export const EN = {
'Conteúdo do arquivo compactado · {n}': 'Archive contents · {n}',
'Banco de dados SQLite · {n}': 'SQLite database · {n}',
'Dados brutos (hexadecimal) · {n}': 'Raw data (hexadecimal) · {n}',
// tipos e campos de metadados vindos do núcleo
// metadata types and fields from the core
'Imagem JPEG': 'JPEG image',
'Imagem PNG': 'PNG image',
'Imagem GIF': 'GIF image',
@@ -482,7 +482,7 @@ export const EN = {
'Última modificação por': 'Last modified by',
'Páginas': 'Pages',
'Duração': 'Duration',
// saúde do disco e confiança vindas do núcleo
// disk health and confidence levels from the core
'SSD (sem rotação)': 'SSD (no rotation)',
'Cache de gravação': 'Write cache',
'Segurança ATA': 'ATA security',
@@ -490,13 +490,13 @@ export const EN = {
'membro de RAID': 'RAID member',
'sem rodapé/estrutura completa: pode estar truncado': 'no footer/complete structure: may be truncated',
'vídeo sem índice (moov)': 'video without index (moov)',
// visualizador de SQLite
// SQLite viewer
'Nenhuma tabela encontrada no esquema. A primeira página pode estar danificada.': 'No table found in the schema. The first page may be damaged.',
'Tabela virtual: o conteúdo não fica armazenado como linhas comuns.': 'Virtual table: the content is not stored as ordinary rows.',
'Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida.': 'WITHOUT ROWID table: the rows live in an index tree, not shown yet.',
'Arquivo truncado: o cabeçalho indica {n} páginas, mas só há {n}. Parte dos dados pode estar faltando.': 'Truncated file: the header says {n} pages, but there are only {n}. Part of the data may be missing.',
// mensagens de erro e avisos do núcleo (valores variáveis usam {n})
// core error and warning messages (variable values use {n})
'job {n} não existe': 'job {n} does not exist',
'a varredura não está pausada': 'the scan is not paused',
'A origem desta sessão não está disponível ({n}). Reconecte o disco ou escolha a origem para continuar.': 'The source of this session is unavailable ({n}). Reconnect the disk or choose the source to continue.',
+8 -8
View File
@@ -1,7 +1,7 @@
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
// continua observando a página para traduzir o que for desenhado depois.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files").
// UI language. Source strings are in Portuguese; in English mode a translator
// replaces displayed strings (text nodes and attributes) using a dictionary, and
// keeps observing the page to translate dynamically rendered elements.
// Variable placeholders use {n} in the dictionary (e.g. "{n} files").
import { EN } from './i18n-en.js';
@@ -16,7 +16,7 @@ export function resolveLang(pref) {
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
// Dados do usuário (nomes, caminhos, conteúdo) nunca são traduzidos.
// User data (names, paths, content) is never translated.
const SKIP = '.xp-cap, .xp-lnm, .db-table, .pv-zip, .hit, pre, code, .mono, .xp-ititle b, dd, textarea, input, script, style';
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
@@ -26,7 +26,7 @@ const patterns = Object.entries(EN)
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
})
// padrões mais específicos (mais texto fixo) primeiro
// more specific patterns (more fixed text) first
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
@@ -43,7 +43,7 @@ export function tr(text) {
const m = core.match(p.re);
if (m) {
let i = 0;
// os valores capturados também podem ter partes traduzíveis (ex.: nomes de tipos)
// captured values may also contain translatable parts (e.g. type names)
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break;
}
@@ -55,7 +55,7 @@ export function tr(text) {
return lead + out + trail;
}
const orig = new WeakMap(); // nó -> texto original em português
const orig = new WeakMap(); // node -> original source text
let observer = null;
function translateNode(n) {
+12 -12
View File
@@ -1,8 +1,8 @@
import { LOC } from './i18n.js';
// Pré-visualização de arquivos recuperados.
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal.
// Preview of recovered files.
// Content is streamed from the core (with Range support) via `url`; nothing is
// written to disk. Supported types: image, video, audio, PDF (pdf.js), text,
// Office/OpenDocument documents (extracted text), ZIP listing, and hex view.
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']);
@@ -44,7 +44,7 @@ async function fetchRange(url, a, b) {
return new Uint8Array(await r.arrayBuffer());
}
// ------------------------------------------------------------------ ZIP mínimo
// ------------------------------------------------------------------ minimal ZIP reader
async function zipDir(url, size) {
const tailLen = Math.min(size, 66000);
const tail = await fetchRange(url, size - tailLen, size - 1);
@@ -86,7 +86,7 @@ async function zipRead(url, e, cap = 8 << 20) {
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
// ------------------------------------------------------------------ renderizadores
// ------------------------------------------------------------------ renderers
function note(host, html, cls = '') {
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
}
@@ -248,7 +248,7 @@ async function office(host, item, url, state) {
const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80);
html = `<div class="pv-sheet"><table><tbody>${rows.map((r) => `<tr>${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => `<td>${esc(c.textContent)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else {
// pptx / odp: texto por slide
// pptx / odp: per-slide text
let slides;
if (ext === 'pptx') {
const files = ents.filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).slice(0, 60);
@@ -275,9 +275,9 @@ async function zip(host, item, url, state) {
note(host, `${ents.length} item(ns) no arquivo compactado.`);
}
// ------------------------------------------------------------------ bancos de dados (SQLite)
// ------------------------------------------------------------------ databases (SQLite)
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
/** Integer values that look like timestamps (Unix, Chrome/WebKit, macOS) become a readable hint. */
function tsHint(v) {
if (!Number.isInteger(v)) return null;
let ms = null, kind = '';
@@ -361,7 +361,7 @@ async function database(host, item, state, large, db, onOpen) {
}
// ------------------------------------------------------------------ API
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
/** Renders the preview of `item` inside `host`. Returns a cleanup function. */
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
const ico = kind === 'video' ? '🎞' : '🎵';
const canInline = inlineMedia && large;
@@ -385,8 +385,8 @@ export function renderPreview(host, item, { url, large = false, thumb = false, i
host.classList.remove('zoomable', 'actual');
host.innerHTML = '<div class="pv-loading"><i></i></div>';
const kind = kindOf(item);
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
// Video and audio: never instantiate <video>/<audio> in the small panel, nor in the large view
// on Linux desktop (WebKitGTK without media plugins), as the webview process would crash.
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
return () => { state.dead = true; };
+2 -2
View File
@@ -1,3 +1,3 @@
# Bibliotecas de terceiros
# Third-party libraries
- **pdf.js** 4.10.38 (build "legacy"), © Mozilla, licença Apache-2.0 (`PDFJS-LICENSE`). Usada só para pré-visualizar PDFs, localmente e sem internet. Origem: https://github.com/mozilla/pdf.js
- **pdf.js** 4.10.38 ("legacy" build), © Mozilla, Apache-2.0 license (`PDFJS-LICENSE`). Used exclusively for local offline PDF preview. Source: https://github.com/mozilla/pdf.js
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "guara-cli"
description = "Guará: recuperação de arquivos pelo terminal (e servidor da interface gráfica)"
description = "Guará: command-line file recovery tool and web UI server"
version.workspace = true
edition.workspace = true
license.workspace = true
+73 -73
View File
@@ -20,34 +20,34 @@ struct Cli {
#[derive(Subcommand)]
enum SignCmd {
/// Cria o par de chaves de assinatura (recusa sobrescrever uma chave existente)
/// Generate a signing keypair (refuses to overwrite an existing key)
Keygen {
/// Onde gravar a chave privada (padrão: assinatura.key na pasta de configuração ou GUARA_SIGNING_KEY)
/// Where to write the private key (default: assinatura.key in the config dir or GUARA_SIGNING_KEY)
#[arg(long)]
key: Option<String>,
},
/// Assina um arquivo e grava <arquivo>.assinatura.json
/// Sign a file and write <file>.assinatura.json
File {
path: String,
#[arg(long)]
key: Option<String>,
/// Nome de quem assina (padrão: GUARA_EXAMINER)
/// Signer name (default: GUARA_EXAMINER)
#[arg(long)]
signer: Option<String>,
},
/// Confere a assinatura de um arquivo
/// Verify a file signature
Verify {
path: String,
/// Arquivo de assinatura (padrão: <arquivo>.assinatura.json)
/// Signature file (default: <file>.assinatura.json)
#[arg(long)]
sig: Option<String>,
/// Impressão digital esperada da chave, publicada pelo perito
/// Expected key fingerprint published by the examiner
#[arg(long)]
trust: Option<String>,
#[arg(long)]
json: bool,
},
/// Confere uma pasta de recuperação: assinatura do manifesto e hash de cada arquivo
/// Verify a recovery folder: manifest signature and hash of every file
Recovery {
dir: String,
#[arg(long)]
@@ -59,103 +59,103 @@ enum SignCmd {
#[derive(Subcommand)]
enum Cmd {
/// Lista discos e pendrives detectados
/// List detected disks and USB drives
Devices {
#[arg(long)]
json: bool,
},
/// Mostra partições, sistemas de arquivos, espaço não particionado e volumes cifrados
/// Show partitions, filesystems, unpartitioned space, and encrypted volumes
Inspect {
/// Dispositivo (ex.: /dev/sdb, \\.\PhysicalDrive1) ou arquivo de imagem
/// Device (e.g. /dev/sdb, \\.\PhysicalDrive1) or image file
path: String,
#[arg(long)]
json: bool,
#[command(flatten)]
asm: AsmArgs,
},
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos
/// Disk health (SMART): status, temperature, usage, and attribute table
Health {
/// Dispositivo ou partição (o SMART lido é o do disco que a contém)
/// Device or partition (reads SMART from the containing disk)
path: String,
#[arg(long)]
json: bool,
},
/// Procura arquivos apagados/perdidos (metadados + assinatura)
/// Scan for deleted/lost files (metadata + file carving)
Scan(ScanArgs),
/// Continua uma varredura pausada (Ctrl+C) a partir do arquivo de sessão
/// Resume a paused scan (Ctrl+C) from the session file
Resume {
session: String,
#[command(flatten)]
out: OutArgs,
},
/// Recupera arquivos de uma sessão salva
/// Recover files from a saved session
Recover {
session: String,
/// Pasta de destino (em OUTRO disco)
/// Destination directory (on ANOTHER disk)
dest: String,
#[command(flatten)]
filter: FilterArgs,
},
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
/// Create a fault-tolerant disk image (skipping bad sectors) to work on a copy
Image {
path: String,
dest: String,
/// Formato da imagem: raw (padrão), e01 (Expert Witness) ou aff4 (AFF4 v1.0), com hash e metadados
/// Image format: raw (default), e01 (Expert Witness), or aff4 (AFF4 v1.0), with hash and metadata
#[arg(long, default_value = "raw")]
format: String,
/// E01: número do caso
/// E01: case number
#[arg(long, default_value = "")]
case: String,
/// E01: examinador
/// E01: examiner
#[arg(long, default_value = "")]
examiner: String,
/// E01: descrição da evidência
/// E01: evidence description
#[arg(long, default_value = "")]
description: String,
/// E01: número da evidência
/// E01: evidence number
#[arg(long, default_value = "")]
evidence: String,
/// E01: observações
/// E01: notes
#[arg(long, default_value = "")]
notes: String,
/// E01: tamanho máximo de cada segmento em MiB (0 = um arquivo só)
/// E01: maximum segment size in MiB (0 = single file)
#[arg(long, default_value_t = 1500)]
segment_mib: u64,
/// E01: compressão 0 (nenhuma) a 9
/// E01: compression level 0 (none) to 9
#[arg(long, default_value_t = 1)]
compression: u32,
},
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
/// Verify an E01/Ex01 image: recomputes MD5 and compares against the embedded hash
Verify { path: String },
/// Assina e confere arquivos (manifestos, relatórios) e pastas de recuperação com Ed25519
/// Sign and verify files (manifests, reports) and recovery folders with Ed25519
#[command(subcommand)]
Sign(SignCmd),
/// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash
/// Show the audit trail (who did what and when) or verify its hash chain
Audit {
/// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG)
/// Audit log file (default: auditoria.jsonl in the config dir or GUARA_AUDIT_LOG)
#[arg(long)]
log: Option<String>,
/// Só confere a cadeia de hashes; sai com erro se ela estiver quebrada
/// Only verify the hash chain; exits with an error if broken
#[arg(long)]
verify: bool,
/// Mostra só as últimas N entradas
/// Show only the last N entries
#[arg(long)]
tail: Option<usize>,
#[arg(long)]
json: bool,
},
/// Exporta os achados de uma sessão para CSV (abre no Excel)
/// Export session findings to CSV (Excel-compatible)
Export {
session: String,
/// Arquivo CSV de saída
/// Output CSV file
csv: String,
#[command(flatten)]
filter: FilterArgs,
},
/// Gera o relatório de recuperação (identificação do caso, origem, achados, hashes): PDF se o arquivo terminar em .pdf, senão HTML
/// Generate the recovery report (case info, source, findings, hashes): PDF if output ends in .pdf, otherwise HTML
Report {
session: String,
/// Arquivo de saída (.pdf ou .html)
/// Output file (.pdf or .html)
out: String,
#[arg(long, default_value = "")]
case: String,
@@ -165,20 +165,20 @@ enum Cmd {
org: String,
#[arg(long, default_value = "")]
notes: String,
/// Calcula MD5/SHA-1/SHA-256 da origem e inclui no relatório (pode demorar)
/// Compute source MD5/SHA-1/SHA-256 and include in the report (may take time)
#[arg(long)]
hash_source: bool,
},
/// Procura texto em todo o disco, inclusive espaço livre (UTF-8 e UTF-16)
/// Search for text across the entire disk, including free space (UTF-8 and UTF-16)
Search {
path: String,
/// Termos a procurar
/// Search terms
#[arg(required = true)]
terms: Vec<String>,
/// Diferencia maiúsculas de minúsculas
/// Case-sensitive search
#[arg(long)]
case_sensitive: bool,
/// Não procura em UTF-16
/// Skip UTF-16 search
#[arg(long)]
no_utf16: bool,
#[arg(long, default_value_t = 200)]
@@ -186,11 +186,11 @@ enum Cmd {
#[arg(long)]
json: bool,
},
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
/// Compute MD5, SHA-1, and SHA-256 of the device or image (integrity check)
Hash { path: String },
/// Lista as sessões salvas automaticamente
/// List autosaved sessions
Sessions,
/// Abre a interface gráfica no navegador (modo local)
/// Open the web UI in the browser (local mode)
Ui {
#[arg(long, default_value_t = 0)]
port: u16,
@@ -201,72 +201,72 @@ enum Cmd {
#[derive(clap::Args, Clone)]
struct FilterArgs {
/// Categorias: image, document, video, audio, archive, database, other
/// Categories: image, document, video, audio, archive, database, other
#[arg(long, value_delimiter = ',')]
category: Vec<String>,
/// Confiança mínima: low, medium, high
/// Minimum confidence: low, medium, high
#[arg(long, default_value = "low")]
min_confidence: String,
/// Só nomes/caminhos que contenham este texto
/// Only match names/paths containing this substring
#[arg(long)]
name: Option<String>,
/// Como organizar o que for recuperado: folders (pastas originais), type, date ou flat
/// How to organize recovered files: folders (original paths), type, date, or flat
#[arg(long, default_value = "folders")]
organize: String,
}
#[derive(clap::Args, Clone)]
struct OutArgs {
/// Salva a sessão (necessária para recover/resume)
/// Save session file (required for recover/resume)
#[arg(long)]
session: Option<String>,
/// Lista os arquivos encontrados
/// List found files
#[arg(long)]
list: bool,
#[arg(long)]
json: bool,
}
/// Montagem do disco: volumes cifrados, RAID e LVM.
/// Disk assembly: encrypted volumes, RAID, and LVM.
#[derive(clap::Args, Clone, Default)]
struct AsmArgs {
/// Senha de volume cifrado (BitLocker, LUKS, FileVault 2). Prefira --ask-password ou GUARA_PASSWORD
/// Encrypted volume password (BitLocker, LUKS, FileVault 2). Prefer --ask-password or GUARA_PASSWORD
#[arg(long = "password")]
passwords: Vec<String>,
/// Pergunta a senha no terminal, sem mostrar o que é digitado
/// Prompt for the password in the terminal without echoing
#[arg(long)]
ask_password: bool,
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos)
/// BitLocker numeric recovery password (8 groups of 6 digits)
#[arg(long = "recovery-key")]
recovery_keys: Vec<String>,
/// Arquivo de chave (.BEK do BitLocker ou keyfile do LUKS)
/// Key file (BitLocker .BEK or LUKS keyfile)
#[arg(long = "key-file")]
key_files: Vec<String>,
/// Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume)
/// Hex-encoded volume master key (shown by Guará when unlocking a volume)
#[arg(long = "volume-key")]
volume_keys: Vec<String>,
/// Outro disco ou imagem do mesmo conjunto (RAID/LVM). Pode repetir; "-" marca um disco ausente
/// Additional disk or image from the same set (RAID/LVM). Repeatable; "-" marks a missing disk
#[arg(long = "member")]
members: Vec<String>,
/// Os discos (alvo + --member, nesta ordem) formam um RAID sem metadados: descobre sozinho nível, faixa e layout
/// Treat disks (target + --member, in order) as a metadata-less RAID and auto-detect level, chunk size, and layout
#[arg(long)]
raid: bool,
/// Fixa o nível do RAID: 0, 5, 6, 1
/// Override RAID level: 0, 5, 6, 1
#[arg(long)]
raid_level: Option<String>,
/// Fixa o tamanho da faixa do RAID em KiB
/// Override RAID chunk size in KiB
#[arg(long)]
raid_chunk_kb: Option<u64>,
/// Fixa o layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
/// Override RAID parity layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
#[arg(long)]
raid_layout: Option<String>,
/// Ordem dos discos no RAID (índices; 0 = alvo)
/// Disk order in the RAID array (indices; 0 = target)
#[arg(long, value_delimiter = ',')]
raid_order: Vec<usize>,
/// Guarda na sessão as chaves dos volumes abertos (a senha nunca é guardada)
/// Store derived volume keys in the session file (passwords are never stored)
#[arg(long)]
remember_keys: bool,
/// Vídeo íntegro da mesma câmera/drone para refazer o índice de MP4 sem moov (pode repetir)
/// Intact reference video from the same camera/drone to rebuild headerless MP4 moov atoms (repeatable)
#[arg(long = "reference")]
references: Vec<String>,
}
@@ -343,15 +343,15 @@ impl AsmArgs {
#[derive(clap::Args)]
struct ScanArgs {
/// Dispositivo ou imagem
/// Device or image path
path: String,
/// Varredura rápida: só metadados dos sistemas de arquivos
/// Quick scan: filesystem metadata only
#[arg(long)]
quick: bool,
/// Também varre áreas ainda alocadas (mais lento)
/// Also carve currently allocated space (slower)
#[arg(long)]
include_allocated: bool,
/// Recupera tudo que casar com os filtros para esta pasta ao terminar
/// Recover all files matching the filters into this folder when finished
#[arg(long)]
recover: Option<String>,
#[command(flatten)]
@@ -684,7 +684,7 @@ fn query_from(f: &FilterArgs) -> Result<Query, String> {
"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.
// Query accepts a single category; multiple categories are filtered afterwards.
Ok(Query {
category: if cats.len() == 1 { Some(cats[0]) } else { None },
min_confidence: Some(min),
@@ -756,7 +756,7 @@ fn recover_job(
filter: &FilterArgs,
) -> Result<(), String> {
let mut q = query_from(filter)?;
// Várias categorias: seleciona por ids.
// Multiple categories: select by IDs.
let cats = parse_cats(&filter.category)?;
let mut ids: Option<Vec<u64>> = None;
if cats.len() > 1 {
@@ -815,7 +815,7 @@ fn recover_job(
Ok(())
}
/// Confere o hash gravado numa imagem E01/Ex01 ou AFF4. `Some(true)` = confere.
/// Verifies the embedded hash of an E01/Ex01 or AFF4 image. `Some(true)` = matches.
fn verify_image(path: &str) -> Result<Option<bool>, String> {
if path.to_ascii_lowercase().ends_with(".aff4") {
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(path)).map_err(s)?;
@@ -869,7 +869,7 @@ fn verify_image(path: &str) -> Result<Option<bool>, String> {
}
}
/// `guara audit`: lista a trilha ou confere a cadeia de hashes.
/// `guara audit`: lists the audit trail or verifies its hash chain.
fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) -> Result<(), String> {
let log = match log {
Some(p) => guara_core::audit::Log::at(p),
@@ -924,7 +924,7 @@ fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) ->
Ok(())
}
/// `guara sign`: chaves, assinatura e conferência.
/// `guara sign`: key generation, signing, and verification.
fn sign_cmd(c: SignCmd) -> Result<(), String> {
use guara_core::sign;
use std::path::{Path, PathBuf};
+2 -2
View File
@@ -1,4 +1,4 @@
//! Servidor local da interface (127.0.0.1 + token): mesma UI do app Tauri.
//! Local UI server (127.0.0.1 + token): serves the same UI as the Tauri app.
use guara_core::service::Engine;
use serde_json::json;
@@ -43,7 +43,7 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
]);
for req in server.incoming_requests() {
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
// One thread per request: streaming video/audio must not block the UI.
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
}
Ok(())
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "guara-core"
description = "Núcleo de recuperação de arquivos do Guará (somente leitura)"
description = "Guará read-only file and volume recovery core"
version.workspace = true
edition.workspace = true
license.workspace = true
+18 -18
View File
@@ -1,8 +1,8 @@
//! AFF4 (Advanced Forensic Format 4, padrão v1.0): contêiner ZIP com metadados em Turtle.
//! AFF4 (Advanced Forensic Format 4, v1.0 standard): ZIP container with Turtle RDF metadata.
//!
//! Leitura de imagens lineares e mapeadas (inclusive esparsas), com blocos comprimidos por
//! snappy, deflate, LZ4 ou sem compressão. Gravação de uma imagem linear com blocos snappy.
//! Conjuntos em vários volumes (spanned/striped) não são suportados.
//! Reads linear and mapped images (including sparse ranges), with chunks compressed via
//! snappy, deflate, LZ4, or stored uncompressed. Writes linear images with snappy chunks.
//! Multi-volume sets (spanned/striped) are not supported.
use crate::error::{Error, Result};
use crate::hashing::MultiHasher;
@@ -15,7 +15,7 @@ use std::path::Path;
use std::sync::Mutex;
pub fn is_aff4(head: &[u8]) -> bool {
// ZIP local file header + primeiro nome "container.description" ou "version.txt".
// ZIP local file header + first entry named "container.description" or "version.txt".
head.len() > 50
&& head.starts_with(b"PK\x03\x04")
&& (find(&head[30..head.len().min(80)], b"container.description")
@@ -80,7 +80,7 @@ impl Turtle<'_> {
}
tok.to_string()
}
/// Um termo: <iri>, "literal"[^^tipo], nome:prefixado ou `a`.
/// A Turtle term: <iri>, "literal"[^^type], prefixed:name, or `a`.
fn term(&mut self) -> Option<String> {
self.ws();
let c = *self.b.get(self.i)?;
@@ -123,7 +123,7 @@ impl Turtle<'_> {
{
self.i += 1;
}
// ponto final de sentença colado ao termo
// trailing statement period attached to the term
let mut e = self.i;
if e > s && self.b[e - 1] == b'.' {
e -= 1;
@@ -165,7 +165,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
t.prefixes.insert(name, iri);
continue;
}
// sujeito
// subject
let Some(subj) =
(if t.b[t.i] == b':' && t.b.get(t.i + 1).map_or(true, |c| c.is_ascii_whitespace()) {
t.i += 1;
@@ -200,7 +200,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
Some(b';') => {
t.i += 1;
t.ws();
// ";" final antes de "."
// trailing ";" before "."
if t.b.get(t.i) == Some(&b'.') {
t.i += 1;
break;
@@ -217,7 +217,7 @@ fn parse_turtle(text: &str) -> HashMap<String, Resource> {
out
}
// ------------------------------------------------------------------ leitor
// ------------------------------------------------------------------ reader
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Method {
@@ -265,7 +265,7 @@ pub struct Aff4Source {
targets: Vec<Target>,
map: Vec<MapEntry>,
size: u64,
/// Metadados legíveis: caso, examinador, notas, hashes.
/// Human-readable metadata: case, examiner, notes, hashes.
pub info: HashMap<String, String>,
cache: Mutex<Option<(usize, u64, Vec<u8>)>>,
index_cache: Mutex<HashMap<(usize, u64), std::sync::Arc<Vec<u8>>>>,
@@ -344,7 +344,7 @@ impl Aff4Source {
}
}
let data_urn = image.get(&ds).unwrap().to_string();
// Hashes gravados no fluxo de dados (ou no mapa).
// Hashes recorded on the data stream (or map).
for r in res
.values()
.filter(|r| r.get(&format!("{NS}hash")).is_some())
@@ -443,7 +443,7 @@ impl Aff4Source {
})
}
/// MD5 do primeiro fluxo de blocos (o que o AFF4 grava em `aff4:hash`), para conferir a integridade.
/// MD5 of the first bevy stream (stored by AFF4 in `aff4:hash`) for integrity verification.
pub fn verify_stream_md5(&self) -> Result<Option<bool>> {
let Some(want) = self.info.get("md5").cloned() else {
return Ok(None);
@@ -604,7 +604,7 @@ impl Source for Aff4Source {
let pos = offset + done as u64;
let i = self.map.partition_point(|e| e.start + e.len <= pos);
let Some(e) = self.map.get(i).filter(|e| e.start <= pos) else {
// vão do mapa: zeros até a próxima entrada
// map gap: zeros up to the next entry
let next = self.map.get(i).map(|e| e.start).unwrap_or(self.size);
let n = ((next - pos) as usize).min(want - done);
buf[done..done + n].fill(0);
@@ -626,7 +626,7 @@ impl Source for Aff4Source {
}
}
// ------------------------------------------------------------------ gravação
// ------------------------------------------------------------------ writer
#[derive(Debug, Clone)]
pub struct Aff4Options {
@@ -703,7 +703,7 @@ fn esc(s: &str) -> String {
.replace('\n', "\\n")
}
/// Grava uma imagem AFF4 linear (um fluxo de blocos snappy) de `src`.
/// Writes a linear AFF4 image (single snappy bevy stream) from `src`.
pub fn write_aff4(
src: &dyn Source,
dest: &Path,
@@ -776,11 +776,11 @@ pub fn write_aff4(
}
}
hasher.update(b);
// bloco curto (último) é guardado como está; comprimido só se ficar menor
// short (final) chunk is stored as-is; compressed only if smaller
let comp = enc_snap
.compress_vec(b)
.map_err(|e| Error::msg(e.to_string()))?;
// Só um bloco cheio pode ficar sem compressão (comprimento == tamanho do bloco).
// Only a full chunk can be stored uncompressed (length == chunk_size).
let data: &[u8] = if want as u64 == chunk && comp.len() >= want {
b
} else {
+27 -27
View File
@@ -1,6 +1,6 @@
//! Monta o disco virtual a partir do que o usuário informou: membros extras (RAID/LVM) e
//! senhas ou chaves para volumes cifrados. É a única porta de entrada para varrer e recuperar
//! discos que não são "simples".
//! Assembles a virtual disk from user-supplied inputs: extra members (RAID/LVM) and
//! passwords or keys for encrypted volumes. Single entry point for scanning and recovering
//! non-trivial disk layouts.
use crate::crypt::{self, Secret, UnlockError};
use crate::error::{Error, Result};
@@ -21,17 +21,17 @@ pub enum SecretInput {
Recovery {
value: String,
},
/// Caminho de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
/// Path to a key file (BitLocker .BEK, LUKS keyfile).
Keyfile {
path: String,
},
/// Chave do volume em hexadecimal.
/// Hex-encoded volume master key.
Key {
hex: String,
},
}
/// Chave de volume guardada na sessão (só quando o usuário pede para lembrar).
/// Volume key saved in the session (only when the user opts in to remember keys).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SavedKey {
pub start: u64,
@@ -39,29 +39,29 @@ pub struct SavedKey {
pub key_hex: String,
}
/// RAID sem metadados: o que o usuário sabe; o resto é descoberto.
/// Metadata-less RAID parameters: user-supplied overrides; missing fields are auto-detected.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RaidSpec {
pub level: Option<crate::raid::Level>,
/// Tamanho da faixa em KiB.
/// Chunk (stripe) size in KiB.
pub chunk_kb: Option<u64>,
pub layout: Option<crate::raid::Layout>,
/// Ordem dos discos: índice 0 é o alvo principal, depois `members` na ordem dada.
/// Disk order: index 0 is the primary target, followed by `members` in the given order.
pub order: Option<Vec<usize>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Assembly {
/// Trata o alvo e `members` como discos de um RAID sem metadados (parâmetros descobertos).
/// Treat target and `members` as a metadata-less RAID array (parameters auto-detected).
#[serde(default)]
pub raid: Option<RaidSpec>,
/// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal.
/// Additional disks or images belonging to the same set (RAID/LVM) besides the primary target.
#[serde(default)]
pub members: Vec<String>,
/// Senhas e chaves: o `snapshot` da sessão as descarta antes de gravar.
/// Passwords and keys: stripped by session `snapshot` before saving to disk.
#[serde(default)]
pub secrets: Vec<SecretInput>,
/// Guarda as chaves de volume derivadas na sessão, para reabrir sem pedir a senha de novo.
/// Persist derived volume keys in the session so it can be reopened without re-prompting.
#[serde(default)]
pub remember_keys: bool,
#[serde(default)]
@@ -96,13 +96,13 @@ pub struct Assembled {
pub source: Arc<dyn Source>,
pub unlocked: Vec<UnlockInfo>,
pub locked: Vec<LockedInfo>,
/// Chaves derivadas, para guardar na sessão se o usuário permitir.
/// Derived keys to persist in the session when permitted by the user.
pub keys: Vec<SavedKey>,
/// Volumes lógicos / partições extras que não estão na tabela do disco.
/// Logical volumes / extra partitions not present in the physical partition table.
pub extra_parts: Vec<PartInfo>,
/// Partições reais que são só contêineres (membros de RAID/LVM já remontados).
/// Physical partitions that act solely as containers (reassembled RAID/LVM members).
pub covered: Vec<u64>,
/// O disco principal é só um membro de RAID: não varrer o disco cru.
/// Primary disk is merely a RAID member: skip scanning the raw disk directly.
pub skip_primary: bool,
pub notes: Vec<String>,
}
@@ -145,7 +145,7 @@ fn try_unlock(window: Arc<dyn Source>, fs: FsKind, secrets: &[Secret]) -> crypt:
}
}
/// Abre `path` e aplica a montagem pedida.
/// Opens `path` and applies the requested assembly.
pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
let base = open_path(Path::new(path)).map_err(|e| Error::msg(format!("{path}: {e}")))?;
assemble_source(base, asm)
@@ -153,7 +153,7 @@ pub fn assemble(path: &str, asm: &Assembly) -> Result<Assembled> {
const MIB: u64 = 1 << 20;
/// Coloca `src` em `start`; se cair dentro de uma região virtual, a substitui por dentro.
/// Maps `src` at `start`; if it falls inside an existing virtual region, replaces it nestedly.
fn patch(regions: &mut Vec<Region>, start: u64, src: Arc<dyn Source>, label: String) {
let len = src.size();
if let Some(r) = regions
@@ -181,10 +181,10 @@ fn patch(regions: &mut Vec<Region>, start: u64, src: Arc<dyn Source>, label: Str
});
}
/// Candidato a membro de RAID/LVM: uma janela de disco (inteiro ou partição).
/// Candidate RAID/LVM member: a disk window (whole disk or partition).
struct Window {
src: Arc<dyn Source>,
/// Início no espaço do disco virtual, se a janela faz parte dele.
/// Start offset in the virtual disk address space, if part of it.
primary_start: Option<u64>,
}
@@ -248,7 +248,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
}
let mut done_containers: std::collections::BTreeSet<String> = Default::default();
let mut tried_pool = false;
// Rodadas: o que foi aberto/montado pode conter outro volume cifrado, RAID ou LVM.
// Passes: newly unlocked/assembled volumes may themselves contain encrypted volumes, RAID, or LVM.
for round in 0..4 {
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false);
@@ -260,7 +260,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
}
let mut progress = false;
// 1) RAID e LVM: reúne janelas do disco principal e dos membros extras.
// 1) RAID and LVM: gather windows from the primary disk and extra members.
if round == 0 || !tried_pool || !out.extra_parts.is_empty() {
let mut pool: Vec<Window> = vec![];
pool.push(Window {
@@ -381,7 +381,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
}
}
// 2) Desbloqueio de volumes cifrados.
// 2) Unlock encrypted volumes.
let current: Arc<dyn Source> = Arc::new(Overlay::new(base.clone(), regions.clone()));
let layout = partition::analyze(current.as_ref(), false);
let mut all_parts: Vec<PartInfo> = layout.partitions.clone();
@@ -438,7 +438,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
}
if !regions.is_empty() {
out.source = Arc::new(Overlay::new(base, regions));
// Atualiza o que se sabe sobre as partições virtuais (algumas foram desbloqueadas depois).
// Refresh virtual partition metadata (some volumes may have been unlocked in a later pass).
for p in &mut out.extra_parts {
if let Some((fs, len)) = partition::detect_fs(out.source.as_ref(), p.start) {
p.fs = fs;
@@ -454,7 +454,7 @@ pub fn assemble_source(base: Arc<dyn Source>, asm: &Assembly) -> Result<Assemble
Ok(out)
}
/// Anexa um volume virtual depois do fim do disco e registra as partições que ele contém.
/// Appends a virtual volume past the end of the disk and registers its inner partitions.
fn add_virtual(
regions: &mut Vec<Region>,
next: &mut u64,
@@ -496,7 +496,7 @@ fn add_virtual(
});
}
/// Escolhe os parâmetros do RAID: o que o usuário deu manda; o resto vem da detecção.
/// Resolves RAID parameters: user overrides take precedence; remaining fields come from detection.
fn pick_raid(
disks: &[Arc<dyn Source>],
spec: &RaidSpec,
+23 -23
View File
@@ -1,13 +1,13 @@
//! Trilha de auditoria: cada ação do examinador (abrir uma origem, varrer, recuperar, criar
//! imagem, exportar) vira uma linha JSON com quem, quando e o quê.
//! Audit trail: each examiner action (opening a source, scanning, recovering, imaging,
//! exporting) is recorded as a JSON line with who, when, and what.
//!
//! As linhas são encadeadas por hash: o `hash` de cada uma é o SHA-256 do `hash` da anterior
//! seguido do corpo da própria linha. Alterar ou apagar uma linha do meio quebra a cadeia e
//! `guara audit --verify` aponta onde. Cortar o fim do arquivo não é detectável sozinho; para
//! isso, guarde o hash da última entrada (a "cabeça") fora da máquina, por exemplo no relatório.
//! Entries are hash-chained: each entry's `hash` is the SHA-256 of the previous `hash`
//! followed by the entry body itself. Modifying or deleting an intermediate line breaks the chain
//! and `guara audit --verify` pinpoints where. Tail truncation cannot be detected from the file
//! alone; store the last entry hash (the "head") off-machine, e.g. in the forensic report.
//!
//! O arquivo fica na pasta de configuração (`auditoria.jsonl`) ou onde `GUARA_AUDIT_LOG`
//! apontar. Segredos (senhas, chaves) nunca entram no registro.
//! Stored in the config directory (`auditoria.jsonl`) or at `GUARA_AUDIT_LOG`.
//! Secrets (passwords, keys) are never written to the log.
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
@@ -32,11 +32,11 @@ struct Body<'a> {
prev: &'a str,
}
/// Uma linha da trilha.
/// A single audit log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub seq: u64,
/// UTC, ISO 8601.
/// UTC timestamp in ISO 8601 format.
pub time: String,
pub user: String,
pub host: String,
@@ -49,14 +49,14 @@ pub struct Entry {
pub hash: String,
}
/// Resultado de conferir a cadeia.
/// Result of verifying the hash chain.
#[derive(Debug, Clone, Serialize)]
pub struct Verification {
pub entries: usize,
pub ok: bool,
/// Entrada (1, 2, ...) onde a cadeia quebra e o motivo.
/// 1-based entry index where the chain breaks and the reason.
pub broken_at: Option<(u64, String)>,
/// Hash da última entrada; guarde-o fora da máquina para detectar truncamento.
/// Hash of the last entry; store off-machine to detect tail truncation.
pub head: String,
}
@@ -72,7 +72,7 @@ fn sha_hex(prev: &str, body: &str) -> String {
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
/// UTC em ISO 8601 a partir de segundos Unix (conversão civil de Howard Hinnant).
/// Formats Unix seconds as ISO 8601 UTC (Howard Hinnant civil calendar algorithm).
pub fn utc_iso(secs: i64) -> String {
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
@@ -106,7 +106,7 @@ fn env(k: &str) -> Option<String> {
std::env::var(k).ok().filter(|v| !v.is_empty())
}
/// Usuário do sistema por trás da ação (o que chamou sudo, quando houver).
/// Underlying OS user performing the action (the user who invoked sudo, if applicable).
fn current_user() -> String {
env("SUDO_USER")
.or_else(|| env("USER"))
@@ -127,7 +127,7 @@ fn host_name() -> String {
.unwrap_or_else(|| "?".into())
}
/// Última linha não vazia do arquivo, lendo só o fim.
/// Reads the last non-empty line of the file by seeking near the end.
fn last_line(path: &Path) -> std::io::Result<Option<String>> {
let mut f = match std::fs::File::open(path) {
Ok(f) => f,
@@ -158,7 +158,7 @@ impl Log {
Self { path: path.into() }
}
/// `GUARA_AUDIT_LOG` ou `auditoria.jsonl` na pasta de configuração.
/// `GUARA_AUDIT_LOG` or `auditoria.jsonl` in the configuration directory.
pub fn default_path() -> PathBuf {
match env("GUARA_AUDIT_LOG") {
Some(p) => PathBuf::from(p),
@@ -174,7 +174,7 @@ impl Log {
&self.path
}
/// Acrescenta uma entrada ao fim da cadeia.
/// Appends a new entry to the end of the hash chain.
pub fn append(&self, action: &str, detail: Value) -> Result<Entry> {
let (seq, prev) = match last_line(&self.path)? {
None => (1, GENESIS.to_string()),
@@ -227,7 +227,7 @@ impl Log {
})
}
/// Todas as entradas, na ordem. Linha ilegível vira erro (use [`Log::verify`] para saber onde).
/// Returns all entries in order. Malformed lines return an error (use [`Log::verify`] to locate).
pub fn entries(&self) -> Result<Vec<Entry>> {
let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t,
@@ -244,7 +244,7 @@ impl Log {
.collect()
}
/// Confere a numeração, o encadeamento e o hash de cada linha.
/// Verifies sequence numbering, hash chaining, and each entry's digest.
pub fn verify(&self) -> Result<Verification> {
let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t,
@@ -298,15 +298,15 @@ impl Log {
}
}
/// Registra uma ação na trilha padrão. Falha ao gravar não interrompe a operação, mas é
/// avisada no stderr: uma ação sem registro precisa ser notada.
/// Records an action in the default audit trail. Write failures do not abort the operation
/// but are logged to stderr so unrecorded actions do not go unnoticed.
pub fn record(action: &str, detail: Value) {
if let Err(e) = Log::open_default().append(action, detail) {
eprintln!("aviso: não foi possível gravar a trilha de auditoria ({action}): {e}");
}
}
/// Atalho para detalhes de um caminho de origem.
/// Helper to build details JSON for a source path.
pub fn path_detail(path: &str) -> Value {
json!({ "path": path })
}
+15 -15
View File
@@ -1,4 +1,4 @@
//! Carving por assinatura/estrutura sobre intervalos de bytes.
//! Signature- and structure-based file carving over byte ranges.
use crate::signatures::{category_for_ext, detect, might_start, Hit};
use crate::source::Source;
@@ -14,9 +14,9 @@ pub struct CarveCfg<'a> {
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.
/// Scans `[start, end)` starting from `*next`. Calls `on_hit(offset, hit)` and returns
/// `true` if the range completed (`false` if cancelled).
/// `advance(done_bytes)` reports progress within the range.
pub fn carve_range(
src: &dyn Source,
start: u64,
@@ -28,7 +28,7 @@ pub fn carve_range(
) -> CarveEnd {
let end = end.min(src.size());
let mut pos = (*next).max(start);
// Alinha ao setor.
// Align to sector boundary.
pos = pos.div_ceil(SECTOR) * SECTOR;
let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
let mut skip_until = 0u64;
@@ -45,13 +45,13 @@ pub fn carve_range(
n
}
Err(_) => {
// Várias falhas seguidas = dispositivo sumiu: para com o ponto de retomada salvo.
// Multiple consecutive read failures = device disconnected: stop with resume point saved.
fails += 1;
if fails >= 4 {
*next = pos - (fails as u64 - 1) * CHUNK as u64;
return CarveEnd::IoLost;
}
// Setor ruim isolado: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
// Isolated bad sector: skip a full block and continue (forensic imaging handles bad sectors better).
pos += CHUNK as u64;
ev.advance(CHUNK as u64);
*next = pos;
@@ -100,30 +100,30 @@ pub fn carve_range(
CarveEnd::Done
}
/// Como o carving de um intervalo terminou.
/// Outcome of carving a byte range.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CarveEnd {
Done,
Cancelled,
/// Várias leituras seguidas falharam: o dispositivo provavelmente foi desconectado.
/// Multiple consecutive reads failed: the device was likely disconnected.
IoLost,
}
/// Receptor de eventos do carving.
/// Receiver for file-carving events.
pub trait CarveEvents {
fn advance(&mut self, bytes: u64);
/// Cada trecho lido, antes de procurar assinaturas (para indexar o que serve a recomposição).
/// Called for each chunk read before signature scanning (used to index candidate defrag clusters).
fn chunk(&mut self, _abs: u64, _data: &[u8]) {}
fn hit(&mut self, offset: u64, hit: &Hit) -> HitAction;
}
/// O que fazer depois de um acerto.
/// Action to take after a signature hit.
pub enum HitAction {
/// Pula o conteúdo do arquivo reconhecido (comportamento padrão).
/// Skip past the recognized file content (default behavior).
Skip,
/// Pula só estes bytes (arquivo cujo resto não é confiável).
/// Skip only this many bytes (when the remainder of the file is unreliable).
SkipLen(u64),
/// Segue setor a setor (acerto descartado).
/// Continue sector by sector (hit discarded).
Continue,
}
+20 -20
View File
@@ -1,6 +1,6 @@
//! BitLocker (Windows 7 em diante, inclusive BitLocker To Go): AES-CBC com ou sem difusor
//! Elephant, e AES-XTS. Protetores: senha, chave de recuperação, chave de inicialização (.BEK),
//! chave limpa (proteção suspensa) e chave do volume já conhecida. TPM não é suportado.
//! BitLocker (Windows 7+, including BitLocker To Go): AES-CBC with or without Elephant
//! diffuser, and AES-XTS. Protectors: password, recovery password, startup key (.BEK),
//! clear key (suspended protection), and raw volume master key. TPM is not supported.
use super::*;
use crate::source::{read_vec, SubSource};
@@ -23,12 +23,12 @@ const GUID_EOW: [u8; 16] = [
0x3b, 0x4d, 0xa8, 0x92, 0x80, 0xdd, 0x0e, 0x4d, 0x9e, 0x4e, 0xb1, 0xe3, 0x28, 0x4e, 0xae, 0xd8,
];
/// Posição da tabela de deslocamentos FVE, se for BitLocker (normal ou To Go).
/// Offset of the FVE metadata table if the volume is BitLocker (standard or To Go).
fn offsets_pos(vbr: &[u8]) -> Option<usize> {
if vbr.len() >= 11 && &vbr[3..11] == b"-FVE-FS-" {
return Some(176);
}
// BitLocker To Go: parece FAT ("MSWIN4.1"), mas guarda o GUID do BitLocker no setor de boot.
// BitLocker To Go: looks like FAT ("MSWIN4.1") but stores the BitLocker GUID in the boot sector.
if vbr.len() >= 464 && (vbr[424..440] == GUID_NORMAL || vbr[424..440] == GUID_EOW) {
return Some(440);
}
@@ -54,7 +54,7 @@ fn words(buf: &[u8]) -> Vec<u32> {
.collect()
}
// Decifragem (ordem do dm-crypt do Linux): somas em ordem crescente.
// Decryption (Linux dm-crypt order): additions in ascending order.
fn diffuser_a_decrypt(d: &mut [u32]) {
let n = d.len();
for _ in 0..5 {
@@ -77,7 +77,7 @@ fn diffuser_b_decrypt(d: &mut [u32]) {
}
}
/// Decifra um setor AES-CBC + difusor Elephant (BitLocker Vista/7 sem XTS).
/// Decrypts an AES-CBC + Elephant diffuser sector (BitLocker Vista/7 pre-XTS).
pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off: u64) {
let mut iv = [0u8; 16];
iv[..8].copy_from_slice(&byte_off.to_le_bytes());
@@ -89,7 +89,7 @@ pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off:
for (i, w) in d.iter().enumerate() {
buf[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes());
}
// Chave do setor: duas cifragens do deslocamento com a chave de ajuste.
// Sector key: two encryptions of the byte offset with the tweak key.
let mut k0 = [0u8; 16];
k0[..8].copy_from_slice(&byte_off.to_le_bytes());
let mut k1 = k0;
@@ -106,7 +106,7 @@ pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off:
// ------------------------------------------------------------------ CCM
/// AES-CCM (nonce de 12 bytes, tag de 16): `blob` = tag cifrada (16) + dados cifrados.
/// AES-CCM (12-byte nonce, 16-byte tag): `blob` = encrypted tag (16) + ciphertext.
fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
if nonce.len() != 12 || blob.len() < 16 {
return None;
@@ -130,7 +130,7 @@ fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
let s = ctr(bi as u32 + 1);
pt.extend(ch.iter().zip(s.iter()).map(|(a, b)| a ^ b));
}
// CBC-MAC sobre B0 + dados.
// CBC-MAC over B0 + plaintext.
let mut b0 = [0u8; 16];
b0[0] = 0x3A;
b0[1..13].copy_from_slice(nonce);
@@ -146,7 +146,7 @@ fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option<Vec<u8>> {
(x == tag).then_some(pt)
}
// --------------------------------------------------------------- chaves
// --------------------------------------------------------------- keys
fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] {
let mut last = [0u8; 32];
@@ -169,7 +169,7 @@ fn password_key(pw: &str, salt: &[u8]) -> [u8; 32] {
stretch(&init, salt)
}
/// Chave de recuperação: 8 blocos de 6 dígitos, cada um múltiplo de 11.
/// Recovery password: 8 groups of 6 digits, each divisible by 11.
fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
let digits: String = rp.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 48 {
@@ -188,7 +188,7 @@ fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> {
Some(stretch(&init, salt))
}
// ----------------------------------------------------------- metadados
// ----------------------------------------------------------- metadata
#[derive(Debug, Clone)]
struct Entry {
@@ -250,7 +250,7 @@ fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
}
let version = le16(&h, 10);
if version != 2 {
return None; // Vista (versão 1) não suportado
return None; // Vista (version 1) not supported
}
let encrypted_size = le64(&h, 16);
let hdr_sectors = le32(&h, 28) as u64;
@@ -293,7 +293,7 @@ fn read_fve(src: &dyn Source, offset: u64) -> Option<Fve> {
})
}
/// Chave de um arquivo .BEK (chave de inicialização).
/// Extracts the key from a .BEK startup key file.
fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
if file.len() < 48 {
return None;
@@ -313,7 +313,7 @@ fn bek_key(file: &[u8]) -> Option<Vec<u8>> {
fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = vec![];
// Chave limpa: o próprio protetor guarda a chave em claro.
// Clear key: the protector itself stores the unencrypted key.
if v.protection == 0 {
for n in &v.nested {
if n.vtype == 1 && n.data.len() >= 36 {
@@ -350,7 +350,7 @@ fn decrypt_key_entry(key: &[u8], e: &Entry) -> Option<Vec<u8>> {
return None;
}
let pt = ccm_decrypt(&aes, &e.data[..12], &e.data[12..])?;
// payload = cabeçalho de entrada (8) + método (4) + chave
// payload = entry header (8) + method (4) + key
(pt.len() > 12).then(|| pt[12..].to_vec())
}
@@ -361,7 +361,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
}
let bps = le16(&vbr, 11).max(512) as u64;
let pos = offsets_pos(&vbr).unwrap_or(176);
// Três cópias dos metadados; usa a primeira legível.
// Three metadata copies exist; use the first valid one.
let mut fve = None;
let mut meta_offsets = vec![];
for i in 0..3 {
@@ -481,7 +481,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
let len = src.size();
let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps);
let mut patches = vec![];
// Áreas de metadados FVE: o Windows não as expõe como dados.
// FVE metadata regions: Windows masks them out as zeros.
for off in meta_offsets {
patches.push(Patch {
at: off,
@@ -492,7 +492,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
});
}
if fve.volume_header_size > 0 && fve.volume_header_offset > 0 {
// O início do volume original foi movido para outro lugar e cifrado lá.
// The original volume header was relocated and encrypted elsewhere.
patches.push(Patch {
at: fve.volume_header_offset,
len: fve.volume_header_size,
+5 -5
View File
@@ -1,5 +1,5 @@
//! FileVault 2 sobre Core Storage (volumes HFS+ cifrados do macOS 10.7 a 10.12 e Fusion Drives).
//! Senha ou chave do volume; chave de recuperação institucional não é suportada.
//! FileVault 2 on Core Storage (encrypted HFS+ volumes from macOS 10.7–10.12 and Fusion Drives).
//! Supports user password or volume key; institutional recovery keys are not supported.
use super::*;
use crate::source::{read_vec, SubSource};
@@ -18,7 +18,7 @@ pub fn detect(head: &[u8]) -> bool {
head.len() >= 512 && head[88] == 0x43 && head[89] == 0x53 && head[10] == 0x10
}
/// Desembrulha uma chave AES-128 de 24 bytes (RFC 3394).
/// Unwraps a 24-byte AES-128 key (RFC 3394).
fn unwrap_key(kek: &[u8], wrapped: &[u8]) -> Option<[u8; 16]> {
let aes = AesAny::new(kek)?;
if wrapped.len() != 24 {
@@ -73,7 +73,7 @@ fn b64(s: &str) -> Option<Vec<u8>> {
Some(out)
}
/// Valor de `<key>NOME</key><tipo ...>VALOR</tipo>` num plist simples (primeira ocorrência com valor).
/// Extracts the value of `<key>NAME</key><type ...>VALUE</type>` from a simple XML plist.
fn plist_value(xml: &str, key: &str) -> Option<String> {
let k = format!("<key>{key}</key>");
let mut from = 0;
@@ -163,7 +163,7 @@ pub fn unlock(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
if b.len() < MD_BLOCK || b.iter().all(|&x| x == 0) {
break;
}
// XTS sobre o bloco inteiro, com o número do bloco como ajuste inicial.
// XTS across the full block, using the block number as the initial tweak.
md_cipher.decrypt_sector(&mut b, i, 0);
match u16::from_le_bytes([b[10], b[11]]) {
0x0019 => {
+6 -6
View File
@@ -1,4 +1,4 @@
//! LUKS1 e LUKS2 (cifras AES; PBKDF2 e Argon2; hashes SHA-1/256/512 e RIPEMD-160).
//! LUKS1 and LUKS2 (AES ciphers; PBKDF2 and Argon2; SHA-1/256/512 and RIPEMD-160 hashes).
use super::*;
use crate::source::{read_vec, SubSource};
@@ -60,7 +60,7 @@ impl Hash {
}
}
/// Difusão do "anti-forensic splitter".
/// Anti-forensic splitter diffusion step.
fn diffuse(block: &mut [u8], h: Hash) {
let ds = h.size();
let mut i = 0u32;
@@ -74,7 +74,7 @@ fn diffuse(block: &mut [u8], h: Hash) {
}
}
/// Recompõe a chave a partir das `stripes` faixas (AFmerge).
/// Merges key material across `stripes` anti-forensic stripes (AFmerge).
pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec<u8> {
let mut buf = vec![0u8; block];
for i in 0..stripes.saturating_sub(1) {
@@ -87,7 +87,7 @@ pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec<u8> {
buf.iter().zip(last).map(|(a, b)| a ^ b).collect()
}
/// Cifra descrita por texto como "aes-xts-plain64" ou "aes-cbc-essiv:sha256".
/// Parses cipher spec strings such as "aes-xts-plain64" or "aes-cbc-essiv:sha256".
pub fn parse_cipher(spec: &str, key: &[u8]) -> std::result::Result<SectorCipher, UnlockError> {
let (alg, rest) = spec
.split_once('-')
@@ -279,7 +279,7 @@ fn jstr<'a>(v: &'a serde_json::Value, k: &str) -> Option<&'a str> {
v.get(k)?.as_str()
}
/// Números no JSON do LUKS2 vêm como texto (inteiros grandes) ou número.
/// Numbers in LUKS2 JSON metadata appear either as strings (for 64-bit ints) or numbers.
fn jnum(v: &serde_json::Value, k: &str) -> Option<u64> {
let x = v.get(k)?;
x.as_u64().or_else(|| x.as_str()?.parse().ok())
@@ -463,7 +463,7 @@ fn unlock_v2(src: Arc<dyn Source>, secrets: &[Secret]) -> UnlockResult {
}
}
/// Atalho: abre uma janela da origem como LUKS.
/// Helper: opens a byte window of the source as a LUKS volume.
pub fn unlock_window(
base: Arc<dyn Source>,
start: u64,
+34 -34
View File
@@ -1,8 +1,8 @@
//! Cifras de disco (somente leitura) e desbloqueio de volumes cifrados.
//! Read-only disk ciphers and encrypted volume unlocking.
//!
//! An unlocked volume is exposed as a standard `Source`: the rest of Guará
//! (scanning, recovery) sees the plaintext filesystem transparently.
//!
//! O volume desbloqueado é exposto como uma `Source` comum: o restante do Guará
//! (varredura, recuperação) enxerga o sistema de arquivos em claro, sem saber
//! que existe cifra por baixo.
pub mod bitlocker;
pub mod filevault;
@@ -14,7 +14,7 @@ use aes::{Aes128, Aes192, Aes256};
use std::io;
use std::sync::Arc;
/// AES com tamanho de chave escolhido em tempo de execução.
/// AES cipher with runtime key size selection.
#[derive(Clone)]
pub enum AesAny {
A128(Aes128),
@@ -55,21 +55,21 @@ fn xor16(a: &mut [u8; 16], b: &[u8]) {
}
}
/// Como o vetor de inicialização de cada setor é calculado.
/// Sector initialization vector (IV) generation mode.
#[derive(Clone)]
pub enum Iv {
/// Número do setor (de 512 bytes) em 64 bits little-endian.
/// 512-byte sector number as 64-bit little-endian.
Plain64,
/// Número do setor em 32 bits little-endian.
/// Sector number as 32-bit little-endian.
Plain32,
Plain64Be,
/// ESSIV: AES(SHA-256(chave), setor).
/// ESSIV: AES(SHA-256(key), sector).
Essiv(AesAny),
/// Número da unidade de dados (setor de `usize` bytes) em 64 bits little-endian.
/// Data unit number (`usize`-byte sector) as 64-bit little-endian.
Unit(u64),
/// BitLocker (Windows 10 1511+): AES(chave, offset em bytes).
/// BitLocker (Windows 10 1511+): AES(key, byte offset).
Eboiv(AesAny),
/// BitLocker CBC com difusor Elephant: IV = AES(chave, offset em bytes).
/// BitLocker CBC with Elephant diffuser: IV = AES(key, byte offset).
Bitlocker(AesAny),
}
@@ -94,7 +94,7 @@ impl Iv {
}
}
/// Cifra aplicada setor a setor.
/// Sector-level disk cipher.
#[derive(Clone)]
pub enum SectorCipher {
Xts {
@@ -106,7 +106,7 @@ pub enum SectorCipher {
key: AesAny,
iv: Iv,
},
/// BitLocker AES-CBC com difusor Elephant (chave de dados + chave de ajuste).
/// BitLocker AES-CBC with Elephant diffuser (data key + tweak key).
Elephant {
key: AesAny,
tweak: AesAny,
@@ -126,8 +126,8 @@ fn gf_double(t: &mut [u8; 16]) {
}
impl SectorCipher {
/// Decifra um setor no lugar. `sector_512` conta setores de 512 bytes; `byte_off` é a posição
/// em bytes relativa ao início dos dados cifrados.
/// Decrypts a sector in place. `sector_512` is the 512-byte sector index; `byte_off` is the
/// byte offset relative to the start of the encrypted payload.
pub fn decrypt_sector(&self, buf: &mut [u8], sector_512: u64, byte_off: u64) {
match self {
SectorCipher::Xts { k1, k2, iv } => {
@@ -178,33 +178,33 @@ pub fn cbc_encrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) {
}
}
/// Volume cifrado visto em claro: `len` bytes que começam em `data_offset` na origem.
/// Plaintext view of an encrypted volume: `len` bytes starting at `data_offset` in the source.
pub struct CryptSource {
inner: Arc<dyn Source>,
cipher: SectorCipher,
data_offset: u64,
len: u64,
sector: u64,
/// Setores antes deste limite estão em claro (BitLocker parcialmente cifrado) ou
/// são cobertos por uma cópia (os primeiros setores do BitLocker).
/// Sectors before this threshold are either unencrypted (partially encrypted BitLocker) or
/// redirected to a backup copy (BitLocker relocated boot sectors).
patches: Vec<Patch>,
/// A partir deste byte (relativo aos dados) o conteúdo ainda não foi cifrado.
/// Bytes at or past this payload-relative offset are still unencrypted.
plain_from: u64,
/// Somado ao número do setor no cálculo do IV (iv_tweak do LUKS2).
/// Added to the sector index when computing the IV (LUKS2 `iv_tweak`).
iv_tweak: u64,
}
/// Trecho do volume lido de outro lugar da origem (BitLocker move o início do volume).
/// Volume range redirected to another offset on the source (BitLocker relocated header sectors).
#[derive(Clone)]
pub struct Patch {
/// Início no volume em claro.
/// Start offset in the logical plaintext volume.
pub at: u64,
pub len: u64,
/// Onde estão os dados, na origem, já em claro.
/// Source offset where the data resides.
pub from: u64,
/// Se os bytes de origem precisam ser decifrados (usando a posição `from_crypt`).
/// Whether the source bytes must be decrypted (using cipher offset `from_crypt`).
pub decrypt_from: Option<u64>,
/// Devolve zeros (áreas de metadados do BitLocker).
/// Returns zeros (BitLocker metadata regions masked out by the OS).
pub zero: bool,
}
@@ -242,7 +242,7 @@ impl CryptSource {
}
fn read_sector_plain(&self, rel: u64, out: &mut [u8]) -> io::Result<()> {
// `rel` alinhado ao setor; `out.len() == sector`.
// `rel` is sector-aligned; `out.len() == sector`.
for p in &self.patches {
if rel >= p.at && rel < p.at + p.len {
if p.zero {
@@ -292,15 +292,15 @@ impl Source for CryptSource {
}
}
/// Algo que o usuário fornece para abrir um volume.
/// User-supplied credential for unlocking an encrypted volume.
#[derive(Clone)]
pub enum Secret {
Password(String),
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos).
/// BitLocker numeric recovery password (8 groups of 6 digits).
RecoveryKey(String),
/// Conteúdo de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS).
/// Raw contents of a key file (BitLocker .BEK, LUKS keyfile).
KeyFile(Vec<u8>),
/// Chave do volume já conhecida (FVEK/master key).
/// Known volume master key (FVEK / LUKS master key).
VolumeKey(Vec<u8>),
}
@@ -317,7 +317,7 @@ impl std::fmt::Debug for Secret {
#[derive(Debug)]
pub enum UnlockError {
/// Nenhuma das senhas/chaves abriu o volume.
/// None of the supplied passwords or keys unlocked the volume.
WrongSecret,
Unsupported(String),
Corrupt(String),
@@ -339,11 +339,11 @@ impl From<UnlockError> for crate::Error {
}
}
/// Volume aberto.
/// Unlocked encrypted volume.
pub struct Unlocked {
pub source: Arc<dyn Source>,
pub description: String,
/// Chave do volume (para reabrir sem a senha, se o usuário permitir guardá-la).
/// Volume master key (can be saved in the session to reopen without a password if permitted).
pub volume_key: Option<Vec<u8>>,
}
+23 -23
View File
@@ -1,24 +1,24 @@
//! Recomposição de arquivos fragmentados no carving.
//! Fragmented file reassembly during carving.
//!
//! Para JPEG: valida os bytes contíguos com o decodificador de entropia; se a imagem quebra,
//! procura entre os clusters "com cara de JPEG" próximos a continuação cujo DC de luminância
//! emenda sem salto, até três fragmentos. É o que dá para fazer sem tabela de alocação, e só
//! vale quando o sistema de arquivos não deixou metadados para o arquivo.
//! For JPEG: validates contiguous bytes with the entropy decoder; if the stream breaks,
//! searches nearby JPEG-like entropy clusters for a continuation whose luminance DC
//! splices smoothly across up to three fragments. Best-effort without an allocation table,
//! used when filesystem metadata is no longer available.
use crate::jpegscan::{self, Header, State, Stop};
use crate::source::{read_vec, Source};
use crate::types::Extent;
const MAX_FILE: usize = 48 << 20;
/// Janela de busca da continuação, em bytes, depois e antes do primeiro fragmento.
/// Search window in bytes (after and before the first fragment) for candidate continuations.
const WINDOW_AFTER: u64 = 512 << 20;
const WINDOW_BEFORE: u64 = 64 << 20;
const PROBE: usize = 8192;
pub const CLUSTER: u64 = 4096;
const MAX_PROBES: usize = 20_000;
/// Marca os clusters (de 4 KiB, alinhados ao início da origem) que parecem dados entropy-coded
/// de JPEG: sem marcadores no meio e com bytes bem distribuídos.
/// Bitmap of 4 KiB source-aligned clusters that look like JPEG entropy-coded data
/// (no embedded unescaped markers and high byte entropy).
#[derive(Default, Clone)]
pub struct ClusterIndex {
bits: Vec<u64>,
@@ -43,7 +43,7 @@ impl ClusterIndex {
.map(|w| w >> (c % 64) & 1 == 1)
.unwrap_or(false)
}
/// Examina um trecho lido e marca os clusters candidatos.
/// Scans a read buffer and marks candidate JPEG entropy clusters.
pub fn scan_chunk(&mut self, abs: u64, buf: &[u8]) {
let first = abs.div_ceil(CLUSTER) * CLUSTER;
let mut o = first;
@@ -90,7 +90,7 @@ pub fn jpeg_like(c: &[u8]) -> bool {
pub struct Pool<'a> {
pub index: &'a ClusterIndex,
/// Extents já atribuídos a outros arquivos.
/// Extents already claimed by other recovered files.
pub used: &'a [Extent],
}
@@ -105,17 +105,17 @@ impl Pool<'_> {
#[derive(Debug)]
pub enum Analysis {
/// Contíguo e consistente; `len` inclui o EOI.
/// Contiguous and valid; `len` includes the EOI marker.
Intact { len: u64 },
/// Remontado a partir de fragmentos.
/// Reassembled from multiple non-contiguous fragments.
Rebuilt { extents: Vec<Extent>, size: u64 },
/// Só o início é confiável.
/// Only the leading prefix is reliable.
Broken { good_len: u64 },
/// Formato que não sabemos validar (progressivo etc.).
/// JPEG variant we do not entropy-validate (progressive, arithmetic, etc.).
Unsupported,
}
/// Procura FF D9 logo depois do último MCU (até 64 bytes de preenchimento).
/// Looks for FF D9 immediately after the final MCU (allowing up to 64 padding bytes).
fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
let end = (pos + 64).min(data.len().saturating_sub(1));
(pos.saturating_sub(2)..end)
@@ -123,7 +123,7 @@ fn eoi_after(data: &[u8], pos: usize) -> Option<usize> {
.map(|i| i + 2)
}
/// Valida o JPEG que começa em `off` e, se estiver quebrado, tenta recompô-lo.
/// Validates the JPEG starting at `off` and attempts fragment reassembly if truncated/broken.
pub fn analyze_jpeg(
src: &dyn Source,
off: u64,
@@ -168,8 +168,8 @@ pub fn analyze_jpeg(
let mut pieces: Vec<(u64, usize)> = vec![];
let mut joined = buf;
let mut cur_base = off; // início absoluto do fragmento atual
let mut frag_start = 0usize; // posição em `joined` onde o fragmento atual começa
let mut cur_base = off; // absolute start offset of the current fragment
let mut frag_start = 0usize; // byte offset in `joined` where the current fragment starts
let mut snaps = d.snaps;
let mut suspect = stop_pos;
@@ -223,7 +223,7 @@ pub fn analyze_jpeg(
}
ranked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
ranked.truncate(120);
// Etapa 2: a continuação certa fecha a imagem exatamente no último MCU.
// Stage 2: the true continuation finishes the image right at the final MCU.
let mut longest: Option<(usize, u64, f64)> = None;
for (sc, q) in ranked {
let avail = (src.size() - q) as usize;
@@ -269,7 +269,7 @@ pub fn analyze_jpeg(
return finish(pieces);
}
}
// Quebrou de novo: onde?
// Broke again: find the new break point.
let mut sp = match d2.stop {
Stop::Marker(_, p) | Stop::Error(p) => p,
_ => joined.len(),
@@ -314,8 +314,8 @@ fn finish(pieces: Vec<(u64, usize)>) -> Analysis {
Analysis::Rebuilt { extents, size }
}
/// Pontuação (menor é melhor) de `q` como continuação de `prefix`: diferença média de pixels
/// nas bordas entre os MCUs novos e seus vizinhos. Natural: poucos níveis de cinza; lixo: dezenas.
/// Score (lower is better) for `q` as a continuation of `prefix`: mean pixel difference
/// along MCU borders between new blocks and their neighbors (smooth in natural images, high on noise).
fn probe_candidate(
src: &dyn Source,
h: &Header,
@@ -385,7 +385,7 @@ fn probe_candidate(
if n < 40 {
return None;
}
// Bytes por MCU depois da emenda, comparados com os do trecho anterior.
// Bytes per MCU after the splice compared against the preceding prefix.
let after = d.dc.len() as f64 - (seam.saturating_sub(snap.mcu)) as f64;
let used = d.state.pos.saturating_sub(prefix.len()) as f64;
let ratio = if after > 4.0 && used > 0.0 {
+5 -5
View File
@@ -1,4 +1,4 @@
//! Enumeração de dispositivos de armazenamento (melhor esforço por plataforma).
//! Storage device enumeration (best-effort per platform).
use serde::{Deserialize, Serialize};
@@ -7,18 +7,18 @@ pub struct DeviceInfo {
pub path: String,
pub name: String,
pub size: u64,
/// "HD", "SSD", "NVMe", "USB", "Disco" ou "Imagem".
/// Device kind label (e.g. "HD", "SSD", "NVMe", "USB").
pub kind: String,
pub removable: bool,
pub model: Option<String>,
pub mounted_at: Vec<String>,
/// O processo atual consegue abrir o dispositivo para leitura bruta.
/// Whether the current process has permission to open the device for raw reading.
pub readable: bool,
/// SSD com descarte (TRIM) ativo: arquivos apagados tendem a sumir de verdade.
/// SSD with TRIM/discard enabled: deleted file blocks are typically zeroed by the controller.
pub trim: Option<bool>,
}
/// (dispositivo, ponto de montagem)
/// `(device_path, mount_point)`
pub fn mount_table() -> Vec<(String, String)> {
#[cfg(target_os = "linux")]
{
+35 -35
View File
@@ -1,8 +1,8 @@
//! Imagens forenses Expert Witness (EWF): E01 (EnCase 1 a 6, SMART) e Ex01 (EnCase 7, v2).
//! Expert Witness Format (EWF) forensic images: E01 (EnCase 1–6, SMART) and Ex01 (EnCase 7, v2).
//!
//! Leitura de conjuntos com vários segmentos (`.E01`, `.E02`, ...), com chunks
//! deflate, bzip2, sem compressão ou com preenchimento por padrão. Escrita de
//! E01 no formato EnCase 6. A origem nunca é alterada.
//! Reads multi-segment sets (`.E01`, `.E02`, ...) with deflate, bzip2, uncompressed,
//! or pattern-filled chunks. Writes EnCase 6 compatible `.E01` images.
//! The source is never modified.
use crate::error::{Error, Result};
use crate::hashing::MultiHasher;
@@ -17,7 +17,7 @@ use std::sync::Mutex;
pub const SIG_V1: [u8; 8] = [0x45, 0x56, 0x46, 0x09, 0x0d, 0x0a, 0xff, 0x00];
pub const SIG_V2: [u8; 8] = [0x45, 0x56, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00];
/// Reconhece o cabeçalho de um segmento EWF.
/// Checks whether `buf` starts with an EWF segment signature.
pub fn is_ewf_header(head: &[u8]) -> bool {
head.len() >= 8 && (head[..8] == SIG_V1 || head[..8] == SIG_V2)
}
@@ -85,12 +85,12 @@ enum Codec {
#[derive(Clone, Copy, Debug)]
enum Kind {
/// Dados guardados como estão; `checksum` indica 4 bytes de Adler-32 no fim.
/// Uncompressed chunk data; `checksum` indicates a trailing 4-byte Adler-32.
Stored {
checksum: bool,
},
Packed(Codec),
/// Chunk inteiro repetindo estes 8 bytes (EnCase 7 "empty block").
/// Full chunk filled by repeating these 8 bytes (EnCase 7 pattern/empty block).
Pattern([u8; 8]),
}
@@ -102,16 +102,16 @@ struct Chunk {
kind: Kind,
}
/// Metadados lidos do conjunto de segmentos.
/// Metadata extracted from the EWF segment set.
#[derive(Debug, Clone, Default)]
pub struct EwfInfo {
/// 1 para E01, 2 para Ex01.
/// Format version: 1 for E01, 2 for Ex01.
pub version: u8,
pub segments: usize,
pub chunk_size: u64,
pub bytes_per_sector: u32,
pub sectors: u64,
/// Caso, examinador, notas etc. (chaves legíveis).
/// Case, examiner, notes, etc. (human-readable key-value pairs).
pub fields: BTreeMap<String, String>,
pub md5: Option<String>,
pub sha1: Option<String>,
@@ -130,7 +130,7 @@ fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// Próximo nome de segmento: E01..E99, EAA..EZZ, FAA... (a mesma letra inicial vira Ex).
/// Computes the next segment extension: E01..E99, EAA..EZZ, FAA... (preserving prefix case).
pub fn next_segment_ext(ext: &str) -> Option<String> {
let b = ext.as_bytes();
if b.len() != 3 {
@@ -175,7 +175,7 @@ pub fn next_segment_ext(ext: &str) -> Option<String> {
Some(if upper { out } else { out.to_ascii_lowercase() })
}
/// Nome do segmento seguinte para E01 (`.E01` -> `.E02`) e Ex01 (`.Ex01` -> `.Ex02`).
/// Path of the next segment for E01 (`.E01` -> `.E02`) and Ex01 (`.Ex01` -> `.Ex02`).
fn next_segment_path(p: &Path) -> Option<PathBuf> {
let ext = p.extension()?.to_str()?;
let new = if ext.len() == 4 && ext[..2].eq_ignore_ascii_case("ex") {
@@ -375,7 +375,7 @@ impl EwfSource {
if flen < 96 {
continue;
}
// Os descritores formam uma lista encadeada para trás, a partir do fim do arquivo.
// Section descriptors form a backward-linked list starting from the end of the file.
let mut pos = flen - 64;
let mut guard = 0;
loop {
@@ -387,7 +387,7 @@ impl EwfSource {
let dsize = le64(&d, 16);
let pad = le32(&d, 28) as u64;
let dstart = pos.saturating_sub(dsize);
let data_len = dsize; // inclui o preenchimento; os decodificadores param no fim do fluxo
let data_len = dsize; // includes padding; decoders stop at the end of the compressed stream
let _ = pad;
let read_data = |max: u64| -> io::Result<Vec<u8>> {
let n = data_len.min(max) as usize;
@@ -515,7 +515,7 @@ impl EwfSource {
Ok(out)
}
/// Verifica o hash MD5 do conteúdo contra o guardado na imagem (`None` se a imagem não o guarda).
/// Verifies the content MD5 against the hash stored in the image (`None` if not present).
pub fn verify(&self) -> Result<Option<bool>> {
let Some(stored) = self.info.md5.clone() else {
return Ok(None);
@@ -559,7 +559,7 @@ impl Source for EwfSource {
let data = &cache.as_ref().unwrap().1;
let within = (pos % self.chunk_size) as usize;
if within >= data.len() {
// Chunk curto (último): o resto é zero até o fim lógico.
// Short (final) chunk: pad remaining bytes with zeros up to the logical end.
let n = (self.chunk_size as usize - within).min(want - done);
buf[done..done + n].fill(0);
done += n;
@@ -611,7 +611,7 @@ fn header_key_name(k: &str) -> &str {
}
}
/// Texto do "header" (E01): linhas "1 / main / chaves / valores", separados por TAB.
/// Parses E01 `header` text: tab-separated lines (`1` / `main` / keys / values).
fn parse_header_text(raw: &[u8], out: &mut BTreeMap<String, String>) {
let text = if raw.len() >= 2 && (raw[0] == 0xFF && raw[1] == 0xFE || raw.get(1) == Some(&0)) {
utf16_text(raw)
@@ -674,16 +674,16 @@ fn parse_v2_text(text: &str, info: &mut EwfInfo, total_bytes: &mut u64, ty: u32)
}
// ======================================================================
// Escrita de E01 (formato EnCase 6)
// E01 writer (EnCase 6 format)
// ======================================================================
#[derive(Debug, Clone)]
pub struct E01Options {
pub chunk_sectors: u32,
pub bytes_per_sector: u32,
/// 0 = sem compressão, 1..=9 = nível deflate.
/// 0 = uncompressed, 1..=9 = deflate compression level.
pub compression: u32,
/// Tamanho máximo de cada segmento (0 = um segmento só).
/// Maximum size of each segment file in bytes (0 = single segment).
pub segment_size: u64,
pub case_number: String,
pub description: String,
@@ -735,7 +735,7 @@ impl SegWriter {
self.pos += b.len() as u64;
Ok(())
}
/// Seção com dados completos (cabeçalho, volume, hash...).
/// Writes a complete EWF section (header, volume, hash, etc.).
fn section(&mut self, ty: &str, data: &[u8], last: bool) -> io::Result<()> {
let size = 76 + data.len() as u64;
let next = if last { self.pos } else { self.pos + size };
@@ -760,12 +760,12 @@ fn header_text(o: &E01Options, now: &str) -> Vec<u8> {
fn volume_data(o: &E01Options, chunks: u32, sectors: u64) -> Vec<u8> {
let mut v = vec![0u8; 1052];
v[0] = 1; // disco fixo
v[0] = 1; // fixed disk
v[4..8].copy_from_slice(&chunks.to_le_bytes());
v[8..12].copy_from_slice(&o.chunk_sectors.to_le_bytes());
v[12..16].copy_from_slice(&o.bytes_per_sector.to_le_bytes());
v[16..24].copy_from_slice(&sectors.to_le_bytes());
v[36] = 1; // mídia física
v[36] = 1; // physical media
v[52] = if o.compression == 0 { 0 } else { 1 };
v[56..60].copy_from_slice(&o.chunk_sectors.to_le_bytes());
let c = adler32(&v[..1048]);
@@ -780,7 +780,7 @@ fn utc_now() -> String {
.unwrap_or(0);
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
// Conversão civil (Howard Hinnant).
// Civil calendar conversion (Howard Hinnant).
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(146097);
@@ -799,8 +799,8 @@ fn utc_now() -> String {
)
}
/// Cria `base.E01` (e `.E02`... se houver divisão) a partir de `src`.
/// Devolve o relatório com os hashes do conteúdo lido.
/// Creates `base.E01` (and `.E02`... when split across segments) from `src`.
/// Returns the imaging report with hashes of the imaged content.
pub fn write_e01(
src: &dyn Source,
base: &Path,
@@ -838,7 +838,7 @@ pub fn write_e01(
base.with_extension(ext)
};
// O segmento está cheio? (reserva espaço para as tabelas e os hashes finais)
// Is the current segment full? (reserves room for tables and trailing hash sections)
let full = |pos: u64, entries: u64| {
opts.segment_size > 0
&& pos + chunk_size + 2 * (entries * 4 + 110) + 1024 >= opts.segment_size
@@ -875,14 +875,14 @@ pub fn write_e01(
false,
)?;
}
// Grupos "sectors" + "table" + "table2" até estourar o limite do segmento.
// Emit "sectors" + "table" + "table2" groups until the segment size limit is reached.
loop {
let remaining = chunks_total - chunk_idx;
if remaining == 0 {
break;
}
let sec_start = w.pos;
// Espaço reservado para a descrição da seção "sectors" (preenchida depois).
// Placeholder for the "sectors" section descriptor (backfilled later).
w.put(&[0u8; 76])?;
let base_off = w.pos;
let mut entries: Vec<u32> = vec![];
@@ -894,7 +894,7 @@ pub fn write_e01(
}
let off = chunk_idx * chunk_size;
let want = (total - off).min(chunk_size) as usize;
// Leitura tolerante: setor ruim vira zeros e entra no mapa de erros.
// Fault-tolerant read: bad sectors are zero-filled and recorded in the error map.
let b = &mut buf[..want];
if crate::source::read_exact_at(src, off, b).is_err() {
let mut o = 0;
@@ -930,21 +930,21 @@ pub fn write_e01(
chunk_idx += 1;
group_chunks += 1;
progress(chunk_idx * chunk_size.min(total), total);
// Mantém cada tabela dentro de 31 bits e o segmento perto do limite.
// Keep each table offset within 31 bits and the segment within its size limit.
if rel > 0x7000_0000 || full(w.pos, entries.len() as u64) {
break;
}
}
// Preenche a seção "sectors".
// Backfill the "sectors" section descriptor.
let sec_end = w.pos;
let table_size = 76 + 24 + entries.len() as u64 * 4 + 4;
let sectors_desc = section_desc("sectors", sec_end, sec_end - sec_start);
// Descrição "sectors" aponta para a tabela que vem logo depois.
// The "sectors" descriptor points to the table immediately following it.
use std::io::{Seek, SeekFrom};
w.f.seek(SeekFrom::Start(sec_start))?;
w.f.write_all(&sectors_desc)?;
w.f.seek(SeekFrom::Start(sec_end))?;
// table e table2 idênticas.
// `table` and `table2` are identical.
for ty in ["table", "table2"] {
let start = w.pos;
w.put(&section_desc(ty, start + table_size, table_size))?;
@@ -971,7 +971,7 @@ pub fn write_e01(
}
}
if chunk_idx >= chunks_total {
// Segmento final: hashes e "done".
// Final segment: hash sections and "done".
let h = std::mem::take(&mut hasher).finalize();
let md5: Vec<u8> = (0..16)
.map(|i| u8::from_str_radix(&h.md5[i * 2..i * 2 + 2], 16).unwrap())
+11 -11
View File
@@ -1,5 +1,5 @@
//! Navegação estilo gerenciador de arquivos: filtros, ordenação, facetas
//! (tipos, datas, pastas, câmeras) e cache de miniaturas.
//! File-manager style browsing: filtering, sorting, facets
//! (types, dates, folders, cameras), and thumbnail caching.
use crate::types::*;
use serde::{Deserialize, Serialize};
@@ -21,14 +21,14 @@ pub struct Query {
pub offset: usize,
#[serde(default = "default_limit")]
pub limit: usize,
/// Pasta (prefixo), "@carved" (achados por assinatura) ou "@raiz".
/// Folder path prefix, "@carved" (carved findings), or "@raiz" (root).
#[serde(default)]
pub folder: Option<String>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub month: Option<u32>,
/// Só arquivos sem data conhecida.
/// Match only files without a known date.
#[serde(default)]
pub undated: Option<bool>,
#[serde(default)]
@@ -39,12 +39,12 @@ pub struct Query {
pub min_size: Option<u64>,
#[serde(default)]
pub max_size: Option<u64>,
/// Megapixels mínimos.
/// Minimum resolution in megapixels.
#[serde(default)]
pub min_mp: Option<f64>,
#[serde(default)]
pub has_gps: Option<bool>,
/// Mostra só a primeira ocorrência de cada conteúdo repetido.
/// Deduplicate: show only the first occurrence of identical content.
#[serde(default)]
pub dedupe: Option<bool>,
}
@@ -67,7 +67,7 @@ impl Query {
.filter(|x| !x.is_empty())
}
/// Itens que atendem ao filtro (na ordem de achado), já sem duplicadas se pedido.
/// Returns items matching the filter (in discovery order), deduplicated if requested.
pub fn select<'a>(&self, found: &'a [Found]) -> Vec<&'a Found> {
let needle = self.needle();
let mut v: Vec<&Found> = found.iter().filter(|f| self.matches(f, &needle)).collect();
@@ -170,7 +170,7 @@ pub fn item_json(f: &Found) -> Value {
})
}
/// Contagens para a navegação lateral.
/// Facet counts for sidebar navigation.
pub fn facets(found: &[Found]) -> Value {
let mut cats: HashMap<&'static str, usize> = HashMap::new();
let mut years: BTreeMap<i64, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
@@ -266,7 +266,7 @@ pub fn cat_key(c: Category) -> &'static str {
}
}
// ------------------------------------------------------------------ cache de miniaturas
// ------------------------------------------------------------------ thumbnail cache
type Key = (String, u64, u32);
@@ -310,7 +310,7 @@ impl Default for ThumbCache {
}
}
/// Limita quantas miniaturas são decodificadas ao mesmo tempo (memória/CPU).
/// Bounds concurrent thumbnail decoding (memory/CPU cap).
pub struct Gate {
n: Mutex<usize>,
cv: Condvar,
@@ -344,7 +344,7 @@ impl Drop for Permit<'_> {
}
}
/// Assinatura rápida: tamanho + FNV-1a do início (32 KiB) e do fim (32 KiB).
/// Fast content fingerprint: file size + FNV-1a of the first 32 KiB and last 32 KiB.
pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
use crate::recover::read_found_at;
if f.size == 0 {
+9 -9
View File
@@ -1,21 +1,21 @@
//! Abre um arquivo achado no aplicativo padrão do sistema (player de vídeo, leitor
//! de PDF, etc.). Usado quando o WebView do app não consegue reproduzir a mídia:
//! o WebKitGTK empacotado no AppImage não traz os plugins de mídia do GStreamer e
//! o processo da página morre ao criar um `<video>`.
//! Opens a recovered file in the OS default application (video player, PDF
//! reader, etc.). Used when the embedded WebView cannot play media directly:
//! WebKitGTK bundled in the AppImage lacks GStreamer media plugins and
//! crashes the web process when instantiating a `<video>` element.
use crate::error::{Error, Result};
use crate::fs::sanitize_name;
use crate::types::Found;
use std::path::{Path, PathBuf};
/// Pasta temporária própria do usuário (nunca compartilhada).
/// Per-user private temporary directory (never shared).
fn temp_dir() -> PathBuf {
let uid = crate::owner::invoking_user()
.map(|u| u.uid.to_string())
.unwrap_or_else(|| {
#[cfg(unix)]
{
// SAFETY: getuid não tem pré-condições.
// SAFETY: `getuid` has no preconditions.
unsafe { libc::getuid() }.to_string()
}
#[cfg(not(unix))]
@@ -26,7 +26,7 @@ fn temp_dir() -> PathBuf {
std::env::temp_dir().join(format!("guara-abrir-{uid}"))
}
/// Remove cópias com mais de um dia.
/// Removes temporary copies older than one day.
fn cleanup(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
@@ -45,7 +45,7 @@ fn cleanup(dir: &Path) {
}
}
/// Copia o arquivo achado para uma pasta temporária do usuário e o abre no aplicativo padrão.
/// Materializes the recovered file into a user temp folder and opens it with the default app.
pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf> {
const MAX: u64 = 8 * 1024 * 1024 * 1024;
if f.size > MAX {
@@ -62,7 +62,7 @@ pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf>
Ok(path)
}
/// Abre o caminho com o aplicativo padrão, na sessão do usuário real mesmo se formos root.
/// Launches the default application for `path`, running in the real user session even under root.
pub fn open_path(path: &Path) -> std::io::Result<()> {
use std::process::{Command, Stdio};
#[cfg(target_os = "linux")]
+20 -20
View File
@@ -1,6 +1,6 @@
//! Mais formatos reconhecidos pela estrutura (cabeçalhos, blocos e tamanhos internos), para o
//! carving devolver o arquivo inteiro e não só o começo. Cada detector devolve o tamanho exato
//! quando a estrutura permite e uma confiança proporcional ao que foi conferido.
//! Additional file formats recognized by internal structure (headers, chunks, and embedded sizes)
//! so file carving recovers complete files rather than prefixes. Each detector returns the exact
//! byte length when determinable and a confidence level proportional to structural validation.
use crate::signatures::Hit;
use crate::source::{read_vec, Source};
@@ -40,7 +40,7 @@ fn hit(len: u64, ext: &'static str, confidence: Confidence) -> Option<Hit> {
})
}
/// Primeiros bytes que disparam os detectores deste módulo.
/// Leading byte values that trigger detectors in this module.
pub fn might_start(b0: u8) -> bool {
matches!(
b0,
@@ -67,7 +67,7 @@ pub fn might_start(b0: u8) -> bool {
)
}
/// Tenta os formatos adicionais. `h` são os primeiros bytes (>= 12) lidos em `off`.
/// Probes additional formats. `h` contains the leading bytes (>= 12) read at `off`.
pub fn detect(src: &dyn Source, off: u64, h: &[u8], max: u64) -> Option<Hit> {
if h.starts_with(&[0x1F, 0x8B, 0x08]) {
return gzip(src, off, max);
@@ -153,9 +153,9 @@ pub fn detect(src: &dyn Source, off: u64, h: &[u8], max: u64) -> Option<Hit> {
None
}
// -------------------------------------------------------------- compactadores
// -------------------------------------------------------------- compressed streams
/// Alimenta um descompactador com blocos de `src` até o fim do fluxo; devolve (bytes lidos, saída crc32 opcional).
/// Feeds chunks from `src` into a decompressor until end-of-stream; returns `(compressed_bytes, optional_crc32)`.
fn inflate_len(
src: &dyn Source,
start: u64,
@@ -311,7 +311,7 @@ fn zstd(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if btype == 3 {
return None;
}
// RLE: o conteúdo é 1 byte; os outros ocupam `bsize`.
// RLE block: 1 byte in stream, expands to `bsize`.
let body = if btype == 1 { 1 } else { bsize };
pos += 3 + body;
if pos - off > max {
@@ -328,7 +328,7 @@ fn zstd(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
hit(p as u64, "zst", Confidence::Medium)
}
// ------------------------------------------------------------------ mídia
// ------------------------------------------------------------------ media
fn vint(b: &[u8]) -> Option<(u64, usize, bool)> {
let first = *b.first()?;
@@ -367,7 +367,7 @@ fn matroska(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
return hit(total, ext, Confidence::High);
}
}
// Tamanho desconhecido (gravação ao vivo): caminha pelos elementos de topo.
// Unknown segment size (live recording): walk top-level EBML elements.
let mut pos = off + (p + sl) as u64;
let mut last_good = pos;
for _ in 0..2_000_000 {
@@ -394,7 +394,7 @@ fn matroska(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
hit(last_good - off, ext, Confidence::Low)
}
/// ID de elemento EBML (mantém os bits de marcação).
/// Reads an EBML element ID (preserving marker bits).
fn vint_id(b: &[u8]) -> Option<(u64, usize, bool)> {
let first = *b.first()?;
let len = first.leading_zeros() as usize + 1;
@@ -456,7 +456,7 @@ fn ogg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
}
fn mpegts(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
// 188 bytes por pacote (ou 192 com carimbo de 4 bytes antes do sync).
// 188 bytes per MPEG-TS packet (or 192 with a 4-byte timecode prefix before the sync byte).
let probe = read_vec(src, off, 188 * 8).ok()?;
if probe.len() < 188 * 8 || !(0..8).all(|i| probe[i * 188] == 0x47) {
return None;
@@ -492,7 +492,7 @@ fn flv(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if t.len() < 15 {
break;
}
// PreviousTagSize(4) + tipo(1) + tamanho(3)
// PreviousTagSize(4) + tag_type(1) + data_size(3)
let ty = t[4];
if !matches!(ty, 8 | 9 | 18) {
break;
@@ -559,7 +559,7 @@ fn jp2(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
};
if ty == b"jp2c" {
if size == 0 {
// até o fim do arquivo: procura EOC FF D9 depois
// codestream extends to EOF: scan for EOC marker FF D9
return hit((pos - off) + 8 + (4 << 20).min(max), "jp2", Confidence::Low);
}
return hit(pos - off + size, "jp2", Confidence::High);
@@ -589,7 +589,7 @@ fn midi(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
hit(pos - off, "mid", Confidence::High)
}
// -------------------------------------------------------- arquivos compactados
// -------------------------------------------------------- archives
fn rar(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let h = read_vec(src, off, 8).ok()?;
@@ -703,7 +703,7 @@ fn swf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
.then_some(())
.and_then(|_| hit(len, "swf", Confidence::High));
}
// CWS: corpo em zlib a partir do byte 8
// CWS: zlib-compressed body starting at byte 8
let (consumed, _, out) = inflate_len(src, off + 8, max, false, true)?;
hit(
8 + consumed,
@@ -716,7 +716,7 @@ fn swf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
)
}
// ---------------------------------------------------------- binários e dados
// ---------------------------------------------------------- binaries and data
fn elf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let h = read_vec(src, off, 64).ok()?;
@@ -757,7 +757,7 @@ fn elf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
)
};
let mut end = shoff + shentsize * shnum;
// Segmentos de programa podem ir além (binários sem tabela de seções).
// Program segments may extend further (stripped binaries without a section header table).
if phnum > 0 && phnum < 256 && phentsize >= 32 {
let ph = read_vec(src, off + phoff, (phnum * phentsize) as usize).ok()?;
for i in 0..phnum as usize {
@@ -947,7 +947,7 @@ fn ico(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
}
end = end.max(o + size);
}
// a imagem de cada entrada: BMP/DIB (tamanho do cabeçalho 40) ou PNG
// each icon entry payload is either a BMP/DIB (40-byte header) or a PNG
let first_off = u32le(&d, 12);
let probe = read_vec(src, off + first_off, 8).ok()?;
if !(probe.starts_with(&[0x89, b'P', b'N', b'G']) || u32le(&probe, 0) == 40) {
@@ -961,7 +961,7 @@ fn regf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if h.len() < 4096 {
return None;
}
// soma XOR dos 127 primeiros u32 = valor em 0x1FC
// XOR checksum of the first 127 u32 words must match the word at 0x1FC
let mut x = 0u32;
for i in 0..127 {
x ^= u32le(&h, i * 4) as u32;
+28 -28
View File
@@ -1,7 +1,7 @@
//! APFS (volumes sem cifragem): lê o contêiner, o mapa de objetos e a árvore de arquivos de cada
//! volume; arquivos apagados vêm de duas fontes: as árvores dos instantâneos (snapshots) e os nós
//! antigos da árvore (copy-on-write) que ainda estão no disco. Volumes cifrados (FileVault) são
//! detectados e deixados para a varredura por assinatura.
//! APFS (unencrypted volumes): parses the container, object map, and file system tree of each
//! volume; deleted files are recovered from two sources: snapshot trees and older copy-on-write
//! B-tree nodes still resident on disk. Encrypted volumes (FileVault) are detected and left for
//! signature carving unless unlocked.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
@@ -32,13 +32,13 @@ const T_CIB: u32 = 7;
const T_CHECKPOINT_MAP: u32 = 0x0C;
const SUBTYPE_FSTREE: u32 = 0x0E;
// tipos de registro da árvore de arquivos
// filesystem B-tree record types
const R_SNAP_METADATA: u8 = 1;
const R_INODE: u8 = 3;
const R_FILE_EXTENT: u8 = 8;
const R_DIR_REC: u8 = 9;
/// Soma de verificação Fletcher-64 do APFS (cobre o bloco menos os 8 bytes iniciais).
/// APFS Fletcher-64 checksum (covers the block excluding the leading 8 bytes).
pub fn fletcher64(data: &[u8]) -> u64 {
let (mut s1, mut s2) = (0u64, 0u64);
for w in data.chunks_exact(4) {
@@ -60,7 +60,7 @@ struct Apfs<'a> {
struct BNode {
flags: u16,
level: u16,
/// (chave, valor) por entrada
/// `(key, value)` slice pair for each node entry
entries: Vec<(Vec<u8>, Vec<u8>)>,
oid: u64,
xid: u64,
@@ -124,7 +124,7 @@ impl<'a> Apfs<'a> {
})
}
/// Todas as entradas de uma árvore física (mapa de objetos): filhos são endereços físicos.
/// Collects all entries from a physical B-tree (object map): child pointers are physical block addresses.
fn walk_physical(
&self,
root: u64,
@@ -156,7 +156,7 @@ impl<'a> Apfs<'a> {
}
}
/// Mapa de objetos: oid -> [(xid, paddr)] ordenado por xid.
/// Object map: `oid -> [(xid, paddr)]` sorted by transaction ID (`xid`).
fn omap(&self, omap_paddr: u64) -> HashMap<u64, Vec<(u64, u64)>> {
let mut m: HashMap<u64, Vec<(u64, u64)>> = HashMap::new();
let Some(ob) = self.checked_block(omap_paddr) else {
@@ -174,7 +174,7 @@ impl<'a> Apfs<'a> {
m
}
/// Endereço físico de um objeto ephemeral (spaceman), achado no mapa de checkpoint mais novo.
/// Physical block address of an ephemeral object (spaceman) from the latest checkpoint map.
fn ephemeral_paddr(&self, nx: &[u8], oid: u64) -> Option<u64> {
let desc_blocks = le32(nx, 104) as u64;
let desc_base = le64(nx, 112);
@@ -203,8 +203,8 @@ impl<'a> Apfs<'a> {
best.map(|(_, p)| p)
}
/// Mapa de espaço livre do contêiner: percorre o spaceman, os chunk-info blocks e os bitmaps
/// (bit 1 = bloco em uso).
/// Container free-space map: walks the spaceman, chunk-info blocks (CIBs), and allocation bitmaps
/// (bit 1 = allocated block).
fn free_map(&self, nx: &[u8]) -> Option<Vec<Extent>> {
let sm_oid = le64(nx, 152);
if sm_oid == 0 {
@@ -214,7 +214,7 @@ impl<'a> Apfs<'a> {
if le32(&sm, 24) & OBJ_TYPE_MASK != T_SPACEMAN {
return None;
}
// Com CABs (contêineres grandes) os endereços dos CIBs não ficam no spaceman.
// With CABs (large containers), CIB addresses live in chunk-info address blocks rather than inline in the spaceman.
if le32(&sm, 68) != 0 {
return None;
}
@@ -289,7 +289,7 @@ fn lookup(m: &HashMap<u64, Vec<(u64, u64)>>, oid: u64, max_xid: u64) -> Option<u
.map(|(_, p)| *p)
}
// ---------------------------------------------------------------- registros
// ---------------------------------------------------------------- records
#[derive(Clone, Debug, Default)]
struct Inode {
@@ -308,7 +308,7 @@ struct Inode {
#[derive(Clone, Debug, Default)]
struct FileRec {
inode: Inode,
names: BTreeMap<u64, String>, // pai -> nome
names: BTreeMap<u64, String>, // parent_id -> name
}
type Files = HashMap<u64, FileRec>;
@@ -327,7 +327,7 @@ fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
let mut ino = Inode {
parent: le64(v, 0),
private_id: le64(v, 8),
ctime: ns(le64(v, 16)), // criação
ctime: ns(le64(v, 16)), // creation timestamp
mtime: ns(le64(v, 24)),
atime: ns(le64(v, 40)),
mode: le16(v, 80),
@@ -338,7 +338,7 @@ fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
let flags = le64(v, 48);
ino.compressed = le32(v, 68) & 0x20 != 0;
let _ = flags;
// campos estendidos
// xfields (extended fields)
let xf = &v[92..];
if xf.len() >= 4 {
let n = le16(xf, 0) as usize;
@@ -383,7 +383,7 @@ fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) {
}
impl Apfs<'_> {
/// Percorre a árvore de arquivos a partir de uma raiz virtual.
/// Walks the filesystem B-tree starting from a virtual root OID.
fn walk_fstree(
&self,
root_oid: u64,
@@ -484,7 +484,7 @@ fn open_container<'a>(src: &'a dyn Source, base: u64) -> Option<(Apfs<'a>, Vec<u
bs,
blocks,
};
// O superbloco mais recente está na área de descritores de checkpoint.
// Find the latest valid container superblock in the checkpoint descriptor area.
let desc_blocks = le32(&b0, 104) as u64;
let desc_base = le64(&b0, 112);
let mut best = b0.clone();
@@ -548,7 +548,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res: &mut VolumeScan) {
let omap = a.omap(v.omap_paddr);
let current_xid = u64::MAX;
// Árvore atual.
// Current live filesystem tree.
let mut live: Files = HashMap::new();
let mut live_ext: Extents = HashMap::new();
let mut live_nodes: HashSet<u64> = HashSet::new();
@@ -556,7 +556,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
live_nodes.insert(node);
add_record(&mut live, &mut live_ext, k, val);
});
// Instantâneos.
// Volume snapshots.
let mut stale: Files = HashMap::new();
let mut stale_ext: Extents = HashMap::new();
if v.snap_tree_oid != 0 {
@@ -583,7 +583,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
}
} else {
for (_, val) in &n.entries {
// árvore de metadados de snapshot: filhos físicos ou virtuais
// snapshot metadata tree: physical or virtual child references
let c = le64(val, 0);
stack.push(lookup(&omap, c, current_xid).unwrap_or(c));
}
@@ -602,7 +602,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
}
}
}
// Nós antigos da árvore de arquivos.
// Older evicted copy-on-write filesystem tree leaf nodes.
if allow_stale_scan {
let total = a.blocks;
for p in 0..total {
@@ -631,7 +631,7 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
}
}
}
// Arquivos que só existem nas versões antigas.
// Files present only in older tree versions or snapshots.
let mut ids: Vec<&u64> = stale.keys().collect();
ids.sort();
let mut seen_first: HashSet<u64> = HashSet::new();
@@ -648,9 +648,9 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
continue;
};
if ex.values().any(|e| e.2 != 0) {
continue; // extensão cifrada
continue; // encrypted extent
}
// nome e pasta: da entrada de diretório, senão do próprio inode
// file name and parent folder: prefer directory entry, fallback to inode xfield
let (name, parent) = match rec.names.iter().next() {
Some((p, n)) => (n.clone(), *p),
None => match &ino.name {
@@ -726,13 +726,13 @@ fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res:
let _ = res;
}
/// Trechos livres (absolutos) do contêiner, segundo o spaceman.
/// Absolute free byte extents in the container according to the spaceman.
pub fn free_extents(src: &dyn Source, base: u64) -> Option<Vec<Extent>> {
let (a, nx) = open_container(src, base)?;
a.free_map(&nx)
}
/// Arquivos e pastas atuais do primeiro volume sem cifragem (caminho, tamanho, é pasta).
/// Live files and folders in the first unencrypted volume `(path, size, is_dir)`.
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some((a, nx)) = open_container(src, base) else {
return vec![];
+30 -30
View File
@@ -1,8 +1,8 @@
//! Btrfs (um dispositivo; perfis single, DUP e RAID 1): árvores atuais para nomes e mapa de
//! espaço livre, e arquivos apagados achados em nós antigos das árvores. Como o Btrfs nunca
//! sobrescreve metadados no lugar (copy-on-write), as versões anteriores dos nós continuam no
//! disco até o espaço ser reaproveitado: varremos os blocos de metadata em busca delas.
//! Extensões comprimidas (zlib, zstd, lzo) são descompactadas na recuperação.
//! Btrfs (single device; single, DUP, and RAID 1 profiles): reads live trees for paths and
//! free-space mapping, and recovers deleted files from historical copy-on-write B-tree nodes.
//! Because Btrfs never overwrites metadata in place, prior leaf generations remain on disk
//! until reallocated: we scan metadata chunks for those older leaves.
//! Compressed extents (zlib, zstd, LZO) are decompressed on recovery.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
@@ -12,7 +12,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
const MAGIC: &[u8; 8] = b"_BHRfS_M";
const SB_OFF: u64 = 0x10000;
// chaves
// key item types
const INODE_ITEM: u8 = 1;
const INODE_REF: u8 = 12;
const EXTENT_DATA: u8 = 108;
@@ -81,7 +81,7 @@ struct Chunk {
logical: u64,
len: u64,
ty: u64,
/// (offset físico do primeiro stripe) por cópia
/// Physical byte offsets of each stripe replica.
phys: Vec<u64>,
}
@@ -153,7 +153,7 @@ impl<'a> Fs<'a> {
fs.chunks.push(ch);
p += 17 + used;
}
// chunk tree completa
// full chunk tree
let mut more = vec![];
let cr = fs.chunk_root;
fs.walk(cr, &mut |k, d| {
@@ -241,7 +241,7 @@ impl<'a> Fs<'a> {
})
}
/// Percorre uma árvore em ordem, chamando `f` para cada item de folha.
/// Walks a B-tree in order, invoking `f` for each leaf item.
fn walk(&self, root: u64, f: &mut dyn FnMut(&Key, &[u8])) {
let mut seen = HashSet::new();
self.walk_inner(root, f, &mut seen, &mut |_| {}, 0);
@@ -314,7 +314,7 @@ struct InodeData {
atime: i64,
has_inode: bool,
refs: HashMap<u64, String>,
/// offset no arquivo -> (geração do nó, item)
/// `file_offset -> (node_generation, extent_item)`
extents: BTreeMap<u64, (u64, Vec<u8>)>,
}
@@ -377,8 +377,8 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
return res;
}
// 1) Árvores atuais: raízes das subvolumes e extents alocados.
let mut roots: Vec<(u64, u64)> = vec![]; // (id da árvore, bytenr)
// 1) Live trees: subvolume roots and allocated extents.
let mut roots: Vec<(u64, u64)> = vec![]; // (tree_id, root_bytenr)
let mut extent_root = 0u64;
let rr = fs.root;
fs.walk(rr, &mut |k, d| {
@@ -416,7 +416,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
}
}
// Extents alocados (árvore de extents) e mapa livre dos blocos de dados.
// Allocated extents (from the extent tree) and free-space map across data chunks.
let mut allocated: Vec<Extent> = vec![];
if extent_root != 0 {
let nodesize = fs.nodesize;
@@ -431,7 +431,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let allocated = merge_extents(allocated);
let mut free: Vec<Extent> = vec![];
for c in fs.chunks.iter().filter(|c| c.ty & 0x1 != 0) {
// blocos de dados: livres = chunk - alocado
// data chunks: free = chunk_range - allocated_extents
let mut cur = c.logical;
let i = allocated.partition_point(|e| e.end() <= c.logical);
for e in &allocated[i..] {
@@ -453,7 +453,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
}
res.free = Some(merge_extents(free));
// 2) Varre os blocos de metadata atrás de versões antigas de folhas de árvores de arquivos.
// 2) Scan metadata chunks for older copy-on-write leaves of filesystem trees.
let mut stale: Files = HashMap::new();
let ns = fs.nodesize as usize;
let step = fs.sectorsize as usize;
@@ -482,7 +482,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let mut i = 0usize;
while i + ns <= buf.len() && (off + i as u64) < c.len {
let logical = c.logical + off + i as u64;
// cabeçalho barato antes do CRC
// cheap header sanity check before computing CRC32C
if buf[i + 32..i + 48] == fs.fsid
&& le64(&buf[i..], 48) == logical
&& buf[i + 100] == 0
@@ -505,7 +505,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
}
}
// 3) Arquivos que só existem nas folhas antigas.
// 3) Files that only exist in historical leaves.
let path_of = |owner: u64, mut ino: u64| -> String {
let mut parts: Vec<String> = vec![];
for _ in 0..64 {
@@ -584,7 +584,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
res
}
/// Converte os itens EXTENT_DATA em extents físicos ou partes (inline/comprimidas).
/// Converts `EXTENT_DATA` items into physical extents or composite parts (inline/compressed).
fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc, bool, bool)> {
let mut parts: Vec<Part> = vec![];
let mut pos = 0u64;
@@ -594,7 +594,7 @@ fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc,
let mut hole = |parts: &mut Vec<Part>, len: u64| {
if len > 0 {
parts.push(Part::Run { offset: 0, len });
// marcador: offset 0 + sparse tratado abaixo
// placeholder: offset 0 + sparse hole handled below
}
};
let _ = &mut hole;
@@ -697,7 +697,7 @@ fn build_data(fs: &Fs, e: &InodeData, allocated: &[Extent]) -> Option<(DataLoc,
.collect();
return Some((DataLoc::Extents { extents: ex }, any_alloc, all_ok));
}
// Buracos viram trechos esparsos: Part::Run com offset 0 não serve; usa Bytes de zeros pequenos.
// Sparse holes: `Part::Run` with offset 0 is not used here; represent holes accordingly.
let mut out: Vec<Part> = vec![];
for (i, p) in parts.into_iter().enumerate() {
if sparse_marks.contains(&i) {
@@ -732,8 +732,8 @@ fn decode(codec: u8, raw: &[u8], ram: usize) -> Option<Vec<u8>> {
}
}
/// Formato de extensão LZO do Btrfs: tamanho total (u32) + segmentos [tamanho (u32)][dados LZO1X]
/// de até um setor de 4 KiB descomprimido cada, sem cruzar fronteiras de setor.
/// Btrfs LZO extent format: total length (`u32`) + `[seg_len (u32)][LZO1X payload]` segments
/// of up to one 4 KiB uncompressed sector each, padded at sector boundaries.
pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
if raw.len() < 4 {
return None;
@@ -742,7 +742,7 @@ pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(ram);
let mut p = 4usize;
while p + 4 <= raw.len().min(total) && out.len() < ram {
// pula o preenchimento até o próximo setor se sobrar menos de 4 bytes
// skip sector-boundary padding when fewer than 4 bytes remain in the sector
let in_sector = 4096 - (p % 4096);
if in_sector < 4 {
p += in_sector;
@@ -758,7 +758,7 @@ pub fn lzo_btrfs(raw: &[u8], ram: usize) -> Option<Vec<u8>> {
Some(out)
}
/// LZO1X (descompressão segura, no mesmo esquema de estados do kernel Linux).
/// Safe LZO1X decompressor (following the Linux kernel state machine).
pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(max_out);
let mut ip = 0usize;
@@ -778,7 +778,7 @@ pub fn lzo1x_decompress(src: &[u8], max_out: usize) -> Option<Vec<u8>> {
*ip += 1;
Some(v)
};
// estado: literais ao fim da instrução anterior (0..3) ou 4 depois de uma corrida de literais
// state: trailing literals from the previous instruction (0..3), or 4 after a literal run
let mut state;
if byte(0)? > 17 {
let t = byte(0)? - 17;
@@ -859,7 +859,7 @@ fn copy_match(out: &mut Vec<u8>, dist: usize, len: usize) -> Option<()> {
Some(())
}
/// Arquivos e pastas atuais com caminho completo e tamanho (usa só as árvores vivas).
/// Lists live files and directories with full path and size from current subvolume trees.
pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
let Some(fs) = Fs::open(src, base) else {
return vec![];
@@ -900,18 +900,18 @@ pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> {
out
}
/// Posições das árvores principais, para ferramentas e testes que precisam editar a imagem.
/// Root locations of core Btrfs trees, used by tests that simulate COW deletions.
pub struct TreeLocations {
pub nodesize: u64,
/// Endereço físico (absoluto na origem) do primeiro nó de cada árvore.
/// Physical byte offset (in the source) of each tree root node.
pub root_tree: u64,
pub fs_tree: u64,
pub extent_tree: u64,
/// Endereços lógicos correspondentes.
/// Corresponding logical `bytenr` addresses.
pub root_tree_logical: u64,
pub fs_tree_logical: u64,
pub extent_tree_logical: u64,
/// Converte endereço lógico em físico.
/// Translates a logical `bytenr` to a physical source offset.
pub chunks: Vec<(u64, u64, u64)>,
}
+5 -5
View File
@@ -1,4 +1,4 @@
//! exFAT: conjuntos de entradas apagados (0x05/0x40/0x41) e bitmap de alocação.
//! exFAT: deleted directory entry sets (`0x05`/`0x40`/`0x41`) and allocation bitmap.
use super::{
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
@@ -92,7 +92,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
bitmap: vec![],
};
// Diretório raiz: procura a entrada do bitmap (0x81).
// Root directory: locate the allocation bitmap entry (0x81).
let root_chain = vol.chain(vol.root);
let root_data = vol.read(&root_chain, 64 << 20);
let mut bm: Option<(u32, u64)> = None;
@@ -115,7 +115,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
.push("exFAT: bitmap de alocação não encontrado; confiança será menor.".into());
}
// Extents vivos: metadados (antes do heap) + clusters alocados no bitmap.
// Live extents: metadata region (before cluster heap) + clusters marked used in the bitmap.
let mut live = vec![Extent::new(base, heap_off - base)];
let mut run_start: Option<u32> = None;
for c in 2..clusters + 2 {
@@ -163,7 +163,7 @@ fn walk(
break;
}
let ty = e[0];
// 0x85 = arquivo/pasta vivo; 0x05 = apagado.
// 0x85 = live file/dir entry; 0x05 = deleted file/dir entry.
if ty != 0x85 && ty != 0x05 {
i += 1;
continue;
@@ -214,7 +214,7 @@ fn walk(
}
let need = size.div_ceil(vol.cbytes) as u32;
let cl: Vec<u32> = if no_chain || deleted {
// Apagados: a cadeia na FAT pode ter sido preservada; senão presume contíguo.
// Deleted files: follow the FAT chain if preserved, otherwise assume contiguous clusters.
let chain = if !no_chain { vol.chain(first) } else { vec![] };
if chain.len() as u32 >= need.max(1) {
chain
+35 -35
View File
@@ -1,10 +1,10 @@
//! ext2/3/4: arquivos apagados com nome, por três caminhos que se completam:
//! ext2/3/4: recovers deleted files with names via three complementary techniques:
//!
//! 1. inodes com `dtime` cujo mapa de extents ainda tem sobras legíveis;
//! 2. cópias antigas do inode guardadas no diário (jbd2), de antes da exclusão;
//! 3. entradas de diretório "escondidas" na folga do `rec_len` (dão o nome).
//! 1. deleted inodes (`dtime != 0`) whose extent tree or block pointers still have remnants;
//! 2. pre-deletion inode copies preserved in the jbd2 journal;
//! 3. unlinked directory entries hidden inside `rec_len` slack space (recovering filenames).
//!
//! Também devolve o mapa de blocos livres, para o carving varrer só o que está livre.
//! Also returns the free-block map so file carving only scans unallocated blocks.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::signatures;
@@ -103,7 +103,7 @@ struct Ext<'a> {
journal_inum: u32,
}
/// Sequência de (bloco lógico, bloco físico, quantidade, não-inicializado).
/// Sequence of `(logical_block, physical_block, block_count, is_uninitialized)`.
type ExtentList = Vec<(u64, u64, u64, bool)>;
impl<'a> Ext<'a> {
@@ -201,7 +201,7 @@ impl<'a> Ext<'a> {
Inode::parse(&self.inode_bytes(ino)?)
}
/// Extents de uma árvore ext4 (raiz em `node`, 60 bytes ou um bloco).
/// Collects extents from an ext4 extent tree (`node` is the 60-byte `i_block` root or a block).
fn walk_extent_node(
&self,
node: &[u8],
@@ -248,7 +248,7 @@ impl<'a> Ext<'a> {
true
}
/// Blocos de um inode ext2/3 (mapa de blocos clássico).
/// Collects data blocks from an ext2/3 indirect block map.
fn block_map(&self, blk: &[u8; 60], max_blocks: u64) -> ExtentList {
let mut v: Vec<u64> = vec![];
let ptr = |i: usize| le32(blk, i * 4) as u64;
@@ -308,7 +308,7 @@ impl<'a> Ext<'a> {
out
}
/// Extents do arquivo (ordenados pelo bloco lógico), cortados em `size` bytes.
/// Returns file byte extents (ordered by logical block) truncated to `size` bytes.
fn file_extents(&self, ino: &Inode, size: u64) -> Vec<Extent> {
let need = size.div_ceil(self.bs);
let mut list: ExtentList = vec![];
@@ -351,7 +351,7 @@ impl<'a> Ext<'a> {
out
}
/// Mapa de blocos livres (bitmap), em extents absolutos.
/// Free-block map (from block bitmaps) as absolute byte extents.
fn free_map(&self, ctx: &Ctx) -> (Vec<Extent>, Vec<bool>) {
let mut free: Vec<Extent> = vec![];
let mut allocated_all: Vec<bool> = vec![];
@@ -418,7 +418,7 @@ struct DirEnt {
live: bool,
}
/// Lê as entradas de um bloco de diretório, inclusive as escondidas na folga.
/// Parses directory entries in a directory block, including unlinked entries in `rec_len` slack.
fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
let mut off = 0usize;
while off + 8 <= b.len() {
@@ -437,11 +437,11 @@ fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
live: true,
});
}
// Folga: entradas apagadas ficam coladas depois do fim "real" desta.
// Slack space: unlinked entries remain tucked right after the active entry's true length.
let mut s = off + if ino != 0 || nlen > 0 { real } else { 8 };
let end = off + rec;
if ino == 0 {
s = end; // entrada nula: sem folga confiável
s = end; // zeroed entry: no reliable slack to scan
}
while s + 8 <= end {
let dino = le32(b, s) as u64;
@@ -472,9 +472,9 @@ fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec<DirEnt>) {
}
struct Names {
/// inode -> (pasta, nome) de entradas vivas.
/// `inode -> (parent_dir, name)` from live directory entries.
live: HashMap<u64, (String, String)>,
/// inode -> (pasta, nome) de entradas apagadas (mais recente por varredura).
/// `inode -> (parent_dir, name)` from unlinked slack directory entries.
dead: HashMap<u64, (String, String)>,
}
@@ -531,9 +531,9 @@ fn walk_dirs(ext: &Ext, ctx: &Ctx) -> Names {
names
}
// ---------------------------------------------------------------- diário
// ---------------------------------------------------------------- journal
/// Cópias de inodes guardadas no diário: inode -> lista (ordem do diário) de registros brutos.
/// Inode copies found in the jbd2 journal: `inode_num -> Vec<raw_inode_bytes>` in journal order.
fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
let mut out: HashMap<u64, Vec<Vec<u8>>> = HashMap::new();
if ext.journal_inum == 0 {
@@ -543,7 +543,7 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
return out;
};
let jext = ext.file_extents(&jino, jino.size.min(1 << 30));
// Blocos físicos do diário em ordem lógica.
// Physical blocks of the journal in logical sequence.
let mut jblocks: Vec<u64> = vec![];
for e in &jext {
if e.sparse {
@@ -576,9 +576,9 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
8 + if is64 { 4 } else { 0 }
};
let have_csum = csum_v2 || csum_v3;
// Tabela de inodes: faixa de blocos de cada grupo.
// Inode table block ranges for each block group.
let itable_blocks = (ext.ipg * ext.inode_size).div_ceil(ext.bs);
let mut tables: Vec<(u64, u64, u64)> = vec![]; // (início, fim, primeiro inode)
let mut tables: Vec<(u64, u64, u64)> = vec![]; // (start_block, end_block, first_inode_num)
for (g, gr) in ext.groups.iter().enumerate() {
tables.push((
gr.inode_table,
@@ -588,9 +588,9 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
}
let n = jblocks.len();
let first = (be32(&jsb, 20) as usize).clamp(1, n);
let mut seqs: Vec<(u32, usize)> = vec![]; // (sequência, índice do descritor)
let mut seqs: Vec<(u32, usize)> = vec![]; // (commit_sequence, descriptor_index)
let mut idx = first;
// Varredura física: qualquer bloco que seja descritor, em ordem do diário.
// Scan journal blocks for descriptor blocks in sequence order.
while idx < n {
if idx % 4096 == 4095 && (ctx.cancelled)() {
break;
@@ -664,7 +664,7 @@ fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap<u64, Vec<Vec<u8>>> {
out
}
// -------------------------------------------------------------- varredura
// -------------------------------------------------------------- scan
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let mut res = VolumeScan::default();
@@ -680,7 +680,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let mut emitted: HashSet<u64> = HashSet::new();
let mut seen_extents: HashSet<(u64, u64)> = HashSet::new();
// 1) Inodes apagados na tabela atual.
// 1) Deleted inodes in the current inode table.
let total = ext.groups.len() as u64;
for (g, gr) in ext.groups.iter().enumerate() {
if g % 16 == 15 && (ctx.cancelled)() {
@@ -711,7 +711,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
if !deleted || !ino.is_reg() {
continue;
}
// Melhor fonte: a cópia do diário anterior à exclusão; senão o que sobrou no inode.
// Best source: pre-deletion copy from the journal; fallback to remnants in the current inode.
let mut cand: Option<(Inode, &str)> = None;
if let Some(snaps) = journal.get(&ino_no) {
for raw in snaps.iter().rev() {
@@ -749,13 +749,13 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
}
}
// 2) Inodes que só existem no diário (liberados e já reaproveitados/zerados).
// 2) Inodes only preserved in the journal (already zeroed in the main inode table).
for (ino_no, snaps) in &journal {
if emitted.contains(ino_no) || names.live.contains_key(ino_no) || *ino_no < 11 {
continue;
}
let cur = ext.read_inode(*ino_no);
// Se o inode atual está vivo e é outro arquivo, não mexe.
// Skip if the inode is currently live in the filesystem.
if cur
.as_ref()
.map(|c| c.dtime == 0 && c.links > 0 && c.mode != 0)
@@ -805,7 +805,7 @@ fn emit_inode(
if ino.flags & EXT4_EXTENTS_FL != 0 {
let mut list: ExtentList = vec![];
if !ext.walk_extent_node(&ino.block, 5, &mut list, true) {
// Sobras de extent em inode truncado: cabeçalho zerado, registros ainda lá.
// Extent remnants in a truncated inode: header count was zeroed, but extent entries remain.
list = remnant_extents(ext, &ino.block);
}
if list.is_empty() {
@@ -834,7 +834,7 @@ fn emit_inode(
if !seen.insert((first.0, first.1)) {
return false;
}
// Quantos blocos de dados continuam livres? Reaproveitados => baixa confiança.
// Check how many data blocks are still unallocated; reallocated blocks lower confidence.
let mut reused = 0u64;
let mut total_blocks = 0u64;
for e in extents.iter().filter(|e| !e.sparse) {
@@ -859,7 +859,7 @@ fn emit_inode(
));
}
if estimated {
// Tamanho desconhecido: tenta achar o fim real pelo conteúdo.
// Unknown exact size (zeroed on deletion): try to refine size via format structure.
if let Some(h) = extents
.iter()
.find(|e| !e.sparse)
@@ -932,7 +932,7 @@ fn layout_from_list(ext: &Ext, list: &ExtentList, size: u64) -> Vec<Extent> {
out
}
/// Lê registros de extent deixados em i_block depois que o cabeçalho foi zerado.
/// Reads leftover extent records from `i_block` after `eh_entries` was zeroed on deletion.
fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList {
let mut out: ExtentList = vec![];
if le16(block, 0) != EXT_MAGIC || le16(block, 6) != 0 {
@@ -958,8 +958,8 @@ fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList {
out
}
/// Quão coerente é a estrutura do volume: fração das entradas de diretório cujo inode confere
/// com o tipo declarado. Serve para escolher parâmetros de RAID sem metadados.
/// Measures filesystem structural coherence: fraction of directory entries whose target inode
/// matches the declared file type. Used to score candidate metadata-less RAID parameters.
pub fn consistency_score(src: &dyn Source, base: u64) -> Option<f64> {
let ext = Ext::open(src, base)?;
let mut queue = vec![2u64];
@@ -988,8 +988,8 @@ pub fn consistency_score(src: &dyn Source, base: u64) -> Option<f64> {
o += ext.bs;
}
}
// A entrada "." de um diretório aponta para ele mesmo: só confere se inode e bloco
// vieram do lugar certo.
// A directory's "." entry points to itself: only matches if both the inode table and
// directory data block were mapped from the right RAID stripe.
dots_total += 1;
if ents.iter().any(|e| e.live && e.name == "." && e.ino == d) {
dots_ok += 1;
+9 -9
View File
@@ -1,5 +1,5 @@
//! FAT12/16/32: entradas de diretório apagadas (inclusive nomes longos) e
//! reconstrução dos clusters pela regra "próximos clusters livres".
//! FAT12/16/32: deleted directory entries (including VFAT Long File Names) and
//! cluster chain reconstruction using contiguous free clusters.
use super::{make_found, merge_extents, sanitize_name, utf16le, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
@@ -118,7 +118,7 @@ impl<'a> Fat<'a> {
}
}
/// Cadeia de clusters viva a partir de `start` (com limite contra ciclos).
/// Follows a live FAT cluster chain from `start` (bounded against cycles).
fn chain(&self, start: u32) -> Vec<u32> {
let mut out = vec![];
let mut c = start;
@@ -172,7 +172,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, kind: FsKind) -> VolumeScan {
.push("FAT: setor de boot ilegível; usando apenas assinatura.".into());
return res;
};
// Estruturas do sistema (reservado + FATs + raiz fixa) são "vivas".
// Filesystem structures (reserved sectors + FATs + fixed root dir) count as allocated.
let mut live = vec![Extent::new(base, fat.data_off - base)];
let mut w = Walk {
fat: &fat,
@@ -257,7 +257,7 @@ impl Walk<'_, '_, '_, '_> {
continue;
}
if deleted || in_deleted {
// Diretório apagado: lê clusters contíguos livres enquanto parecerem entradas.
// Deleted directory: read contiguous free clusters while they look like directory entries.
let cl = self.contiguous_free(start, 16);
let data = self
.fat
@@ -359,7 +359,7 @@ impl Walk<'_, '_, '_, '_> {
}
}
/// Corta a lista de extents para exatamente `size` bytes.
/// Truncates an extent list to cover at most `size` bytes.
pub fn trim_extents(mut ex: Vec<Extent>, size: u64) -> Vec<Extent> {
let mut left = size;
let mut out = vec![];
@@ -416,15 +416,15 @@ fn long_name(parts: &[(u8, [u16; 13])], short: &[u8; 11]) -> Option<String> {
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.
// Deleted LFN entries preserve the checksum of the original 8.3 short name,
// whereas the short entry's first byte became 0xE5; check both variants.
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.
// Deleted 8.3 name lost its first byte: accept LFN checksum if any valid first byte matches.
let mut hit = false;
for c in 0u8..=255 {
let mut s2 = *short;
+20 -20
View File
@@ -1,8 +1,8 @@
//! HFS+ / HFSX (inclusive volumes embutidos num wrapper HFS clássico).
//! HFS+ / HFSX (including volumes embedded inside a classic HFS wrapper).
//!
//! Lê o catálogo (árvore B+) para nomes e caminhos, o bitmap de alocação para o mapa de
//! espaço livre e procura arquivos apagados em três lugares: sobras dentro dos nós folha do
//! catálogo, cópias antigas de nós no diário (journal) e registros de arquivo "soltos".
//! Reads the catalog B-tree for names and paths, the allocation bitmap for free-space
//! mapping, and searches for deleted files in three places: slack space inside catalog leaf
//! nodes, older leaf node copies in the journal, and orphaned file records.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::source::{read_vec, Source};
@@ -49,7 +49,7 @@ fn parse_fork(b: &[u8]) -> Fork {
f
}
/// Posição do volume HFS+ dentro da partição (wrapper HFS clássico) ou `None` se não for HFS+.
/// Byte offset of the HFS+ volume inside the partition (handles HFS wrapper), or `None`.
pub fn locate(src: &dyn Source, base: u64) -> Option<u64> {
let h = read_vec(src, base + 1024, 512).ok()?;
if h.len() < 512 {
@@ -106,7 +106,7 @@ impl<'a> Vol<'a> {
self.base + b * self.bs
}
/// Todas as extensões de um fork (inclusive as do arquivo de overflow).
/// Returns all extents of a fork (including overflow extents from the extents B-tree).
fn fork_extents(&self, f: &Fork, file_id: u32, fork_type: u8) -> Vec<Extent> {
let mut ex: Vec<(u32, u32)> = f.extents.clone();
let mut have: u64 = ex.iter().map(|e| e.1 as u64).sum();
@@ -124,7 +124,7 @@ impl<'a> Vol<'a> {
.collect()
}
/// Extensões além das oito iniciais, procuradas no arquivo de overflow (árvore B+).
/// Looks up extents beyond the first eight in the extents overflow B-tree.
fn overflow_extents(&self, file_id: u32, fork_type: u8, mut start: u32) -> Vec<(u32, u32)> {
let mut out = vec![];
let node_size = match self.tree_node_size(&self.extents_file) {
@@ -140,7 +140,7 @@ impl<'a> Vol<'a> {
continue;
}
for r in records(&node) {
// chave: keyLength u16, forkType u8, pad u8, fileID u32, startBlock u32
// key: keyLength u16, forkType u8, pad u8, fileID u32, startBlock u32
if r.len() < 12 + 64 || be16(r, 0) != 10 {
continue;
}
@@ -170,7 +170,7 @@ impl<'a> Vol<'a> {
self.read_fork_bytes(f, n as u64 * ns as u64, ns)
}
/// Lê bytes de um fork que tem só extensões iniciais ou overflow já resolvido (para árvores).
/// Reads bytes from a fork using its resolved extent list (used for system B-trees).
fn read_fork_bytes(&self, f: &Fork, off: u64, len: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(len);
let mut pos = 0u64;
@@ -190,7 +190,7 @@ impl<'a> Vol<'a> {
}
}
/// Registros de um nó B+ (fatias, da chave em diante).
/// Returns the record slices of a B-tree node (each starting at its key).
fn records(node: &[u8]) -> Vec<&[u8]> {
let n = be16(node, 10) as usize;
let mut offs = vec![];
@@ -233,7 +233,7 @@ fn date(d: u32) -> Option<i64> {
(d != 0).then(|| d as i64 - HFS_EPOCH)
}
/// Decodifica um registro do catálogo (chave + dados). `max_id` descarta lixo.
/// Decodes a catalog record (key + data). `max_id` filters out garbage CNIDs.
fn decode_record(
r: &[u8],
max_id: u32,
@@ -265,7 +265,7 @@ fn decode_record(
}
}
3 if rec.len() >= 10 => {
// thread de pasta: chave = (id da pasta, ""); dados = (pai, nome)
// folder thread record: key = (folder_id, ""); data = (parent_id, folder_name)
let pn = be16(rec, 8) as usize;
if pn <= 255 {
threads.entry(parent).or_insert((
@@ -304,7 +304,7 @@ fn decode_record(
}
}
/// Procura registros de arquivo nas sobras de um nó (entre os dados e a tabela de offsets).
/// Carves deleted catalog file records from B-tree node slack space (between active records and offset table).
fn scan_slack(
node: &[u8],
max_id: u32,
@@ -346,7 +346,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
return res;
};
// Mapa de espaço livre.
// Free-space map from the allocation file.
if let Some(bm) = vol.read_fork_all(&vol.alloc, (vol.total / 8 + 1) as usize) {
let mut free = vec![];
let mut run: Option<u64> = None;
@@ -409,7 +409,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
n = be32(&node, 0);
}
// Diário: cópias antigas de nós folha do catálogo.
// Journal: older copies of catalog leaf nodes.
if vol.journal_info != 0 {
scan_journal(
&vol,
@@ -422,7 +422,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
);
}
// Caminho de cada pasta a partir dos threads.
// Reconstruct folder paths from folder thread records.
let path_of = |parent: u32| -> String {
let mut parts = vec![];
let mut cur = parent;
@@ -462,9 +462,9 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
if !seen.insert((f.id, ex[0].offset)) {
continue;
}
// Mantém só os bytes do arquivo.
// Trim extents to the logical file size.
let extents = super::fat::trim_extents(ex, f.data.size);
// Blocos já reaproveitados?
// Check whether any blocks have already been reallocated.
let reused = extents.iter().filter(|e| !covered_by(&free_set, e)).count();
let (conf, note) = if reused == 0 {
(
@@ -523,7 +523,7 @@ impl Vol<'_> {
}
}
/// Procura nós folha do catálogo nas transações guardadas no diário.
/// Scans the HFS+ journal buffer for historical catalog leaf nodes.
fn scan_journal(
vol: &Vol,
ns: usize,
@@ -556,7 +556,7 @@ fn scan_journal(
let mut i = 0;
while i + ns <= buf.len().min(n + ns) && i < n {
let nd = &buf[i..i + ns];
// nó folha do catálogo: kind = -1, altura 1, registros plausíveis
// catalog leaf node: kind = -1, height = 1, plausible record offsets
if nd[8] == 0xFF && nd[9] == 1 && be16(nd, 10) > 0 && (be16(nd, 10) as usize) < ns / 8 {
let table = ns - 2 * (be16(nd, 10) as usize + 1);
if (be16(nd, ns - 2) as usize) == 14
+13 -13
View File
@@ -1,8 +1,8 @@
//! Leitores de metadados de sistemas de arquivos (somente leitura).
//! Read-only filesystem metadata readers.
//!
//! 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.
//! Each reader walks volume metadata structures looking for deleted files,
//! returns currently allocated ("live") extents so file carving can skip them,
//! and, when available, returns the exact free-space map.
pub mod apfs;
pub mod btrfs;
@@ -18,12 +18,12 @@ 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.
/// Context shared across all filesystem metadata readers.
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.
/// Reports `(done, total)` progress within the current reader phase.
pub progress: &'a (dyn Fn(u64, u64) + Sync),
pub emit: &'a mut dyn FnMut(Found),
}
@@ -34,12 +34,12 @@ impl Ctx<'_> {
}
}
/// Resultado de varrer um volume.
/// Output of scanning a volume's metadata.
#[derive(Default)]
pub struct VolumeScan {
/// Extents (absolutos) ocupados por arquivos existentes e metadados.
/// Absolute byte extents occupied by live files and filesystem metadata.
pub live: Vec<Extent>,
/// Se o leitor conhece o mapa de espaço livre (ext), os trechos livres (absolutos).
/// Absolute free byte extents when the filesystem provides an explicit allocation bitmap.
pub free: Option<Vec<Extent>>,
pub warnings: Vec<String>,
}
@@ -57,7 +57,7 @@ pub struct NewFound<'a> {
pub note: Option<&'a str>,
}
/// Monta um `Found`, ajustando a confiança pelo conteúdo real no disco.
/// Builds a `Found` record, adjusting confidence based on actual on-disk header bytes.
pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
let ext = n
.name
@@ -116,7 +116,7 @@ pub fn make_found(ctx: &Ctx, n: NewFound) -> Found {
}
}
/// Ordena e funde extents adjacentes/sobrepostos.
/// Sorts and merges adjacent or overlapping byte extents.
pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
v.retain(|e| e.len > 0);
v.sort_by_key(|e| e.offset);
@@ -135,7 +135,7 @@ pub fn merge_extents(mut v: Vec<Extent>) -> Vec<Extent> {
out
}
/// Há sobreposição entre [off, off+len) e algum extent de `sorted` (já mesclado)?
/// Returns `true` if `[off, off + len)` overlaps any extent in `sorted` (must be pre-merged).
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)
@@ -159,7 +159,7 @@ pub fn dos_to_unix(date: u16, time: u16) -> Option<i64> {
Some(days_from_civil(year, month, day) * 86400 + h * 3600 + m * 60 + s)
}
/// Dias desde 1970-01-01 (algoritmo de Howard Hinnant).
/// Days since 1970-01-01 UTC (Howard Hinnant civil calendar algorithm).
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;
+10 -10
View File
@@ -1,5 +1,5 @@
//! NTFS: percorre a MFT atrás de registros apagados, resolve caminhos e
//! reconstrói os data runs. Coleta também os extents vivos.
//! NTFS: walks the MFT for deleted file records, reconstructs folder paths,
//! and decodes data runs. Also collects live allocated extents.
use super::{
make_found, merge_extents, overlaps, sanitize_name, utf16le, Ctx, NewFound, VolumeScan,
@@ -9,10 +9,10 @@ use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::HashMap;
struct Run {
/// Cluster lógico inicial no arquivo (VCN).
/// Starting Virtual Cluster Number (VCN) within the file.
vcn: u64,
len: u64,
/// None = esparso.
/// `None` = sparse run.
lcn: Option<u64>,
}
@@ -61,7 +61,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
return res;
}
// Registro 0 ($MFT) define onde está toda a MFT.
// MFT record 0 ($MFT) defines the data runs covering the entire MFT.
let mut mft_runs: Option<(Vec<Run>, u64)> = None;
for lcn in [mft_lcn, mirr_lcn] {
if let Some(mut r) = read_vec(ctx.src, base + lcn * cl, rec_size as usize)
@@ -96,7 +96,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
let mut dirs: HashMap<u64, (u64, String)> = HashMap::new();
let mut deleted: Vec<(u64, Rec)> = vec![];
// Lê a MFT em blocos grandes seguindo os runs.
// Read the MFT in large chunks across its data runs.
let chunk_recs = (1 << 20) / rec_size.max(1);
let mut idx = 0u64;
let mut buf: Vec<u8>;
@@ -161,7 +161,7 @@ pub fn scan(ctx: &mut Ctx, base: u64) -> VolumeScan {
unsupported,
} => {
if unsupported {
continue; // comprimido/cifrado (EFS): fora do escopo da v1
continue; // compressed/encrypted (EFS) attributes are skipped
}
let mut ex = vec![];
for r in &runs {
@@ -291,7 +291,7 @@ 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).
/// Parses an MFT `FILE` record (applies USA fixups in place).
fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
if r.len() < 48 || &r[0..4] != b"FILE" {
return None;
@@ -308,7 +308,7 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
break;
}
if r[end - 2..end] != usn {
return None; // registro rasgado
return None; // torn MFT record (fixup mismatch)
}
r[end - 2] = r[usa_off + i * 2];
r[end - 1] = r[usa_off + i * 2 + 1];
@@ -316,7 +316,7 @@ fn parse_record(r: &mut [u8], bps: u64, _base: u64, _cl: u64) -> Option<Rec> {
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)
return None; // extension MFT record (handled via $ATTRIBUTE_LIST, skipped as standalone)
}
let mut rec = Rec {
in_use: flags & 1 != 0,
+26 -26
View File
@@ -1,7 +1,7 @@
//! XFS (v4 e v5): mapa de espaço livre pela árvore de blocos livres de cada grupo de alocação e
//! arquivos apagados. Ao liberar um inode, o XFS zera o modo e o tamanho mas não apaga a lista de
//! extents que ficou dentro do inode; e a entrada de diretório liberada perde só os 4 primeiros
//! bytes (o número do inode), preservando o nome. Juntamos as duas sobras.
//! XFS (v4 and v5): free-space map from each allocation group's free-block B+tree and deleted
//! file recovery. When freeing an inode, XFS zeroes mode and size if not fully wiped, leaving
//! inline extent records in the inode fork; freed directory entries only overwrite the first
//! 4 bytes of the 8-byte inode number, preserving the low 32 bits and the filename.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::signatures;
@@ -88,7 +88,7 @@ impl<'a> Xfs<'a> {
self.base + agno * self.agblocks * self.bs
}
/// Endereço físico de um bloco "fsblock" (agno << agblklog | agbno).
/// Converts an XFS `fsblock` (`agno << agblklog | agbno`) to a physical byte offset.
fn fsb_off(&self, fsb: u64) -> Option<u64> {
let (agno, agbno) = (fsb >> self.agblklog, fsb & ((1 << self.agblklog) - 1));
(agno < self.agcount && agbno < self.agblocks).then(|| self.ag_off(agno) + agbno * self.bs)
@@ -116,7 +116,7 @@ impl<'a> Xfs<'a> {
}
}
/// Extents de um inode com a lista no próprio inode (formato 2) ou em árvore (formato 3).
/// Reads extents from an inode data fork (format 2 inline extent list or format 3 B+tree).
fn extents_of(&self, ino: &[u8], max_blocks: u64) -> Vec<(u64, u64, u64)> {
let fmt = ino[5];
let start = self.core_size(ino);
@@ -135,7 +135,7 @@ impl<'a> Xfs<'a> {
}
}
3 => {
// raiz da árvore de extents no inode: nível, nregistros, chaves e ponteiros
// B+tree root inside the inode fork: level, numrecs, keys, and block pointers
let level = be16(lit, 0) as usize;
let nrecs = be16(lit, 2) as usize;
let maxrecs = (lit.len() - 4) / 16;
@@ -180,7 +180,7 @@ impl<'a> Xfs<'a> {
}
}
/// Registro de extent empacotado em 128 bits: flag(1) | início no arquivo(54) | bloco(52) | quantidade(21).
/// Unpacks a 128-bit XFS extent record: `flag(1) | startoff(54) | startblock(52) | blockcount(21)`.
fn unpack_extent(r: &[u8]) -> (u64, u64, u64) {
let hi = u64::from_be_bytes(r[0..8].try_into().unwrap());
let lo = u64::from_be_bytes(r[8..16].try_into().unwrap());
@@ -190,7 +190,7 @@ fn unpack_extent(r: &[u8]) -> (u64, u64, u64) {
(startoff, startblock, count)
}
/// Percorre as folhas de uma árvore B+ curta, entregando cada registro de 16 (inobt) ou 8 (bnobt) bytes.
/// Walks the leaves of an AG B+tree, yielding each 16-byte (`inobt`) or 8-byte (`bnobt`) record.
fn walk_short_btree(
x: &Xfs,
agno: u64,
@@ -201,7 +201,7 @@ fn walk_short_btree(
f: &mut dyn FnMut(&[u8]),
) {
let mut cur = root;
// desce pela esquerda até a folha
// descend leftmost branch down to the leaf level
for lvl in (1..levels).rev() {
let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else {
return;
@@ -241,10 +241,10 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
return res;
};
// Espaço livre por grupo e inodes livres.
// Collect free-space ranges and free inode records across all allocation groups.
let mut free: Vec<Extent> = vec![];
let mut free_set: Vec<(u64, u64)> = vec![]; // (fsblock inicial, quantidade) para checar blocos
let mut chunks: Vec<(u64, u64, u64)> = vec![]; // (agno, startino, máscara livre)
let mut free_set: Vec<(u64, u64)> = vec![]; // (start_fsblock, block_count) for free-block checks
let mut chunks: Vec<(u64, u64, u64)> = vec![]; // (agno, startino, free_bitmask)
for agno in 0..x.agcount {
if (ctx.cancelled)() {
return res;
@@ -278,10 +278,10 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
.is_some_and(|&(s, c)| s <= fsb && s + c >= fsb + count)
};
// Nomes: entradas de diretório liberadas (guardam o nome e os 4 bytes baixos do inode).
// Names: freed directory entries (preserve filename and low 32 bits of the inode number).
let (live_dirs, stale_names) = collect_names(&x, ctx);
// Inodes livres com a lista de extents ainda dentro do inode.
// Free inodes that still contain leftover extent records in their data fork.
let max_blocks = x.dblocks;
let mut seen_first: HashSet<u64> = HashSet::new();
for (agno, startino, mask) in &chunks {
@@ -290,7 +290,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
}
for i in 0..64u64 {
if mask >> i & 1 == 0 {
continue; // em uso
continue; // allocated inode
}
let ino = (agno << (x.agblklog + x.inopblog)) | (startino + i);
let Some(raw) = x.read_ino(ino) else { continue };
@@ -298,13 +298,13 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
if mode != 0 && be32(&raw, 16) != 0 {
continue;
}
// Só inodes que foram arquivos: o formato de extents continua gravado e há sobras.
// Check freed inodes that still have non-zero extent records in the data fork.
if raw[5] != 2 {
continue;
}
let start = x.core_size(&raw);
let lit = &raw[start..];
// lê registros plausíveis em sequência
// read consecutive plausible extent records
let mut ext: Vec<(u64, u64, u64)> = vec![];
let mut next_off = 0u64;
for k in 0..lit.len() / 16 {
@@ -346,7 +346,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
} else {
"blocos do arquivo já reaproveitados".to_string()
};
// tamanho exato: tenta achar o fim real pelo conteúdo
// exact size was zeroed on deletion: refine size from file structure when possible
let mut est = true;
if let Some(h) = read_vec(ctx.src, extents[0].offset, 128)
.ok()
@@ -394,7 +394,7 @@ pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
res
}
/// Percorre os diretórios vivos; devolve (caminho de cada diretório por inode, nome liberado por inode baixo).
/// Walks live directories; returns `(dir_paths_by_inode, freed_names_by_low_inode_u32)`.
fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64, String)>) {
let mut paths: HashMap<u64, String> = HashMap::new();
let mut stale: HashMap<u64, (u64, String)> = HashMap::new();
@@ -414,7 +414,7 @@ fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64
let start = x.core_size(&raw);
match raw[5] {
1 => {
// pasta curta guardada no inode
// shortform directory stored inline inside the inode
let lit = &raw[start..];
let count = lit[0] as usize;
let i8 = lit[1] != 0;
@@ -443,7 +443,7 @@ fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64
let ex = x.extents_of(&raw, 1 << 20);
let _ = size;
for (off, fsb, cnt) in ex {
// blocos de dados de diretório ficam antes do offset de 32 GiB (depois vêm folhas e índice)
// directory data blocks live below the 32 GiB logical offset (leaf/freeindex blocks live above)
if off * x.bs >= 32u64 << 30 {
continue;
}
@@ -479,7 +479,7 @@ fn parse_dir_block(
_ => return,
};
let is_block = magic == b"XD2B" || magic == b"XDB3";
// em blocos únicos, o final tem a tabela de folhas
// single-block directory: trim the trailing leaf/tail table
let end = if is_block {
let cnt = be32(b, b.len() - 8) as usize;
b.len().saturating_sub(8 + cnt * 8)
@@ -493,7 +493,7 @@ fn parse_dir_block(
if len < 8 || p + len > end {
break;
}
// entrada liberada: sobraram (inode baixo, nome); outras ainda podem estar dentro da região
// freed entry region: extract `(low_inode_u32, name)` and any subsequent unlinked entries inside the region
scan_free_region(x, &b[p..p + len], dino, stale);
p += len;
continue;
@@ -517,7 +517,7 @@ fn parse_dir_block(
}
fn scan_free_region(x: &Xfs, r: &[u8], dir_ino: u64, stale: &mut HashMap<u64, (u64, String)>) {
// 1) a primeira entrada perdeu só os 4 primeiros bytes do número do inode
// 1) the first unlinked entry at the start of the free tag lost only the upper 4 bytes of its inode number
let try_at = |p: usize, low: u64, stale: &mut HashMap<u64, (u64, String)>| -> Option<usize> {
let nl = *r.get(p + 8)? as usize;
let name = r.get(p + 9..p + 9 + nl)?;
@@ -532,7 +532,7 @@ fn scan_free_region(x: &Xfs, r: &[u8], dir_ino: u64, stale: &mut HashMap<u64, (u
if r.len() >= 16 {
let low = be32(r, 4) as u64;
if let Some(sz) = try_at(0, low, stale) {
// entradas seguintes dentro da mesma região, com o inode inteiro
// subsequent coalesced entries inside the same free region still have their full 8-byte inode number
let mut p = sz;
while p + 16 <= r.len() {
let ino = be64(r, p);
+1 -1
View File
@@ -1,4 +1,4 @@
//! Hashes de integridade usados em perícia: MD5, SHA-1 e SHA-256 numa única passada.
//! Forensic integrity hashes: computes MD5, SHA-1, and SHA-256 in a single pass.
use md5::Md5;
use serde::{Deserialize, Serialize};
+31 -31
View File
@@ -1,6 +1,6 @@
//! Saúde do disco antes de varrer: lê o SMART (via `smartctl`, quando instalado) e
//! os contadores de erro do kernel. Um disco com defeito deve ser copiado para uma
//! imagem primeiro: cada leitura extra pode piorar o estado dele.
//! Pre-scan disk health assessment: reads SMART data (via `smartctl`, when installed) and
//! kernel I/O error counters. A failing drive should be imaged first, as every additional
//! read pass can accelerate media degradation.
use serde::Serialize;
use serde_json::Value;
@@ -8,43 +8,43 @@ use std::process::{Command, Stdio};
#[derive(Debug, Clone, Serialize, Default)]
pub struct Health {
/// Foi possível avaliar? (imagens de disco e dispositivos sem SMART ficam sem avaliação)
/// Whether SMART assessment was possible (disk images and unsupported bridges return `false`).
pub available: bool,
/// "ok", "warning", "failing" ou "unknown".
/// Overall status: `"ok"`, `"warning"`, `"failing"`, or `"unknown"`.
pub status: String,
pub summary: String,
pub details: Vec<(String, String)>,
pub advice: Vec<String>,
/// Ficha do dispositivo, no estilo do CrystalDiskInfo (modelo, firmware, interface...).
/// CrystalDiskInfo-style device summary (model, firmware, interface, etc.).
pub info: Vec<(String, String)>,
/// Saúde estimada em % (SSD: vida útil restante). `None` em HD, que não informa isso.
/// Estimated health percentage (SSD remaining life). `None` for HDDs that do not report it.
pub health_pct: Option<u8>,
pub temperature_c: Option<i64>,
pub power_on_hours: Option<i64>,
pub power_cycles: Option<i64>,
/// Total lido e gravado pelo computador, em bytes, quando o disco informa.
/// Total host bytes read and written, when reported by the drive.
pub host_reads: Option<u64>,
pub host_writes: Option<u64>,
/// Tabela completa de atributos SMART.
/// Full table of SMART attributes.
pub attributes: Vec<Attr>,
/// Caminho avaliado de fato (o disco inteiro, quando `path` é uma partição).
/// Actual device path queried (the containing whole disk when `path` is a partition).
pub disk: String,
/// Partição pedida, quando a avaliação foi feita no disco que a contém.
/// Original partition path when the query was redirected to its parent disk.
pub partition: Option<String>,
}
/// Uma linha da tabela de atributos SMART.
/// A single row in the SMART attribute table.
#[derive(Debug, Clone, Serialize, Default)]
pub struct Attr {
/// Identificador em hexadecimal, como o CrystalDiskInfo mostra ("05", "C5").
/// Two-digit hex attribute ID as displayed by CrystalDiskInfo (`"05"`, `"C5"`).
pub id: String,
pub name: String,
/// Valores normalizados (ATA): atual, pior já visto e limite de falha.
/// Normalized ATA values: current, worst, and failure threshold.
pub value: Option<i64>,
pub worst: Option<i64>,
pub thresh: Option<i64>,
pub raw: String,
/// "ok", "warning" ou "failing".
/// Attribute status: `"ok"`, `"warning"`, or `"failing"`.
pub status: String,
}
@@ -61,7 +61,7 @@ impl Health {
const IMAGE_ADVICE: &str = "Crie a imagem do disco primeiro (botão \"Criar imagem\" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.";
/// Avalia o dispositivo `path`. Para arquivos comuns (imagens) devolve "sem avaliação".
/// Evaluates disk health for `path`. Regular image files return an unassessed report.
pub fn check(path: &str) -> Health {
#[cfg(unix)]
let is_dev = {
@@ -88,8 +88,8 @@ pub fn check(path: &str) -> Health {
h
}
/// O SMART é do disco, não da partição: `/dev/sda1` vira `/dev/sda`,
/// `/dev/nvme0n1p2` vira `/dev/nvme0n1` e `/dev/disk2s1` vira `/dev/disk2`.
/// SMART belongs to the physical disk rather than a partition: `/dev/sda1` -> `/dev/sda`,
/// `/dev/nvme0n1p2` -> `/dev/nvme0n1`, and `/dev/disk2s1` -> `/dev/disk2`.
pub fn parent_disk(path: &str) -> String {
#[cfg(target_os = "linux")]
{
@@ -117,7 +117,7 @@ pub fn parent_disk(path: &str) -> String {
}
fn smartctl_name(path: &str) -> String {
// Windows: \\.\PhysicalDriveN -> /dev/sdX (convenção do smartctl)
// Windows: \\.\PhysicalDriveN -> /dev/sdX (smartctl device naming convention)
if let Some(n) = path
.strip_prefix(r"\\.\PhysicalDrive")
.and_then(|n| n.parse::<u8>().ok())
@@ -139,9 +139,9 @@ fn run_smartctl(path: &str) -> Option<Value> {
.stderr(Stdio::null())
.output()
.ok()?;
// smartctl usa bits do código de saída para avisos; o JSON vale mesmo assim
// smartctl encodes warnings in exit status bits; the JSON output is still valid
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
// sem permissão ou dispositivo não suportado
// insufficient permissions or unsupported device
if v.pointer("/smartctl/messages")
.and_then(|m| m.as_array())
.is_some_and(|m| m.iter().any(|x| x["severity"] == "error"))
@@ -163,14 +163,14 @@ fn attr(v: &Value, id: u64) -> Option<i64> {
.as_i64()
}
/// Interpreta a saída JSON do `smartctl` (testável sem disco).
/// Parses `smartctl -j` JSON output (unit-testable without hardware).
pub fn parse_smartctl(v: &Value) -> Health {
let mut h = Health {
available: true,
status: "ok".into(),
..Default::default()
};
let mut bad = 0u32; // 2 = falhando, 1 = atenção
let mut bad = 0u32; // 2 = failing, 1 = warning
let mut note = |h: &mut Health, sev: u32, k: &str, val: String| {
bad = bad.max(sev);
h.details.push((k.to_string(), val));
@@ -204,7 +204,7 @@ pub fn parse_smartctl(v: &Value) -> Health {
h.details
.push(("Autoavaliação SMART".into(), "aprovado".into()));
}
// ATA (HD e SSD SATA)
// ATA (SATA HDDs and SSDs)
if let Some(n) = attr(v, 5) {
if n > 0 {
note(
@@ -320,7 +320,7 @@ fn n(v: &Value, p: &str) -> Option<i64> {
v.pointer(p).and_then(|x| x.as_i64())
}
/// Ficha do dispositivo e métricas gerais (temperatura, horas, total lido/gravado, saúde %).
/// Extracts device identity and summary metrics (temperature, power-on hours, host R/W, health %).
fn fill_info(v: &Value, h: &mut Health) {
let mut put = |k: &str, val: Option<String>| {
if let Some(val) = val {
@@ -395,7 +395,7 @@ fn fill_info(v: &Value, h: &mut Health) {
h.power_on_hours = n(v, "/power_on_time/hours");
h.power_cycles = n(v, "/power_cycle_count");
if let Some(l) = v.get("nvme_smart_health_information_log") {
// a unidade NVMe vale 1000 setores de 512 bytes
// 1 NVMe data unit = 1,000 x 512-byte sectors
h.host_reads = l["data_units_read"].as_u64().map(|u| u * 512_000);
h.host_writes = l["data_units_written"].as_u64().map(|u| u * 512_000);
h.health_pct = l["percentage_used"]
@@ -406,7 +406,7 @@ fn fill_info(v: &Value, h: &mut Health) {
let lba = n(v, "/logical_block_size").unwrap_or(512) as u64;
h.host_reads = attr(v, 242).map(|x| x as u64 * lba);
h.host_writes = attr(v, 241).map(|x| x as u64 * lba);
// SSD SATA: vida útil restante no valor normalizado de um destes atributos
// SATA SSD: remaining life percentage stored in the normalized value of one of these attributes
// (231 SSD Life Left, 233 Media Wearout, 177 Wear Leveling, 202 Percent Lifetime Remain)
h.health_pct = [231u64, 233, 177, 202, 169]
.iter()
@@ -416,7 +416,7 @@ fn fill_info(v: &Value, h: &mut Health) {
}
}
/// Capacidade como o fabricante anuncia (base 1000): "500,1 GB".
/// Formats drive capacity in decimal manufacturer units (base 1000, e.g. `"500,1 GB"`).
fn decimal_size(b: u64) -> String {
let units = ["B", "kB", "MB", "GB", "TB", "PB"];
let mut x = b as f64;
@@ -437,7 +437,7 @@ fn attr_field(v: &Value, id: u64, f: &str) -> Option<i64> {
.as_i64()
}
/// Atributos cujo valor bruto acima de zero já é motivo de atenção.
/// Critical ATA attributes where any non-zero raw count warrants a warning.
const RAW_WARN: [u64; 5] = [5, 196, 197, 198, 187];
fn fill_attributes(v: &Value, h: &mut Health) {
@@ -484,7 +484,7 @@ fn fill_attributes(v: &Value, h: &mut Health) {
}
}
if let Some(l) = v.get("nvme_smart_health_information_log") {
// mesma ordem e nomes que o CrystalDiskInfo usa para NVMe
// same order and labels used by CrystalDiskInfo for NVMe health logs
let rows: [(u8, &str, &str); 15] = [
(1, "Critical Warning", "critical_warning"),
(2, "Composite Temperature", "temperature"),
@@ -529,7 +529,7 @@ fn fill_attributes(v: &Value, h: &mut Health) {
}
}
/// Contadores de erro de E/S do kernel (Linux): reforçam o aviso do SMART.
/// Linux kernel sysfs I/O error counters: supplement SMART warnings.
fn kernel_errors(path: &str, h: &mut Health) {
#[cfg(target_os = "linux")]
{
+4 -4
View File
@@ -1,4 +1,4 @@
//! Rotas HTTP de mídia (`/file` com Range e `/thumb`) compartilhadas pelo modo `guara ui`.
//! Media HTTP routes (`/file` with Range support and `/thumb`) shared by `guara ui` mode.
use crate::service::Engine;
use tiny_http::{Header, Method, Request, Response, StatusCode};
@@ -7,15 +7,15 @@ pub fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
}
/// O cabeçalho Host bate com um dos endereços esperados (defesa contra DNS rebinding)?
/// Checks whether the HTTP `Host` header matches an allowed local address (DNS rebinding defense).
pub fn host_ok(req: &Request, hosts: &[String]) -> bool {
req.headers()
.iter()
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()))
}
/// Responde `/file/<job>/<id>` (com Range) e `/thumb/<job>/<id>?s=NNN`.
/// Devolve a requisição quando ela não é uma rota de mídia.
/// Serves `/file/<job>/<id>` (with Range support) and `/thumb/<job>/<id>?s=NNN`.
/// Returns `Err(req)` when the request is not a media route.
pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> {
let full = req.url().to_string();
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
+9 -9
View File
@@ -1,5 +1,5 @@
//! Imagem forense de um disco: leitura tolerante a setores ruins, com mapa de
//! erros e hash. Serve para trabalhar sempre sobre a cópia.
//! Forensic disk imaging: fault-tolerant reading across bad sectors with an error
//! map and integrity hashes so recovery always runs against a copy.
use crate::error::{Error, Result};
use crate::hashing::MultiHasher;
@@ -16,7 +16,7 @@ pub struct ImageReport {
pub md5: String,
#[serde(default)]
pub sha1: String,
/// (offset, tamanho) dos trechos ilegíveis, preenchidos com zeros.
/// `(offset, length)` of unreadable byte ranges that were zero-filled in the output image.
pub bad_ranges: Vec<(u64, u64)>,
pub cancelled: bool,
}
@@ -29,7 +29,7 @@ const SECTOR: usize = 512;
pub enum ImageFormat {
#[default]
Raw,
/// Expert Witness (EnCase 6): `.E01`, dividido em segmentos.
/// Expert Witness Format (EnCase 6): segmented `.E01`.
E01 {
#[serde(default)]
case_number: String,
@@ -41,14 +41,14 @@ pub enum ImageFormat {
examiner: String,
#[serde(default)]
notes: String,
/// Tamanho máximo de cada segmento em MiB (0 = um arquivo só).
/// Maximum size of each segment in MiB (0 = single segment).
#[serde(default = "default_segment_mib")]
segment_mib: u64,
/// 0 = sem compressão, 1 a 9 = deflate.
/// 0 = uncompressed, 1..=9 = deflate compression level.
#[serde(default = "default_compression")]
compression: u32,
},
/// AFF4 (padrão v1.0, blocos snappy).
/// AFF4 (v1.0 standard, snappy-compressed chunks).
Aff4 {
#[serde(default)]
case_number: String,
@@ -119,7 +119,7 @@ pub fn create_image_as(
compression: *compression,
..Default::default()
};
// O destino pode vir com ou sem a extensão .E01.
// Destination path may be passed with or without the `.E01` extension.
let base = match dest.extension().and_then(|e| e.to_str()) {
Some(e) if e.eq_ignore_ascii_case("e01") => dest.with_extension(""),
_ => dest.to_path_buf(),
@@ -160,7 +160,7 @@ pub fn create_image(
match read_full(src, pos, &mut buf[..want]) {
Ok(()) => {}
Err(_) => {
// Refaz setor a setor para isolar os trechos ruins.
// Retry sector by sector to isolate the exact bad sectors.
let mut o = 0;
while o < want {
let n = SECTOR.min(want - o);
+34 -34
View File
@@ -1,13 +1,13 @@
//! Decodificador de entropia de JPEG sequencial (Huffman, 8 bits), sem IDCT.
//! Baseline sequential JPEG Huffman entropy decoder (8-bit), plus lightweight IDCT.
//!
//! Serve para validar se os bytes de um JPEG são realmente contínuos e para reconstruir arquivos
//! fragmentados: o fluxo é decodificado MCU a MCU, guardando o DC de luminância (uma miniatura
//! 1/8 da imagem) e pontos de retomada. Depois de uma quebra os bytes viram lixo, e a
//! descontinuidade do DC (ou um erro de código) denuncia onde.
//! Validates whether a JPEG stream is contiguous and assists in reassembling fragmented
//! JPEGs: decodes MCU by MCU, tracking luminance DC values (a 1/8th thumbnail) and
//! decoder checkpoints. After a fragmentation break, foreign clusters cause either
//! a Huffman/marker error or a sharp luminance discontinuity.
#[derive(Clone, Debug)]
struct Huff {
// decodificação canônica
// canonical Huffman decoding tables
mincode: [i32; 17],
maxcode: [i32; 18],
valptr: [i32; 17],
@@ -54,9 +54,9 @@ pub struct Header {
pub restart: u32,
pub mcus_x: u32,
pub mcus_y: u32,
/// Onde começam os dados entropy-coded (depois do SOS).
/// Byte offset where entropy-coded scan data begins (immediately after SOS).
pub scan_start: usize,
/// Tabelas de quantização (ordem zigzag).
/// Quantization tables (in zigzag order).
pub qt: [[u16; 64]; 4],
dc: [Option<Huff>; 4],
ac: [Option<Huff>; 4],
@@ -68,7 +68,7 @@ impl Header {
}
}
/// Lê os segmentos até o SOS. `None` se não for JPEG sequencial suportado.
/// Parses JPEG marker segments up to SOS. Returns `None` if not a supported baseline JPEG.
pub fn parse_header(d: &[u8]) -> Option<Header> {
if d.len() < 4 || d[0] != 0xFF || d[1] != 0xD8 {
return None;
@@ -119,7 +119,7 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
.collect();
sof = Some((w, h, comps));
}
0xC2 | 0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return None, // progressivo, aritmético etc.
0xC2 | 0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return None, // progressive, arithmetic coding, etc.
0xC4 => {
let mut q = 0;
while q + 17 <= seg.len() {
@@ -167,7 +167,7 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
let (w, h, comps) = sof?;
let ns = *seg.first()? as usize;
if ns != comps.len() || seg.len() < 1 + ns * 2 + 3 {
return None; // varredura não intercalada
return None; // non-interleaved scan
}
let mut cs = vec![];
for i in 0..ns {
@@ -212,20 +212,20 @@ pub fn parse_header(d: &[u8]) -> Option<Header> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Stop {
/// Todos os MCUs foram decodificados.
/// All MCUs in the frame were decoded.
Done,
/// Marcador inesperado (inclusive EOI) na posição dada.
/// Unexpected marker (including early EOI) at the given byte offset.
Marker(u8, usize),
/// Código inválido ou coeficiente fora do limite.
/// Invalid Huffman code or DCT coefficient index out of bounds.
Error(usize),
/// Faltam bytes.
/// Truncated bitstream (ran out of input bytes).
Truncated,
}
/// Estado completo do decodificador num limite de byte, para retomar com outros dados.
/// Complete entropy decoder snapshot at a byte boundary, allowing resumption on candidate fragments.
#[derive(Clone, Debug)]
pub struct State {
/// Próximo byte a buscar no fluxo.
/// Next byte offset to read from the entropy stream.
pub pos: usize,
bitbuf: u32,
bitcnt: u32,
@@ -236,7 +236,7 @@ pub struct State {
}
impl State {
/// Último DC de luminância (preditor) no ponto de retomada.
/// Last luminance DC predictor value at this checkpoint.
pub fn luma_pred(&self) -> i32 {
self.pred[0]
}
@@ -254,9 +254,9 @@ impl State {
}
pub struct Decoded {
/// DC de luminância (absoluto) por MCU decodificado.
/// Absolute luminance DC coefficient for each decoded MCU.
pub dc: Vec<i32>,
/// Estados guardados em intervalos (para retomada): (MCU, estado ao fim dele).
/// Periodic decoder checkpoints for resumption: `(mcu_index, state_after_mcu)`.
pub snaps: Vec<State>,
pub stop: Stop,
pub state: State,
@@ -341,13 +341,13 @@ fn extend(v: u32, t: u32) -> i32 {
}
}
/// Decodifica até `max_mcus` MCUs a partir de `state`, sobre `data` (fluxo bruto com FF00).
/// `snap_every` > 0 guarda estados a cada tantos bytes consumidos.
/// Decodes up to `max_mcus` MCUs starting from `state` over `data` (raw byte-stuffed stream).
/// When `snap_every > 0`, records a resumption snapshot every `snap_every` consumed bytes.
pub fn decode(h: &Header, data: &[u8], state: State, max_mcus: u32, snap_every: usize) -> Decoded {
decode_hook(h, data, state, max_mcus, snap_every, None)
}
/// Como `decode`, mas entrega os coeficientes (zigzag) dos blocos de luminância de cada MCU.
/// Like `decode`, but also invokes `on_luma` with the zigzag DCT coefficients of each MCU's luma blocks.
pub fn decode_hook(
h: &Header,
data: &[u8],
@@ -369,9 +369,9 @@ pub fn decode_hook(
let mut stop = Stop::Done;
let mut decoded = 0u32;
'mcu: while b.st.mcu < total && decoded < max_mcus {
// Marcador de reinício entre MCUs.
// Expected restart marker between MCUs.
if h.restart > 0 && b.st.mcus_since_rst == h.restart {
// descarta bits de preenchimento e espera RSTn
// discard byte-align padding bits and verify RSTn marker
b.st.bitbuf = 0;
b.st.bitcnt = 0;
let p = b.st.pos;
@@ -486,8 +486,8 @@ fn fail(b: &Bits, data: &[u8]) -> Stop {
}
}
/// Primeiro MCU suspeito: janela de `win` MCUs cuja diferença média de DC é anormalmente alta.
/// Devolve o índice do MCU onde a janela começa.
/// Detects the first suspicious MCU where a sliding window of `win` MCUs exhibits an abnormally
/// high mean DC jump. Returns the starting MCU index of that window.
pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> {
let mx = h.mcus_x as usize;
let d: Vec<f64> = (0..dc.len())
@@ -505,7 +505,7 @@ pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> {
if d.len() < win * 2 {
return None;
}
// Referência: mediana das diferenças vistas até aqui (histograma, robusta a bordas).
// Baseline reference: running median of row-to-row DC differences (robust to sharp edges).
let mut hist = vec![0u32; 2048];
let mut seen = 0u32;
let mut sum: f64 = d[..win].iter().sum();
@@ -529,7 +529,7 @@ pub fn first_discontinuity(h: &Header, dc: &[i32]) -> Option<usize> {
return Some(i + 1 - win);
}
}
// Só entra na referência o que ainda parece normal.
// Only update the baseline histogram with rows that still look normal.
if i < win * 3 || d[i] < 300.0 {
hist[(d[i] as usize).min(2047)] += 1;
seen += 1;
@@ -566,7 +566,7 @@ fn cos_table() -> &'static [[f32; 8]; 8] {
})
}
/// IDCT de um bloco (coeficientes em zigzag, quantizados) para 8x8 pixels de luminância.
/// Dequantizes and runs an 8x8 IDCT on a zigzag coefficient block into 8x8 luma pixels.
pub fn idct_block(coef: &[i32; 64], qt: &[u16; 64]) -> [u8; 64] {
let mut f = [[0f32; 8]; 8];
for z in 0..64 {
@@ -574,7 +574,7 @@ pub fn idct_block(coef: &[i32; 64], qt: &[u16; 64]) -> [u8; 64] {
f[n / 8][n % 8] = (coef[z] * qt[z] as i32) as f32;
}
let t = cos_table();
// linhas
// rows
let mut tmp = [[0f32; 8]; 8];
for v in 0..8 {
for x in 0..8 {
@@ -598,7 +598,7 @@ pub fn idct_block(coef: &[i32; 64], qt: &[u16; 64]) -> [u8; 64] {
out
}
/// Luminância de um MCU (blocos `h` x `v` da primeira componente) como imagem (8h x 8v).
/// Renders the luma blocks (`h` x `v`) of a single MCU into an `8h x 8v` pixel tile.
pub fn mcu_luma(h: &Header, blocks: &[[i32; 64]]) -> Vec<u8> {
let c = &h.comps[0];
let (bw, bh) = (c.h as usize, c.v as usize);
@@ -615,7 +615,7 @@ pub fn mcu_luma(h: &Header, blocks: &[[i32; 64]]) -> Vec<u8> {
out
}
/// Plano de luminância do que já foi decodificado.
/// Luma pixel plane of the decoded image prefix.
pub struct Plane {
pub w: usize,
pub h: usize,
@@ -648,7 +648,7 @@ impl Plane {
}
}
/// Decodifica e preenche o plano com as luminâncias.
/// Decodes `data` from `state` and renders the luma plane.
pub fn decode_plane(
h: &Header,
data: &[u8],
+3 -3
View File
@@ -5,10 +5,10 @@
clippy::large_enum_variant,
clippy::needless_range_loop
)]
//! Guará: núcleo de recuperação de arquivos.
//! Guará: forensic file recovery core.
//!
//! Princípio central: a origem é aberta **somente para leitura**; nada neste
//! crate escreve no dispositivo que está sendo recuperado.
//! Core principle: the source is opened **strictly read-only**; nothing in this
//! crate ever writes to the device being recovered.
pub mod aff4;
pub mod assemble;
+14 -14
View File
@@ -1,5 +1,5 @@
//! LVM2: lê o rótulo do volume físico (PV) e o texto de metadados, e remonta os volumes
//! lógicos (lineares, em faixas, espelhados e RAID 0/1/5/6/10 do LVM).
//! LVM2: parses Physical Volume (PV) labels and ASCII metadata rings to reassemble
//! Logical Volumes (linear, striped, mirrored, and LVM RAID 0/1/5/6/10).
use crate::raid::{Layout, Level, Member, RaidParams, RaidSource};
use crate::source::{read_vec, Source};
@@ -11,7 +11,7 @@ const MDA_MAGIC: &[u8; 16] = b" LVM2 x[5A%r0N*>";
#[derive(Clone, Debug)]
pub struct PvInfo {
/// UUID de 32 caracteres, sem hífens.
/// 32-character LVM UUID normalized without hyphens.
pub uuid: String,
pub size: u64,
pub seqno: u64,
@@ -19,7 +19,7 @@ pub struct PvInfo {
pub text: String,
}
/// Lê rótulo + metadados de um PV.
/// Reads the PV label and metadata text from a physical volume candidate.
pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
let head = read_vec(src, 0, 2048).ok()?;
if head.len() < 2048 {
@@ -34,7 +34,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
let pv = lab.get(off..)?;
let uuid = String::from_utf8_lossy(pv.get(..32)?).to_string();
let size = u64::from_le_bytes(pv.get(32..40)?.try_into().ok()?);
// Lista de áreas de dados, depois de áreas de metadados; cada lista termina em (0,0).
// Null-terminated list of data area descriptors followed by metadata area descriptors.
let mut o = 40;
let rd = |o: usize| -> Option<(u64, u64)> {
Some((
@@ -78,7 +78,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
text = t;
}
} else {
// O texto dá a volta no anel.
// Metadata text wraps around the circular ring buffer.
let first = mda_size - loc_off;
if let (Ok(a), Ok(b)) = (
read_vec(src, mda_off + loc_off, first as usize),
@@ -113,7 +113,7 @@ pub fn read_pv(src: &dyn Source) -> Option<PvInfo> {
best
}
// ------------------------------------------------------------------ texto
// ------------------------------------------------------------------ text parser
#[derive(Clone, Debug)]
pub enum Val {
@@ -125,7 +125,7 @@ pub enum Val {
#[derive(Clone, Debug, Default)]
pub struct Section {
pub values: BTreeMap<String, Val>,
/// Subseções na ordem do arquivo.
/// Child sections in declaration order.
pub sections: Vec<(String, Section)>,
}
@@ -277,7 +277,7 @@ pub fn parse(text: &str) -> Option<Section> {
.section(true)
}
// ------------------------------------------------------------- montagem
// ------------------------------------------------------------- assembly
fn dashed(u: &str) -> String {
if u.len() != 32 {
@@ -297,7 +297,7 @@ fn dashed(u: &str) -> String {
#[derive(Clone)]
enum Map {
/// Lê de `src` a partir de `base` (linear).
/// Linear segment: reads from `src` starting at `base`.
Linear {
src: Option<Arc<dyn Source>>,
base: u64,
@@ -413,7 +413,7 @@ fn mk_stripes(list: &[Val]) -> Vec<(String, u64)> {
struct Ctx<'a> {
ext: u64,
pvs: BTreeMap<String, (Option<Arc<dyn Source>>, u64)>, // nome -> (origem, pe_start em bytes)
pvs: BTreeMap<String, (Option<Arc<dyn Source>>, u64)>, // pv_name -> (source, pe_start_in_bytes)
lvs: &'a Section,
cache: BTreeMap<String, Option<Arc<dyn Source>>>,
warnings: Vec<String>,
@@ -487,7 +487,7 @@ impl Ctx<'_> {
}
})
.collect();
// Pares (rimage, rmeta) ou lista só de imagens.
// Pairs of `(rimage, rmeta)` or a list of image LVs.
let data_lvs: Vec<&String> =
imgs.iter().filter(|n| n.contains("_rimage_")).collect();
let mut members: Vec<Option<Member>> = vec![];
@@ -544,7 +544,7 @@ impl Ctx<'_> {
}
}
/// Monta os volumes lógicos de cada grupo de volumes presente nos PVs fornecidos.
/// Reassembles all user Logical Volumes across every Volume Group found in `pvs`.
pub fn assemble_vgs(pvs: Vec<(Arc<dyn Source>, PvInfo)>) -> Vec<VgResult> {
let mut by_vg: BTreeMap<String, Vec<(Arc<dyn Source>, PvInfo)>> = BTreeMap::new();
for (s, p) in pvs {
@@ -590,7 +590,7 @@ pub fn assemble_vgs(pvs: Vec<(Arc<dyn Source>, PvInfo)>) -> Vec<VgResult> {
};
let mut lvs = vec![];
for (name, def) in &lvsec.sections {
// Imagens internas do RAID e metadados não são volumes de usuário.
// Internal RAID sub-LVs (`_rimage_*`, `_rmeta_*`) are not user-visible volumes.
let hidden = def
.list("status")
.map(|l| !l.iter().any(|v| matches!(v, Val::Str(s) if s == "VISIBLE")))
+20 -20
View File
@@ -1,6 +1,6 @@
//! Extração de metadados de arquivos recuperados: EXIF (câmera, datas, GPS),
//! informações de PDF, propriedades de documentos Office/OpenDocument e
//! dados de vídeo MP4/MOV. Tudo é leitura posicional, sem gravar em disco.
//! Metadata extraction for recovered files: EXIF (camera, timestamps, GPS),
//! PDF info dictionary, Office/OpenDocument properties, and MP4/MOV
//! container headers. Performed entirely via positional reads without writing to disk.
use crate::recover::read_found_at;
use crate::source::Source;
@@ -12,16 +12,16 @@ use std::io::Read;
pub struct Meta {
pub kind: String,
pub fields: Vec<(String, String)>,
/// (latitude, longitude) em graus decimais.
/// `(latitude, longitude)` in decimal degrees.
pub gps: Option<(f64, f64)>,
/// Data em que a foto foi tirada (EXIF), s desde 1970 (hora da câmera lida como UTC).
/// EXIF capture timestamp in seconds since 1970 (camera wall-clock interpreted as UTC).
#[serde(default)]
pub taken: Option<i64>,
#[serde(default)]
pub camera: Option<String>,
#[serde(default)]
pub px: Option<(u32, u32)>,
/// Orientação EXIF (1..8).
/// EXIF orientation tag (`1..=8`).
#[serde(default)]
pub orientation: u16,
}
@@ -142,7 +142,7 @@ fn jpeg(fr: &Fr, m: &mut Meta) {
jpeg_bytes(&d, m);
}
/// Lê os segmentos de cabeçalho de um JPEG (já em memória): dimensões e EXIF.
/// Parses JPEG header segments (in memory) for dimensions and EXIF tags.
pub fn jpeg_bytes(d: &[u8], m: &mut Meta) {
let mut p = 2usize;
while p + 4 <= d.len() && d[p] == 0xFF {
@@ -168,7 +168,7 @@ pub fn jpeg_bytes(d: &[u8], m: &mut Meta) {
}
}
/// Miniatura JPEG embutida no EXIF (IFD1), útil quando a imagem principal está danificada.
/// Extracts the embedded EXIF thumbnail (IFD1), useful when the main image is corrupted.
pub fn exif_thumbnail(d: &[u8]) -> Option<Vec<u8>> {
let mut p = 2usize;
while p + 4 <= d.len() && d[p] == 0xFF {
@@ -222,7 +222,7 @@ struct Entry {
tag: u16,
typ: u16,
count: u32,
pos: usize, // onde estão os dados do valor dentro do TIFF
pos: usize, // offset of the tag value payload within the TIFF header
}
impl Tiff<'_> {
@@ -423,7 +423,7 @@ fn exif(d: &[u8], m: &mut Meta) {
}
}
/// "2023:07:14 18:22:05" -> segundos desde 1970 (hora da câmera lida como UTC)
/// `"2023:07:14 18:22:05"` -> seconds since 1970 (camera local time treated as UTC).
fn exif_epoch(s: &str) -> Option<i64> {
let b = s.as_bytes();
if b.len() < 19 || b[4] != b':' || b[7] != b':' {
@@ -444,7 +444,7 @@ fn exif_epoch(s: &str) -> Option<i64> {
Some(crate::fs::days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se)
}
/// "2023:07:14 18:22:05" -> "2023-07-14T18:22:05" (a interface formata por idioma)
/// `"2023:07:14 18:22:05"` -> `"2023-07-14T18:22:05"` (formatted per locale by the UI).
fn exif_date(s: &str) -> String {
let b = s.as_bytes();
if b.len() >= 19 && b[4] == b':' && b[7] == b':' {
@@ -526,7 +526,7 @@ fn pdf_string(d: &[u8], key: &str) -> Option<String> {
let k = key.as_bytes();
for at in find_all(d, k) {
let mut p = at + k.len();
// evita /Creator casar com /CreatorXyz
// prevent `/Creator` from prefix-matching `/CreatorXyz`
if d.get(p).map(|c| c.is_ascii_alphanumeric()).unwrap_or(false) {
continue;
}
@@ -544,7 +544,7 @@ fn pdf_string(d: &[u8], key: &str) -> Option<String> {
b'\\' if i + 1 < d.len() => {
i += 1;
if d[i].is_ascii_digit() && d[i] < b'8' {
// escape octal \ddd (1 a 3 dígitos)
// octal escape `\ddd` (1 to 3 digits)
let mut v = 0u32;
let mut n = 0;
while n < 3 && i < d.len() && d[i] >= b'0' && d[i] < b'8' {
@@ -609,11 +609,11 @@ fn pdf_text(b: &[u8]) -> String {
.collect();
return String::from_utf16_lossy(&u);
}
// PDFDocEncoding ~ Latin-1 na prática
// PDFDocEncoding is effectively Latin-1 for printable characters
b.iter().map(|&c| c as char).collect()
}
/// "D:20230714182205-03'00'" -> "2023-07-14T18:22:05" (a interface formata por idioma)
/// `"D:20230714182205-03'00'"` -> `"2023-07-14T18:22:05"` (formatted per locale by the UI).
fn pdf_date(s: &str) -> String {
let t = s.trim_start_matches("D:");
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
@@ -704,7 +704,7 @@ fn zip_read(fr: &Fr, e: &ZipEnt) -> Option<Vec<u8>> {
}
}
/// Texto do primeiro elemento `<tag ...>texto</tag>`.
/// Extracts the inner text of the first `<tag ...>text</tag>` element.
fn xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}");
let mut from = 0;
@@ -733,7 +733,7 @@ fn unescape(s: &str) -> String {
.replace("&amp;", "&")
}
/// Datas ISO 8601 ficam como estão; a interface as formata no idioma escolhido.
/// Preserves ISO 8601 date prefixes (`YYYY-MM-DD[THH:MM:SS]`) for locale-aware UI formatting.
fn iso_date(s: &str) -> String {
let b = s.as_bytes();
if b.len() >= 19 && b[4] == b'-' && b[10] == b'T' {
@@ -916,7 +916,7 @@ fn moov(d: &[u8], m: &mut Meta) {
}
}
/// Instante em ISO 8601 UTC ("2023-07-14T18:22:05Z"), para a interface formatar.
/// Formats a Unix timestamp as ISO 8601 UTC (`"2023-07-14T18:22:05Z"`).
pub fn iso_unix(t: i64) -> String {
let days = t.div_euclid(86400);
let rem = t.rem_euclid(86400);
@@ -941,13 +941,13 @@ pub fn fmt_unix(t: i64) -> String {
)
}
/// (ano, mês, dia) de um instante em segundos desde 1970.
/// Returns `(year, month, day)` for a Unix timestamp in seconds.
pub fn ymd(t: i64) -> (i64, u32, u32) {
let (y, m, d) = civil_from_days(t.div_euclid(86400));
(y, m as u32, d as u32)
}
/// Inverso de `days_from_civil` (Howard Hinnant).
/// Inverse of `days_from_civil` (Howard Hinnant civil calendar algorithm).
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
+49 -49
View File
@@ -1,7 +1,7 @@
//! Reconstrução de vídeo MP4/MOV sem o átomo `moov` (gravação interrompida de câmera ou drone,
//! arquivo cortado). Os quadros ficam no `mdat` como unidades NAL com prefixo de tamanho; o
//! índice (`moov`) é refeito a partir delas: tamanhos de amostra, quadros-chave, tempo e, para
//! H.264, a ordem de apresentação (POC) quando há quadros B. O áudio não é reconstruído.
//! Reconstructs MP4/MOV video files missing their `moov` atom (interrupted camera/drone recordings
//! or truncated files). Video frames sit in `mdat` as length-prefixed NAL units; we rebuild the
//! `moov` index from them: sample sizes, keyframes, timing, and for H.264, presentation order
//! (POC) when B-frames are present. Audio tracks are not reconstructed.
use crate::source::{read_vec, Source};
@@ -58,7 +58,7 @@ impl<'a> Bits<'a> {
}
}
/// Remove os bytes de "emulation prevention" (00 00 03 -> 00 00).
/// Strips H.264/HEVC emulation prevention bytes (`00 00 03` -> `00 00`).
fn unescape(nal: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(nal.len());
let mut z = 0;
@@ -86,7 +86,7 @@ pub struct Sps264 {
pub poc_type: u32,
pub log2_max_poc_lsb: u32,
pub frame_mbs_only: bool,
/// (unidades por tique, escala de tempo)
/// `(num_units_in_tick, time_scale)` from VUI timing info.
pub timing: Option<(u32, u32)>,
}
@@ -173,7 +173,7 @@ pub fn parse_sps264(nal: &[u8]) -> Option<Sps264> {
};
s.width = (w * 16).checked_sub((cl + cr) * sub_w)?;
s.height = ((2 - s.frame_mbs_only as u32) * h * 16).checked_sub((ct + cb) * sub_h)?;
// VUI até o timing_info.
// Parse VUI parameters up to `timing_info_present_flag`.
if b.bit()? == 1 {
if b.bit()? == 1 && b.u(8)? == 255 {
b.skip(32);
@@ -202,7 +202,7 @@ pub fn parse_sps264(nal: &[u8]) -> Option<Sps264> {
Some(s)
}
/// (primeiro_mb == 0, tipo de fatia, pps_id, frame_num, idr, poc_lsb) do cabeçalho de fatia H.264.
/// Parses `(first_mb == 0, slice_type, pps_id, frame_num, is_idr, poc_lsb)` from an H.264 slice header.
struct SliceInfo {
frame_num: u32,
first_mb0: bool,
@@ -240,7 +240,7 @@ fn slice264(nal: &[u8], sps: &Sps264, pic_order_present: bool) -> Option<SliceIn
})
}
/// bottom_field_pic_order_in_frame_present_flag do PPS.
/// Extracts `bottom_field_pic_order_in_frame_present_flag` from an H.264 PPS.
fn pps_pic_order_present(nal: &[u8]) -> bool {
let rbsp = unescape(&nal[1..]);
let mut b = Bits::new(&rbsp);
@@ -278,7 +278,7 @@ pub fn parse_sps_hevc(nal: &[u8]) -> Option<SpsHevc> {
*v = b.u(8)? as u8;
let _ = i;
}
// general: 2+1+5 bits, 32 compat, 48 flags, 8 level => lemos 12 bytes (1+4+6+1)
// general profile/tier/level: 2+1+5 bits, 32 compat, 48 constraint flags, 8 level => 12 bytes (1+4+6+1)
let mut sub_profile = vec![];
let mut sub_level = vec![];
for _ in 0..max_sub {
@@ -320,9 +320,9 @@ pub fn parse_sps_hevc(nal: &[u8]) -> Option<SpsHevc> {
Some(s)
}
// -------------------------------------------------------------- amostras
// -------------------------------------------------------------- samples
/// Configuração de codec (SPS/PPS/VPS) extraída de um vídeo íntegro da mesma câmera.
/// Codec parameter sets (SPS/PPS/VPS) extracted from a donor video recorded by the same camera.
#[derive(Clone, Debug)]
pub struct MediaConfig {
pub codec: Codec,
@@ -331,9 +331,9 @@ pub struct MediaConfig {
pub vps: Vec<Vec<u8>>,
}
/// Procura o `avcC`/`hvcC` de um MP4 completo (lê até `max` bytes a partir de `off`).
/// Extracts `avcC`/`hvcC` codec configuration from an intact MP4 (reading up to `max` bytes from `off`).
pub fn config_from_mp4(src: &dyn Source, off: u64, max: u64) -> Option<MediaConfig> {
// O moov costuma estar no começo ou no fim: procura nos dois lugares.
// The `moov` atom is typically near the beginning or the end of the file: check both.
let size = src.size();
let head = read_vec(src, off, max.min(4 << 20) as usize).ok()?;
let mut hay: Vec<Vec<u8>> = vec![head];
@@ -416,7 +416,7 @@ pub enum Codec {
#[derive(Clone, Debug)]
struct Sample {
/// Posição absoluta (na origem) do primeiro byte da amostra (prefixo de tamanho incluso).
/// Absolute source offset of the first byte of the sample (including the 4-byte length prefix).
off: u64,
size: u32,
sync: bool,
@@ -425,9 +425,9 @@ struct Sample {
}
pub struct Rebuilt {
/// Bytes do arquivo original antes do `mdat` (ftyp etc.).
/// Leading atoms from the original file before `mdat` (`ftyp`, etc.).
pub pre_len: u64,
/// Onde começam e terminam os dados dos quadros na origem.
/// Start and end offsets of the video sample payload in the source.
pub payload: (u64, u64),
pub moov: Vec<u8>,
pub frames: usize,
@@ -439,7 +439,7 @@ pub struct Rebuilt {
}
impl Rebuilt {
/// Cabeçalho `mdat` de 64 bits para o payload.
/// 16-byte 64-bit `mdat` atom header covering the sample payload.
pub fn mdat_header(&self) -> Vec<u8> {
let size = 16 + (self.payload.1 - self.payload.0);
let mut v = Vec::with_capacity(16);
@@ -457,7 +457,7 @@ fn hevc_nal_ok(b0: u8, b1: u8) -> bool {
b0 & 0x81 == 0 && ((b0 >> 1) & 0x3F) <= 40 && b1 & 0x07 != 0 && (b1 >> 3) == 0
}
/// Cabeçalho do átomo `mdat` que começa em `at`: (início dos dados, tamanho declarado ou 0).
/// Parses the `mdat` header at `at`: returns `(payload_start, declared_end_or_0)`.
fn mdat_at(src: &dyn Source, at: u64) -> Option<(u64, u64)> {
let h = read_vec(src, at, 16).ok()?;
if h.len() < 8 || &h[4..8] != b"mdat" {
@@ -471,7 +471,7 @@ fn mdat_at(src: &dyn Source, at: u64) -> Option<(u64, u64)> {
}
}
/// Reconstrói o índice de um MP4 que começa em `off`, sem `moov`. `avail` limita a leitura.
/// Rebuilds the `moov` index for a headerless/truncated MP4 starting at `off` (reading at most `avail` bytes).
pub fn rebuild(src: &dyn Source, off: u64, avail: u64, refs: &[MediaConfig]) -> Option<Rebuilt> {
if let Some(r) = rebuild_with(src, off, avail, None) {
return Some(r);
@@ -486,7 +486,7 @@ fn rebuild_with(
avail: u64,
preset: Option<&MediaConfig>,
) -> Option<Rebuilt> {
// Átomos antes do mdat.
// Parse leading atoms before `mdat`.
let mut p = 0u64;
let mut pre_end = None;
for _ in 0..16 {
@@ -520,7 +520,7 @@ fn rebuild_with(
(off + pre_len + declared).min(hard_end)
};
// Lê o payload em blocos, interpretando NALs.
// Scan the `mdat` payload in chunks, parsing length-prefixed NAL units.
let mut samples: Vec<Sample> = vec![];
let mut cache: Vec<u8> = vec![];
let mut cache_off = data_start;
@@ -536,7 +536,7 @@ fn rebuild_with(
let s = (o - *cache_off) as usize;
cache.get(s..s + n).map(|x| x.to_vec())
};
// Detecta o codec: o que forma a cadeia mais longa de unidades NAL válidas.
// Detect the codec (H.264 vs HEVC) that yields the longest initial chain of valid NAL units.
let chain = |c: Codec| -> usize {
let Ok(head) = read_vec(src, data_start, 64 << 10) else {
return 0;
@@ -599,10 +599,10 @@ fn rebuild_with(
let mut idr_total = 0usize;
let mut idr_bad = 0usize;
let mut cur: Option<Sample> = None;
let mut group_start: Option<u64> = None; // início do grupo de NALs não-VCL que precede um quadro
let mut group_start: Option<u64> = None; // start offset of the non-VCL NAL group preceding a frame
let mut nal_count = 0usize;
let mut max_len = 0u64;
// POC (H.264, tipo 0)
// POC (H.264 pic_order_cnt_type 0)
let mut prev_msb: i64 = 0;
let mut prev_lsb: i64 = 0;
@@ -617,7 +617,7 @@ fn rebuild_with(
};
let mut ok = ok;
if ok && nal_count >= 3 && len > (max_len * 6).max(1 << 20) {
// Um "tamanho" muito maior que os já vistos é lixo (áudio lido como vídeo).
// A declared NAL length far larger than valid range is interleaved audio or garbage.
ok = false;
}
if ok {
@@ -629,7 +629,7 @@ fn rebuild_with(
max_len = max_len.max(len);
}
if !valid {
// Pode ser áudio intercalado: procura a próxima unidade NAL plausível.
// Likely an interleaved audio chunk: scan ahead for the next plausible video NAL sequence.
let found = scan_nal(
src,
codec,
@@ -642,7 +642,7 @@ fn rebuild_with(
);
match found {
Some(q) if nal_count > 0 => {
// O que veio antes fecha o quadro atual.
// Flush the current frame before starting the next one.
if let Some(mut s) = cur.take() {
s.size = (group_start.unwrap_or(pos) - s.off) as u32;
samples.push(s);
@@ -734,9 +734,9 @@ fn rebuild_with(
let _ = ty;
if is_vcl {
if first_of_pic {
// fecha o quadro anterior
// flush previous frame
if let Some(mut s) = cur.take() {
// tamanho até o início do grupo atual (ou deste NAL)
// frame size up to the start of the current non-VCL group (or this VCL NAL)
let end = group_start.unwrap_or(pos);
s.size = (end - s.off) as u32;
samples.push(s);
@@ -816,7 +816,7 @@ fn rebuild_with(
return None;
}
let (num, den) = match timing {
Some((units, scale)) if codec == Codec::Avc => (scale, 2 * units), // quadros por segundo = scale / (2 * units)
Some((units, scale)) if codec == Codec::Avc => (scale, 2 * units), // fps = time_scale / (2 * num_units_in_tick)
Some((units, scale)) => (scale, units),
None => (30, 1),
};
@@ -827,10 +827,10 @@ fn rebuild_with(
(num, den)
};
let fps = num as f64 / den as f64;
// Escala: tique do quadro = den, escala de tempo = num.
// Timescale: frame tick = den, timescale = num.
let (timescale, dur) = (num, den);
// Ordem de apresentação (POC) por trecho entre IDR.
// Compute presentation order (ctts composition offsets) from POC within each IDR GOP.
let mut ctts: Option<Vec<i64>> = None;
if samples.iter().all(|s| s.poc.is_some()) {
let mut offs = vec![0i64; samples.len()];
@@ -936,7 +936,7 @@ fn build_moov(
let movie_ts = 1000u32;
let movie_dur = media_dur * movie_ts as u64 / timescale as u64;
// Entrada de amostra
// Visual sample entry (`avc1` / `hvc1`)
let mut entry = vec![];
entry.extend_from_slice(&[0; 6]);
entry.extend_from_slice(&1u16.to_be_bytes());
@@ -975,18 +975,18 @@ fn build_moov(
Codec::Hevc => {
let h = hevc.unwrap();
let mut c = vec![1];
c.extend_from_slice(&h.ptl[..12.min(h.ptl.len())]); // espaço/tier/perfil, compat, restrições, nível
c.extend_from_slice(&h.ptl[..12.min(h.ptl.len())]); // profile_space/tier/profile_idc, profile_compatibility, constraint_indicator, level_idc
c.truncate(13);
while c.len() < 13 {
c.push(0);
}
c.extend_from_slice(&0xF000u16.to_be_bytes()); // segmentação espacial
c.push(0xFC); // paralelismo
c.extend_from_slice(&0xF000u16.to_be_bytes()); // min_spatial_segmentation_idc
c.push(0xFC); // parallelismType
c.push(0xFC | h.chroma as u8);
c.push(0xF8 | (h.bit_depth_luma - 8) as u8);
c.push(0xF8 | (h.bit_depth_chroma - 8) as u8);
c.extend_from_slice(&0u16.to_be_bytes()); // avgFrameRate
c.push(0x0B); // constFrameRate=0, 1 camada temporal, lengthSizeMinusOne=3
c.push(0x0B); // constantFrameRate=0, numTemporalLayers=1, lengthSizeMinusOne=3
let arrays: [(u8, &[Vec<u8>]); 3] = [(32, vps), (33, sps), (34, pps)];
let present: Vec<_> = arrays.iter().filter(|(_, v)| !v.is_empty()).collect();
c.push(present.len() as u8);
@@ -1047,7 +1047,7 @@ fn build_moov(
.collect();
let mut stbl_parts = vec![stsd, stts];
if let Some(c) = ctts {
// entradas agrupadas
// run-length encoded entries
let mut runs: Vec<(u32, u32)> = vec![];
for &v in c {
match runs.last_mut() {
@@ -1080,7 +1080,7 @@ fn build_moov(
let vmhd = full(b"vmhd", 0, 1, &[0; 8]);
let minf = bx(b"minf", &[vmhd, dinf, stbl].concat());
let mut mdhd_body = vec![];
mdhd_body.extend_from_slice(&[0; 8]); // criação/modificação
mdhd_body.extend_from_slice(&[0; 8]); // creation_time / modification_time
mdhd_body.extend_from_slice(&u32b(timescale));
mdhd_body.extend_from_slice(&u32b(media_dur as u32));
mdhd_body.extend_from_slice(&0x55C4u16.to_be_bytes()); // "und"
@@ -1100,8 +1100,8 @@ fn build_moov(
tkhd_body.extend_from_slice(&[0; 4]);
tkhd_body.extend_from_slice(&u32b(movie_dur as u32));
tkhd_body.extend_from_slice(&[0; 8]);
tkhd_body.extend_from_slice(&[0; 4]); // camada, grupo
tkhd_body.extend_from_slice(&[0; 4]); // volume, reservado
tkhd_body.extend_from_slice(&[0; 4]); // layer, alternate_group
tkhd_body.extend_from_slice(&[0; 4]); // volume, reserved
for m in matrix {
tkhd_body.extend_from_slice(&u32b(m));
}
@@ -1136,7 +1136,7 @@ fn build_moov(
bx(b"moov", &[mvhd, trak].concat())
}
/// A unidade NAL parece de verdade? Valida o cabeçalho de fatia e o tamanho de SPS/PPS/AUD.
/// Checks whether a candidate NAL unit looks structurally valid (slice header, SPS/PPS/AUD sizes).
fn nal_plausible(
codec: Codec,
head: &[u8],
@@ -1167,7 +1167,7 @@ fn nal_plausible(
return false;
}
if let Some(sp) = sps {
// frame_num de IDR é sempre 0
// IDR slice `frame_num` is always 0
if t == 5 {
if let Some(fnum) = b.u(sp.log2_max_frame_num) {
return fnum == 0;
@@ -1194,7 +1194,7 @@ fn nal_plausible(
}
}
/// Quantas unidades NAL válidas seguidas (sem vãos) começam em `at` (até `max`).
/// Counts how many consecutive valid length-prefixed NAL units start at `at` (up to `max`).
fn chain_len(
buf: &[u8],
base: u64,
@@ -1232,8 +1232,8 @@ fn chain_len(
n
}
/// Próxima posição em [from, limit) onde começa uma sequência de unidades NAL plausíveis.
/// Prefere o primeiro ponto com duas ou mais unidades seguidas; senão, uma unidade isolada.
/// Finds the next offset in `[from, limit)` where a plausible NAL unit sequence begins.
/// Prefers an offset with two or more back-to-back valid NALs; falls back to a single valid NAL.
fn scan_nal(
src: &dyn Source,
codec: Codec,
@@ -1247,7 +1247,7 @@ fn scan_nal(
if limit <= from + 8 {
return None;
}
// Lê um pouco além do limite para poder seguir a cadeia de unidades.
// Read slightly past `limit` so we can verify the following NAL in the chain.
let want =
((limit - from) as usize + (cap as usize).min(4 << 20)).min((data_end - from) as usize);
let buf = read_vec(src, from, want).ok()?;
@@ -1259,7 +1259,7 @@ fn scan_nal(
if l2 >= 2 && l2 <= cap && q + 4 + l2 <= data_end {
let nb = buf[i + 4];
let good_head = match codec {
// Um quadro novo começa por parâmetros/AUD/SEI ou por fatia do macrobloco 0.
// A new access unit starts with parameter sets/AUD/SEI or a slice at macroblock 0.
Codec::Avc => {
avc_nal_ok(nb)
&& (matches!(nb & 0x1F, 6..=9)
+7 -7
View File
@@ -1,11 +1,11 @@
//! Notificações do sistema (varredura concluída, disco desconectado...), usando os
//! comandos nativos de cada sistema, sem dependências extras.
//! Native OS desktop notifications (scan finished, disk disconnected, etc.) using
//! standard platform CLI tools with zero extra crate dependencies.
use std::process::{Command, Stdio};
/// Avisos que o núcleo envia ao sistema operacional. O texto é montado no
/// idioma efetivo da interface (`settings::ui_lang`), porque notificações não
/// passam pelo dicionário do DOM.
/// Notification events emitted by the core. Messages are formatted in the
/// effective UI language (`settings::ui_lang`) since OS notifications bypass
/// the DOM i18n translator.
pub enum Notice {
ScanDone { count: usize, target: String },
RecoverDone { count: usize, destination: String },
@@ -53,7 +53,7 @@ impl Notice {
}
}
/// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
/// Sends a desktop notification asynchronously. Failures are silently ignored.
pub fn send(notice: Notice) {
let (title, body) = notice.text(&crate::settings::ui_lang());
std::thread::spawn(move || {
@@ -72,7 +72,7 @@ pub fn send(notice: Notice) {
#[cfg(target_os = "linux")]
fn command(title: &str, body: &str) -> Option<Command> {
// Como root, notifica na sessão do usuário real.
// When running as root, dispatch the notification inside the real user's session.
let mut c = if let Some(u) = crate::owner::invoking_user() {
let mut c = Command::new("runuser");
c.args(["-u", &u.name, "--", "env"]);
+11 -11
View File
@@ -1,6 +1,6 @@
//! Quando o Guará roda como administrador (sudo/pkexec), tudo que ele grava para
//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que
//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário.
//! When Guará runs elevated as root (`sudo`/`pkexec`), all user-facing outputs
//! (sessions, reports, recovered files) are chowned back to the invoking user
//! rather than root, and default paths resolve inside that user's home directory.
use std::io;
use std::path::{Path, PathBuf};
@@ -13,10 +13,10 @@ pub struct User {
pub home: PathBuf,
}
/// O usuário real por trás de um processo root iniciado por sudo/pkexec.
/// Identifies the real non-root user who launched an elevated `sudo`/`pkexec` process.
#[cfg(unix)]
pub fn invoking_user() -> Option<User> {
// SAFETY: geteuid não tem pré-condições.
// SAFETY: `geteuid` has no preconditions.
if unsafe { libc::geteuid() } != 0 {
return None;
}
@@ -30,7 +30,7 @@ pub fn invoking_user() -> Option<User> {
lookup_user(&passwd, uid, name.as_deref())
}
/// Procura o usuário (por uid ou, na falta dele, por nome) num conteúdo no formato do /etc/passwd.
/// Looks up a user (by UID first, falling back to username) in `/etc/passwd`-formatted text.
pub fn lookup_user(passwd: &str, uid: Option<u32>, name: Option<&str>) -> Option<User> {
for l in passwd.lines() {
let f: Vec<&str> = l.split(':').collect();
@@ -57,7 +57,7 @@ pub fn invoking_user() -> Option<User> {
None
}
/// Pasta pessoal para sugerir ao usuário (a dele, mesmo se estivermos como root).
/// Returns the invoking user's home directory (even when running as root).
pub fn user_home() -> PathBuf {
if let Some(u) = invoking_user() {
return u.home;
@@ -69,7 +69,7 @@ pub fn user_home() -> PathBuf {
)
}
/// Entrega o arquivo/pasta ao usuário que abriu o Guará (não faz nada fora do modo root).
/// Transfers ownership of `path` to the invoking user (no-op when not running as root).
pub fn give_to_user(path: &Path) {
#[cfg(unix)]
if let Some(u) = invoking_user() {
@@ -79,7 +79,7 @@ pub fn give_to_user(path: &Path) {
let _ = path;
}
/// Como `create_dir_all`, mas as pastas criadas ficam com o dono certo.
/// Like `fs::create_dir_all`, but chowns newly created directories to the invoking user.
pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
let mut missing = vec![];
let mut cur = Some(path);
@@ -97,7 +97,7 @@ pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
Ok(())
}
/// Como `fs::write`, mas o arquivo fica com o dono certo.
/// Like `fs::write`, but chowns the written file to the invoking user.
pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> {
if let Some(parent) = path.parent() {
create_dir_all_owned(parent)?;
@@ -126,7 +126,7 @@ mod tests {
(1001, 1002, "/home/maria")
);
assert!(lookup_user(PASSWD, Some(4242), None).is_none());
// o uid manda sobre o nome
// UID takes precedence over username
assert_eq!(
lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid,
1001
+23 -23
View File
@@ -1,5 +1,5 @@
//! Tabelas de partição (MBR/GPT), detecção de sistemas de arquivos,
//! volumes cifrados e busca de partições perdidas.
//! Partition tables (MBR/GPT), filesystem and encrypted volume detection,
//! and lost-partition scanning across unallocated gaps.
use crate::source::{read_vec, Source};
use crate::types::{DiskLayout, FsKind, Gap, PartInfo};
@@ -7,7 +7,7 @@ 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.
/// Identifies the filesystem or container starting at `start` and its byte size when known.
pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)> {
let b = read_vec(src, start, 4096).ok()?;
if b.len() < 1100 {
@@ -16,7 +16,7 @@ pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)>
if let Some(r) = detect_fs_in(&b) {
return Some(r);
}
// Assinaturas mais adiante no volume: Btrfs (64 KiB) e RAID mdadm 1.2 (4 KiB).
// Signatures located further into the volume: Btrfs (64 KiB) and mdadm 1.2 RAID (4 KiB).
if let Ok(h) = read_vec(src, start + 0x10000, 0x100) {
if h.len() >= 0x80 && &h[0x40..0x48] == b"_BHRfS_M" {
return Some((
@@ -33,7 +33,7 @@ pub fn detect_fs(src: &dyn Source, start: u64) -> Option<(FsKind, Option<u64>)>
None
}
/// Mesma detecção sobre um buffer que começa no setor candidato (>= 1100 bytes).
/// Runs volume signature detection on a buffer starting at the candidate sector (>= 1100 bytes).
pub fn detect_fs_in(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
if b.len() < 1100 {
return None;
@@ -133,8 +133,8 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
let root_secs = (root_entries * 32).div_ceil(bps);
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
let clusters = data / spc;
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta).
// BPB structure takes precedence: zero root-entry count + 32-bit FAT size = FAT32 even on
// small cluster counts (`mkfs.vfat -F 32` on small images produces this and Linux mounts it).
let is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
let kind = if is32 {
FsKind::Fat32
@@ -146,7 +146,7 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
Some((kind, Some(total * bps)))
}
/// Lê o rótulo (label) do volume quando disponível de forma simples.
/// Reads the volume label when directly accessible in the superblock/boot sector.
fn fat_label(b: &[u8], kind: FsKind) -> Option<String> {
let off = if kind == FsKind::Fat32 { 71 } else { 43 };
let s = String::from_utf8_lossy(b.get(off..off + 11)?)
@@ -155,9 +155,9 @@ fn fat_label(b: &[u8], kind: FsKind) -> Option<String> {
(!s.is_empty() && s != "NO NAME").then_some(s)
}
// ----------------------------------------------------------------- tabelas
// ----------------------------------------------------------------- partition tables
/// Analisa o disco. `search_lost` varre lacunas atrás de volumes perdidos (lento em discos grandes).
/// Inspects disk layout. `search_lost` scans unpartitioned gaps for lost volumes.
pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
let size = src.size();
let mut layout = DiskLayout {
@@ -174,7 +174,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
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).
// Sector 0 may be either a partitioned MBR or a partitionless VBR ("superfloppy").
let looks_boot = detect_fs_in(&read_vec(src, 0, 4096).unwrap_or_default()).is_some();
if !looks_boot {
let mp = read_mbr(src, &sector0, size);
@@ -197,7 +197,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
}
}
// Volume sem tabela de partições (pendrive "superfloppy" ou partição crua).
// Unpartitioned volume ("superfloppy" USB drive or single-partition image).
if parts.is_empty() {
if let Some((fs, len)) = detect_fs(src, 0) {
parts.push(PartInfo {
@@ -217,12 +217,12 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
parts.sort_by_key(|p| p.start);
layout.gaps = compute_gaps(&parts, size);
// Partições perdidas: procurar assinaturas de volumes nas lacunas.
// Lost partitions: scan unallocated gaps for volume signatures.
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.
// No partition table or volume at sector 0: scan the entire disk for lost volumes.
lost = find_lost(
src,
&[Gap {
@@ -304,7 +304,7 @@ fn read_mbr(src: &dyn Source, s0: &[u8], size: u64) -> Vec<PartInfo> {
continue;
}
if ty == 0xEE {
continue; // MBR protetor de GPT
continue; // GPT protective MBR
}
if matches!(ty, 0x05 | 0x0F | 0x85) {
ext_base = Some(lba * SECTOR);
@@ -365,7 +365,7 @@ fn part(index: usize, start: u64, len: u64, table_type: Option<String>) -> PartI
}
fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
// Tenta o cabeçalho primário (LBA 1) e depois o de backup (último setor).
// Try the primary GPT header (LBA 1) first, falling back to the backup header (last LBA).
let hdr = read_vec(src, SECTOR, 512)
.ok()
.filter(|h| h.len() == 512 && &h[0..8] == b"EFI PART")
@@ -383,7 +383,7 @@ fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
}
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.
// Backup GPT header points to the backup partition array; abort if both fail.
tbl.resize(n * esz, 0);
}
let mut out = vec![];
@@ -418,7 +418,7 @@ fn read_gpt(src: &dyn Source, size: u64) -> Option<Vec<PartInfo>> {
}
fn gpt_type_name(guid: &[u8]) -> &'static str {
// GUIDs em formato de disco (mixed-endian).
// Known partition type GUIDs in on-disk mixed-endian byte order.
match guid {
[0x28, 0x73, 0x2A, 0xC1, 0x1F, 0xF8, 0xD2, 0x11, 0xBA, 0x4B, 0x00, 0xA0, 0xC9, 0x3E, 0xC9, 0x3B] => {
"EFI System"
@@ -445,7 +445,7 @@ fn gpt_type_name(guid: &[u8]) -> &'static str {
}
}
/// Procura volumes (por assinatura) dentro de lacunas, em limites de setor.
/// Scans unallocated byte gaps on sector boundaries for lost filesystem/volume headers.
fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo> {
const CHUNK: u64 = 4 * 1024 * 1024;
let mut found: Vec<PartInfo> = vec![];
@@ -468,7 +468,7 @@ fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo
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.
// Fast pre-filter: only run full detection when a known magic byte sequence matches.
let quick = &s[3..11] == b"NTFS "
|| &s[3..11] == b"EXFAT "
|| &s[3..11] == b"-FVE-FS-"
@@ -477,13 +477,13 @@ fn find_lost(src: &dyn Source, gaps: &[Gap], known: &[PartInfo]) -> Vec<PartInfo
|| (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).
// ext2/3/4: the superblock sits 1024 bytes into the volume,
// so `off` (1024 bytes before the magic) is the candidate volume start.
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.
// Avoid false-positive FAT detection on generic MBR boot code.
if ok && (fs != FsKind::Fat12 || len >= 512 * 1024) {
let mut p =
part(0, abs, len, Some("encontrada por varredura".into()));
+14 -14
View File
@@ -1,8 +1,8 @@
//! Gerador mínimo de PDF (A4, texto e tabelas) para o relatório de recuperação.
//! Minimal standalone PDF generator (A4, text, and tables) for forensic recovery reports.
//!
//! Usa só as fontes padrão do PDF (Helvetica, Helvetica-Bold e Courier) com codificação
//! WinAnsi, então o arquivo é autocontido e não depende de fontes instaladas. Caracteres fora
//! do Latin-1/WinAnsi viram `?`.
//! Uses only built-in PDF Type 1 fonts (Helvetica, Helvetica-Bold, and Courier) with WinAnsi
//! encoding so output files are self-contained with no embedded font dependencies. Characters
//! outside Latin-1/WinAnsi are replaced with `?`.
use flate2::write::ZlibEncoder;
use flate2::Compression;
@@ -29,7 +29,7 @@ impl Font {
}
}
// Larguras AFM (milésimos de em) dos caracteres ASCII 32..=126.
// AFM glyph widths (in thousandths of an em) for ASCII 32..=126.
const HELV: [u16; 95] = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667,
@@ -45,7 +45,7 @@ const HELV_B: [u16; 95] = [
278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
];
/// Letra base de um caractere acentuado do Latin-1 (a largura é a da letra sem acento).
/// Maps a Latin-1 accented character to its ASCII base letter for glyph width lookup.
fn base_letter(c: char) -> char {
match c {
'À'..='Å' => 'A',
@@ -88,7 +88,7 @@ pub fn text_width(s: &str, size: f64, f: Font) -> f64 {
s.chars().map(|c| char_width(c, f)).sum::<f64>() * size
}
/// Byte WinAnsi de um caractere (ou `?`).
/// Converts a Unicode char to its WinAnsi byte (or `?` if unrepresentable).
fn win_ansi(c: char) -> u8 {
let n = c as u32;
match c {
@@ -120,8 +120,8 @@ fn escape(s: &str) -> String {
o
}
/// Quebra `s` em linhas de no máximo `width` pontos. Sem espaço onde quebrar (caminhos,
/// hashes), corta no meio da palavra.
/// Greedy word-wraps `s` into lines of at most `width` points. Long tokens without spaces
/// (paths, hashes) are broken mid-token.
pub fn wrap(s: &str, size: f64, f: Font, width: f64) -> Vec<String> {
let mut lines = vec![];
for para in s.split('\n') {
@@ -270,7 +270,7 @@ impl Pdf {
self.y -= 3.0;
}
/// Aviso destacado com barra lateral.
/// Renders a highlighted callout box with a colored left accent bar.
pub fn note(&mut self, s: &str) {
let size = 9.0;
let lines = wrap(s, size, Font::Regular, Self::content_width() - 16.0);
@@ -292,7 +292,7 @@ impl Pdf {
self.y -= h + 6.0;
}
/// Linhas rótulo/valor; o valor pode ser monoespaçado.
/// Renders key/value metadata rows (with optional monospace value styling).
pub fn kv(&mut self, rows: &[(&str, &str, Font)]) {
let lw = 140.0;
let size = 9.5;
@@ -318,7 +318,7 @@ impl Pdf {
self.y -= 4.0;
}
/// Tabela com cabeçalho repetido a cada página e linhas que quebram texto.
/// Renders a multi-page table with repeating headers and wrapped cell text.
pub fn table(&mut self, cols: &[Col], rows: &[Vec<String>]) {
let size = 8.0;
let pad = 3.0;
@@ -391,7 +391,7 @@ impl Pdf {
self.y -= 6.0;
}
/// Fecha o documento. `date` é a data de criação em UTC (`AAAAMMDDHHMMSS`).
/// Finalizes and serializes the PDF document. `date` is the UTC creation timestamp (`YYYYMMDDHHMMSS`).
pub fn finish(mut self, date: &str) -> Vec<u8> {
if !self.cur.is_empty() || self.pages.is_empty() {
self.pages.push(std::mem::take(&mut self.cur));
@@ -416,7 +416,7 @@ impl Pdf {
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
// 1 catálogo, 2 páginas, 3-5 fontes, 6 info, depois (página, conteúdo) por página
// PDF objects: 1 catalog, 2 pages root, 3-5 fonts, 6 info dict, then (page, content stream) per page
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
let kids: String = (0..total)
.map(|i| format!("{} 0 R", 7 + 2 * i))
+27 -27
View File
@@ -1,8 +1,8 @@
//! RAID por software: linear, 0, 1, 4, 5, 6 e 10, a partir dos discos soltos.
//! Software RAID reconstruction: linear, 0, 1, 4, 5, 6, and 10 from member disks.
//!
//! Lê os metadados do mdadm (superbloco 0.90 e 1.x) para montar sozinho; sem metadados, o
//! módulo `raid_detect` descobre ordem, tamanho de faixa e layout. Se faltarem discos, o
//! array é lido em modo degradado (paridade reconstrói o que faltar).
//! Parses Linux `mdadm` superblocks (v0.90 and v1.x) for automatic assembly; for metadata-less
//! arrays, `raid_detect` infers disk order, chunk size, and parity layout. Missing disks are
//! reconstructed on the fly in degraded mode via XOR / GF(2^8) parity.
use crate::source::{read_vec, Source};
use std::io;
@@ -34,7 +34,7 @@ impl Level {
}
}
/// Posição da paridade e ordem dos dados, como no driver md do Linux.
/// Parity placement and data stripe ordering, matching the Linux kernel `md` driver.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Layout {
@@ -42,7 +42,7 @@ pub enum Layout {
RightAsymmetric,
LeftSymmetric,
RightSymmetric,
/// RAID 4 e variantes "parity-0"/"parity-n".
/// RAID 4 and fixed-parity (`parity-first` / `parity-last`) variants.
ParityFirst,
ParityLast,
}
@@ -72,23 +72,23 @@ impl Layout {
#[derive(Clone)]
pub struct Member {
pub src: Arc<dyn Source>,
/// Onde começam os dados dentro do membro.
/// Byte offset where array data begins within this member disk.
pub data_offset: u64,
}
#[derive(Clone)]
pub struct RaidParams {
pub level: Level,
/// Tamanho da faixa por disco, em bytes.
/// Stripe chunk size per disk in bytes.
pub chunk: u64,
pub layout: Layout,
/// Membros na ordem do array; `None` = disco ausente.
/// Member disks in array slot order (`None` = missing/failed disk).
pub members: Vec<Option<Member>>,
/// RAID 10: cópias "perto" (near) e "longe" (far).
/// RAID 10 layout parameters: `near` and `far` replica counts.
pub near: u32,
pub far: u32,
pub far_offset: bool,
/// Bytes úteis por membro (0 = até o fim do menor).
/// Usable data bytes per member (0 = use the shortest member's length).
pub per_member: u64,
}
@@ -113,7 +113,7 @@ pub struct RaidSource {
per_member: u64,
}
// ------------------------------------------------------------ GF(2^8) para RAID 6
// ------------------------------------------------------------ GF(2^8) arithmetic for RAID 6
fn gf_mul(mut a: u8, mut b: u8) -> u8 {
let mut r = 0u8;
@@ -231,7 +231,7 @@ impl RaidSource {
Ok(())
}
/// (disco de paridade P, disco de paridade Q, disco que guarda o dado `d` da linha `row`)
/// Returns `(p_disk, q_disk, data_disk_for_d)` for stripe `row`.
fn raid5_map(&self, row: u64, d: usize) -> (usize, usize) {
let n = self.p.members.len();
let r = (row % n as u64) as usize;
@@ -273,7 +273,7 @@ impl RaidSource {
let disk = if self.p.layout == Layout::LeftSymmetric {
(pd + 2 + d) % n
} else {
// assimétrico: dados em ordem pulando P e Q
// asymmetric layout: data chunks placed in ascending disk order, skipping P and Q
let mut k = d;
for skip in sorted2(pd, qd) {
if k >= skip {
@@ -305,7 +305,7 @@ impl RaidSource {
}
}
/// Lê `buf.len()` bytes dentro de um único chunk lógico.
/// Reads `buf.len()` bytes within a single logical stripe chunk.
fn read_in_chunk(&self, chunk_idx: u64, within: u64, buf: &mut [u8]) -> io::Result<()> {
let n = self.p.members.len();
let c = self.p.chunk;
@@ -333,7 +333,7 @@ impl RaidSource {
if self.p.members[disk].is_some() {
return self.read_member(disk, off, buf);
}
// Reconstrói por XOR de todos os outros (dados + paridade) da linha.
// Reconstruct missing disk chunk by XORing all remaining data and parity chunks in the stripe.
buf.fill(0);
let mut tmp = vec![0u8; buf.len()];
for i in 0..n {
@@ -374,7 +374,7 @@ impl RaidSource {
}
}
/// Onde ficam todas as cópias de um chunk lógico (como `raid10_find_phys` do kernel).
/// Computes all physical `(disk, chunk_index)` replicas for a logical RAID 10 chunk (matches Linux `raid10_find_phys`).
fn r10_locations(&self, chunk_idx: u64) -> Vec<(usize, u64)> {
let n = self.p.members.len() as u64;
let c = self.p.chunk;
@@ -416,7 +416,7 @@ impl RaidSource {
let dd = n - 2;
let (pd, qd, _) = self.raid6_map(row, 0);
let len = buf.len();
// Estado de cada dado da linha.
// Read all available data chunks in the stripe.
let disks: Vec<usize> = (0..dd).map(|k| self.raid6_map(row, k).2).collect();
let missing_data: Vec<usize> = (0..dd)
.filter(|&k| self.p.members[disks[k]].is_none())
@@ -549,7 +549,7 @@ impl Source for RaidSource {
}
}
// ============================================================ metadados do mdadm
// ============================================================ mdadm superblock metadata
#[derive(Clone, Debug)]
pub struct MdSuper {
@@ -560,10 +560,10 @@ pub struct MdSuper {
pub layout: u32,
pub chunk: u64,
pub raid_disks: u32,
/// Papel deste disco no array (None = reserva/falho).
/// Slot index of this disk in the array (`None` = spare or failed).
pub role: Option<u32>,
pub data_offset: u64,
/// Bytes de dados por membro.
/// Usable data size per member in bytes.
pub data_size: u64,
pub events: u64,
pub array_size: u64,
@@ -589,10 +589,10 @@ fn level_from(v: i32) -> Option<Level> {
})
}
/// Procura um superbloco md nas posições possíveis de `src` (um disco ou partição).
/// Probes `src` (disk or partition) for an `mdadm` superblock at standard v1.x and v0.90 offsets.
pub fn read_md_super(src: &dyn Source) -> Option<MdSuper> {
let size = src.size();
// 1.1 no início, 1.2 a 4 KiB, 1.0 a 8 KiB do fim.
// v1.1 at 0, v1.2 at 4 KiB, v1.0 near end of device (8 KiB-aligned).
let mut cands: Vec<(u64, &str)> = vec![(0, "1.1"), (4096, "1.2")];
if size > 16384 {
cands.push(((size & !4095) - 8192, "1.0"));
@@ -644,7 +644,7 @@ pub fn read_md_super(src: &dyn Source) -> Option<MdSuper> {
array_size,
});
}
// 0.90: no fim, alinhado a 64 KiB.
// v0.90: within the last 64 KiB-aligned boundary at the end of the device.
if size > 131072 {
let off = (size & !65535) - 65536;
if let Ok(b) = read_vec(src, off, 4096) {
@@ -688,7 +688,7 @@ fn uuid_str(u: &[u8; 16]) -> String {
u.iter().map(|b| format!("{b:02x}")).collect()
}
/// Um array reconhecido pelos superblocos dos discos fornecidos.
/// A software RAID array assembled from `mdadm` superblocks.
pub struct FoundArray {
pub uuid: String,
pub name: String,
@@ -697,7 +697,7 @@ pub struct FoundArray {
pub missing: usize,
}
/// Agrupa membros por UUID e monta cada array (degradado se for possível).
/// Groups member disks by array UUID and assembles each array (including degraded arrays when recoverable).
pub fn assemble_md(members: Vec<(Arc<dyn Source>, MdSuper)>) -> Vec<Result<FoundArray, String>> {
let mut groups: Vec<(String, Vec<(Arc<dyn Source>, MdSuper)>)> = vec![];
for (s, m) in members {
@@ -709,7 +709,7 @@ pub fn assemble_md(members: Vec<(Arc<dyn Source>, MdSuper)>) -> Vec<Result<Found
}
let mut out = vec![];
for (uuid, mut list) in groups {
// O membro com mais eventos manda; membros desatualizados são tratados como ausentes.
// Use the member with the highest event counter as authoritative; stale members are treated as missing.
list.sort_by_key(|(_, m)| std::cmp::Reverse(m.events));
let top = list[0].1.clone();
let n = top.raid_disks as usize;
+10 -10
View File
@@ -1,5 +1,5 @@
//! Descobre sozinho nível, ordem dos discos, tamanho de faixa e layout de um RAID sem
//! metadados, testando as combinações e medindo a coerência do sistema de arquivos que sai.
//! Automatically detects level, disk order, chunk size, and parity layout of a metadata-less
//! RAID array by scoring parity consistency and filesystem structural coherence across combinations.
use crate::partition;
use crate::raid::{Layout, Level, Member, RaidParams, RaidSource};
@@ -12,7 +12,7 @@ pub struct Candidate {
pub level: Level,
pub chunk: u64,
pub layout: Layout,
/// Índices dos membros fornecidos, na ordem do array.
/// Indices into the input member slice, ordered by array slot.
pub order: Vec<usize>,
pub score: f64,
}
@@ -96,7 +96,7 @@ fn ntfs_score(src: &dyn Source, start: u64) -> f64 {
if r.len() < 64 {
continue;
}
// Registros além do fim do MFT estão zerados: não contam contra.
// Zero-filled blocks past the active MFT records do not penalize the score.
if r.iter().all(|&x| x == 0) {
continue;
}
@@ -112,7 +112,7 @@ fn ntfs_score(src: &dyn Source, start: u64) -> f64 {
}
}
/// Coerência do volume que começa em `start`.
/// Scores the structural coherence of a candidate volume starting at `start`.
fn volume_score(src: &dyn Source, start: u64, fs: FsKind) -> f64 {
match fs {
FsKind::Ntfs => ntfs_score(src, start),
@@ -140,7 +140,7 @@ fn first_sector_plausible(src: &dyn Source) -> bool {
b.len() >= 1100 && ((b[510] == 0x55 && b[511] == 0xAA) || partition::detect_fs_in(&b).is_some())
}
/// Testa as combinações e devolve as melhores (maior pontuação primeiro).
/// Evaluates candidate RAID configurations and returns them sorted by score (best first).
pub fn detect(
members: &[Arc<dyn Source>],
cancelled: &(dyn Fn() -> bool + Sync),
@@ -149,7 +149,7 @@ pub fn detect(
if n < 2 {
return vec![];
}
// Trabalha sobre um prefixo em memória de cada disco.
// Operate on an in-memory prefix of each member disk for fast candidate evaluation.
let cached: Vec<Arc<dyn Source>> = members
.iter()
.map(|m| {
@@ -163,7 +163,7 @@ pub fn detect(
}
let mut out: Vec<Candidate> = vec![];
// RAID 1: cópias idênticas.
// RAID 1: identical mirror copies.
let probe = |s: &Arc<dyn Source>| read_vec(s.as_ref(), 0, 1 << 20).unwrap_or_default();
let first = probe(&cached[0]);
if first.iter().any(|&x| x != 0) && cached.iter().skip(1).all(|c| probe(c) == first) {
@@ -177,7 +177,7 @@ pub fn detect(
out.push(c);
}
// RAID 5/4 satisfaz "XOR de todos os discos = 0" em toda faixa.
// RAID 5/4 satisfies `XOR across all disks == 0` on every active stripe.
let sample = 4usize << 20;
let bufs: Vec<Vec<u8>> = cached
.iter()
@@ -246,7 +246,7 @@ pub fn detect(
let Ok(r) = RaidSource::new(params) else {
continue;
};
// O primeiro setor do volume precisa estar num disco que o tenha.
// The first sector of the candidate volume must reside on a disk where that signature was seen.
if any_first {
let mut b = [0u8; 512];
let _ = r.read_at(0, &mut b);
+6 -6
View File
@@ -1,19 +1,19 @@
//! Reencontra o disco de uma sessão quando ele foi desconectado e voltou com
//! outro nome (por exemplo `/dev/sda` virando `/dev/sdb`).
//! Re-identifies and reconnects a session's source disk when unplugged and reattached
//! under a different OS device node (e.g. `/dev/sda` becoming `/dev/sdb`).
use crate::device;
use crate::source::{fingerprint, open_path, Source};
use std::path::Path;
use std::sync::Arc;
/// A origem é compatível com o que a sessão espera?
/// `size == 0` ou `fp == None` significam "não sei" e não reprovam.
/// Checks whether `src` matches the size and fingerprint expected by the session.
/// `size == 0` or `fp == None` mean unknown (legacy session) and do not reject the source.
pub fn matches(src: &dyn Source, size: u64, fp: Option<&str>) -> bool {
(size == 0 || src.size() == size) && fp.map_or(true, |f| fingerprint(src).as_deref() == Some(f))
}
/// Procura entre os discos conectados o que corresponde à sessão.
/// Com impressão digital, exige igualdade; sem ela, só aceita se houver um único disco do mesmo tamanho.
/// Searches attached storage devices for the disk matching the session's fingerprint.
/// Requires an exact fingerprint match when present; otherwise accepts a unique size match.
pub fn find_matching_source(
size: u64,
fp: Option<&str>,
+23 -23
View File
@@ -1,5 +1,5 @@
//! Gravação dos arquivos recuperados (sempre em outro destino) com SHA-256
//! e manifesto da sessão.
//! Writes recovered files to a separate destination volume along with SHA-256
//! digests, recovery journal, and session manifest.
use crate::error::{Error, Result};
use crate::fs::sanitize_name;
@@ -33,12 +33,12 @@ pub struct RecoverReport {
pub failed: Vec<(u64, String)>,
pub total_bytes: u64,
pub cancelled: bool,
/// Arquivos já recuperados e conferidos numa execução anterior (pulados ao retomar).
/// Files already recovered and SHA-256 verified in a prior run (skipped on resume).
#[serde(default)]
pub skipped: usize,
}
/// Recusa destinos que ficam no mesmo dispositivo da origem.
/// Rejects destination paths located on the same physical device as the source.
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
crate::owner::create_dir_all_owned(dest)?;
let dest = dest.canonicalize()?;
@@ -69,24 +69,24 @@ pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()>
}
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.
// For image files: only reject writing directly over the source image file itself.
}
}
Ok(())
}
/// Como organizar os arquivos recuperados dentro do destino.
/// Directory layout strategy for recovered files inside the destination folder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RecoverLayout {
/// Pastas originais quando conhecidas; o resto separado por tipo (padrão).
/// Preserve original folder hierarchy when known; group carved files by type (default).
#[default]
Folders,
/// Uma pasta por tipo (Fotos, Documentos, Vídeos...).
/// Group into one folder per category (Photos, Documents, Videos, etc.).
Type,
/// Pastas por ano e mês (data da foto, ou a de modificação).
/// Group into `YYYY/YYYY-MM` folders by capture or modification date.
Date,
/// Tudo direto na pasta de destino.
/// Place all files flat inside the destination folder.
Flat,
}
@@ -130,7 +130,7 @@ pub fn recover_with(
recover_resumable(src, files, dest, layout, "", false, cancelled, progress)
}
/// Uma linha do diário de recuperação (`guara-diario.jsonl`): cada arquivo gravado e conferido.
/// A single entry in the recovery journal (`guara-diario.jsonl`) for a written and verified file.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct JournalEntry {
source: String,
@@ -156,9 +156,9 @@ fn sha256_of_file(p: &Path) -> Option<String> {
Some(h.finalize().sha256)
}
/// Como `recover_with`, mas mantém um diário e, com `resume`, pula o que já foi
/// recuperado (e conferido por SHA-256) numa execução interrompida.
/// `source_key` identifica a origem para que o diário de outro disco não seja reaproveitado.
/// Like `recover_with`, but maintains an append-only journal and, when `resume` is set, skips
/// files already written and SHA-256 verified during an interrupted run.
/// `source_key` binds the journal to the source disk so another disk's journal is never reused.
#[allow(clippy::too_many_arguments)]
pub fn recover_resumable(
src: &dyn Source,
@@ -202,7 +202,7 @@ pub fn recover_resumable(
break;
}
progress(i, files.len(), report.total_bytes);
// retomada: já está lá e confere?
// resume check: does the file already exist on disk with the expected size and SHA-256?
if let Some(e) = prior.get(&f.id) {
let p = dest.join(&e.file.path);
if e.name == f.name && e.file.size == f.size && p.is_file() {
@@ -215,7 +215,7 @@ pub fn recover_resumable(
report.recovered.push(e.file.clone());
continue;
}
// incompleto ou alterado: refaz (é um arquivo nosso)
// incomplete or modified partial file from our previous run: overwrite it
let _ = std::fs::remove_file(&p);
}
}
@@ -321,7 +321,7 @@ fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
p
}
/// Espaço livre (bytes) no volume onde `path` está (ou estaria).
/// Returns available free bytes on the filesystem containing `path`.
pub fn free_space(path: &Path) -> Option<u64> {
let mut p = path;
while !p.exists() {
@@ -331,7 +331,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
{
use std::os::unix::ffi::OsStrExt;
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?;
// SAFETY: `c` é uma string C válida e `st` é uma estrutura de saída local.
// SAFETY: `c` is a valid NUL-terminated C string and `st` is a valid local output struct.
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
return None;
@@ -348,7 +348,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
.chain(std::iter::once(0))
.collect();
let mut free = 0u64;
// SAFETY: `wide` termina em NUL e o ponteiro de saída é válido.
// SAFETY: `wide` is NUL-terminated and the output pointer is valid.
let ok = unsafe {
GetDiskFreeSpaceExW(
wide.as_ptr(),
@@ -361,7 +361,7 @@ pub fn free_space(path: &Path) -> Option<u64> {
}
}
/// Escreve o conteúdo e devolve o SHA-256.
/// Streams the recovered file content to `out` and returns its hex SHA-256 digest.
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
let mut out = std::fs::OpenOptions::new()
.write(true)
@@ -443,8 +443,8 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
Ok(hasher.finalize())
}
/// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset`
/// (usado para streaming/pré-visualização sem gravar em disco).
/// Reads up to `buf.len()` bytes of a recovered file's logical content starting at `offset`
/// (used for zero-copy streaming and preview without writing to disk).
pub fn read_found_at(
src: &dyn Source,
f: &Found,
@@ -510,7 +510,7 @@ pub fn read_found_at(
}
}
/// Um arquivo achado visto como origem de leitura posicional (para leitores de formato).
/// Adapter exposing a recovered `Found` file as a positional `Source` (for format parsers).
pub struct FoundSource<'a> {
pub src: &'a dyn Source,
pub found: &'a Found,
+8 -8
View File
@@ -1,5 +1,5 @@
//! Exportação para análise e relatório de recuperação (cadeia de custódia):
//! CSV dos achados e um relatório HTML autocontido, pronto para imprimir.
//! Forensic export and chain-of-custody recovery reporting:
//! CSV findings table and self-contained, print-ready HTML/PDF reports.
use crate::hashing::Hashes;
use crate::meta::fmt_unix;
@@ -19,11 +19,11 @@ pub struct CaseInfo {
pub notes: String,
}
/// Situação da trilha de auditoria no momento do relatório.
/// Status of the audit hash chain at the time the report is generated.
pub struct AuditNote {
pub entries: usize,
pub ok: bool,
/// Hash da última entrada, para guardar fora da máquina.
/// Head hash of the latest audit entry, to be archived off-machine.
pub head: String,
}
@@ -42,7 +42,7 @@ pub struct ReportInput<'a> {
}
fn cell(s: &str) -> String {
// aspas e escapa; neutraliza "injeção de fórmula" em planilhas
// quote and escape; neutralize spreadsheet formula injection (`=`, `+`, `-`, `@`)
let mut t = s.replace('"', "\"\"");
if t.starts_with(['=', '+', '-', '@', '\t', '\r']) {
t.insert(0, '\'');
@@ -66,7 +66,7 @@ fn iso(t: Option<i64>) -> String {
}
}
/// CSV com `;` (abre direto no Excel em português) e BOM UTF-8.
/// Writes a UTF-8 BOM `;`-delimited CSV of findings.
pub fn csv(found: &[&Found]) -> String {
let mut out = String::from("\u{feff}id;nome;pasta;extensao;categoria;tamanho_bytes;origem;confianca;modificado_utc;criado_utc;acessado_utc;foto_tirada;camera;largura;altura;gps;deslocamento_no_disco;observacao\r\n");
for f in found {
@@ -237,7 +237,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
o.push_str("</table>");
}
// Resumo
// Summary
let mut by_cat = std::collections::BTreeMap::new();
let mut conf = [0usize; 3];
let mut bytes = 0u64;
@@ -318,7 +318,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background
o
}
/// Relatório em PDF (A4), com as mesmas seções do HTML.
/// Generates an A4 PDF recovery report containing the same sections as the HTML report.
pub fn pdf(r: &ReportInput) -> Vec<u8> {
use crate::pdf::{Col, Font, Pdf, BLACK, GRAY};
let now = std::time::SystemTime::now()
+15 -15
View File
@@ -1,5 +1,5 @@
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos →
//! carving no espaço livre/desconhecido → fusão sem duplicatas.
//! Scan orchestrator: partition layout -> filesystem metadata readers ->
//! signature carving across free/unallocated space -> deduplicated merge.
use crate::carve::{carve_range, CarveCfg, CarveEnd, CarveEvents, HitAction};
use crate::fs::{self, merge_extents, overlaps, Ctx};
@@ -18,12 +18,12 @@ pub struct Progress {
pub total: u64,
}
/// Receptor dos resultados (a interface/CLI implementa).
/// Callback sink for scan progress and findings (implemented by the CLI and UI service).
pub trait Sink {
fn found(&mut self, f: Found);
/// Um achado de carving confirmou um arquivo já listado por metadados.
/// A signature-carved hit confirmed a file already discovered via filesystem metadata.
fn confirm(&mut self, id: u64, confidence: Confidence);
/// Um achado já emitido ganhou dados melhores (ex.: JPEG remontado de fragmentos).
/// A previously emitted finding was upgraded (e.g. fragmented JPEG reassembled or MP4 `moov` rebuilt).
fn replace(&mut self, f: Found) {
self.found(f);
}
@@ -39,14 +39,14 @@ pub struct RangeState {
pub next: u64,
}
/// Estado serializável para pausar/retomar.
/// Serializable scan state for pause/resume checkpoints.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Checkpoint {
pub fs_done: bool,
pub ranges: Vec<RangeState>,
pub next_id: u64,
pub layout: Option<DiskLayout>,
/// A varredura parou porque o dispositivo parou de responder (desconectado?).
/// True if the scan halted because the source device stopped responding (disconnected).
#[serde(default)]
pub io_lost: bool,
}
@@ -93,7 +93,7 @@ pub fn run(
layout.gaps.clear();
layout.table = "membro de RAID".into();
}
// Volumes lógicos e outros volumes montados que não estão na tabela do disco.
// Logical volumes and unlocked virtual volumes not present in the physical partition table.
for p in &opts.extra_parts {
if !layout.partitions.iter().any(|q| q.start == p.start) {
let mut p = p.clone();
@@ -103,7 +103,7 @@ pub fn run(
}
cp.layout = Some(layout.clone());
// Índices para fusão: primeiro offset -> (id, confiança), e extents de achados por metadados.
// Merge indices: `first_offset -> (id, confidence)` and allocated extents of metadata findings.
let mut starts: HashMap<u64, (u64, Confidence)> = HashMap::new();
let mut fs_extents: Vec<Extent> = vec![];
for f in existing {
@@ -239,7 +239,7 @@ pub fn run(
Outcome::Completed
}
/// Arquivo que parece quebrado: tenta recompor depois de varrer o disco todo.
/// Truncated/fragmented candidate queued for post-scan reassembly once all clusters are indexed.
struct Pending {
found: Found,
hit_len: u64,
@@ -256,16 +256,16 @@ struct Handler<'a> {
total: u64,
index: crate::defrag::ClusterIndex,
pending: Vec<Pending>,
/// Vídeos íntegros achados no carving (início, tamanho): fontes de SPS/PPS.
/// Intact MP4 videos found during carving `(offset, size)`: donor sources for SPS/PPS.
intact_mp4: Vec<(u64, u64)>,
refs: Vec<crate::mp4rebuild::MediaConfig>,
/// Extents de arquivos carved já emitidos (não podem servir de fragmento para outro).
/// Byte extents of already-emitted carved files (excluded from candidate defrag fragments).
carved: Vec<Extent>,
}
impl Handler<'_> {
fn rebuild_mp4(&mut self, p: Pending, off: u64) {
// Referências: as informadas pelo usuário e os vídeos íntegros mais próximos no disco.
// Donor references: user-supplied reference videos plus the closest intact videos on disk.
let mut refs = self.refs.clone();
let mut near = self.intact_mp4.clone();
near.sort_by_key(|&(o, _)| o.abs_diff(off));
@@ -316,7 +316,7 @@ impl Handler<'_> {
}
let mut used = self.fs_extents.clone();
used.extend(self.carved.iter().copied());
// O próprio começo do arquivo não conta como "ocupado" para ele mesmo.
// Exclude the file's own leading prefix from the claimed-extents filter.
used.retain(|e| !(e.offset == off));
let pool = crate::defrag::Pool {
index: &self.index,
@@ -365,7 +365,7 @@ impl CarveEvents for Handler<'_> {
return HitAction::Skip;
}
if overlaps(&self.fs_extents, off, 1) {
return HitAction::Continue; // dentro de arquivo já listado (ex.: miniatura embutida)
return HitAction::Continue; // embedded inside an already-listed file (e.g. EXIF thumbnail): skip duplicate
}
let mut hit = hit.clone();
let mut skip_len = None;
+8 -8
View File
@@ -1,5 +1,5 @@
//! Busca de texto bruta em todo o disco (inclusive espaço livre e não particionado),
//! em UTF-8 e UTF-16LE, útil quando não há estrutura de arquivo para recuperar.
//! Raw full-disk text search (including free and unpartitioned space)
//! across UTF-8 and UTF-16LE encodings, useful when filesystem structures are gone.
use crate::source::Source;
use serde::{Deserialize, Serialize};
@@ -7,10 +7,10 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchOpts {
pub terms: Vec<String>,
/// Ignora maiúsculas/minúsculas (letras ASCII).
/// Case-insensitive matching (ASCII letters).
#[serde(default = "yes")]
pub ignore_case: bool,
/// Procura também a codificação UTF-16 (Windows, Office, bancos de dados).
/// Also match UTF-16LE encoded strings (Windows, Office, SQLite, etc.).
#[serde(default = "yes")]
pub utf16: bool,
#[serde(default = "default_max")]
@@ -116,7 +116,7 @@ fn snippet(buf: &[u8], at: usize, len: usize, utf16: bool) -> String {
.collect()
}
/// Varre `src` inteira. Retorna o total de ocorrências. `on_hit` pode devolver false para parar.
/// Scans `src` from start to end. Returns total matches found; `on_hit` may return `false` to stop.
pub fn search(
src: &dyn Source,
o: &SearchOpts,
@@ -144,7 +144,7 @@ pub fn search(
Ok(0) => break,
Ok(n) => got += n,
Err(_) => {
// setor ruim: pula este trecho
// unreadable chunk: skip and continue
for b in &mut buf[got..want] {
*b = 0;
}
@@ -155,7 +155,7 @@ pub fn search(
if got == 0 {
break;
}
let fresh = got.min(CHUNK); // início de ocorrências aceitas neste bloco
let fresh = got.min(CHUNK); // start offsets of accepted hits within this chunk (prevents duplicate UTF-8/16 hits at same pos)
let folded: Vec<u8>;
let hay: &[u8] = if o.ignore_case {
folded = buf[..got].to_ascii_lowercase();
@@ -163,7 +163,7 @@ pub fn search(
} else {
&buf[..got]
};
let mut chunk_hits: Vec<(usize, usize)> = vec![]; // (offset no bloco, índice do padrão)
let mut chunk_hits: Vec<(usize, usize)> = vec![]; // (offset_in_chunk, pattern_index)
for (pi, p) in pats.iter().enumerate() {
find_all(hay, p, |i| {
if i < fresh {
+56 -56
View File
@@ -1,5 +1,5 @@
//! 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.
//! Background service layer backing the GUI (Tauri and `guara ui` mode):
//! manages scan/recovery/imaging jobs, sessions, and JSON-RPC dispatch.
use crate::device;
use crate::error::{Error, Result};
@@ -49,7 +49,7 @@ struct State {
report: Option<Value>,
started: Instant,
finished_in: Option<f64>,
/// Leitura de datas EXIF/dimensões em andamento: (feitos, total).
/// Background EXIF/dimension enrichment progress: `(done, total)`.
enrich: Option<(u64, u64)>,
recoveries: Vec<recover::RecoverReport>,
started_unix: Option<i64>,
@@ -64,11 +64,11 @@ pub struct Job {
cancel: AtomicBool,
stop_requested: AtomicBool,
src: Mutex<Option<Arc<dyn Source>>>,
/// Caminho atualmente usado para ler a origem (pode mudar se o disco voltar com outro nome).
/// Active device/image path used to read the source (may update if reattached under a new node).
src_path: Mutex<String>,
/// Impressão digital do disco (reconhecer o mesmo disco depois de reconectar).
/// Source disk content fingerprint (used to recognize the drive after reconnection).
fingerprint: Mutex<Option<String>>,
/// Arquivo de sessão salvo automaticamente.
/// Path to the autosaved session file.
session_file: Mutex<Option<PathBuf>>,
st: Mutex<State>,
}
@@ -213,7 +213,7 @@ impl Engine {
job
}
// ---------------------------------------------------------------- varredura
// ---------------------------------------------------------------- scan
pub fn start_scan(&self, path: &str, mut opts: ScanOptions) -> Result<String> {
let raw = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
@@ -282,7 +282,7 @@ impl Engine {
}
s.checkpoint.as_ref().is_some_and(|c| c.io_lost)
};
// Sem origem, ou depois de uma desconexão: reabre o disco (mesmo com outro nome).
// Source not yet open or previously disconnected: try to reopen (even under a new device name).
if j.source().is_none() || io_lost {
self.reconnect(&j).map_err(|e| {
Error::msg(format!(
@@ -359,14 +359,14 @@ impl Engine {
Ok(json!({ "total": total, "items": page }))
}
/// Contagens para a navegação lateral (tipos, datas, pastas, câmeras).
/// Sidebar navigation facet counts (categories, dates, folders, cameras).
pub fn facets(&self, id: &str) -> Result<Value> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
Ok(crate::explorer::facets(&s.found))
}
/// Esquema de um banco SQLite achado, com o número de linhas de cada tabela.
/// Schema of a recovered SQLite database, including row counts per table.
pub fn db_info(&self, id: &str, file: u64) -> Result<Value> {
let (f, src) = self.open_file(id, file)?;
let fs = recover::FoundSource {
@@ -383,7 +383,7 @@ impl Engine {
Ok(v)
}
/// Uma página de linhas de uma tabela (ou os registros apagados prováveis dela).
/// A page of live table rows (or carvable deleted records from free blocks).
pub fn db_rows(
&self,
id: &str,
@@ -415,13 +415,13 @@ impl Engine {
Ok(serde_json::to_value(&page)?)
}
/// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo.
/// Detailed format metadata (EXIF, PDF, Office, video, etc.) for a recovered file.
pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> {
let (f, src) = self.open_file(id, file)?;
Ok(crate::meta::extract(src.as_ref(), &f))
}
/// Miniatura JPEG (cache em memória; decodificações limitadas em paralelo).
/// Generates or fetches an in-memory cached JPEG thumbnail (with bounded concurrency).
pub fn thumb(&self, job: &str, id: u64, size: u32) -> std::result::Result<Arc<Vec<u8>>, u16> {
let size = size.clamp(48, 640);
let key = (job.to_string(), id, size);
@@ -445,7 +445,7 @@ impl Engine {
}
}
/// Achado + origem para leitura posicional (streaming de pré-visualização).
/// Returns `(Found, Source)` for positional streaming preview.
pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc<dyn Source>)> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
@@ -456,7 +456,7 @@ impl Engine {
Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
}
// ---------------------------------------------------------------- recuperação
// ---------------------------------------------------------------- recovery
pub fn start_recover(
&self,
@@ -575,13 +575,13 @@ impl Engine {
Ok(job.id.clone())
}
// ---------------------------------------------------------------- imagem
// ---------------------------------------------------------------- disk imaging
pub fn start_image(&self, path: &str, dest: &str) -> Result<String> {
self.start_image_as(path, dest, &image::ImageFormat::Raw)
}
/// Cria a imagem em bruto ou em outro formato forense (E01 ou AFF4).
/// Creates a raw, E01, or AFF4 forensic disk image.
pub fn start_image_as(
&self,
path: &str,
@@ -644,9 +644,9 @@ impl Engine {
Ok(job.id.clone())
}
// ---------------------------------------------------------------- exportação e relatório
// ---------------------------------------------------------------- export and report
/// CSV dos achados que atendem ao filtro (ou todos). Devolve quantas linhas gravou.
/// Exports matching findings (or all findings) to CSV. Returns the number of rows written.
pub fn export_csv(&self, id: &str, path: &str, q: Option<&Query>) -> Result<usize> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
@@ -662,7 +662,7 @@ impl Engine {
Ok(items.len())
}
/// Relatório HTML de recuperação (identificação do caso, origem, achados, hashes).
/// Generates an HTML or PDF forensic recovery report (case info, source, findings, hashes).
pub fn export_report(
&self,
id: &str,
@@ -714,7 +714,7 @@ impl Engine {
Ok(())
}
// ---------------------------------------------------------------- busca de texto e hash da origem
// ---------------------------------------------------------------- text search and source hashing
pub fn start_search(&self, path: &str, opts: crate::search::SearchOpts) -> Result<String> {
if opts.terms.iter().all(|t| t.trim().is_empty()) {
@@ -776,7 +776,7 @@ impl Engine {
}))
}
/// Calcula MD5/SHA-1/SHA-256 da origem inteira (verificação de integridade).
/// Computes MD5/SHA-1/SHA-256 across the entire source (integrity verification job).
pub fn start_hash(&self, path: &str) -> Result<String> {
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
let job = self.register(
@@ -833,7 +833,7 @@ impl Engine {
Ok(job.id.clone())
}
// ---------------------------------------------------------------- sessões
// ---------------------------------------------------------------- sessions
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?;
@@ -860,7 +860,7 @@ impl Engine {
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
))
})?;
// Clicar de novo numa sessão já aberta reaproveita o job em vez de criar outro igual.
// Re-opening an already loaded session reuses its existing job ID.
let want = Path::new(path);
if let Some(j) = self
.jobs
@@ -899,8 +899,8 @@ impl Engine {
Status::Paused
};
}
// A sessão sempre abre. Tenta religar o disco (inclusive se ele voltou com outro nome);
// sem a origem ainda dá para navegar, exportar e gerar relatório.
// Always load the session metadata. Attempt to reconnect the source disk (even under a new path);
// if the source is offline, browsing, filtering, CSV export, and reporting still work.
match self.reconnect(&job) {
Ok(Some(nome)) => job.st.lock().unwrap().warnings.push(format!(
"O disco foi reconhecido de novo como {nome} (o nome mudou desde a varredura)."
@@ -911,7 +911,7 @@ impl Engine {
data.target
)),
}
// continua salvando automaticamente no mesmo arquivo se a varredura for retomada
// keep autosaving to the same file if the scan is resumed
*job.session_file.lock().unwrap() = Some(PathBuf::from(path));
crate::audit::record(
"session.load",
@@ -923,9 +923,9 @@ impl Engine {
Ok(job.id.clone())
}
/// Reabre a origem da sessão: tenta o caminho atual e o original e, se o disco voltou com
/// outro nome, procura entre os dispositivos conectados o que tem o mesmo conteúdo.
/// `Ok(Some(caminho))` quando achou o disco num caminho diferente do esperado.
/// Reopens the session's source: tries the current and original paths first, then scans attached
/// devices for a matching fingerprint if the drive reconnected under a new device node.
/// Returns `Ok(Some(new_path))` when found at a different device path.
fn reconnect(&self, j: &Arc<Job>) -> Result<Option<String>> {
let fp = j.fingerprint.lock().unwrap().clone();
let size =
@@ -970,8 +970,8 @@ impl Engine {
))
}
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
/// Recusa uma origem que não seja o mesmo disco, a menos que `force`.
/// Rebinds a loaded session to a user-selected source device or disk image.
/// Rejects a source whose fingerprint/size mismatches the session unless `force` is set.
pub fn relink(&self, id: &str, path: &str, force: bool) -> Result<()> {
let j = self.job(id)?;
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
@@ -1010,7 +1010,7 @@ impl Engine {
Ok(())
}
/// Discos conectados que podem ser a origem desta sessão (mesmo tamanho primeiro).
/// Lists attached disks that could match this session's source (same-size candidates first).
pub fn candidates(&self, id: &str) -> Result<Value> {
let j = self.job(id)?;
let fp = j.fingerprint.lock().unwrap().clone();
@@ -1042,9 +1042,9 @@ impl Engine {
Ok(json!(out))
}
// ---------------------------------------------------------------- sessões automáticas
// ---------------------------------------------------------------- autosaved sessions
/// Sessões salvas automaticamente (mais recentes primeiro).
/// Lists autosaved sessions (most recent first).
pub fn sessions_list(&self) -> Value {
let dir = sessions_dir();
let mut items: Vec<Value> = vec![];
@@ -1071,7 +1071,7 @@ impl Engine {
json!(items)
}
/// Apaga uma sessão automática (e a miniatura de metadados).
/// Deletes an autosaved session file (and its `.meta.json` sidecar).
pub fn session_delete(&self, path: &str) -> Result<()> {
let p = Path::new(path);
if p.parent() != Some(sessions_dir().as_path()) {
@@ -1162,7 +1162,7 @@ impl Engine {
"health" => json!(crate::health::check(&s("path")?)),
"open_url" => {
let u = s("url")?;
// só mapas do OpenStreetMap: evita abrir endereços arbitrários a partir de metadados
// restrict to OpenStreetMap URLs: never open arbitrary URLs derived from untrusted file metadata
if !u.starts_with("https://www.openstreetmap.org/") {
return Err(Error::msg("endereço não permitido"));
}
@@ -1332,7 +1332,7 @@ struct SessionFile {
use crate::explorer::cat_key;
/// 503 quando a origem da sessão está desconectada; 404 nos demais casos.
/// Returns HTTP 503 when the session source is disconnected, or 404 otherwise.
fn status_of(e: Error) -> u16 {
if e.to_string().contains("não está disponível") {
503
@@ -1356,7 +1356,7 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
}
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
// Salva a sessão sozinho enquanto a varredura roda (e ao terminar/pausar).
// Periodically autosaves the session while scanning (and upon completion or pause).
let saver = job.clone();
std::thread::spawn(move || autosave_loop(saver));
std::thread::spawn(move || {
@@ -1420,8 +1420,8 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
});
}
/// Lê EXIF/dimensões das imagens achadas (em segundo plano) para permitir
/// navegar por data, câmera e resolução.
/// Background worker that extracts EXIF dates, camera models, GPS, and image dimensions
/// so findings can be browsed by date, camera, and resolution.
fn spawn_enrich(job: Arc<Job>) {
std::thread::spawn(move || {
let Some(src) = job.source() else { return };
@@ -1487,8 +1487,8 @@ pub fn base64(data: &[u8]) -> String {
out
}
/// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar
/// script (HTML/SVG/XML) saem como texto puro.
/// Returns a safe MIME type for preview streaming. Formats capable of executing
/// scripts (HTML/SVG/XML) are served as `text/plain`.
pub fn mime_for(ext: &str) -> &'static str {
match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
@@ -1513,13 +1513,13 @@ pub fn mime_for(ext: &str) -> &'static str {
}
}
/// Trecho de um arquivo achado, pronto para responder a uma requisição HTTP
/// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri.
/// Byte slice of a recovered file, ready to answer an HTTP `Range` request
/// in both `guara ui` mode and the Tauri custom protocol handler.
pub struct FileSlice {
found: Found,
src: Arc<dyn Source>,
pub start: u64,
/// Inclusivo.
/// Inclusive end byte offset.
pub end: u64,
pub total: u64,
pub partial: bool,
@@ -1549,7 +1549,7 @@ impl FileSlice {
("Accept-Ranges", "bytes".into()),
("Cache-Control", "no-store".into()),
("X-Content-Type-Options", "nosniff".into()),
// Conteúdo recuperado é não confiável: nunca executa script nem carrega nada.
// Recovered content is untrusted: forbid script execution and external resource loading via CSP.
(
"Content-Security-Policy",
"sandbox; default-src 'none'".into(),
@@ -1569,7 +1569,7 @@ impl FileSlice {
}
h
}
/// Lê o trecho inteiro em memória (limitado a `cap` bytes).
/// Reads the requested byte range into memory (capped at `cap` bytes).
pub fn read_all(&self, cap: u64) -> std::io::Result<Vec<u8>> {
let n = self.len().min(cap) as usize;
let mut out = vec![0u8; n];
@@ -1621,7 +1621,7 @@ impl std::io::Read for FileSliceReader {
}
impl Engine {
/// Resolve `/file/<job>/<id>` + cabeçalho Range. `Err(status)` em caso de falha.
/// Resolves `/file/<job>/<id>` with optional `Range` header. Returns `Err(http_status)` on failure.
pub fn file_slice(
&self,
job: &str,
@@ -1663,9 +1663,9 @@ impl Engine {
}
}
// ---------------------------------------------------------------- sessões automáticas
// ---------------------------------------------------------------- autosaved sessions
/// Reaplica ao disco reaberto a montagem (volumes desbloqueados, RAID) da sessão.
/// Re-applies the session's virtual disk assembly (unlocked volumes, RAID, LVM) to a reopened source.
fn finish_source(j: &Job, raw: Arc<dyn Source>) -> Arc<dyn Source> {
match crate::assemble::assemble_source(raw.clone(), &j.opts.assembly) {
Ok(a) => a.source,
@@ -1700,7 +1700,7 @@ fn new_session_path(target: &str) -> PathBuf {
fn snapshot(j: &Job) -> SessionFile {
let s = j.st.lock().unwrap();
// Senhas e chaves nunca vão para o arquivo de sessão (só as chaves de volume, se o usuário pediu).
// Passwords and raw keyfiles are stripped before saving (only derived volume keys are kept if opted in).
let mut options = j.opts.clone();
options.assembly.secrets.clear();
if !options.assembly.remember_keys {
@@ -1722,14 +1722,14 @@ fn snapshot(j: &Job) -> SessionFile {
}
}
/// Grava a sessão de forma atômica (arquivo temporário + renomear), com o dono certo.
/// Atomically writes the session file (temp file + rename) owned by the invoking user.
fn write_session(j: &Job, path: &Path) -> Result<()> {
let data = snapshot(j);
let tmp = path.with_extension("json.tmp");
crate::owner::write_owned(&tmp, serde_json::to_vec(&data)?)?;
std::fs::rename(&tmp, path)?;
crate::owner::give_to_user(path);
// metadados pequenos para listar as sessões recentes sem ler o arquivo inteiro
// lightweight `.meta.json` sidecar so recent sessions can be listed without parsing full snapshots
let status = if data.done {
"concluída"
} else if data.checkpoint.is_some() {
@@ -1749,7 +1749,7 @@ fn write_session(j: &Job, path: &Path) -> Result<()> {
Ok(())
}
/// Salva de tempos em tempos (o intervalo cresce se a sessão for grande) e no fim.
/// Autosaves periodically (backing off as snapshot write time grows) and once on completion.
fn autosave_loop(job: Arc<Job>) {
let mut last = Instant::now();
let mut interval = std::time::Duration::from_secs(45);
@@ -1766,7 +1766,7 @@ fn autosave_loop(job: Arc<Job>) {
if write_session(&job, &path).is_err() {
return;
}
// nunca gasta mais de ~10% do tempo salvando
// spend at most ~10% of wall-clock time writing snapshots
interval = (t0.elapsed() * 10).max(std::time::Duration::from_secs(45));
last = Instant::now();
if !running {
+14 -14
View File
@@ -1,5 +1,5 @@
//! Preferências do usuário (tema etc.), compartilhadas entre o app normal e o
//! reiniciado como administrador.
//! User preferences (theme, language, notifications, etc.), shared between normal and
//! elevated (root/administrator) instances.
use crate::error::Result;
use serde::{Deserialize, Serialize};
@@ -7,24 +7,24 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// "auto", "light" ou "dark".
/// Theme mode: `"auto"`, `"light"`, or `"dark"`.
#[serde(default = "auto")]
pub theme: String,
/// Idioma da interface: "auto", "pt" ou "en".
/// UI language preference: `"auto"`, `"pt"`, or `"en"`.
#[serde(default = "auto")]
pub lang: String,
/// Idioma que a interface resolveu de fato ("pt" ou "en"), para as
/// notificações do sistema e a saída do terminal acompanharem a tela.
/// Effective language resolved by the UI (`"pt"` or `"en"`), so OS desktop
/// notifications and CLI messages match the active UI language.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_lang: Option<String>,
/// Notificação do sistema quando uma tarefa longa termina.
/// Send a desktop notification when a long-running task finishes.
#[serde(default = "yes")]
pub notify: bool,
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
/// Show OpenStreetMap tiles for geotagged photos (fetches tiles over the network).
#[serde(default)]
pub maps: bool,
/// Procurar versão nova no Gitea ao abrir o app (uma consulta ao `latest.json` da release,
/// sem enviar nada além do que qualquer navegador envia).
/// Check Gitea Releases for updates on startup (fetches `latest.json` once
/// without sending any telemetry).
#[serde(default = "yes")]
pub updates: bool,
}
@@ -50,8 +50,8 @@ impl Default for Settings {
}
}
/// Pasta de configuração do usuário *real*, mesmo quando rodando como root
/// via sudo/pkexec (para o tema não "mudar" ao elevar privilégios).
/// Configuration directory of the *real* invoking user, even when running as root
/// via `sudo`/`pkexec` (so theme and language settings persist across elevation).
pub fn config_dir() -> PathBuf {
if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") {
if !d.is_empty() {
@@ -111,8 +111,8 @@ pub fn load() -> Settings {
.unwrap_or_default()
}
/// Idioma efetivo para textos gerados fora da interface (notificações e CLI).
/// Usa o que a interface informou; sem isso, a preferência e o ambiente.
/// Effective language for strings generated outside the webview (OS notifications and CLI).
/// Uses the UI-resolved language when available, falling back to saved preference and `LANG`.
pub fn ui_lang() -> String {
let s = load();
if let Some(l) = s.ui_lang.as_deref() {
+30 -30
View File
@@ -1,19 +1,19 @@
//! Assinatura digital (Ed25519) de manifestos, relatórios e qualquer outro arquivo, e conferência
//! de uma recuperação inteira.
//! Ed25519 digital signatures for manifests, reports, and arbitrary files, plus full recovery
//! folder verification.
//!
//! A assinatura fica ao lado do arquivo, em `<arquivo>.assinatura.json`, e cobre o SHA-256 do
//! conteúdo, o tamanho, o nome, a hora e o assinante. Quem confere recalcula o hash do arquivo,
//! refaz a mensagem a partir dos campos do JSON e valida com a chave pública que vem nele. Isso
//! prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de
//! quem é a chave, compare a impressão digital (`fingerprint`) com a que o perito publicou ou
//! registrou fora da máquina, passando-a em `trusted_fingerprint`.
//! Signatures are stored alongside the file in `<file>.assinatura.json` and cover the content
//! SHA-256, byte size, filename, timestamp, and signer name. Verification recomputes the file
//! hash, reconstructs the canonical signed message, and checks the Ed25519 signature against the
//! embedded public key. To bind the signature to a specific examiner, compare `fingerprint`
//! against the examiner's published fingerprint via `trusted_fingerprint`.
//!
//! A hora é a do relógio da máquina de quem assinou: não é um carimbo de tempo de terceiros
//! (RFC 3161) e, sozinha, não prova *quando* a assinatura foi feita.
//!
//! A chave privada fica em `assinatura.key` na pasta de configuração (ou onde
//! `GUARA_SIGNING_KEY` apontar), em arquivo de texto com permissão 0600 nos sistemas Unix. Quem
//! precisa de mais proteção deve guardá-la num pendrive ou volume cifrado e apontar para lá.
//! Note: `signed_at_utc` comes from the signing machine's local clock, not an RFC 3161 trusted
//! timestamp authority.
//!
//! The private key is stored in `assinatura.key` inside the config directory (or at
//! `GUARA_SIGNING_KEY`) with `0600` permissions on Unix. For higher security, store the key on
//! an encrypted removable volume and point `GUARA_SIGNING_KEY` to it.
use crate::error::{Error, Result};
use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature};
@@ -41,7 +41,7 @@ fn unhex(s: &str) -> Option<Vec<u8>> {
.collect()
}
/// Impressão digital da chave pública: os 10 primeiros bytes do SHA-256, em grupos de 4 hex.
/// Public key fingerprint: first 10 bytes of SHA-256(pubkey), formatted as 5 groups of 4 hex chars.
pub fn fingerprint(public_key: &[u8]) -> String {
let h = Sha256::digest(public_key);
let h = hex(&h[..10]);
@@ -52,7 +52,7 @@ pub fn fingerprint(public_key: &[u8]) -> String {
.join(":")
}
/// Compara impressões digitais ignorando maiúsculas, `:` e espaços.
/// Compares two key fingerprints ignoring case, colons, hyphens, and whitespace.
fn same_fingerprint(a: &str, b: &str) -> bool {
let n = |s: &str| {
s.chars()
@@ -64,7 +64,7 @@ fn same_fingerprint(a: &str, b: &str) -> bool {
!a.is_empty() && a == b
}
/// Par de chaves de assinatura.
/// Ed25519 signing keypair.
pub struct SigningKey {
kp: KeyPair,
}
@@ -108,7 +108,7 @@ impl SigningKey {
})
}
/// Grava a chave privada (0600 no Unix) e, ao lado, a pública (`.pub`). Recusa sobrescrever.
/// Writes the private key (`0600` on Unix) and companion `.pub` file. Refuses to overwrite existing keys.
pub fn save(&self, path: &Path) -> Result<()> {
use std::io::Write;
if let Some(dir) = path.parent() {
@@ -148,7 +148,7 @@ fn pub_path(key: &Path) -> PathBuf {
key.with_extension("pub")
}
/// `GUARA_SIGNING_KEY` ou `assinatura.key` na pasta de configuração.
/// Resolves `GUARA_SIGNING_KEY` or `assinatura.key` in the configuration directory.
pub fn default_key_path() -> PathBuf {
match std::env::var("GUARA_SIGNING_KEY") {
Ok(p) if !p.is_empty() => PathBuf::from(p),
@@ -156,7 +156,7 @@ pub fn default_key_path() -> PathBuf {
}
}
/// Arquivo que acompanha `path` com a assinatura.
/// Companion `<path>.assinatura.json` sidecar path.
pub fn signature_path(path: &Path) -> PathBuf {
let mut n = path.as_os_str().to_os_string();
n.push(".assinatura.json");
@@ -190,7 +190,7 @@ fn message(f: &SignatureFile) -> Vec<u8> {
.into_bytes()
}
/// SHA-256 e tamanho de um arquivo, lido em blocos.
/// Computes the hex SHA-256 and byte length of a file.
pub fn hash_file(path: &Path) -> Result<(String, u64)> {
let mut f = std::fs::File::open(path)?;
let mut h = Sha256::new();
@@ -215,7 +215,7 @@ fn now_iso() -> String {
crate::audit::utc_iso(secs)
}
/// Assina `path` e grava `<path>.assinatura.json`. Devolve o caminho da assinatura.
/// Signs `path` and writes `<path>.assinatura.json`. Returns the signature file path.
pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<PathBuf> {
let (sha256, size) = hash_file(path)?;
let mut sig = SignatureFile {
@@ -242,17 +242,17 @@ pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<
Ok(out)
}
/// Resultado de conferir uma assinatura.
/// Result of verifying a detached `.assinatura.json` signature.
#[derive(Debug, Clone, Serialize)]
pub struct Check {
/// O arquivo é o mesmo e a assinatura vale para a chave que vem nela.
/// True if the file hash matches and the Ed25519 signature is valid for the embedded public key.
pub valid: bool,
pub file_matches: bool,
pub signature_valid: bool,
pub fingerprint: String,
pub signed_at: String,
pub signer: Option<String>,
/// `Some(true/false)` quando uma impressão digital de confiança foi informada.
/// `Some(true/false)` when a `trusted_fingerprint` was provided for comparison.
pub trusted: Option<bool>,
pub problems: Vec<String>,
}
@@ -320,8 +320,8 @@ pub fn verify_file(
})
}
/// Confere uma pasta de recuperação: a assinatura do manifesto (se houver) e o hash de cada
/// arquivo contra o que o manifesto registrou.
/// Verifies a recovery directory: checks the manifest signature (if present) and verifies every
/// recovered file's SHA-256 against the manifest.
#[derive(Debug, Clone, Serialize)]
pub struct RecoveryCheck {
pub manifest_signature: Option<Check>,
@@ -329,7 +329,7 @@ pub struct RecoveryCheck {
pub ok: usize,
pub missing: Vec<String>,
pub changed: Vec<String>,
/// `true` quando tudo confere (e, havendo assinatura, ela vale).
/// `true` when all recovered files match their manifest hashes (and the manifest signature is valid if present).
pub valid: bool,
}
@@ -353,7 +353,7 @@ pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<
let (Some(rel), Some(want)) = (f["path"].as_str(), f["sha256"].as_str()) else {
continue;
};
// o manifesto é dado de entrada: não deixa sair da pasta
// manifest paths are untrusted input: reject path traversal outside the recovery folder
if rel.split('/').any(|c| c == "..") || Path::new(rel).is_absolute() {
changed.push(format!("{rel} (caminho fora da pasta)"));
continue;
@@ -380,8 +380,8 @@ pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<
})
}
/// Assina `path` se existir uma chave configurada; nunca interrompe quem chamou. Registra na
/// trilha de auditoria.
/// Auto-signs `path` if a signing key exists; never aborts the caller on error. Logs to the
/// audit trail.
pub fn sign_if_configured(path: &Path) {
let kp = default_key_path();
if !kp.is_file() {
+22 -22
View File
@@ -1,13 +1,13 @@
//! Assinaturas de formatos e validação estrutural para o carving.
//! File format signatures and structural validators for file 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.
//! Each detector parses the internal container structure (headers, chunks, footers)
//! to determine the exact file size and confidence level rather than relying on
//! magic bytes alone.
use crate::source::{read_vec, Source};
use crate::types::{Category, Confidence};
/// Resultado de um detector: arquivo reconhecido a partir de `offset`.
/// Output of a format detector: a file recognized starting at `offset`.
#[derive(Debug, Clone)]
pub struct Hit {
pub len: u64,
@@ -37,8 +37,8 @@ pub fn category_for_ext(ext: &str) -> Category {
}
}
/// Verifica se o início do conteúdo é coerente com a extensão.
/// `None` = extensão sem assinatura conhecida (não dá para julgar).
/// Checks whether the leading bytes `head` match the expected magic for `ext`.
/// Returns `None` if `ext` has no known magic signature.
pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
let ext = ext.to_ascii_lowercase();
let starts = |m: &[u8]| head.starts_with(m);
@@ -86,8 +86,8 @@ pub fn matches_ext(ext: &str, head: &[u8]) -> Option<bool> {
Some(r)
}
/// Tabela de primeiros bytes -> detectores candidatos.
/// O carving consulta `candidates(first_byte)` e chama `detect`.
/// Dispatch table mapping leading byte -> candidate format detectors.
/// The carver queries `candidates(first_byte)` and invokes `detect`.
pub fn might_start(b0: u8) -> bool {
crate::formats::might_start(b0)
|| matches!(
@@ -111,8 +111,8 @@ pub fn might_start(b0: u8) -> bool {
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).
/// Attempts to recognize a file starting at `off`.
/// `head` contains the leading bytes already read at `off` (at least 16 when available).
pub fn detect(src: &dyn Source, off: u64, head: &[u8], max_size: u64) -> Option<Hit> {
if head.len() < 12 {
return None;
@@ -204,7 +204,7 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if !saw_sof {
return None;
}
// Dados entropy-coded: procurar EOI (FF D9) ignorando FF00 e RSTn.
// Entropy-coded scan data: scan for EOI (`FF D9`), skipping `FF 00` byte stuffing and `RSTn`.
let mut pos = off + start as u64;
let limit = off + max.min(512 * 1024 * 1024);
let mut buf = vec![0u8; 1 << 20];
@@ -230,7 +230,7 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
}
pos += n as u64;
}
// Sem EOI: imagem truncada/sobrescrita; ainda útil, mas incerta.
// Missing EOI: truncated or overwritten JPEG; still emit with Low confidence.
Some(Hit {
len: (pos - off).min(4 * 1024 * 1024),
ext: "jpg",
@@ -252,8 +252,8 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
}
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
let ty = &c[4..8];
// Bloco válido: 4 letras, 3ª maiúscula (bit reservado), tamanho plausível e dentro do
// disco. Texto ASCII solto parece "tamanho" de ~1,9 GB e não pode virar um PNG gigante.
// Valid PNG chunk: 4 ASCII letters with uppercase 3rd letter (reserved bit), plausible size within
// the source. Prevents random ASCII text after a truncated PNG from being read as a ~1.9 GB chunk.
let valid = ty.iter().all(|b| b.is_ascii_alphabetic())
&& ty[2].is_ascii_uppercase()
&& len <= 128 * 1024 * 1024
@@ -278,7 +278,7 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
});
}
}
// Sem IEND: só vale como imagem truncada se já tem dados e é de tamanho razoável.
// Missing `IEND`: only keep as a truncated PNG if at least one `IDAT` was seen and size is reasonable.
if first || !saw_idat || p > 64 * 1024 * 1024 {
return None;
}
@@ -388,7 +388,7 @@ fn pdf(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
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.
// Continue scanning if `%%EOF` is followed by an incremental PDF update.
let tail = read_vec(src, end, 32).ok()?;
let rest: Vec<u8> = tail
.iter()
@@ -428,7 +428,7 @@ fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
// ---------------------------------------------------------------- ZIP & derivados
// ---------------------------------------------------------------- ZIP & derivatives
fn zip(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let limit = max.min(4 << 30);
@@ -508,7 +508,7 @@ fn classify_zip(src: &dyn Source, start: u64, names: &[String]) -> &'static str
} 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.
// An uncompressed `mimetype` entry at the start identifies ODF/EPUB containers.
let head = read_vec(src, start, 256).unwrap_or_default();
let s = String::from_utf8_lossy(&head);
if s.contains("opendocument.text") {
@@ -574,7 +574,7 @@ fn isobmff(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
}
size = u64::from_be_bytes(b[8..16].try_into().unwrap());
} else if size == 0 {
// Até o fim do arquivo: tamanho desconhecido.
// Box extends to EOF: exact size unknown.
size = total - p;
}
if size < 8 || p + size > max {
@@ -705,7 +705,7 @@ pub fn crc32(data: &[u8]) -> u32 {
!crc
}
// ---------------------------------------------------------------- OLE2 (doc/xls/ppt legado)
// ---------------------------------------------------------------- OLE2 (legacy doc/xls/ppt)
fn ole(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let hdr = read_vec(src, off, 512).ok()?;
@@ -782,7 +782,7 @@ fn mp3(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
if frames < 20 {
return None;
}
// Possível tag ID3v1 de 128 bytes ao final.
// Include optional trailing 128-byte ID3v1 tag.
if let Ok(t) = read_vec(src, off + p, 3) {
if t == b"TAG" {
p += 128;
+20 -20
View File
@@ -1,16 +1,16 @@
//! Acesso somente leitura a discos, partições e imagens.
//! Read-only positional access to disks, partitions, and forensic images.
//!
//! Nenhum tipo deste módulo expõe escrita: a origem nunca é modificada.
//! No type in this module exposes write operations: the source is never modified.
use std::fs::File;
use std::io;
use std::path::Path;
use std::sync::Arc;
/// Origem de bytes legível por posição absoluta.
/// Read-only byte source addressable by absolute 64-bit byte offset.
pub trait Source: Send + Sync {
fn size(&self) -> u64;
/// Lê até `buf.len()` bytes a partir de `offset`. Retorna 0 no fim.
/// Reads up to `buf.len()` bytes starting at `offset`. Returns `0` at EOF.
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize>;
}
@@ -23,7 +23,7 @@ impl<T: Source + ?Sized> Source for Arc<T> {
}
}
/// Lê exatamente `buf.len()` bytes, ou falha com `UnexpectedEof`.
/// Reads exactly `buf.len()` bytes at `offset`, or fails with `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)?;
@@ -39,7 +39,7 @@ pub fn read_exact_at(src: &dyn Source, mut offset: u64, mut buf: &mut [u8]) -> i
Ok(())
}
/// Lê o máximo possível (pode devolver menos que `len` no fim da origem).
/// Reads up to `len` bytes into a `Vec<u8>` (may return fewer bytes near EOF).
pub fn read_vec(src: &dyn Source, offset: u64, len: usize) -> io::Result<Vec<u8>> {
let mut v = vec![0u8; len];
let mut got = 0;
@@ -54,11 +54,11 @@ pub fn read_vec(src: &dyn Source, offset: u64, len: usize) -> io::Result<Vec<u8>
Ok(v)
}
/// Arquivo de imagem ou dispositivo de blocos aberto somente para leitura.
/// Image file or raw block device opened strictly read-only.
pub struct FileSource {
file: File,
size: u64,
/// Dispositivos brutos (Windows/macOS) exigem leituras alinhadas ao setor.
/// Raw block devices on Windows/macOS require sector-aligned reads.
align: u64,
}
@@ -105,7 +105,7 @@ impl Source for FileSource {
if self.align <= 1 {
return self.raw_read(offset, &mut buf[..want]);
}
// Leitura alinhada para dispositivos brutos.
// Bounce buffer for sector-aligned reads on raw devices.
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);
@@ -128,7 +128,7 @@ impl Source for FileSource {
}
}
/// Janela [start, start+len) de outra origem (uma partição, por exemplo).
/// Sub-window `[start, start + len)` of another `Source` (e.g. a partition).
pub struct SubSource {
inner: Arc<dyn Source>,
start: u64,
@@ -155,7 +155,7 @@ impl Source for SubSource {
}
}
/// Origem em memória (testes e dados pequenos).
/// In-memory byte source (for tests and small buffers).
pub struct MemSource(pub Vec<u8>);
impl Source for MemSource {
@@ -195,7 +195,7 @@ fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
#[cfg(windows)]
fn open_readonly(path: &Path, _device: bool) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
// FILE_SHARE_READ | FILE_SHARE_WRITE: não bloqueia outros processos.
// `FILE_SHARE_READ | FILE_SHARE_WRITE`: do not lock out other processes.
std::fs::OpenOptions::new()
.read(true)
.share_mode(0x1 | 0x2)
@@ -211,7 +211,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
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.
// SAFETY: read-only size query ioctls with pointers to valid stack variables.
let ok = unsafe {
libc::ioctl(fd, DKIOCGETBLOCKSIZE, &mut bs) == 0
&& libc::ioctl(fd, DKIOCGETBLOCKCOUNT, &mut bc) == 0
@@ -225,7 +225,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
f.seek(SeekFrom::End(0))
}
/// Abre um dispositivo Windows (`\\.\PhysicalDriveN`) e obtém o tamanho.
/// Opens a Windows raw device (`\\.\PhysicalDriveN`) read-only and queries its byte size.
#[cfg(windows)]
pub fn open_windows_device(path: &Path) -> io::Result<FileSource> {
let file = open_readonly(path, true)?;
@@ -244,7 +244,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
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.
// SAFETY: valid output buffer matching the exact struct layout.
let ok = unsafe {
DeviceIoControl(
file.as_raw_handle() as _,
@@ -263,7 +263,7 @@ fn device_size(file: &File, _path: &Path) -> io::Result<u64> {
Ok(info.Length as u64)
}
/// Abre um caminho (imagem ou dispositivo) como origem somente leitura.
/// Opens a path (raw disk, partition, or forensic/virtual disk image) as a read-only `Source`.
pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
#[cfg(windows)]
{
@@ -273,7 +273,7 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
}
}
let file = FileSource::open(path)?;
// Imagens forenses EWF (E01/Ex01) são abertas como o disco que contêm.
// Forensic containers (EWF, AFF4) and virtual disks (QCOW2, VMDK, VHD, VHDX, DMG) are opened transparently.
let mut head = [0u8; 96];
let got = file.read_at(0, &mut head).unwrap_or(0);
if got >= 4 && head.starts_with(b"PK") && crate::aff4::is_aff4(&head[..got]) {
@@ -295,9 +295,9 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
Ok(Arc::new(FileSource::open(path)?))
}
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
/// (tabela de partições, cabeçalhos e backup). Serve para reconhecer o mesmo disco
/// quando ele volta com outro nome (`/dev/sda` virando `/dev/sdb`).
/// Content fingerprint of a source disk: SHA-256 of its byte size plus the first and last MiB
/// (partition tables, headers, and backup GPT). Used to recognize the same physical drive
/// if it is unplugged and reattached under a different device name (`/dev/sda` -> `/dev/sdb`).
pub fn fingerprint(src: &dyn Source) -> Option<String> {
use sha2::{Digest, Sha256};
const MIB: u64 = 1024 * 1024;
+36 -36
View File
@@ -1,9 +1,9 @@
//! Leitor de bancos SQLite feito para arquivos recuperados.
//! Standalone SQLite B-tree reader tailored for recovered databases.
//!
//! Lê as páginas B-tree diretamente (sem a biblioteca do SQLite), então funciona
//! em bancos danificados ou truncados, para os quais a biblioteca oficial desiste.
//! Também procura **registros apagados** nas áreas livres das páginas, onde o
//! SQLite deixa o conteúdo antigo até que outro dado o sobrescreva.
//! Parses B-tree pages directly (without linking `libsqlite3`), so it works on
//! damaged or truncated database files that the official library rejects.
//! Also carves **deleted records** from page freeblocks and unallocated cell areas
//! where SQLite leaves unlinked row payloads until overwritten.
use crate::source::Source;
use serde::Serialize;
@@ -18,7 +18,7 @@ pub enum Cell {
Int(i64),
Real(f64),
Text(String),
/// Tamanho e os primeiros bytes (hex) para pré-visualizar; `mime` se reconhecido.
/// Byte length and leading hex preview of a BLOB, plus `mime` when recognized.
Blob {
len: usize,
head: String,
@@ -41,7 +41,7 @@ pub struct DbTable {
pub columns: Vec<DbColumn>,
pub without_rowid: bool,
pub virtual_table: bool,
/// Coluna `INTEGER PRIMARY KEY` (alias do rowid), se houver.
/// Index of the `INTEGER PRIMARY KEY` column (`rowid` alias), if any.
#[serde(skip)]
pub rowid_alias: Option<usize>,
}
@@ -83,7 +83,7 @@ pub struct Db<'a> {
type Res<T> = Result<T, String>;
// ------------------------------------------------------------------ utilidades
// ------------------------------------------------------------------ utilities
fn varint(b: &[u8]) -> Option<(i64, usize)> {
let mut v: u64 = 0;
@@ -158,7 +158,7 @@ fn decode_text(b: &[u8], enc: u32) -> String {
}
}
/// Decodifica um registro (cabeçalho de tipos + corpo).
/// Decodes a SQLite record payload (serial type header + column values).
fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
let (hlen, n) = varint(p)?;
let hlen = hlen as usize;
@@ -201,7 +201,7 @@ fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
Some(cells)
}
// ------------------------------------------------------------------ banco
// ------------------------------------------------------------------ database
impl<'a> Db<'a> {
pub fn open(src: &'a dyn Source) -> Res<Self> {
@@ -259,7 +259,7 @@ impl<'a> Db<'a> {
}
}
/// Lê o payload completo de uma célula (segue as páginas de overflow).
/// Reads the full payload of a B-tree cell, following overflow page chains when needed.
fn payload(
&self,
page: &[u8],
@@ -304,8 +304,8 @@ impl<'a> Db<'a> {
Some(out)
}
/// Percorre as folhas de uma árvore de tabela chamando `f(rowid, payload)`.
/// `f` devolve false para parar. Devolve os avisos de corrupção encontrados.
/// Walks all leaf pages of a table B-tree, invoking `f(rowid, payload)` for each cell.
/// `f` returns `false` to stop early. Returns any page/cell corruption warnings encountered.
fn walk_table(&self, root: u32, f: &mut dyn FnMut(i64, &[u8]) -> bool) -> Vec<String> {
let mut warnings = vec![];
let mut stack = vec![(root, 0u32)];
@@ -407,7 +407,7 @@ impl<'a> Db<'a> {
leaves
}
// ---------------------------------------------------------- esquema
// ---------------------------------------------------------- schema
pub fn schema(&self) -> DbSchema {
let mut s = DbSchema {
@@ -486,7 +486,7 @@ impl<'a> Db<'a> {
.ok_or_else(|| format!("tabela {name} não encontrada"))
}
/// Conta as linhas da tabela (sem decodificar o conteúdo).
/// Counts rows in a table B-tree without decoding cell payloads.
pub fn count_rows(&self, t: &DbTable) -> u64 {
if t.rootpage == 0 || t.virtual_table || t.without_rowid {
return 0;
@@ -500,7 +500,7 @@ impl<'a> Db<'a> {
n
}
// ---------------------------------------------------------- linhas
// ---------------------------------------------------------- rows
pub fn rows(&self, table: &str, offset: usize, limit: usize) -> Res<RowsPage> {
let t = self.table(table)?;
@@ -550,11 +550,11 @@ impl<'a> Db<'a> {
Ok(page)
}
// ---------------------------------------------------------- registros apagados
// ---------------------------------------------------------- deleted records
/// Procura registros apagados nas áreas livres das páginas da tabela.
/// São "prováveis": o SQLite não marca o que foi apagado, então validamos pela forma
/// do registro (mesmo número de colunas, tipos e textos coerentes).
/// Carves deleted records from unallocated gaps and freeblocks across table leaf pages.
/// Candidates are validated structurally against the table schema (column count,
/// column type affinities, and valid UTF-8 text).
pub fn deleted_rows(&self, table: &str) -> Res<RowsPage> {
let t = self.table(table)?;
let n = t.columns.len();
@@ -579,7 +579,7 @@ impl<'a> Db<'a> {
if content > ptr_end {
regions.push((ptr_end, content));
}
// cadeia de blocos livres
// page freeblock chain
let mut fb = be16(&pg, hb + 1).unwrap_or(0);
let mut guard = 0;
while fb != 0 && guard < 2000 {
@@ -620,10 +620,10 @@ impl<'a> Db<'a> {
Ok(out)
}
/// Tenta ler um registro apagado no início de `b`, em três formas:
/// (a) cabeçalho íntegro; (b) tamanho do cabeçalho perdido (o SQLite sobrescreve os 4
/// primeiros bytes do bloco livre); (c) como (b), mas sem o primeiro tipo (coluna que é
/// alias do rowid, sempre NULL).
/// Attempts to parse a deleted record at the start of `b` in three layouts:
/// (a) intact record header; (b) overwritten header-size varint (SQLite overwrites the first
/// 4 bytes of a freeblock with `next`/`size`); (c) like (b), but with the first serial type
/// (`INTEGER PRIMARY KEY` rowid alias, always `NULL`) also overwritten.
fn try_deleted(&self, b: &[u8], t: &DbTable) -> Option<(Vec<Cell>, usize)> {
let n = t.columns.len();
// (a)
@@ -641,8 +641,8 @@ impl<'a> Db<'a> {
None
}
/// Lê `ntypes` tipos seriais a partir de `b[start..]` (com um tipo inicial opcional já
/// conhecido) e o corpo logo depois do cabeçalho.
/// Reads `ntypes` serial type varints from `b[start..]` (with an optional synthesized leading
/// type) followed immediately by the value body.
fn record_with_types(
&self,
b: &[u8],
@@ -654,7 +654,7 @@ impl<'a> Db<'a> {
let n = t.columns.len();
let mut types: Vec<i64> = first.into_iter().collect();
let mut o = start;
let mut hdr_bytes = 1usize; // o próprio byte do tamanho do cabeçalho
let mut hdr_bytes = 1usize; // the header-length varint byte itself
for _ in 0..ntypes {
let (ty, m) = varint(b.get(o..)?)?;
types.push(ty);
@@ -665,7 +665,7 @@ impl<'a> Db<'a> {
hdr_bytes += 1;
}
if start == 1 {
// forma (a): o primeiro byte declara o tamanho do cabeçalho e precisa bater
// layout (a): the leading byte declares the total header size and must match
if *b.first()? as usize != o {
return None;
}
@@ -685,7 +685,7 @@ impl<'a> Db<'a> {
if body == 0 || o + body > b.len() {
return None;
}
// monta um registro canônico para reaproveitar o decodificador
// reassemble a canonical record buffer to reuse `decode_record`
let mut rec = vec![];
let mut hdr = vec![];
for &ty in &types {
@@ -716,7 +716,7 @@ impl<'a> Db<'a> {
}
}
/// O tipo serial é compatível com o tipo declarado da coluna (afinidade do SQLite)?
/// Checks whether a serial type is plausible for the declared column affinity.
fn type_ok(decl: &str, ty: i64) -> bool {
if ty == 10 || ty == 11 || ty < 0 {
return false;
@@ -757,7 +757,7 @@ fn put_varint(out: &mut Vec<u8>, v: i64) {
}
}
/// O SQLite guarda REAIS inteiros como inteiros; devolve-os como REAL quando a coluna é REAL.
/// SQLite stores whole-number REAL values as integers; promote them back to `Real` for `REAL` columns.
fn apply_affinity(cells: &mut [Cell], cols: &[DbColumn]) {
for (c, col) in cells.iter_mut().zip(cols) {
let d = col.decl_type.to_uppercase();
@@ -786,14 +786,14 @@ fn unquote(s: &str) -> String {
}
}
/// Extrai colunas, alias de rowid e WITHOUT ROWID de um `CREATE TABLE`.
/// Parses column definitions, `INTEGER PRIMARY KEY` rowid alias, and `WITHOUT ROWID` from a `CREATE TABLE` statement.
fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
let upper = sql.to_uppercase();
let without_rowid = upper.trim_end().ends_with("WITHOUT ROWID");
let Some(open) = sql.find('(') else {
return (vec![], None, without_rowid);
};
// fecha o parêntese correspondente
// find matching closing parenthesis
let mut depth = 0;
let mut close = sql.len();
let mut in_q: Option<char> = None;
@@ -814,7 +814,7 @@ fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
}
}
let body = &sql[open + 1..close.min(sql.len())];
// separa por vírgulas de nível 0
// split on top-level commas (depth 0)
let mut parts = vec![];
let (mut d, mut start, mut q) = (0, 0, None::<char>);
for (i, ch) in body.char_indices() {
@@ -894,7 +894,7 @@ fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
(cols, alias, without_rowid)
}
/// O arquivo começa com o cabeçalho de um banco SQLite?
/// Returns `true` if `b` starts with the 16-byte SQLite 3 file header magic.
pub fn is_sqlite(head: &[u8]) -> bool {
head.len() >= 16 && &head[0..16] == MAGIC
}
+6 -6
View File
@@ -1,6 +1,6 @@
//! Miniaturas para a grade de arquivos. Decodifica com redução na própria
//! decodificação (JPEG) para ser rápido mesmo com milhares de fotos e, se a
//! imagem estiver danificada, tenta a miniatura embutida no EXIF.
//! Thumbnail generator for the file explorer grid. Uses DCT-scaled JPEG decoding
//! for fast generation across thousands of photos, falling back to the embedded
//! EXIF thumbnail when the main image is damaged.
use crate::recover::read_found_at;
use crate::source::Source;
@@ -25,7 +25,7 @@ fn read_all(src: &dyn Source, f: &Found) -> Option<Vec<u8>> {
(!v.is_empty()).then_some(v)
}
/// Miniatura JPEG com o maior lado = `max` (None se não for imagem ou não decodificar).
/// Renders a JPEG thumbnail with longest edge `<= max` (`None` if not a decodable image).
pub fn thumbnail(src: &dyn Source, f: &Found, max: u32) -> Option<Vec<u8>> {
let data = read_all(src, f)?;
let img = match f.ext.to_ascii_lowercase().as_str() {
@@ -59,7 +59,7 @@ fn decode_jpeg(data: &[u8], max: u32) -> Option<RgbImage> {
jpeg_decoder::PixelFormat::CMYK32 => px
.chunks_exact(4)
.flat_map(|c| {
// Adobe CMYK vem invertido
// Adobe CMYK JPEGs store inverted channel values
let k = c[3] as u32;
[
(c[0] as u32 * k / 255) as u8,
@@ -76,7 +76,7 @@ fn decode_jpeg(data: &[u8], max: u32) -> Option<RgbImage> {
fn decode_other(data: &[u8], max: u32) -> Option<RgbImage> {
let dynimg = image::load_from_memory(data).ok()?;
let rgba = dynimg.to_rgba8();
// Achata a transparência sobre cinza claro.
// Composite alpha channel over a neutral light-gray background.
let mut rgb = RgbImage::new(rgba.width(), rgba.height());
for (x, y, p) in rgba.enumerate_pixels() {
let a = p[3] as u32;
+40 -40
View File
@@ -1,4 +1,4 @@
//! Tipos compartilhados entre núcleo, CLI e interface gráfica.
//! Core data types shared across `guara-core`, the CLI, and the GUI.
use serde::{Deserialize, Serialize};
@@ -69,12 +69,12 @@ impl Category {
}
}
/// Trecho contíguo de bytes na origem (offset absoluto).
/// Contiguous byte range `[ offset, offset + len )` on the source.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Extent {
pub offset: u64,
pub len: u64,
/// Trecho esparso: lido como zeros.
/// Sparse hole (`offset == 0`): read as zero bytes.
#[serde(default, skip_serializing_if = "is_false")]
pub sparse: bool,
}
@@ -99,14 +99,14 @@ impl Extent {
#[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 file payload stored inline inside the metadata record (e.g. resident NTFS `$DATA`).
Resident {
bytes: Vec<u8>,
},
Extents {
extents: Vec<Extent>,
},
/// Arquivo montado: trechos em memória (cabeçalhos refeitos) e trechos do disco.
/// Composite file made of in-memory slices (rebuilt headers/`moov`) and on-disk runs.
Parts {
parts: Vec<Part>,
},
@@ -119,11 +119,11 @@ pub enum Part {
#[serde(with = "b64")]
bytes: Vec<u8>,
},
/// Trecho contíguo da origem.
/// Contiguous byte run on the source.
Run { offset: u64, len: u64 },
/// Zeros (buracos de arquivos esparsos).
/// Zero-filled range (sparse file hole).
Zeros { len: u64 },
/// Extensão comprimida na origem (Btrfs): descompactada ao ler.
/// Compressed on-disk extent (Btrfs): decompressed on the fly when read.
Packed(Packed),
}
@@ -137,20 +137,20 @@ pub enum PackedCodec {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Packed {
/// Onde estão os bytes comprimidos na origem.
/// Source byte offset of the compressed extent.
pub offset: u64,
pub disk_len: u64,
/// Tamanho da extensão depois de descompactada.
/// Total uncompressed byte length of the extent.
pub ram_len: u64,
/// Bytes a pular no começo da extensão descompactada.
/// Leading uncompressed bytes to skip within the extent.
pub skip: u64,
/// Bytes úteis a partir de `skip`.
/// Number of uncompressed bytes to expose starting at `skip`.
pub len: u64,
pub codec: PackedCodec,
}
impl Packed {
/// Descompacta a extensão e devolve os `len` bytes úteis.
/// Decompresses the extent and returns the `[skip .. skip + len]` slice.
pub fn read(&self, src: &dyn crate::source::Source) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let raw = crate::source::read_vec(src, self.offset, self.disk_len as usize)?;
@@ -283,12 +283,12 @@ impl Origin {
}
}
/// Um arquivo encontrado (apagado, perdido ou reconstruído por assinatura).
/// A discovered file (deleted metadata entry, orphaned inode, or signature-carved hit).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Found {
pub id: u64,
pub name: String,
/// Pasta de origem, quando o sistema de arquivos a preserva.
/// Original parent directory path, when preserved by filesystem metadata.
pub dir: Option<String>,
pub ext: String,
pub category: Category,
@@ -296,31 +296,31 @@ pub struct Found {
pub origin: Origin,
pub confidence: Confidence,
pub data: DataLoc,
/// Segundos desde 1970-01-01 UTC.
/// Seconds since 1970-01-01 00:00:00 UTC.
pub modified: Option<i64>,
#[serde(default)]
pub created: Option<i64>,
#[serde(default)]
pub accessed: Option<i64>,
/// Data em que a foto foi tirada (EXIF; hora da câmera lida como UTC), s desde 1970.
/// EXIF capture timestamp (camera local time interpreted as UTC), in seconds since 1970.
#[serde(default)]
pub taken: Option<i64>,
#[serde(default)]
pub camera: Option<String>,
/// Largura × altura em pixels, quando conhecidas.
/// Image/video dimensions `(width, height)` in pixels, when known.
#[serde(default)]
pub px: Option<(u32, u32)>,
/// Há coordenadas GPS nos metadados.
/// Whether EXIF GPS coordinates are present.
#[serde(default)]
pub gps: bool,
/// Assinatura rápida do conteúdo (tamanho + início + fim) para achar duplicadas.
/// Fast content hash (size + head + tail) used for duplicate detection.
#[serde(default)]
pub sig: Option<u64>,
pub note: Option<String>,
}
impl Found {
/// Data mais representativa: quando foi tirada > modificada > criada.
/// Best representative timestamp: `taken > modified > created`.
pub fn when(&self) -> Option<i64> {
self.taken.or(self.modified).or(self.created)
}
@@ -362,11 +362,11 @@ pub enum FsKind {
Refs,
Bitlocker,
Luks,
/// FileVault 2 (Core Storage).
/// Apple FileVault 2 (Core Storage).
Corestorage,
/// Membro de volume LVM (physical volume).
/// Linux LVM2 Physical Volume (PV).
Lvm,
/// Membro de RAID por software (mdadm).
/// Linux `mdadm` software RAID member.
Raid,
Unknown,
}
@@ -394,7 +394,7 @@ impl FsKind {
}
}
/// Volume cifrado: só detectamos (v1), sem desbloquear.
/// Returns `true` if this kind represents an encrypted container.
pub fn encrypted(self) -> Option<&'static str> {
match self {
FsKind::Bitlocker => Some("BitLocker"),
@@ -404,7 +404,7 @@ impl FsKind {
}
}
/// Há leitor de metadados (arquivos apagados com nome) para este sistema.
/// Returns `true` if a dedicated filesystem metadata reader exists for this format.
pub fn has_metadata_scanner(self) -> bool {
matches!(
self,
@@ -429,10 +429,10 @@ pub struct PartInfo {
pub len: u64,
pub fs: FsKind,
pub label: Option<String>,
/// Encontrada por varredura, não consta da tabela de partições.
/// Discovered by scanning unallocated gaps rather than listed in the partition table.
pub lost: bool,
pub encrypted: Option<String>,
/// Descrição do tipo na tabela (ex.: "Microsoft basic data").
/// Partition type label from the partition table (e.g. `"Microsoft basic data"`).
pub table_type: Option<String>,
}
@@ -445,10 +445,10 @@ pub struct Gap {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiskLayout {
pub size: u64,
/// "gpt", "mbr" ou "none".
/// Partition table scheme: `"gpt"`, `"mbr"`, or `"none"`.
pub table: String,
pub partitions: Vec<PartInfo>,
/// Espaço não particionado relevante (> 1 MiB).
/// Significant unpartitioned byte gaps (`> 1 MiB`).
pub gaps: Vec<Gap>,
pub warnings: Vec<String>,
}
@@ -456,37 +456,37 @@ pub struct DiskLayout {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScanMode {
/// Só metadados dos sistemas de arquivos (segundos).
/// Fast scan: filesystem metadata readers only (completes in seconds).
Quick,
/// Metadados + carving por assinatura em todo espaço livre/desconhecido.
/// Deep scan: filesystem metadata plus signature carving across free/unallocated space.
Deep,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions {
pub mode: ScanMode,
/// Inclui áreas ainda alocadas no carving (mais lento, acha cópias antigas).
/// Also carve currently allocated space (slower, finds older copies inside allocated blocks).
#[serde(default)]
pub include_allocated: bool,
/// Limita o carving a estas categorias (vazio = todas).
/// Restrict signature carving to these categories (empty = all categories).
#[serde(default)]
pub categories: Vec<Category>,
/// Tamanho máximo aceito para arquivos carved (bytes).
/// Maximum byte size accepted for a single carved file.
#[serde(default = "default_max_carve")]
pub max_carve_size: u64,
/// Membros extras (RAID/LVM) e segredos para volumes cifrados.
/// Extra disk members (RAID/LVM) and credentials for unlocking encrypted volumes.
#[serde(default)]
pub assembly: crate::assemble::Assembly,
/// Partições que não estão na tabela do disco (volumes lógicos, desbloqueados).
/// Virtual partitions not present in the physical partition table (LVM LVs, unlocked volumes).
#[serde(default)]
pub extra_parts: Vec<PartInfo>,
/// Partições que só contêm RAID/LVM remontado: a varredura entra pelos volumes virtuais.
/// Physical partitions that act solely as assembled RAID/LVM containers (skipped in favor of virtual volumes).
#[serde(default)]
pub covered: Vec<u64>,
/// O disco principal é só membro de RAID sem metadados: varre apenas os volumes virtuais.
/// Primary disk is part of a whole-disk RAID array: scan only the assembled virtual volumes.
#[serde(default)]
pub skip_primary: bool,
/// Vídeos íntegros da mesma câmera/drone: fornecem SPS/PPS para remontar MP4 sem moov.
/// Intact donor videos from the same camera/drone: supply SPS/PPS to rebuild headerless MP4s.
#[serde(default)]
pub references: Vec<String>,
}
+6 -6
View File
@@ -1,6 +1,6 @@
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem
//! clara.
//! DMG (Apple UDIF): parses the `koly` trailer, plist `mish` block tables, and zero-fill, raw,
//! ADC, zlib, bzip2, and LZFSE (`ULFO`) chunks. Encrypted DMG images (`encrcdsa`) are rejected
//! with a descriptive error.
use super::{bad, be32, be64, read_exact, read_vec, Backend};
use crate::source::{FileSource, Source};
@@ -21,7 +21,7 @@ pub(super) fn is_dmg(file: &FileSource) -> bool {
&& be32(&t, 8) == 512
}
/// Imagem cifrada pelo `hdiutil`: começa com `encrcdsa` ou `cdsaencr`.
/// Checks whether `head` starts with an `hdiutil` encrypted DMG magic (`encrcdsa` / `cdsaencr`).
pub(super) fn is_encrypted(head: &[u8]) -> bool {
head.starts_with(b"encrcdsa") || head.starts_with(b"cdsaencr")
}
@@ -65,7 +65,7 @@ fn base64(s: &str) -> Vec<u8> {
out
}
/// Descompacta ADC (Apple Data Compression).
/// Decompresses an Apple Data Compression (ADC) chunk.
fn adc(src: &[u8], max: usize) -> io::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(max);
let mut i = 0;
@@ -210,7 +210,7 @@ impl Backend for Dmg {
return Ok(n);
};
if voff < c.start {
// espaço que nenhuma tabela cobre
// unmapped gap between `mish` runs: zero-fill
let n = buf.len().min((c.start - voff) as usize);
buf[..n].fill(0);
return Ok(n);
+23 -23
View File
@@ -1,10 +1,10 @@
//! Imagens de disco virtual e contêineres: QCOW2, VMDK, VHD, VHDX e DMG (UDIF).
//! Virtual disk images and containers: QCOW2, VMDK, VHD, VHDX, and DMG (UDIF).
//!
//! Each format is opened read-only as a logical block device and plugs transparently into
//! the standard pipeline (partitions, filesystems, encryption) via [`Source`]. Differencing
//! disks (VHD/VHDX parent locators, QCOW2 backing files, VMDK parent chains) resolve reads
//! up the backing chain to the base image.
//!
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
//! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos
//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a
//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi
//! alterado depois do filho, os dados lidos refletem o pai atual.
mod dmg;
mod qcow2;
@@ -36,21 +36,21 @@ pub(crate) fn le64(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
/// Leitura dentro de um único bloco do formato; devolve quantos bytes entregou (pode ser
/// menos que `buf.len()`, nunca zero antes do fim do disco).
/// Reads within a single format block/cluster; returns the number of bytes filled (may be
/// less than `buf.len()`, but never `0` before the logical end of disk).
pub(crate) trait Backend: Send + Sync {
fn size(&self) -> u64;
fn read_chunk(&self, voff: u64, buf: &mut [u8]) -> io::Result<usize>;
}
/// Disco virtual aberto de uma imagem de contêiner.
/// Opened virtual disk backed by a container image.
pub struct VDisk {
inner: Box<dyn Backend>,
format: &'static str,
}
impl VDisk {
/// Nome do formato (`QCOW2`, `VMDK`, `VHD`, `VHDX`, `DMG`).
/// Format label (`"QCOW2"`, `"VMDK"`, `"VHD"`, `"VHDX"`, `"DMG"`).
pub fn format(&self) -> &'static str {
self.format
}
@@ -88,12 +88,12 @@ pub(crate) fn io_err(e: io::Error, what: &str) -> Error {
Error::msg(format!("{what}: {e}"))
}
/// Cadeias de arquivos pai (VHD/VHDX diferenciais) não passam deste limite, o que também
/// corta ciclos.
/// Maximum parent/backing chain depth (prevents infinite loops on cyclic parent references).
///
const MAX_DEPTH: u32 = 16;
/// Formato de contêiner reconhecido pelos primeiros bytes (e, para VHD fixo e DMG, pelo
/// fim do arquivo). `None` quando é um disco bruto.
/// Identifies a virtual disk container format from its leading bytes (and trailing footer
/// for fixed VHD and DMG). Returns `None` for raw disk images.
pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
if head.starts_with(b"QFI\xfb") {
return Some("QCOW2");
@@ -113,7 +113,7 @@ pub fn detect(file: &FileSource, head: &[u8]) -> Option<&'static str> {
None
}
/// Abre `path` como disco virtual, ou `Ok(None)` se não for um contêiner conhecido.
/// Opens `path` as a virtual disk, or returns `Ok(None)` if it is not a recognized container.
pub fn open(path: &Path) -> Result<Option<VDisk>> {
open_depth(path, 0)
}
@@ -139,8 +139,8 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
Ok(Some(VDisk { inner, format }))
}
/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o
/// arquivo é tratado como disco bruto sem tentar reconhecer o formato.
/// Opens a parent/backing image at `depth`. When `raw` is `true`, treats the backing file
/// directly as a raw image without probing for a container header.
pub(crate) fn open_parent(
candidates: &[std::path::PathBuf],
depth: u32,
@@ -170,7 +170,7 @@ pub(crate) fn open_parent(
)))
}
/// Lê de um arquivo por posição e exige todos os bytes.
/// Positional read helper that fills all of `buf` or returns `UnexpectedEof`.
pub(crate) fn read_exact(f: &FileSource, off: u64, buf: &mut [u8]) -> io::Result<()> {
crate::source::read_exact_at(f, off, buf)
}
@@ -181,7 +181,7 @@ pub(crate) fn read_vec(f: &FileSource, off: u64, len: usize) -> io::Result<Vec<u
Ok(v)
}
/// Tabelas em memória têm teto: um cabeçalho adulterado não pode pedir gigabytes.
/// Upper bound on in-memory allocation tables so a corrupted header cannot trigger multi-GB allocations.
pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Result<usize> {
const MAX: u64 = 512 * 1024 * 1024;
if entries.saturating_mul(entry_size) > MAX {
@@ -190,7 +190,7 @@ pub(crate) fn check_table(entries: u64, entry_size: u64, what: &str) -> io::Resu
Ok(entries as usize)
}
/// UTF-16 (big ou little endian) até o primeiro nulo.
/// Decodes UTF-16 (BE or LE) up to the first NUL code unit.
pub(crate) fn utf16(b: &[u8], big: bool) -> String {
let units: Vec<u16> = b
.chunks_exact(2)
@@ -200,8 +200,8 @@ pub(crate) fn utf16(b: &[u8], big: bool) -> String {
String::from_utf16_lossy(&units)
}
/// Caminhos onde procurar o disco pai: o caminho gravado (relativo ao arquivo filho, com `\`
/// virando `/`) e, como alternativa, só o nome do arquivo na mesma pasta.
/// Candidate paths for locating a parent/backing disk: relative to the child image's directory
/// (normalizing `\` to `/`), falling back to the basename in the same directory.
pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::PathBuf> {
let mut out: Vec<std::path::PathBuf> = vec![];
for n in names {
@@ -228,7 +228,7 @@ pub(crate) fn candidate_paths(dir: &Path, names: &[String]) -> Vec<std::path::Pa
out
}
/// Lê do disco pai (ou zeros, sem pai); o que o pai não cobre também é zero.
/// Reads from the parent disk (or fills zeros if there is no parent or `off` is past parent EOF).
pub(crate) fn read_parent(
parent: &Option<Box<dyn Source>>,
voff: u64,
+9 -9
View File
@@ -1,7 +1,7 @@
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd),
//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai.
//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um
//! arquivo local (`json:`, `nbd:`, URLs) são recusadas.
//! QCOW2 (QEMU) v2 and v3: L1/L2 tables, compressed clusters (deflate and zstd),
//! zero-flagged clusters, and backing files (unallocated child clusters fall through to parent).
//! Encrypted images, external data files, extended L2 subclusters, and non-local backing URIs
//! (`json:`, `nbd:`, URLs) are rejected.
use super::{
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
@@ -22,7 +22,7 @@ pub(super) struct Qcow2 {
l1: Vec<u64>,
zstd: bool,
parent: Option<Box<dyn Source>>,
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
/// Single-entry cache of the last decompressed cluster: `(guest_cluster_offset, data)`.
cache: Mutex<Option<(u64, Vec<u8>)>>,
}
@@ -85,8 +85,8 @@ impl Qcow2 {
})
}
/// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw`
/// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz.
/// Opens the backing file. Its format comes from header extension `0xE2792ACA`; `"raw"`
/// makes the backing file open directly as raw bytes without probing.
fn open_backing(
file: &FileSource,
h: &[u8],
@@ -109,7 +109,7 @@ impl Qcow2 {
"o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados"
)));
}
// extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes
// header extensions follow the header, 8-byte aligned
let hlen = if version == 3 && h.len() >= 104 {
be32(h, 100) as u64
} else {
@@ -201,7 +201,7 @@ impl Backend for Qcow2 {
}
let off = entry & OFFSET_MASK;
if entry & 1 != 0 {
buf[..n].fill(0); // zerado de propósito: não consulta o pai
buf[..n].fill(0); // explicitly zeroed grain (`GTE == 1`): does not fall back to parent
} else if off == 0 {
read_parent(&self.parent, voff, &mut buf[..n])?;
} else {
+4 -4
View File
@@ -1,6 +1,6 @@
//! VHD (Microsoft Virtual PC e Hyper-V): discos fixos, dinâmicos e diferenciais. O disco
//! diferencial lê o pai (achado pelos localizadores do cabeçalho) nos setores que a camada
//! filha não preencheu.
//! VHD (Microsoft Virtual PC and Hyper-V): fixed, dynamic, and differencing disks.
//! Differencing disks resolve parent locators (`W2ku`/`W2ru`) and fall back to the parent
//! image for sectors not present in the child block bitmap.
use super::{
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
@@ -42,7 +42,7 @@ fn footer_ok(f: &[u8]) -> bool {
!sum == be32(f, 64)
}
/// VHD fixo: disco bruto seguido de um rodapé de 512 bytes.
/// Fixed VHD: raw disk payload followed by a 512-byte `conectix` footer.
pub(super) fn is_fixed(file: &FileSource) -> bool {
let size = file.size();
if size < 2 * SECTOR {
+7 -7
View File
@@ -1,8 +1,8 @@
//! VHDX (Hyper-V): tabela de regiões, metadados, BAT com blocos de bitmap intercalados,
//! discos fixos, dinâmicos e diferenciais (pai achado pelo localizador de pai).
//! VHDX (Hyper-V): region table, metadata table, Block Allocation Table (BAT) with interleaved
//! sector bitmap blocks, and fixed, dynamic, and differencing disks.
//!
//! The VHDX transaction log is not replayed on disk (opening an image never writes to it).
//!
//! O log de transações não é reproduzido: abrir a imagem nunca a escreve. Se o disco foi
//! desligado no meio de uma escrita, os dados ainda no log não aparecem.
use super::{
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
@@ -14,7 +14,7 @@ use std::path::Path;
const MB: u64 = 1 << 20;
/// GUID em texto para os 16 bytes gravados em disco (três primeiros campos little-endian).
/// Converts a hyphenated GUID string into on-disk mixed-endian 16-byte representation.
fn guid(s: &str) -> [u8; 16] {
let hex: Vec<u8> = s
.replace('-', "")
@@ -93,7 +93,7 @@ impl Vhdx {
}
let chunk_ratio = (1u64 << 23) * sector_size / block_size;
let payload = size.div_ceil(block_size);
// Com pai, cada trecho termina com a entrada do bitmap de setores, mesmo que o disco acabe antes.
// With a parent (`has_parent`), each chunk ratio group is followed by a sector bitmap BAT entry.
let want = if has_parent {
payload.div_ceil(chunk_ratio) * (chunk_ratio + 1)
} else {
@@ -166,7 +166,7 @@ impl Backend for Vhdx {
read_exact(&self.file, off + within, &mut buf[..n])?;
}
7 => {
// Parcialmente presente: o bitmap do trecho diz, setor a setor, onde está o dado.
// Partially present block: consult the chunk's sector bitmap sector by sector.
let chunk = block / self.chunk_ratio;
let bm_entry = self.bat(chunk * (self.chunk_ratio + 1) + self.chunk_ratio);
let ss = self.sector_size;
+14 -14
View File
@@ -1,7 +1,7 @@
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos
//! que o filho não alocou; o formato SEsparse é recusado.
//! VMDK (VMware, VirtualBox): sparse extents (including `streamOptimized` with deflate-compressed
//! grains) and text descriptors referencing `FLAT`, `SPARSE`, and `ZERO` extents such as
//! `monolithicFlat` and `twoGbMaxExtentSparse`. Snapshot/delta VMDKs fall back to their parent
//! for unallocated grains; `SEsparse` is rejected.
use super::{
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
@@ -42,7 +42,7 @@ pub(super) struct Vmdk {
parent: Option<Box<dyn Source>>,
}
/// Valor de uma chave `chave=valor` do descritor, sem aspas.
/// Extracts the unquoted value of a `key=value` line from a VMDK text descriptor.
fn desc_value(descriptor: &str, key: &str) -> Option<String> {
descriptor.lines().find_map(|l| {
l.trim()
@@ -52,8 +52,8 @@ fn desc_value(descriptor: &str, key: &str) -> Option<String> {
})
}
/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido
/// logo depois do cabeçalho de uma extensão esparsa.
/// Reads the VMDK text descriptor from `path` (either a standalone text file or embedded inside
/// a monolithic sparse header).
fn descriptor_of(path: &Path) -> Option<String> {
let f = FileSource::open(path).ok()?;
let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?;
@@ -68,8 +68,8 @@ fn descriptor_of(path: &Path) -> Option<String> {
.then(|| String::from_utf8_lossy(&head).into_owned())
}
/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só
/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter.
/// Parent reference parsed from a VMDK descriptor: `parentFileNameHint` and/or VirtualBox
/// `ddb.uuid.parent`, plus the expected `parentCID`.
struct ParentRef {
name: Option<String>,
uuid: Option<String>,
@@ -85,7 +85,7 @@ fn parent_ref(descriptor: &str) -> Option<ParentRef> {
(cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid })
}
/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`.
/// Searches the child directory (and up to two parent directories) for a VMDK whose `ddb.uuid.image` matches `uuid`.
fn find_by_uuid(dir: &Path, uuid: &str) -> Option<std::path::PathBuf> {
let mut d = Some(dir);
for _ in 0..3 {
@@ -169,8 +169,8 @@ impl Sparse {
))
}
/// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo
/// (o chamador completa com o pai); grão zerado de propósito devolve zeros.
/// Reads within a single grain. Returns `(bytes_read, allocated_or_zeroed)`: when the `bool` is
/// `false`, the grain is unallocated in this extent and the caller should read from the parent.
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> {
let g = off / self.grain;
let within = off % self.grain;
@@ -213,7 +213,7 @@ impl Sparse {
}
}
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
/// Parses extent lines from a text descriptor (e.g. `RW 2048 FLAT "disk-f001.vmdk" 0`).
fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
let mut out = vec![];
let mut start = 0u64;
@@ -290,7 +290,7 @@ impl Vmdk {
));
}
if let Some(cid) = &r.cid {
// o pai precisa ser o mesmo de quando o filho foi criado
// verify that the parent's `CID` matches the child's `parentCID`
if let Some(p) = found.iter().find(|p| p.is_file()) {
let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID"));
if have
+6 -6
View File
@@ -1,8 +1,8 @@
//! Disco virtual: a origem real com trechos substituídos por volumes desbloqueados,
//! membros de RAID montados ou volumes lógicos (LVM) anexados depois do fim do disco.
//! Virtual disk address space: overlays a base `Source` with unlocked encrypted volumes,
//! assembled RAID arrays, or LVM logical volumes appended beyond the physical end of disk.
//!
//! Os deslocamentos continuam sendo "do disco": quem varre e quem recupera enxergam o mesmo
//! espaço de endereços, desde que a montagem seja refeita igual (a `Assembly` é determinística).
//! Byte offsets remain globally addressable: scanning and recovery operate on a single unified
//! address space as long as the deterministic `Assembly` is re-applied.
use crate::source::Source;
use std::io;
@@ -58,7 +58,7 @@ impl Source for Overlay {
let n = want.min((r.start + r.len - offset) as usize);
return r.src.read_at(offset - r.start, &mut buf[..n]);
}
// Antes da próxima região: lê da base só até ela.
// Offset precedes the next virtual region: read from the base source up to the region start.
let n = want.min((r.start - offset) as usize);
return self.read_base(offset, &mut buf[..n]);
}
@@ -69,7 +69,7 @@ impl Source for Overlay {
impl Overlay {
fn read_base(&self, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if offset >= self.base.size() {
// Lacuna entre a base e uma região anexada.
// Unmapped gap between the end of the base source and an appended virtual region.
buf.fill(0);
return Ok(buf.len());
}
+3 -3
View File
@@ -1,4 +1,4 @@
//! AFF4: imagens de referência da Evimetry (github.com/aff4/ReferenceImages) e ida e volta.
//! AFF4: Evimetry reference images (`github.com/aff4/ReferenceImages`) and write/read roundtrip.
use guara_core::aff4::{write_aff4, Aff4Options, Aff4Source};
use guara_core::source::{open_path, read_vec, MemSource, Source};
@@ -28,11 +28,11 @@ fn reads_reference_images_and_stored_hash_matches() {
Some(true),
"{name}: hash do fluxo"
);
// setor 0 legível pelo caminho genérico
// sector 0 is readable through the generic `source::open` entrypoint
let s = open_path(&p).unwrap();
let head = read_vec(s.as_ref(), 0, 4096).unwrap();
assert_eq!(head.len(), 4096);
// leitura em vãos do mapa devolve zeros
// reading unmapped gaps in a sparse AFF4 map returns zeros
let far = read_vec(s.as_ref(), 200 << 20, 512).unwrap();
assert_eq!(far.len(), 512);
eprintln!(
+11 -11
View File
@@ -1,4 +1,4 @@
//! APFS: imagem real do dfvfs (4 checkpoints) e exclusão simulada pela troca da raiz da árvore.
//! APFS: real `dfvfs` image (4 checkpoints) and simulated COW deletion by swapping the fs-tree root.
mod common;
use common::*;
@@ -69,7 +69,7 @@ fn quick_scan_is_clean_on_pristine_volume() {
);
}
// --- manipulação bruta de nós (independente do módulo) ---
// --- raw B-tree node helpers (independent of the production parser) ---
fn le16(b: &[u8], o: usize) -> usize {
u16::from_le_bytes([b[o], b[o + 1]]) as usize
@@ -144,7 +144,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
let mut d = std::fs::read(&p).unwrap();
let bs = 4096usize;
let blk = |d: &[u8], n: u64| d[n as usize * bs..(n as usize + 1) * bs].to_vec();
// Superbloco mais recente do contêiner.
// Locate the latest container superblock (`NXSB`).
let (desc_blocks, desc_base) = (le32(&d, 104) as u64, le64(&d, 112));
let mut nx = d[..bs].to_vec();
for i in 0..desc_blocks {
@@ -153,11 +153,11 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
nx = b;
}
}
// omap do contêiner -> volume
// container omap -> volume superblock (`APSB`)
let cont_omap = blk(&d, le64(&nx, 160));
let cont_tree = blk(&d, le64(&cont_omap, 48));
let (_, cont_entries) = read_node(&{
// omap usa chaves fixas (16/16): reaproveita o leitor variável convertendo a tabela
// omap uses fixed-size keys/values (16/16): convert its TOC to variable-size format for `read_var_node`
let mut c = cont_tree.clone();
fixed_to_var(&mut c);
c
@@ -187,7 +187,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
let root = blk(&d, root_paddr);
assert_eq!(le16(&root, 34), 0, "raiz deveria ser folha nesta imagem");
let (_, recs) = read_node(&root);
// inode do arquivo
// find target file inode number
let victim = b"passwords.txt";
let ino = recs
.iter()
@@ -216,12 +216,12 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
.collect();
assert!(kept.len() < recs.len());
let newnode = write_node(&root, &kept);
// bloco livre para a nova versão
// pick an unused block for the new COW root leaf
let free = (10..d.len() / bs)
.find(|&n| d[n * bs..(n + 1) * bs].iter().all(|&x| x == 0) && n != vol_tree_paddr as usize)
.expect("bloco livre");
d[free * bs..(free + 1) * bs].copy_from_slice(&newnode);
// aponta o mapa de objetos para a nova versão (mesmo xid), editando o valor da entrada
// repoint the volume omap entry to `free_blk` (keeping the same `xid`)
let toc_off = le16(&vt_orig, 40);
let key_base = 56 + toc_off + le16(&vt_orig, 42);
let _ = key_base;
@@ -236,7 +236,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
let dir = tempfile::tempdir().unwrap();
let p2 = dir.path().join("a.img");
std::fs::write(&p2, &d).unwrap();
// O arquivo sumiu da árvore atual...
// The file is gone from the current live tree...
let src = FileSource::open(&p2).unwrap();
assert!(!live_paths(&src, 0).iter().any(|f| f.0 == "passwords.txt"));
init();
@@ -275,7 +275,7 @@ fn file_removed_from_the_current_tree_survives_in_the_old_node() {
);
}
/// Converte a tabela de entradas fixas (4 bytes) de um nó de omap para o formato variável (8 bytes) em memória.
/// Converts a fixed-entry TOC (4 bytes/entry) of an omap node into variable-entry format (8 bytes/entry) in memory.
fn fixed_to_var(b: &mut Vec<u8>) {
let flags = le16(b, 32) as u16;
if flags & 4 == 0 {
@@ -302,7 +302,7 @@ fn fixed_to_var(b: &mut Vec<u8>) {
fn write_node_flagsless(old: &[u8], entries: &[Entry]) -> Vec<u8> {
let mut b = write_node(old, entries);
// write_node usa as flags de `old`
// `write_node` preserves the node flags from `old`
let fl = le16(&b, 32) as u16;
b[32..34].copy_from_slice(&fl.to_le_bytes());
b
+3 -3
View File
@@ -1,4 +1,4 @@
//! Trilha de auditoria: encadeamento por hash, detecção de adulteração e registro das ações do motor.
//! Audit trail: SHA-256 hash chaining, tamper detection, and engine action logging.
mod common;
use common::*;
@@ -28,7 +28,7 @@ fn entries_are_numbered_and_chained() {
assert!(e[0].time.ends_with('Z') && e[0].time.len() == 20);
let v = log.verify().unwrap();
assert!(v.ok && v.entries == 4 && v.head == e[3].hash);
// continua de onde parou, inclusive numa instância nova
// continues the chain across separate `Log` instances
let again = Log::at(log.path());
assert_eq!(again.append("mais", json!({})).unwrap().seq, 5);
assert!(again.verify().unwrap().ok);
@@ -74,7 +74,7 @@ fn removing_or_reordering_lines_is_detected() {
tamper(&log, |l| l.swap(1, 2));
assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 2);
// trocar o hash por outro também não passa: o seguinte deixa de continuar
// recomputing a single tampered line's hash still breaks the next entry's `prev_hash` link
let (_d, log) = log_with(3);
tamper(&log, |l| {
let h = l[0].rsplit('"').nth(1).unwrap().to_string();
+4 -4
View File
@@ -1,5 +1,5 @@
//! BitLocker: imagens reais do conjunto de testes do cryptsetup (GitLab), com senhas conhecidas.
//! Baixe com `scripts/fetch-testdata.sh`; sem elas os testes são pulados.
//! BitLocker: real test images from the `cryptsetup` test suite (with known passwords/recovery keys).
//! Download with `scripts/fetch-testdata.sh`; tests are skipped if images are absent.
use guara_core::crypt::{bitlocker, Secret, UnlockError};
use guara_core::partition::detect_fs_in;
@@ -31,7 +31,7 @@ fn conf(d: &std::path::Path) -> BTreeMap<String, BTreeMap<String, String>> {
}
fn check_fs(src: &dyn Source, what: &str) {
// Imagens "encrypt-on-write" não têm sistema de arquivos reconhecível (UUID vazio no images.conf).
// Used-space-only ("encrypt-on-write") images in `images.conf` have an empty filesystem UUID.
if what.contains("-eow ") {
return;
}
@@ -59,7 +59,7 @@ fn opens_all_reference_images_with_password_and_recovery_key() {
let src: Arc<dyn Source> = Arc::new(FileSource::open(&path).unwrap());
let pw = c.get("PW").cloned().unwrap_or_default();
let rp = c.get("RP").cloned().unwrap_or_default();
// Proteção suspensa (chave limpa): abre sem segredo nenhum.
// Suspended protection (clear key present on disk): unlocks without any user secret.
if let Ok(u) = bitlocker::unlock(src.clone(), &[]) {
check_fs(u.source.as_ref(), &format!("{name} (chave limpa)"));
eprintln!("ok {name}: chave limpa");
+15 -15
View File
@@ -1,5 +1,5 @@
//! Btrfs: imagens criadas com mkfs.btrfs --rootdir; exclusão simulada como o COW faz
//! (nova versão da folha em outro bloco, a antiga continua no disco).
//! Btrfs: images created with `mkfs.btrfs --rootdir`; COW deletion simulated by writing a
//! new fs-tree leaf generation to a free metadata block while leaving the old leaf on disk.
mod common;
use common::*;
@@ -89,7 +89,7 @@ fn lists_the_live_tree_with_paths_and_sizes() {
assert!(listed.iter().any(|x| x.0 == "docs/sub" && x.2));
}
// --- manipulação bruta de folhas (independente do módulo) ---
// --- raw B-tree leaf manipulation helpers (independent of the production reader) ---
struct Leaf {
raw: Vec<u8>,
@@ -118,7 +118,7 @@ impl Leaf {
let c = crc32c(&self.raw[32..]);
self.raw[0..4].copy_from_slice(&c.to_le_bytes());
}
/// Nova versão da folha sem os itens que `drop` rejeita, num novo endereço lógico.
/// Rebuilds a B-tree leaf without items matching `drop`, at a new logical `bytenr` and generation.
fn without(&self, drop: impl Fn(u64, u8) -> bool, new_bytenr: u64) -> Leaf {
let mut kept: Vec<(usize, Vec<u8>)> = vec![];
for i in 0..self.nritems() {
@@ -152,7 +152,7 @@ fn superblock(img: &[u8]) -> &[u8] {
&img[0x10000..0x11000]
}
/// Mapa lógico -> físico dos chunks de sistema (suficiente para imagens pequenas de um dispositivo).
/// Logical -> physical chunk mapping from `sys_chunk_array` (sufficient for small single-device images).
fn chunk_map(img: &[u8]) -> Vec<(u64, u64, u64)> {
let sb = superblock(img);
let n = u32::from_le_bytes(sb[160..164].try_into().unwrap()) as usize;
@@ -193,7 +193,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
let fl = Leaf {
raw: img[fs_leaf.0..fs_leaf.0 + nodesize].to_vec(),
};
// inode do arquivo apagado
// find the objectid (inode) of the target file
let victim = "relatorio.dat";
let mut ino = 0;
for k in 0..fl.nritems() {
@@ -207,7 +207,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
}
}
assert!(ino > 256);
// extent de dados do arquivo (para liberar no extent tree)
// locate the file's data extent `(disk_bytenr, disk_num_bytes)` so we can free it in the extent tree
let mut data_bytenr = 0;
for k in 0..fl.nritems() {
let (obj, ty, _, _, _) = fl.item(k);
@@ -216,7 +216,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
}
}
assert!(data_bytenr > 0);
// Novo bloco livre para a nova versão: primeiro bloco do metadata sem folha válida e todo zero.
// Pick an all-zero metadata block for the new COW leaf generation.
let free_slot = (0..img.len() / nodesize)
.map(|n| n * nodesize)
.find(|&o| {
@@ -228,11 +228,11 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
&& o < fs_leaf.0 + (8 << 20)
})
.expect("bloco livre");
// logical = bytenr da folha antiga + (offset físico - offset físico antigo): mesmo chunk
// logical `bytenr` within the same metadata chunk
let new_bytenr = to_logical(free_slot);
let newleaf = fl.without(|o, _| o == ino, new_bytenr);
img[free_slot..free_slot + nodesize].copy_from_slice(&newleaf.raw);
// Aponta o ROOT_ITEM da árvore 5 para a nova versão (e sobe a geração), na folha da árvore raiz.
// Update tree 5 (`FS_TREE`) `ROOT_ITEM` in the root tree to point to the new leaf and bump generation.
let mut rl = Leaf {
raw: img[root_leaf.0..root_leaf.0 + nodesize].to_vec(),
};
@@ -245,7 +245,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
}
rl.seal();
img[root_leaf.0..root_leaf.0 + nodesize].copy_from_slice(&rl.raw);
// Libera o extent de dados no extent tree (em outro endereço da mesma folha).
// Remove the file's data extent from the extent tree so its data blocks are marked free.
let el = Leaf {
raw: img[ext_leaf.0..ext_leaf.0 + nodesize].to_vec(),
};
@@ -290,7 +290,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
let rp = PathBuf::from(&rep.recovered[0].path);
let got = std::fs::read(if rp.is_absolute() { rp } else { out.join(rp) }).unwrap();
assert!(got == i.files[0].1);
// Os arquivos que continuam existindo não aparecem como apagados.
// Files that are still live in the current tree must not be reported as deleted.
assert!(!c
.found
.iter()
@@ -300,7 +300,7 @@ fn deleted_file_survives_in_the_previous_version_of_the_leaf() {
#[test]
fn lzo_literals_and_a_match() {
// 20 literais + fim
// 20 literals + EOF marker
let mut s = vec![17 + 20];
s.extend((0..20u8).map(|i| b'a' + i));
s.extend_from_slice(&[0x11, 0, 0]);
@@ -308,7 +308,7 @@ fn lzo_literals_and_a_match() {
lzo1x_decompress(&s, 4096).unwrap(),
(0..20u8).map(|i| b'a' + i).collect::<Vec<_>>()
);
// "abcd" + M2 (dist 4, len 4) + fim
// "abcd" + M2 backreference (distance 4, length 4) + EOF marker
let s = vec![17 + 4, b'a', b'b', b'c', b'd', 0x6C, 0x00, 0x11, 0, 0];
assert_eq!(lzo1x_decompress(&s, 4096).unwrap(), b"abcdabcd");
}
@@ -334,7 +334,7 @@ fn packed_extents_decode_zlib_and_zstd() {
codec: PackedCodec::Zlib,
};
assert_eq!(p.read(&MemSource(disk)).unwrap(), &plain[1000..6000]);
// zstd, se o utilitário existir
// zstd-compressed extent (if the `zstd` CLI is installed)
if have("zstd") {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("p.bin");
+2 -2
View File
@@ -37,7 +37,7 @@ impl Sink for Collect {
}
}
/// Isola as configurações/sessões automáticas dos testes numa pasta temporária.
/// Isolates `GUARA_CONFIG_DIR` (settings and autosaved sessions) inside a per-test temp directory.
pub fn init() {
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
let d = DIR.get_or_init(|| tempfile::tempdir().unwrap());
@@ -69,7 +69,7 @@ pub fn sha256(b: &[u8]) -> String {
.collect()
}
/// Gera (uma vez) as imagens de teste; `None` se as ferramentas não existirem.
/// Builds (or reuses) the filesystem test fixtures once per test binary; returns `None` if `mkfs` tools are missing.
pub fn fixtures() -> Option<PathBuf> {
static ONCE: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
ONCE.get_or_init(fixtures_inner).clone()
+5 -5
View File
@@ -66,7 +66,7 @@ fn rebuilds_a_jpeg_split_around_another_one() {
let mut bb = b.clone();
pad(&mut bb);
disk.extend_from_slice(&bb);
disk.extend(noise(20 * 4096, 5)); // lixo
disk.extend(noise(20 * 4096, 5)); // non-JPEG filler block
let a2_off = disk.len() as u64;
let mut rest = a[split..].to_vec();
pad(&mut rest);
@@ -76,7 +76,7 @@ fn rebuilds_a_jpeg_split_around_another_one() {
let mut idx = ClusterIndex::new(disk.len() as u64);
idx.scan_chunk(0, &disk);
// O fragmento correto precisa estar marcado.
// The continuation cluster must be flagged as a JPEG entropy candidate.
assert!(idx.get(a2_off));
eprintln!("TRUE a2_off={a2_off}");
let pool = Pool {
@@ -97,7 +97,7 @@ fn rebuilds_a_jpeg_split_around_another_one() {
}
other => panic!("esperava Rebuilt, veio {other:?}"),
}
// O outro JPEG, íntegro, continua íntegro.
// The intact decoy JPEG remains a single contiguous extent.
let pool2 = Pool {
index: &idx,
used: &[],
@@ -124,7 +124,7 @@ fn rebuilds_three_fragments_among_many_decoys() {
decoys.push((disk.len() as u64, d.len() as u64));
disk.extend_from_slice(&d);
if k == 1 {
// segundo fragmento de A no meio
// second fragment of image A placed in the middle
let off2 = disk.len() as u64;
disk.extend_from_slice(&a[s1..s2]);
decoys.push((off2, (s2 - s1) as u64));
@@ -142,7 +142,7 @@ fn rebuilds_three_fragments_among_many_decoys() {
let used: Vec<Extent> = decoys
.iter()
.enumerate()
.filter(|(i, _)| *i != 2) // o fragmento do meio de A não é de outro arquivo
.filter(|(i, _)| *i != 2) // only the intact image B is marked as already claimed; A's middle fragment is unclaimed
.map(|(_, (o, l))| Extent::new(*o, *l))
.collect();
let pool = Pool {
+37 -37
View File
@@ -1,4 +1,4 @@
//! Discos com tabela de partições, espaço não particionado, partições perdidas e cifragem.
//! Partitioned disks (GPT/MBR), unpartitioned space, lost partitions, sessions, and disconnect handling.
mod common;
use common::*;
@@ -39,7 +39,7 @@ fn png(extra: usize) -> Vec<u8> {
v
}
/// Disco: [1 MiB vazio][partição FAT32 do fixture][8 MiB não particionado com um PNG]
/// Synthetic disk: `[1 MiB gap][FAT32 partition][8 MiB unpartitioned gap containing a PNG]`
#[test]
fn particao_mbr_e_espaco_nao_particionado() {
let Some(dir) = fixtures() else { return };
@@ -66,14 +66,14 @@ fn particao_mbr_e_espaco_nao_particionado() {
"gap: {:?}",
layout.gaps
);
// Metadados da partição (offsets corrigidos pela posição da partição)...
// Partition metadata findings (with byte offsets translated to disk-global offsets)...
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.
// ...plus the carved PNG from the unpartitioned gap.
let carved: Vec<_> = res
.found
.iter()
@@ -117,11 +117,11 @@ fn particao_perdida_e_encontrada_sem_tabela() {
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.
// BitLocker signature in partition 1, LUKS signature in partition 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.
// A plaintext PNG planted inside the locked container range must not be carved while locked.
let p = png(10_000);
let at = (2 * MIB) as usize;
disk[at..at + p.len()].copy_from_slice(&p);
@@ -141,7 +141,7 @@ fn volume_cifrado_e_detectado_e_nao_varrido() {
#[test]
fn gpt_e_lido() {
// GPT mínimo com 1 partição de dados.
// Minimal GPT with 1 basic data partition.
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];
@@ -183,7 +183,7 @@ fn imagem_forense_e_identica_e_origem_intacta() {
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.
// Never overwrite an existing file in the destination directory.
assert!(image::create_image(src.as_ref(), &dest, &|| false, &mut |_, _| {}).is_err());
}
@@ -193,7 +193,7 @@ fn pausa_e_retoma_encontra_o_mesmo() {
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.
// Cancel on the 3rd progress callback, then resume from the saved 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();
@@ -233,7 +233,7 @@ fn pausa_e_retoma_encontra_o_mesmo() {
assert_eq!(a, b);
}
/// Streaming de pré-visualização: Range devolve exatamente os bytes do arquivo recuperado.
/// Preview streaming: HTTP `Range` slices return the exact bytes of the recovered file.
#[test]
fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
init();
@@ -302,7 +302,7 @@ fn configuracoes_de_tema_persistem() {
settings::load().updates,
"a verificação de atualização vem ligada"
);
// arquivo de uma versão anterior, sem o campo novo
// legacy session JSON missing newly added fields
std::fs::write(
settings::config_dir().join("settings.json"),
r#"{"theme":"dark"}"#,
@@ -328,8 +328,8 @@ fn wait_done(eng: &service::Engine, job: &str) {
panic!("job não terminou");
}
/// Gera uma sessão no formato da v0.1.0 (sem os campos que vieram depois) a partir de uma
/// varredura real da imagem, com o alvo informado.
/// Strips post-v0.1.0 fields from a real scan session JSON to simulate a legacy v0.1.0 session file.
///
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
init();
let eng = service::Engine::new();
@@ -358,7 +358,7 @@ fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTe
tmp
}
/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
/// Backward compatibility: a session saved by v0.1.0 still loads and recovers cleanly.
#[test]
fn sessao_da_v0_1_0_abre_e_funciona() {
init();
@@ -374,7 +374,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
assert_eq!(st["source"], true);
let res = eng.results(&job, &service::Query::default()).unwrap();
assert_eq!(res["total"], 7);
// recuperar a partir da sessão antiga continua dando o mesmo conteúdo
// recovering from the legacy session produces the exact same file contents
let out = tempfile::tempdir().unwrap();
let rj = eng
.start_recover(
@@ -392,14 +392,14 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
}
}
/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
/// A missing source does not block opening a session; relinking the source restores full access.
#[test]
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
init();
let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img");
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe");
// sessão pausada (como as de uma varredura interrompida)
// paused session (as produced by an interrupted scan)
let mut v: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
v["done"] = serde_json::json!(false);
@@ -413,7 +413,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.as_str()
.unwrap()
.contains("não está disponível"));
// navegar e exportar funcionam
// browsing, filtering, CSV export, and HTML reporting work even while the source is offline
assert_eq!(
eng.results(&job, &service::Query::default()).unwrap()["total"],
7
@@ -424,7 +424,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.unwrap(),
7
);
// pré-visualização, retomada e recuperação avisam com clareza
// preview, resume, and recovery return a clear error while the source is offline
assert_eq!(eng.thumb(&job, 1, 128).err(), Some(503));
assert!(eng
.resume(&job)
@@ -442,7 +442,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
)
.unwrap_err();
assert!(e.to_string().contains("não está disponível"), "{e}");
// reconecta
// relink the session to the relocated disk image
eng.relink(&job, img.to_str().unwrap(), false).unwrap();
let st = eng.status(&job).unwrap();
assert_eq!(st["source"], true);
@@ -458,7 +458,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.unwrap();
wait_done(&eng, &rj);
assert_eq!(hashes_in(out.path()).len(), 7);
// arquivo que não é sessão
// non-session file returns a clear error
std::fs::write(tmp.path(), b"nao e json").unwrap();
let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err();
assert!(
@@ -467,7 +467,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
);
}
// ------------------------------------------------------------------ desconexão e reconexão do disco
// ------------------------------------------------------------------ disk disconnect and reconnect
#[test]
fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() {
@@ -477,14 +477,14 @@ fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() {
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
let eng = service::Engine::new();
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
// sessão antiga não tem impressão digital: o relink aprende a do primeiro disco aceito
// legacy session had no fingerprint: `relink_session` records the fingerprint of the accepted disk
eng.relink(&job, img.to_str().unwrap(), false).unwrap();
let work = tempfile::tempdir().unwrap();
// clone idêntico (por exemplo uma imagem do disco): aceito
// identical clone (e.g. a raw disk image of the drive): accepted without `force`
let clone = work.path().join("clone.img");
std::fs::copy(&img, &clone).unwrap();
eng.relink(&job, clone.to_str().unwrap(), false).unwrap();
// mesmo tamanho, mas outro conteúdo inicial: recusado, a menos que force
// same byte size but different disk content: rejected unless `force` is true
let outro = work.path().join("outro.img");
let mut bytes = std::fs::read(&img).unwrap();
bytes[0..16].copy_from_slice(b"OUTRO DISCO....!");
@@ -494,7 +494,7 @@ fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() {
.unwrap_err();
assert!(e.to_string().contains("conteúdo inicial"), "{e}");
eng.relink(&job, outro.to_str().unwrap(), true).unwrap();
// tamanho diferente: recusado
// different byte size: rejected unless `force` is true
let menor = work.path().join("menor.img");
std::fs::write(&menor, &bytes[..bytes.len() / 2]).unwrap();
let e = eng
@@ -503,7 +503,7 @@ fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() {
assert!(e.to_string().contains("bytes"), "{e}");
}
/// Fonte que "some" a partir de certo ponto, como um disco puxado da porta USB.
/// Simulated `Source` that fails with `EIO` past `fail_from`, mimicking an unplugged USB drive.
struct Flaky {
inner: MemSource,
broken_from: u64,
@@ -541,8 +541,8 @@ fn varredura_pausa_quando_o_disco_some_e_retoma_do_ponto_certo() {
v
};
let (a, b) = (png(3000), png(4000));
disk[2 * mib..2 * mib + a.len()].copy_from_slice(&a); // antes da falha
disk[50 * mib..50 * mib + b.len()].copy_from_slice(&b); // depois da falha
disk[2 * mib..2 * mib + a.len()].copy_from_slice(&a); // PNG before the disconnect point
disk[50 * mib..50 * mib + b.len()].copy_from_slice(&b); // PNG after the disconnect point
let flaky = Flaky {
inner: MemSource(disk.clone()),
broken_from: 16 * mib as u64,
@@ -555,9 +555,9 @@ fn varredura_pausa_quando_o_disco_some_e_retoma_do_ponto_certo() {
};
assert!(cp.io_lost, "deveria marcar perda de E/S");
assert_eq!(sink.found.len(), 1, "só o PNG anterior à falha");
// o ponto de retomada não pula os blocos que falharam
// the saved checkpoint offset does not skip past the unreadable blocks
assert!(cp.ranges[0].next <= 16 * mib as u64 + 8 * mib as u64);
// disco volta: retoma e acha o resto, sem duplicar o primeiro
// once the drive is reconnected, resuming finds the second PNG without duplicating the first
let healthy = MemSource(disk);
let before = sink.found.clone();
let out = scan::run(&healthy, &opts, Some(cp), &before, &|| false, &mut sink);
@@ -577,7 +577,7 @@ fn varredura_salva_sessao_sozinha_e_ela_reabre() {
.start_scan(img.to_str().unwrap(), ScanOptions::default())
.unwrap();
wait_done(&eng, &job);
// o salvamento final roda numa thread própria
// wait briefly for the background autosave thread to flush the final snapshot
let mut item = None;
for _ in 0..200 {
let list = eng.sessions_list();
@@ -596,7 +596,7 @@ fn varredura_salva_sessao_sozinha_e_ela_reabre() {
assert_eq!(item["found"], 7);
let path = item["path"].as_str().unwrap().to_string();
assert!(std::path::Path::new(&path).exists());
// reabre como qualquer sessão
// reopening the autosaved session restores all findings
let job2 = eng.load_session(&path).unwrap();
let st = eng.status(&job2).unwrap();
assert_eq!(
@@ -607,13 +607,13 @@ fn varredura_salva_sessao_sozinha_e_ela_reabre() {
),
("done", 7, true)
);
// apagar
// deleting the autosaved session removes it from the list
eng.session_delete(&path).unwrap();
assert!(!std::path::Path::new(&path).exists());
assert!(eng.session_delete("/etc/passwd").is_err());
}
// ------------------------------------------------------------------ organização e espaço
// ------------------------------------------------------------------ output organization and free-space check
#[test]
fn recuperar_organiza_por_tipo_data_ou_tudo_junto() {
@@ -652,7 +652,7 @@ fn recuperar_organiza_por_tipo_data_ou_tudo_junto() {
});
assert!(achou, "{layout:?}: {:?}", walk_names(out.path()));
}
// por data: usa a data de modificação (ano/mês)
// organize by date: groups into `YYYY/YYYY-MM` by modification/capture date
let out = tempfile::tempdir().unwrap();
recover::recover_with(
src.as_ref(),
@@ -699,7 +699,7 @@ fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img");
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
// sessão com um arquivo "enorme" (1 EB)
// synthetic session with a 1 EB file to trigger the insufficient destination space check
let mut v: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
v["found"][0]["size"] = serde_json::json!(1u64 << 60);
+2 -2
View File
@@ -1,4 +1,4 @@
//! Ponta a ponta: volume LUKS2 com ext4 dentro, arquivo apagado, senha informada ao Guará.
//! End-to-end encrypted recovery: LUKS2 container holding an ext4 volume with a deleted file.
mod common;
use common::*;
@@ -150,7 +150,7 @@ fn deleted_file_inside_luks2_is_recovered_with_password() {
.success());
let base: Arc<dyn Source> = Arc::new(FileSource::open(&img).unwrap());
// Sem senha: o volume segue fechado e a varredura não inventa arquivos.
// Without the password: the LUKS2 volume stays locked and no plaintext files are exposed.
let locked = assemble_source(base.clone(), &Assembly::default()).unwrap();
assert_eq!(locked.unlocked.len(), 0);
assert_eq!(locked.locked.len(), 1);
+4 -4
View File
@@ -1,4 +1,4 @@
//! Ponta a ponta: RAID 5 de 4 discos (um perdido) com ext4 dentro e um arquivo apagado.
//! End-to-end RAID recovery: degraded 4-disk RAID 5 (one missing disk) with ext4 and a deleted file.
mod common;
use common::*;
@@ -92,7 +92,7 @@ fn deleted_file_on_degraded_raid5() {
let logical = std::fs::read(&plain).unwrap();
// RAID 5 left-symmetric
let data_off = 2048u64; // setores
let data_off = 2048u64; // capacity in 512-byte sectors
let mut disks = vec![vec![0u8; data_off as usize * 512 + rows * CHUNK]; n];
for row in 0..rows {
let pd = n - 1 - row % n;
@@ -110,7 +110,7 @@ fn deleted_file_on_degraded_raid5() {
let mut paths = vec![];
for (i, d) in disks.iter_mut().enumerate() {
if i == 2 {
continue; // disco que morreu
continue; // simulate failed/missing disk 1
}
let sb = md_super(n as u32, i as u32, data_off, (rows * CHUNK / 512) as u64);
d[4096..8192].copy_from_slice(&sb);
@@ -230,7 +230,7 @@ fn raid5_without_metadata_is_detected_and_scanned() {
}
disks[pd][row * chunk..(row + 1) * chunk].copy_from_slice(&p);
}
let order = [1usize, 2, 0]; // fornecidos fora de ordem
let order = [1usize, 2, 0]; // member disks passed in scrambled order `[2, 0, 3, 1]`
let mut paths = vec![];
for (j, &i) in order.iter().enumerate() {
let p = dir.path().join(format!("m{j}.img"));
+2 -2
View File
@@ -20,7 +20,7 @@ fn reads_references_made_by_libewf() {
assert_eq!(s.size(), want.len() as u64, "{name}");
let got = read_vec(s.as_ref(), 0, want.len()).unwrap();
assert!(got == want, "{name}: conteúdo difere");
// leitura que cruza chunks e termina no fim
// cross-chunk read reaching EOF
let tail = read_vec(s.as_ref(), want.len() as u64 - 5000, 9000).unwrap();
assert_eq!(tail, &want[want.len() - 5000..], "{name}");
}
@@ -105,7 +105,7 @@ fn compressed_output_is_smaller_and_odd_sizes_work() {
assert_eq!(read_vec(r.as_ref(), 0, 100_352).unwrap(), data);
}
/// Gera uma imagem para validar com ferramentas externas (ewfverify). `GUARA_E01_OUT=/caminho/base`.
/// Optional helper to write an `.E01` for external `ewfverify` validation when `GUARA_E01_OUT` is set.
#[test]
#[ignore]
fn emit_for_external_verification() {
+5 -5
View File
@@ -1,4 +1,4 @@
//! exFAT: injeta um conjunto de entradas APAGADO num sistema criado por mkfs.exfat.
//! exFAT: injects a deleted directory entry set (`0x05`/`0x40`/`0x41`) into a `mkfs.exfat` image.
mod common;
use common::*;
@@ -30,7 +30,7 @@ fn exfat_apagado_e_recuperado_com_nome() {
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.
// Find the first unused 32-byte slot in the root directory cluster.
let mut slot = root_off;
while d[slot] != 0 {
slot += 32;
@@ -43,15 +43,15 @@ fn exfat_apagado_e_recuperado_com_nome() {
d[data_off..data_off + content.len()].copy_from_slice(&content);
let name = "relatório final.txt";
let units: Vec<u16> = name.encode_utf16().collect();
// 0x05 = arquivo apagado (0x85 sem o bit "em uso"), secundárias 0x40 e 0x41.
// `0x05` = deleted file entry (`0x85` with in-use bit cleared), followed by `0x40` stream and `0x41` name entries.
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 + 12..slot + 14].copy_from_slice(&0x6000u16.to_le_bytes()); // timestamp
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 + 1] = 0x03; // `NoFatChain` bit set (contiguous allocation)
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());
+8 -8
View File
@@ -1,4 +1,4 @@
//! ext4: arquivos apagados com nome, a partir de imagens criadas com mke2fs/debugfs.
//! ext2/3/4: deleted file recovery (with original filenames) on images built with `mke2fs`/`debugfs`.
mod common;
use common::*;
@@ -152,7 +152,7 @@ fn deleted_by_debugfs_keeps_extents_and_name() {
);
assert!(recovered(&i.img, f) == *data, "{name}");
}
// O arquivo que não foi apagado não aparece.
// Live files that were not deleted must not be listed.
assert!(!c
.found
.iter()
@@ -161,7 +161,7 @@ fn deleted_by_debugfs_keeps_extents_and_name() {
#[test]
fn truncated_inode_recovered_by_remnant_extents() {
// Simula o que o kernel faz ao apagar: tamanho zerado e contagem de extents zerada.
// Simulate Linux kernel `ext4_evict_inode`: zero `i_size` and `eh_entries` while leaving extent records.
let Some(i) = build(&["-t", "ext4"]) else {
return;
};
@@ -181,7 +181,7 @@ fn truncated_inode_recovered_by_remnant_extents() {
.find(|f| f.name == "contrato.dat")
.expect("contrato.dat");
assert_eq!(f.origin, Origin::Ext);
// Tamanho exato é perdido: recupera todos os blocos (9000 bytes arredondados para 3 blocos de 4K).
// Exact byte size was zeroed on deletion: recovers all 3 allocated 4 KiB blocks (12,288 bytes).
assert_eq!(f.size, 12288);
let got = recovered(&i.img, f);
assert_eq!(&got[..9000], &i.files[1].1[..]);
@@ -195,7 +195,7 @@ fn journal_copy_restores_exact_size() {
let Some(i) = build(&["-t", "ext4", "-O", "has_journal"]) else {
return;
};
// Bloco da tabela de inodes que contém o inode do arquivo.
// Locate the inode table block and byte offset for ` target ` via `debugfs imap`.
let ino = inode_of(&i.img, "/fotos/viagem.dat");
let o = debugfs(&i.img, &["stats"]);
let _ = o;
@@ -234,7 +234,7 @@ fn journal_copy_restores_exact_size() {
eprintln!("SKIP: debugfs sem suporte a escrita no diário: {j}");
return;
}
// Exclui e zera como o kernel faria.
// Delete the file and wipe its inode table slot (leaving only the journal copy).
debugfs(
&i.img,
&[
@@ -260,8 +260,8 @@ fn journal_copy_restores_exact_size() {
#[test]
fn ext2_blockmap_deleted_is_not_invented() {
// ext2 zera os ponteiros ao apagar; sem diário não há o que recuperar por metadados,
// mas a varredura não pode falhar nem listar o arquivo que continua existindo.
// Linux ext2 zeroes block pointers on unlink; without a journal, metadata recovery finds nothing,
// but the scan must succeed cleanly without reporting live files as deleted.
let Some(i) = build(&["-t", "ext2"]) else {
return;
};
+10 -10
View File
@@ -1,11 +1,11 @@
//! Saúde do disco, notificações, configurações e retomada da recuperação.
//! Disk health (SMART), notifications, user settings, and resumable recovery journal tests.
mod common;
use common::*;
use guara_core::*;
use serde_json::json;
// ------------------------------------------------------------------ saúde (SMART)
// ------------------------------------------------------------------ disk health (SMART)
#[test]
fn smart_reprovado_e_setores_ruins_viram_alerta_serio() {
@@ -119,12 +119,12 @@ fn imagem_de_disco_nao_tem_smart() {
assert_eq!(h.status, "unknown");
}
// ------------------------------------------------------------------ configurações
// ------------------------------------------------------------------ settings
#[test]
fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
init();
// arquivo gravado pela v0.3: só tinha o tema
// legacy v0.3 settings file containing only `theme`: newer fields use defaults
std::fs::create_dir_all(settings::config_dir()).unwrap();
std::fs::write(
settings::config_dir().join("settings.json"),
@@ -158,7 +158,7 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
);
}
// ------------------------------------------------------------------ notificações
// ------------------------------------------------------------------ notifications
#[cfg(target_os = "linux")]
#[test]
@@ -195,7 +195,7 @@ fn notificacao_chama_o_notify_send() {
);
}
// ------------------------------------------------------------------ retomar recuperação
// ------------------------------------------------------------------ resumable recovery
#[test]
fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
@@ -214,7 +214,7 @@ fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
let out = tempfile::tempdir().unwrap();
let key = "disco-de-teste";
// 1ª execução: "cai a energia" depois de 3 arquivos
// 1st run: abort after writing 3 of the 7 files
let n = std::sync::atomic::AtomicUsize::new(0);
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 3;
let r1 = recover::recover_resumable(
@@ -233,7 +233,7 @@ fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
let journal = std::fs::read_to_string(out.path().join(recover::JOURNAL)).unwrap();
assert_eq!(journal.lines().count(), 3);
// 2ª execução: retoma, pula os 3 e faz os outros 4
// 2nd run: resume skips the 3 verified files and writes the remaining 4
let r2 = recover::recover_resumable(
src.as_ref(),
&files,
@@ -259,7 +259,7 @@ fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
7
);
// um arquivo é danificado entre as execuções: é refeito, os outros 6 são pulados
// corrupt one recovered file on disk: resume re-recovers that file and skips the other 6
let victim = out.path().join(&r2.recovered[0].path);
std::fs::write(&victim, b"lixo").unwrap();
let r3 = recover::recover_resumable(
@@ -279,7 +279,7 @@ fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
"o arquivo danificado deveria ter sido refeito"
);
// outro disco (outra chave) não reaproveita o diário
// a different `source_key` does not reuse the previous disk's recovery journal
let r4 = recover::recover_resumable(
src.as_ref(),
&files,
+1 -1
View File
@@ -1,4 +1,4 @@
//! FileVault 2 (Core Storage): imagem de referência do cryptsetup (senha heslo123).
//! FileVault 2 (Core Storage): `cryptsetup` reference image (password `heslo123`).
use guara_core::crypt::{filevault, Secret, UnlockError};
use guara_core::hashing::MultiHasher;
+23 -23
View File
@@ -1,5 +1,5 @@
//! Recursos periciais: hashes, metadados (EXIF/PDF/Office/MP4), miniaturas,
//! busca de texto, exportações e navegação (facetas/duplicadas).
//! Forensic features: multi-hash, metadata extraction (EXIF/PDF/Office/MP4), thumbnails,
//! full-disk text search, CSV/HTML exports, and explorer facets/deduplication.
use guara_core::explorer::{self, Query};
use guara_core::hashing::hash_bytes;
@@ -79,7 +79,7 @@ fn rat(p: &[(u32, u32)]) -> Vec<u8> {
}
fn jpeg_with_exif(orientation: u16, w: usize, h: usize) -> Vec<u8> {
// pixels: gradiente
// gradient pixel pattern
let mut px = Vec::with_capacity(w * h * 3);
for y in 0..h {
for x in 0..w {
@@ -143,7 +143,7 @@ fn exif_camera_data_e_gps() {
let m = meta::extract(&MemSource(vec![]), &f);
assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D"));
assert_eq!(m.px, Some((64, 48)));
// 2023-07-14 18:22:05 lido como UTC
// `2023:07:14 18:22:05` interpreted as UTC
assert_eq!(m.taken, Some(1_689_358_925));
let (lat, lon) = m.gps.unwrap();
assert!((lat + 23.5550).abs() < 0.001, "{lat}");
@@ -157,14 +157,14 @@ fn exif_camera_data_e_gps() {
#[test]
fn miniatura_respeita_orientacao_e_tamanho() {
let src = MemSource(vec![]);
// 64x48 com orientação 6 (girar 90 graus) => retrato 48x64, reduzido para caber em 32
// 64x48 with EXIF orientation 6 (90° CW) -> portrait 48x64, scaled down to fit within 32px
let f = resident("foto.jpg", jpeg_with_exif(6, 64, 48));
let t = thumb::thumbnail(&src, &f, 32).expect("miniatura");
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
dec.decode().unwrap();
let i = dec.info().unwrap();
assert_eq!((i.width, i.height), (24, 32));
// PNG também
// PNG thumbnail generation
let mut img = ::image::RgbImage::new(100, 50);
for p in img.pixels_mut() {
*p = ::image::Rgb([200, 30, 30]);
@@ -179,7 +179,7 @@ fn miniatura_respeita_orientacao_e_tamanho() {
(dec.info().unwrap().width, dec.info().unwrap().height),
(40, 20)
);
// lixo não vira miniatura
// random bytes return `None`
assert!(thumb::thumbnail(&src, &resident("x.jpg", vec![1, 2, 3, 4, 5]), 32).is_none());
}
@@ -325,7 +325,7 @@ fn metadados_de_mp4() {
assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
}
// ------------------------------------------------------------------ busca de texto
// ------------------------------------------------------------------ text search
#[test]
fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
@@ -338,7 +338,7 @@ fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
.flat_map(|c| c.to_le_bytes())
.collect();
put(&mut disk, 3 * mb + 6, &u16s);
// exatamente cruzando o limite do primeiro bloco de 8 MiB
// string placed right across the 8 MiB chunk boundary
put(&mut disk, 8 * mb - 4, b"SEGREDO-123");
let opts = search::SearchOpts {
terms: vec!["segredo-123".into()],
@@ -364,7 +364,7 @@ fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
assert_eq!(hits[1].offset, 3 * mb as u64 + 6);
assert_eq!(hits[1].encoding, "UTF-16");
assert_eq!(hits[2].offset, 8 * mb as u64 - 4);
// sem ignorar maiúsculas só acha a grafia exata
// case-sensitive mode matches only exact casing
let opts = search::SearchOpts {
terms: vec!["Segredo-123".into()],
ignore_case: false,
@@ -381,7 +381,7 @@ fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
assert_eq!(n, 1);
}
// ------------------------------------------------------------------ exportações
// ------------------------------------------------------------------ exports
#[test]
fn csv_escapa_e_neutraliza_formulas() {
@@ -431,7 +431,7 @@ fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
assert!(html.contains("somente para leitura"));
}
// ------------------------------------------------------------------ navegação
// ------------------------------------------------------------------ explorer facets & sorting
#[test]
fn facetas_filtros_e_duplicadas() {
@@ -473,7 +473,7 @@ fn facetas_filtros_e_duplicadas() {
Some("Canon"),
Some(8),
),
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // exact content duplicate of `a.jpg`
mk(4, "d.jpg", None, None, None, None),
];
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
@@ -485,7 +485,7 @@ fn facetas_filtros_e_duplicadas() {
assert_eq!(f["undated"], 1);
assert_eq!(f["carved"], 1);
assert_eq!(f["duplicates"], 1);
assert_eq!(f["small"], 4); // todos têm 10 bytes
assert_eq!(f["small"], 4); // all three files are 10 bytes long
assert_eq!(q(r#"{"min_size":30720}"#).select(&found).len(), 0);
assert_eq!(f["cameras"][0]["name"], "Canon");
let dcim = f["folders"]
@@ -507,8 +507,8 @@ fn facetas_filtros_e_duplicadas() {
assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1);
let mut items = q("{}").select(&found);
explorer::sort_items(&mut items, Some("date"));
assert_eq!(items[0].id, 2); // mais recente primeiro
assert_eq!(items.last().unwrap().id, 4); // sem data por último
assert_eq!(items[0].id, 2); // newest first
assert_eq!(items.last().unwrap().id, 4); // undated files sort last
}
// ------------------------------------------------------------------ carving
@@ -521,8 +521,8 @@ fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
c
}
/// Regressão: texto ASCII depois de um PNG truncado vira "tamanho" de ~1,9 GB;
/// o carving não pode inventar um PNG gigante.
/// Regression test: ASCII text immediately following a truncated PNG looks like a ~1.9 GB chunk
/// length; the carver must reject the bogus chunk and never emit a multi-GB PNG.
#[test]
fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
let mut d = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
@@ -540,13 +540,13 @@ fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
);
assert_eq!(hit.confidence, Confidence::Low);
// sem nenhum dado de imagem (só cabeçalho) não é arquivo
// header-only PNG without any `IDAT` chunk is rejected
let mut d2 = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
d2.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
d2.resize(1 << 20, b'e');
assert!(signatures::detect(&MemSource(d2.clone()), 0, &d2[..64], 8 << 30).is_none());
// PNG completo continua com confiança alta e tamanho exato
// complete PNG with `IEND` still carves with `High` confidence and exact byte length
let mut ok = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
ok.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
ok.extend(png_chunk(b"IDAT", &[1; 100]));
@@ -557,10 +557,10 @@ fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
assert_eq!((h.len as usize, h.confidence), (n, Confidence::High));
}
// ------------------------------------------------------------------ abrir no player do sistema
// ------------------------------------------------------------------ open in external system app
/// O vídeo é copiado para uma pasta temporária e entregue ao aplicativo padrão do sistema
/// (aqui um `xdg-open`/`open` falso grava o que recebeu).
/// `open_external` materializes the recovered file into a private temp folder and invokes
/// the platform opener (stubbed here via a fake `xdg-open`/`open` script in `PATH`).
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn abre_no_player_do_sistema() {
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more